Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 54b7d0b39f fix(metadata): dropdowns for enum fields; manual Tested Pages insert
Tier, scope, source, strategy, metric, comparator and severity were free
text, so a typo such as "key" for KEY silently dropped a page from the
daily key-page tests. Each now uses an enum possible value source with
plain labels and the stored values the code already compares against
(no data migration).

Tested Pages offered Insert while URL was not editable but required, so
a manual insert could never succeed. URL is now editable and validated
(absolute https, no credentials or fragment) by TargetCustomizer, which
also fills path, site, source MANUAL and inSitemap false.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:43:01 -05:00
james.maes 31a33dcbe4 Merge pull request #5 from QRun-IO/feature/release-rc-automation
ci: automate RC releases; hotfix/* pushes no longer publish
2026-10-13 14:43:49 -05:00
james.maesandClaude Opus 5.5 fe2e814300 ci: automate RC releases; hotfix/* pushes no longer publish
Removes publish_hotfix_release: a hotfix/* branch push published a GA
release to Maven Central with no tag or review (QRun-IO/qbit-cdn#12,
QRun-IO/qqq#1121). hotfix/* branches now run test_only; a hotfix
release publishes from a reviewed vX.Y.Z tag through publish_release.
Adds .github/workflows/release-rc.yml (reusable QRun-IO/.github
qbit-release-rc): a manual dispatch, or merging a develop PR labelled
release:rc, merges develop into the release line and the release/* push
runs the existing CircleCI RC publish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:38:02 -05:00
james.maes 122e9e230b Merge pull request #4 from QRun-IO/feature/ga-only-release-tags
ci: publish only GA tags (vX.Y.Z), never RC tags
2026-10-12 16:01:19 -05:00
james.maesandClaude Opus 5.5 fdd457da0f ci: publish only GA tags (vX.Y.Z), never RC tags
An RC tag matched /v.*/ and the orb republished it as the next RC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:58:22 -05:00
james.maes 957f1ab43d Merge pull request #1 from QRun-IO/chore/qrun-repo-standard
chore: adopt the QRun qbit repository standard
2026-10-12 14:54:45 -05:00
james.maesandClaude Opus 5.5 84fd23510e chore: adopt the QRun qbit repository standard
Add the standard repository files (CircleCI qqq-orb pipeline where
missing, .editorconfig, .gitattributes, CODEOWNERS, Dependabot, community
pointer files, CHANGELOG, CLAUDE.md/AGENTS.md, docs/DESIGN.md), make the
Maven wrapper executable, and add the README Build/License sections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:43:33 -05:00
james.maesandClaude Opus 5.5 66f6e42ee8 refactor: name permissions web_performance.* per the owner naming rule
Permission base webPerformance -> web_performance (read, write, test,
admin, view). Process and widget names stay webPerformance*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:02:57 -05:00
james.maesandClaude Opus 5.5 7af5ed758e refactor: name tables web_performance_ per the owner naming rule
Owner rule (2026-10-12): table names are lowercase snake_case and every
qbit prefixes its tables with its full name, no abbreviations.

- table prefix webperf_ -> web_performance_ (tables, backend names,
  possible-value sources)
- Liquibase property webperf.tablePrefix -> web_performance.tablePrefix,
  changelog webperf.changelog.sql -> web_performance.changelog.sql
- name prefix and permission base webperf -> webPerformance (processes,
  widgets, process fields, permissions)

Java package and class names unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:55:14 -05:00
james.maesandClaude Opus 5.5 c6a984606c fix: pace CrUX queries under 150 a minute
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:38:41 -05:00
james.maesandClaude Opus 5.5 11b652ae0c feat: P1 lab and field data (PSI/CrUX runners, queue worker, actions, widgets)
- PsiLabRunner (PageSpeed Insights v5, every category, key never in errors)
  and LabResultParser (Lighthouse 13 scores, metrics, LCP element, findings
  keyed by audit id and Lighthouse version, PSI field data)
- CruxClient (CrUX queryRecord; 404 is NO_DATA, stored as such)
- WebPerfService: sitemap sync (nothing deleted), queue with merge and
  daily cap (SKIPPED_QUOTA), worker with conditional claim, retry with
  backoff, findings, gzipped full report, latest page results; CrUX upsert
- standard streamed ETL actions: Test page speed (pages and content
  records), Run site audit, Sync pages from sitemap, Retry, Refresh Google
  field data; hidden scheduled worker, daily key pages, weekly audit,
  daily sync and CrUX
- Performance home widgets (Site Scores, Trend, Slowest Pages, Top
  Opportunities, Google Field Data, Recent Runs) and the record
  Performance panel; readable at every width, real data only
- tests on H2 with the real changelog and a real Lighthouse 13 result of
  www.kof22.com, Google stood in by a local HTTP server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:37:26 -05:00
51 changed files with 6469 additions and 131 deletions

No files matched your search

+2 -13
View File
@@ -22,7 +22,7 @@ workflows:
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*|hotfix\/.*)/
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
@@ -57,15 +57,4 @@ workflows:
branches:
ignore: /.*/
tags:
only: [/v.*/]
publish_hotfix_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: hotfix
filters:
branches:
only: [/hotfix\/.*/]
only: [/^v\d+\.\d+\.\d+$/]
+20
View File
@@ -0,0 +1,20 @@
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 3
tab_width = 3
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.{cmd,bat}]
end_of_line = crlf
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.cmd text eol=crlf
mvnw text eol=lf
+1
View File
@@ -0,0 +1 @@
* @KofTwentyTwo
+25
View File
@@ -0,0 +1,25 @@
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
# major updates stay separate so each gets its own review. At most three version-update PRs per
# ecosystem are open at once. Security updates are enabled in the repository settings and are
# not limited by this schedule.
version: 2
updates:
- package-ecosystem: maven
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
maven-minor-and-patch:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
github-actions:
patterns: ['*']
+25
View File
@@ -0,0 +1,25 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
type: boolean
default: false
pull_request:
types: [closed]
branches: [develop]
permissions:
contents: write
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
+46
View File
@@ -0,0 +1,46 @@
# AGENTS.md
This file provides guidance to Codex when working with code in this repository.
## Project Overview
**QBit Web Performance**: QQQ QBit: PageSpeed-style web performance for QQQ sites (Lighthouse lab runs, CrUX field data, real-user Core Web Vitals, budgets and regressions). Design: [QRun-IO/qqq#1088](https://github.com/QRun-IO/qqq/pull/1088) (`docs/specs/2026-10-12-web-performance-design.md`). Implementation in progress.
## Build Commands
```bash
./mvnw compile # Compile (always use the wrapper: Maven 3.10 cannot read the parent POM)
./mvnw test # Unit tests
./mvnw verify # Unit tests + JaCoCo gate (thresholds in pom.xml)
```
## Architecture
**Entry point:** `WebPerfQBitProducer implements QBitMetaDataProducer<WebPerfQBitConfig>` in `com.kingsrook.qbits.webperf`. Component producers in this package (and below) are found by package scanning.
## Naming (owner rule)
- Tables are lowercase snake_case prefixed with the full qbit name: `web_performance_<name>`. The QQQ table name and the backend (SQL) name are identical; possible-value sources take the table name.
- Permissions use the full qbit name: `web_performance.<action>` (for example `web_performance.view`, `web_performance.admin`).
- Qbit data lives in standard, visible QQQ tables, secured by permissions and record security locks, never hidden.
- Processes run as table or record actions, never in the nav.
## Code Style
- 3-space indentation, opening braces on the next line, `return (x);`.
- Javadoc flower-box comments (`/*****` with `**` lines) on every class and method.
- Fluent setters (`withX()`).
- Wrapper types (`Integer`, `Boolean`) in config and entities.
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Tests: see pom.xml.
## Release
- CircleCI qqq-orb 0.6.8.
- `develop` publishes SNAPSHOTs.
- A `release/*` branch publishes RCs (the orb bumps the version).
- A `v*` tag publishes the release.
+12
View File
@@ -0,0 +1,12 @@
# Changelog
All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning.
## [0.1.0] - Unreleased
### Fixed
- Use dropdowns for tier, scope, source, strategy, metric, comparator and severity, preserving existing stored values.
- Allow manual Tested Pages inserts with a URL. Validate absolute HTTPS URLs and fill in the site, path and Manual source automatically; validate URL edits too.
### Added
- First implementation; see README.md for the tables, permissions and configuration.
+46
View File
@@ -0,0 +1,46 @@
# CLAUDE.md
This file provides guidance to Claude Code when working with code in this repository.
## Project Overview
**QBit Web Performance**: QQQ QBit: PageSpeed-style web performance for QQQ sites (Lighthouse lab runs, CrUX field data, real-user Core Web Vitals, budgets and regressions). Design: [QRun-IO/qqq#1088](https://github.com/QRun-IO/qqq/pull/1088) (`docs/specs/2026-10-12-web-performance-design.md`). Implementation in progress.
## Build Commands
```bash
./mvnw compile # Compile (always use the wrapper: Maven 3.10 cannot read the parent POM)
./mvnw test # Unit tests
./mvnw verify # Unit tests + JaCoCo gate (thresholds in pom.xml)
```
## Architecture
**Entry point:** `WebPerfQBitProducer implements QBitMetaDataProducer<WebPerfQBitConfig>` in `com.kingsrook.qbits.webperf`. Component producers in this package (and below) are found by package scanning.
## Naming (owner rule)
- Tables are lowercase snake_case prefixed with the full qbit name: `web_performance_<name>`. The QQQ table name and the backend (SQL) name are identical; possible-value sources take the table name.
- Permissions use the full qbit name: `web_performance.<action>` (for example `web_performance.view`, `web_performance.admin`).
- Qbit data lives in standard, visible QQQ tables, secured by permissions and record security locks, never hidden.
- Processes run as table or record actions, never in the nav.
## Code Style
- 3-space indentation, opening braces on the next line, `return (x);`.
- Javadoc flower-box comments (`/*****` with `**` lines) on every class and method.
- Fluent setters (`withX()`).
- Wrapper types (`Integer`, `Boolean`) in config and entities.
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Tests: see pom.xml.
## Release
- CircleCI qqq-orb 0.6.8.
- `develop` publishes SNAPSHOTs.
- A `release/*` branch publishes RCs (the orb bumps the version).
- A `v*` tag publishes the release.
+3
View File
@@ -0,0 +1,3 @@
# Code of Conduct
See Project level [Code of Conduct](https://github.com/QRun-IO/qqq/blob/develop/CODE_OF_CONDUCT.md)
+3
View File
@@ -0,0 +1,3 @@
# Code Style Guide
* See Project [Code Style Guide](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md)
+6
View File
@@ -0,0 +1,6 @@
# Contributing to QBit Web Performance
* See Project [Contributing](https://github.com/QRun-IO/qqq/blob/develop/CONTRIBUTING.md)
* Branches follow GitFlow: `feature/*` from `develop`, pull requests to `develop`, `release/*` for release candidates, `v*` tags for releases.
* Commits are signed and follow Conventional Commits.
* Build and test locally with `./mvnw verify` (Java 21).
+22 -14
View File
@@ -16,7 +16,7 @@ Design and owner decisions: QRun-IO/qqq `docs/specs/2026-10-12-web-performance-d
This is a **skeleton (0.1.0-SNAPSHOT)**. It is local only, and the GitHub repository does not exist yet. It contains:
- the producer and config, with validation and secret redaction
- the nine `webperf_` tables, their lookups and the Performance app
- the nine `web_performance_` tables, their lookups and the Performance app
- the host-included changelog, including the seeded budgets (D12)
- the SPIs: `UrlSource`, `ContentResolver`, `LabRunner`, `DeployProbe`, `RumSource`, `ChangeEventSource`
- two implementations that need no Google API key: `SitemapUrlSource` and `HttpBuildIdProbe`, which reads Website-Frontend's `/api/build`
@@ -40,27 +40,33 @@ new WebPerfQBitProducer()
The host owns the database. Include the QBit's changelog from the host's master changelog, with the same prefix as the config:
```yaml
- property: { name: webperf.tablePrefix, value: webperf_ }
- include: { file: db/liquibase/qbit-web-performance/webperf.changelog.sql }
- property: { name: web_performance.tablePrefix, value: web_performance_ }
- include: { file: db/liquibase/qbit-web-performance/web_performance.changelog.sql }
```
## Tested pages and dropdowns
Add a manual tested page with an absolute HTTPS URL, an optional label, a tier and an enabled flag. The QBit fills in its site key, path and Manual source. URLs cannot contain credentials or fragments; URL edits use the same validation and update the path. Sitemap sync remains available for automatic discovery.
Tier, Scope, Source, Strategy, Metric, Comparator and Severity use dropdowns with plain labels and the existing stored values. Each table uses its own applicable choices. Existing seeded budgets need no migration.
## Tables
| Table | Written by | Notes |
|---|---|---|
| `webperf_target` (Tested Pages) | sitemap sync, people | `label`, `tier` and `enabled` are editable; field-audited |
| `webperf_lab_run` (Lab Runs) | the QBit | read-only; one Lighthouse run |
| `webperf_lab_finding` (Findings) | the QBit | read-only; keyed by `(lighthouse_version, audit_id)` |
| `webperf_lab_report` (Lighthouse Reports) | the QBit | read-only; the gzipped PSI response, kept forever by default |
| `webperf_field_snapshot` (Google Field Data) | the QBit | read-only; CrUX p75 or NO_DATA |
| `webperf_budget` (Budgets) | people, seeded | editable; field-audited; 13 seeded reference rows |
| `webperf_regression` (Regressions) | the QBit, record actions | read-only in the UI; field-audited |
| `webperf_change_event` (Site Changes) | probes, other QBits | read-only |
| `webperf_comparison` (Before and After) | the QBit | read-only |
| `web_performance_target` (Tested Pages) | sitemap sync, people | `url`, `label`, `tier` and `enabled` are editable; field-audited |
| `web_performance_lab_run` (Lab Runs) | the QBit | read-only; one Lighthouse run |
| `web_performance_lab_finding` (Findings) | the QBit | read-only; keyed by `(lighthouse_version, audit_id)` |
| `web_performance_lab_report` (Lighthouse Reports) | the QBit | read-only; the gzipped PSI response, kept forever by default |
| `web_performance_field_snapshot` (Google Field Data) | the QBit | read-only; CrUX p75 or NO_DATA |
| `web_performance_budget` (Budgets) | people, seeded | editable; field-audited; 13 seeded reference rows |
| `web_performance_regression` (Regressions) | the QBit, record actions | read-only in the UI; field-audited |
| `web_performance_change_event` (Site Changes) | probes, other QBits | read-only |
| `web_performance_comparison` (Before and After) | the QBit | read-only |
## Permissions
Tables and the app use `READ_WRITE_PERMISSIONS` with the base name `webperf` (configurable), which gives `webperf.read` and `webperf.write`. Processes will add `webperf.test`, `webperf.manage` and `webperf.admin` (design §8.1). No table stores a secret.
Tables and the app use `READ_WRITE_PERMISSIONS` with the base name `web_performance` (configurable), which gives `web_performance.read` and `web_performance.write`. Processes will add `web_performance.test`, `web_performance.manage` and `web_performance.admin` (design §8.1). No table stores a secret.
## Build
@@ -68,4 +74,6 @@ Tables and the app use `READ_WRITE_PERMISSIONS` with the base name `webperf` (co
./mvnw verify
```
License: Apache-2.0.
## License
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
+4
View File
@@ -0,0 +1,4 @@
# Security Policy
* See Project [Security Policy](https://github.com/QRun-IO/qqq/blob/develop/SECURITY.md)
* Report a vulnerability privately through this repository's **Security** tab ("Report a vulnerability").
+3
View File
@@ -0,0 +1,3 @@
# QBit Web Performance: design
The design for this qbit is tracked in [QRun-IO/qqq#1088](https://github.com/QRun-IO/qqq/pull/1088) (`docs/specs/2026-10-12-web-performance-design.md`). This repository implements it; the implementation is in progress.
Vendored Regular → Executable
View File
File mode changed.
+1 -1
View File
@@ -70,7 +70,7 @@
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
</dependency>
<!-- RDBMS table backend details for the webperf_ tables. -->
<!-- RDBMS table backend details for the web_performance_ tables. -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
@@ -28,6 +28,7 @@ import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import com.kingsrook.qbits.webperf.field.CruxClient;
import com.kingsrook.qbits.webperf.spi.ChangeEventSource;
import com.kingsrook.qbits.webperf.spi.ContentResolver;
import com.kingsrook.qbits.webperf.spi.DeployProbe;
@@ -54,11 +55,11 @@ public class WebPerfQBitConfig implements QBitConfig
{
private static final long serialVersionUID = 1L;
public static final String DEFAULT_TABLE_PREFIX = "webperf_";
public static final String DEFAULT_NAME_PREFIX = "webperf";
public static final String DEFAULT_TABLE_PREFIX = "web_performance_";
public static final String DEFAULT_NAME_PREFIX = "webPerformance";
public static final String DEFAULT_APP_NAME = "webPerformance";
public static final String DEFAULT_APP_LABEL = "Performance";
public static final String DEFAULT_PERMISSION_BASE = "webperf";
public static final String DEFAULT_PERMISSION_BASE = "web_performance";
public static final int DEFAULT_MAX_RUNS_PER_DAY = 500;
/** Google's documented PSI quota: 25,000 queries a day. */
@@ -80,9 +81,11 @@ public class WebPerfQBitConfig implements QBitConfig
private List<String> strategies = new ArrayList<>(List.of("MOBILE", "DESKTOP"));
private List<String> categories = new ArrayList<>(List.of("PERFORMANCE", "ACCESSIBILITY", "BEST_PRACTICES", "SEO"));
private List<String> contentTables = new ArrayList<>();
private List<String> keyPaths = new ArrayList<>(List.of("/"));
private int concurrency = 2;
private int maxRunsPerDay = DEFAULT_MAX_RUNS_PER_DAY;
private int minSpacingMillis = 2000;
private int cruxSpacingMillis = 400;
private int publishSettleSeconds = 180;
private int deploySettleSeconds = 300;
private int reportRetentionDays = 0;
@@ -92,6 +95,7 @@ public class WebPerfQBitConfig implements QBitConfig
private transient UrlSource urlSource;
private transient ContentResolver contentResolver;
private transient LabRunner labRunner;
private transient CruxClient cruxClient;
private transient RumSource rumSource;
private transient List<DeployProbe> deployProbes = new ArrayList<>();
private transient List<ChangeEventSource> changeEventSources = new ArrayList<>();
@@ -151,6 +155,7 @@ public class WebPerfQBitConfig implements QBitConfig
assertCondition(reportRetentionDays >= 0, "reportRetentionDays must be 0 (keep forever) or more", errors);
assertCondition(isZone(timeZone), "timeZone is not a valid zone id: " + timeZone, errors);
assertCondition(keyPaths != null && keyPaths.stream().allMatch(p -> p != null && p.startsWith("/")), "keyPaths must be paths starting with /", errors);
for(String table : contentTables)
{
assertCondition(qInstance.getTable(table) != null, "content table " + table + " is not defined", errors);
@@ -174,7 +179,7 @@ public class WebPerfQBitConfig implements QBitConfig
** A table base name with the prefix.
**
** @param baseName e.g. "target"
** @return e.g. "webperf_target"
** @return e.g. "web_performance_target"
*******************************************************************************/
public String applyPrefix(String baseName)
{
@@ -187,7 +192,7 @@ public class WebPerfQBitConfig implements QBitConfig
** A process, widget or app-level name with the name prefix.
**
** @param name e.g. "TestPageSpeed"
** @return e.g. "webperfTestPageSpeed"
** @return e.g. "webPerformanceTestPageSpeed"
*******************************************************************************/
public String qualify(String name)
{
@@ -558,6 +563,49 @@ public class WebPerfQBitConfig implements QBitConfig
/*******************************************************************************
** Fluent setter for keyPaths: the pages tested daily and after deploys
** (tier KEY when synced), e.g. "/", "/blog", "/projects".
*******************************************************************************/
public WebPerfQBitConfig withKeyPaths(List<String> keyPaths)
{
this.keyPaths = keyPaths == null ? new ArrayList<>() : new ArrayList<>(keyPaths);
return (this);
}
/*******************************************************************************
** Getter for keyPaths.
*******************************************************************************/
public List<String> getKeyPaths()
{
return (keyPaths);
}
/*******************************************************************************
** Fluent setter for cruxClient (defaults to Google's with the API key).
*******************************************************************************/
public WebPerfQBitConfig withCruxClient(CruxClient cruxClient)
{
this.cruxClient = cruxClient;
return (this);
}
/*******************************************************************************
** Getter for cruxClient.
*******************************************************************************/
public CruxClient getCruxClient()
{
return (cruxClient);
}
/*******************************************************************************
** Fluent setter for concurrency.
*******************************************************************************/
@@ -621,6 +669,28 @@ public class WebPerfQBitConfig implements QBitConfig
/*******************************************************************************
** Fluent setter for cruxSpacingMillis: the pause between CrUX queries
** (400 ms keeps them under the API's 150 a minute).
*******************************************************************************/
public WebPerfQBitConfig withCruxSpacingMillis(int cruxSpacingMillis)
{
this.cruxSpacingMillis = cruxSpacingMillis;
return (this);
}
/*******************************************************************************
** Getter for cruxSpacingMillis.
*******************************************************************************/
public int getCruxSpacingMillis()
{
return (cruxSpacingMillis);
}
/*******************************************************************************
** Fluent setter for publishSettleSeconds.
*******************************************************************************/
@@ -51,8 +51,8 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** .produce(qInstance);
** </pre>
** The tables come from the host-included changelog
** (db/liquibase/qbit-web-performance/webperf.changelog.sql), whose
** webperf.tablePrefix property must equal the config's prefix. The QBit
** (db/liquibase/qbit-web-performance/web_performance.changelog.sql), whose
** web_performance.tablePrefix property must equal the config's prefix. The QBit
** never runs DDL.
*******************************************************************************/
public class WebPerfQBitProducer implements QBitMetaDataProducer<WebPerfQBitConfig>
@@ -161,6 +161,10 @@ public class WebPerfQBitProducer implements QBitMetaDataProducer<WebPerfQBitConf
{
qBitConfig.setTableNamePrefix(namespace.endsWith("_") ? namespace : namespace + "_");
}
if(StringUtils.hasContent(namespace) && WebPerfQBitConfig.DEFAULT_NAME_PREFIX.equals(qBitConfig.getNamePrefix()))
{
qBitConfig.withNamePrefix(namespace.replaceAll("[^A-Za-z0-9]", ""));
}
List<String> errors = new ArrayList<>();
qBitConfig.validate(qInstance, errors);
@@ -171,6 +175,10 @@ public class WebPerfQBitProducer implements QBitMetaDataProducer<WebPerfQBitConf
errors.add("table " + qBitConfig.applyPrefix(base) + " already exists in the instance (another install needs another namespace)");
}
}
if(qInstance.getProcess(qBitConfig.qualify(com.kingsrook.qbits.webperf.processes.WebPerfProcesses.SCHEDULED_SYNC)) != null)
{
errors.add("processes named " + qBitConfig.getNamePrefix() + "* already exist in the instance (another install needs another namespace)");
}
if(qInstance.getApp(qBitConfig.getAppName()) != null)
{
errors.add("app " + qBitConfig.getAppName() + " already exists in the instance");
@@ -181,6 +189,7 @@ public class WebPerfQBitProducer implements QBitMetaDataProducer<WebPerfQBitConf
}
qInstance.addQBit(getQBitMetaData());
new WebPerfRuntime(qBitConfig).addSelfToInstance(qInstance);
new WebPerfMetaData(qBitConfig).addTo(qInstance);
LOG.info("Web performance registered", logPair("tablePrefix", qBitConfig.getTableNamePrefix()), logPair("app", qBitConfig.getAppName()),
@@ -0,0 +1,201 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf;
import com.kingsrook.qbits.webperf.field.CruxClient;
import com.kingsrook.qbits.webperf.lab.PsiLabRunner;
import com.kingsrook.qbits.webperf.service.WebPerfService;
import com.kingsrook.qbits.webperf.spi.LabRunner;
import com.kingsrook.qbits.webperf.store.WebPerfStore;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
/*******************************************************************************
** One install's runtime state on a QInstance (supplemental meta-data named
** "webPerformance.&lt;prefix&gt;", so two namespaced installs never collide):
** its config, store and service. Process steps find it through the
** "webPerformanceInstance" value every QBit process carries; widgets through their
** default values.
*******************************************************************************/
public class WebPerfRuntime implements QSupplementalInstanceMetaData
{
public static final String NAME_PREFIX = "webPerformance.";
public static final String INSTANCE_FIELD = "webPerformanceInstance";
private final WebPerfQBitConfig config;
/*******************************************************************************
** Runtime for a validated config.
**
** @param config the config
*******************************************************************************/
public WebPerfRuntime(WebPerfQBitConfig config)
{
this.config = config;
}
/*******************************************************************************
** The runtime of an install on an instance.
**
** @param qInstance the instance
** @param tablePrefix the install's table prefix
** @return the runtime
** @throws IllegalStateException when not registered
*******************************************************************************/
public static WebPerfRuntime of(QInstance qInstance, String tablePrefix)
{
WebPerfRuntime runtime = qInstance == null ? null : QSupplementalInstanceMetaData.of(qInstance, NAME_PREFIX + tablePrefix);
if(runtime == null)
{
throw (new IllegalStateException("qbit-web-performance (" + tablePrefix + ") is not registered on this QInstance"));
}
return (runtime);
}
/*******************************************************************************
** The runtime a process step belongs to (its webPerformanceInstance value).
**
** @param input the step input
** @return the runtime
*******************************************************************************/
public static WebPerfRuntime forInput(RunBackendStepInput input)
{
return (of(QContext.getQInstance(), input.getValueString(INSTANCE_FIELD)));
}
/*******************************************************************************
** The store on the current instance's backend.
**
** @return the store
*******************************************************************************/
public WebPerfStore store()
{
return (store(QContext.getQInstance()));
}
/*******************************************************************************
** The store on an instance's backend.
**
** @param qInstance the instance
** @return the store
*******************************************************************************/
public WebPerfStore store(QInstance qInstance)
{
RDBMSBackendMetaData backend = (RDBMSBackendMetaData) qInstance.getBackend(config.getBackendName());
return (new WebPerfStore(config, () -> ConnectionManager.getConnection(backend)));
}
/*******************************************************************************
** The service on the current instance.
**
** @return the service
*******************************************************************************/
public WebPerfService service()
{
return (new WebPerfService(this, store()));
}
/*******************************************************************************
** The lab runner: the configured one, else PageSpeed Insights with the
** Google API key, else null (no key: lab tests are off).
**
** @return the runner or null
*******************************************************************************/
public LabRunner labRunner()
{
if(config.getLabRunner() != null)
{
return (config.getLabRunner());
}
return (config.hasGoogleApiKey() ? new PsiLabRunner(config.getGoogleApiKey()) : null);
}
/*******************************************************************************
** The CrUX client: the configured one, else Google's with the API key,
** else null (no key: field data checks are off).
**
** @return the client or null
*******************************************************************************/
public CruxClient cruxClient()
{
if(config.getCruxClient() != null)
{
return (config.getCruxClient());
}
return (config.hasGoogleApiKey() ? new CruxClient(config.getGoogleApiKey()) : null);
}
/*******************************************************************************
** Name of this supplemental meta-data.
*******************************************************************************/
@Override
public String getName()
{
return (NAME_PREFIX + config.getTableNamePrefix());
}
/*******************************************************************************
** Add to an instance.
*******************************************************************************/
@Override
public void addSelfToInstance(QInstance qInstance)
{
qInstance.withSupplementalMetaData(this);
}
/*******************************************************************************
** Getter for config.
**
** @return the config
*******************************************************************************/
public WebPerfQBitConfig getConfig()
{
return (config);
}
}
@@ -26,7 +26,7 @@ import java.util.List;
/*******************************************************************************
** The QBit's table base names (design §4.2). The SQL and QQQ table name is
** the configured prefix plus the base name, e.g. "webperf_target".
** the configured prefix plus the base name, e.g. "web_performance_target".
*******************************************************************************/
public final class WebPerfSchema
{
@@ -45,10 +45,10 @@ public final class WebPerfSchema
FIELD_SNAPSHOT, LAB_REPORT);
/** Classpath location of the host-included Liquibase changelog. */
public static final String CHANGELOG = "db/liquibase/qbit-web-performance/webperf.changelog.sql";
public static final String CHANGELOG = "db/liquibase/qbit-web-performance/web_performance.changelog.sql";
/** The changelog property holding the table prefix (must equal the config's prefix). */
public static final String CHANGELOG_PREFIX_PROPERTY = "webperf.tablePrefix";
public static final String CHANGELOG_PREFIX_PROPERTY = "web_performance.tablePrefix";
@@ -0,0 +1,274 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.field;
import java.io.IOException;
import java.io.Serializable;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Google field data from the Chrome UX Report API (design §2.1, §2.3):
** p75 over a rolling 28 days for a URL or an origin, per form factor.
**
** A page or site Google has no data for (not public, or not popular enough,
** the usual case for a small site) is answered 404 by the API and reported
** here as NO_DATA: a real result, stored and shown as such, never hidden or
** replaced. The API key is never part of an exception or stored value.
*******************************************************************************/
public class CruxClient implements Serializable
{
private static final long serialVersionUID = 1L;
public static final String DEFAULT_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord";
/** Form factors queried; ALL omits the form factor (every device combined). */
public static final List<String> FORM_FACTORS = List.of("ALL", "PHONE", "DESKTOP");
private static final Set<String> SCOPES = Set.of("URL", "ORIGIN");
private final String endpoint;
private final String apiKey;
/*******************************************************************************
** The result of one query.
**
** @param scope URL or ORIGIN
** @param key the URL or origin
** @param formFactor ALL, PHONE, DESKTOP or TABLET
** @param status OK or NO_DATA
** @param periodStart first day of the collection period (null for NO_DATA)
** @param periodEnd last day of the collection period (null for NO_DATA)
** @param p75LcpMs LCP p75
** @param p75InpMs INP p75
** @param p75Cls CLS p75
** @param p75FcpMs FCP p75
** @param p75TtfbMs TTFB p75
** @param distributions per-metric good / needs-improvement / poor densities as JSON
*******************************************************************************/
public record CruxResult(String scope, String key, String formFactor, String status, LocalDate periodStart, LocalDate periodEnd, Integer p75LcpMs,
Integer p75InpMs, Double p75Cls, Integer p75FcpMs, Integer p75TtfbMs, String distributions) implements Serializable
{
}
/*******************************************************************************
** A client on Google's endpoint.
**
** @param apiKey the Google API key
*******************************************************************************/
public CruxClient(String apiKey)
{
this(DEFAULT_ENDPOINT, apiKey);
}
/*******************************************************************************
** A client on an endpoint (tests point it at a local server).
**
** @param endpoint the queryRecord URL
** @param apiKey the Google API key
*******************************************************************************/
public CruxClient(String endpoint, String apiKey)
{
if(apiKey == null || apiKey.isBlank())
{
throw (new IllegalArgumentException("A CrUX client needs an API key"));
}
this.endpoint = endpoint;
this.apiKey = apiKey;
}
/*******************************************************************************
** Query one URL or origin for one form factor.
**
** @param scope URL or ORIGIN
** @param key the URL or origin
** @param formFactor ALL, PHONE, DESKTOP or TABLET
** @return the result (NO_DATA when Google has none)
** @throws IOException on a transport failure or an unexpected response
** @throws InterruptedException when interrupted
*******************************************************************************/
public CruxResult query(String scope, String key, String formFactor) throws IOException, InterruptedException
{
if(!SCOPES.contains(scope))
{
throw (new IllegalArgumentException("Unknown scope: " + scope));
}
JSONObject body = new JSONObject().put("ORIGIN".equals(scope) ? "origin" : "url", key);
if(!"ALL".equals(formFactor))
{
body.put("formFactor", formFactor);
}
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
HttpRequest request = HttpRequest.newBuilder(URI.create(endpoint + "?key=" + URLEncoder.encode(apiKey, StandardCharsets.UTF_8)))
.timeout(Duration.ofSeconds(30))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build();
HttpResponse<String> response;
try
{
response = client.send(request, HttpResponse.BodyHandlers.ofString());
}
catch(IOException e)
{
throw (new IOException("CrUX call failed for " + key + " (" + e.getClass().getSimpleName() + ")"));
}
if(response.statusCode() == 404)
{
return (new CruxResult(scope, key, formFactor, "NO_DATA", null, null, null, null, null, null, null, null));
}
if(response.statusCode() != 200)
{
throw (new IOException("CrUX returned HTTP " + response.statusCode() + " for " + key));
}
return (parse(scope, key, formFactor, response.body()));
}
/*******************************************************************************
** Parse a queryRecord response.
**
** @param scope URL or ORIGIN
** @param key the URL or origin
** @param formFactor the form factor
** @param json the response body
** @return the result
*******************************************************************************/
static CruxResult parse(String scope, String key, String formFactor, String json)
{
JSONObject record = new JSONObject(json).optJSONObject("record");
JSONObject metrics = record == null ? null : record.optJSONObject("metrics");
if(metrics == null || metrics.isEmpty())
{
return (new CruxResult(scope, key, formFactor, "NO_DATA", null, null, null, null, null, null, null, null));
}
JSONObject distributions = new JSONObject();
for(String name : metrics.keySet())
{
JSONArray histogram = metrics.getJSONObject(name).optJSONArray("histogram");
if(histogram != null)
{
JSONArray densities = new JSONArray();
for(int i = 0; i < histogram.length(); i++)
{
densities.put(histogram.getJSONObject(i).optDouble("density", 0));
}
distributions.put(name, densities);
}
}
JSONObject period = record.optJSONObject("collectionPeriod");
Double cls = p75(metrics, "cumulative_layout_shift");
return (new CruxResult(scope, key, formFactor, "OK",
period == null ? null : date(period.optJSONObject("firstDate")),
period == null ? null : date(period.optJSONObject("lastDate")),
round(p75(metrics, "largest_contentful_paint")),
round(p75(metrics, "interaction_to_next_paint")),
cls == null ? null : Math.round(cls * 10000) / 10000.0,
round(p75(metrics, "first_contentful_paint")),
round(p75(metrics, "experimental_time_to_first_byte")),
distributions.toString()));
}
/*******************************************************************************
** A metric's p75 (CrUX sends CLS as a string), or null.
*******************************************************************************/
private static Double p75(JSONObject metrics, String name)
{
JSONObject metric = metrics.optJSONObject(name);
JSONObject percentiles = metric == null ? null : metric.optJSONObject("percentiles");
if(percentiles == null || !percentiles.has("p75") || percentiles.isNull("p75"))
{
return (null);
}
Object value = percentiles.get("p75");
try
{
return (value instanceof Number number ? number.doubleValue() : Double.parseDouble(String.valueOf(value)));
}
catch(NumberFormatException e)
{
return (null);
}
}
/*******************************************************************************
** A whole number, or null.
*******************************************************************************/
private static Integer round(Double value)
{
return (value == null ? null : (int) Math.round(value));
}
/*******************************************************************************
** A CrUX {year, month, day} date, or null.
*******************************************************************************/
private static LocalDate date(JSONObject date)
{
if(date == null || !date.has("year"))
{
return (null);
}
return (LocalDate.of(date.getInt("year"), date.getInt("month"), date.getInt("day")));
}
/*******************************************************************************
** No key in the string form.
*******************************************************************************/
@Override
public String toString()
{
return ("CruxClient{endpoint=" + endpoint + ", apiKey=<redacted>}");
}
}
@@ -0,0 +1,473 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.lab;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Reads a PageSpeed Insights v5 response (or a bare Lighthouse result) into
** the values the QBit stores: category scores (0-100), the lab metrics,
** the LCP element, run warnings and errors, the findings worth acting on,
** and the Google field data PSI attaches (loadingExperience).
**
** Findings are the audits of the categories that ran which score below
** Lighthouse's passing mark (0.9) and are scored at all (not "not
** applicable", "manual", "informative" or errored). An audit referenced by
** several categories is reported once, under the first. Audit ids are kept
** as Lighthouse names them, with the Lighthouse version, because
** Lighthouse 13 replaced most performance audits with insight audits.
*******************************************************************************/
public final class LabResultParser
{
/** Lighthouse's passing score. */
public static final double PASSING = 0.9;
/** Category ids in the response, and the column they fill. */
public static final Map<String, String> CATEGORIES = Map.of(
"performance", "PERFORMANCE",
"accessibility", "ACCESSIBILITY",
"best-practices", "BEST_PRACTICES",
"seo", "SEO");
private static final Set<String> UNSCORED = Set.of("notApplicable", "manual", "informative", "error");
private static final int MAX_ITEMS = 5;
private static final int MAX_TEXT = 300;
/*******************************************************************************
** One finding.
**
** @param category PERFORMANCE, ACCESSIBILITY, BEST_PRACTICES or SEO
** @param auditId the Lighthouse audit id
** @param kind INSIGHT, OPPORTUNITY, DIAGNOSTIC or FAILED_AUDIT
** @param title the audit title
** @param score the audit score (0-1)
** @param displayValue the audit's display value (may be null)
** @param savingsMs estimated time savings (may be null)
** @param savingsBytes estimated byte savings (may be null)
** @param metricSavings the audit's metric savings as JSON (may be null)
** @param topItems up to five URLs or element selectors, as a JSON array (may be null)
*******************************************************************************/
public record Finding(String category, String auditId, String kind, String title, Double score, String displayValue, Integer savingsMs,
Long savingsBytes, String metricSavings, String topItems)
{
}
/*******************************************************************************
** Google field data (CrUX) as PSI reports it for the URL or its origin.
**
** @param scope URL or ORIGIN
** @param key the URL or origin
** @param fallback whether PSI fell back to the origin for a URL request
** @param p75LcpMs LCP p75 (ms)
** @param p75InpMs INP p75 (ms)
** @param p75Cls CLS p75
** @param p75FcpMs FCP p75 (ms)
** @param p75TtfbMs TTFB p75 (ms)
** @param distributions the per-metric distributions as JSON
*******************************************************************************/
public record FieldData(String scope, String key, boolean fallback, Integer p75LcpMs, Integer p75InpMs, Double p75Cls, Integer p75FcpMs,
Integer p75TtfbMs, String distributions)
{
}
/*******************************************************************************
** Everything read from one response.
**
** @param finalUrl the URL after redirects
** @param lighthouseVersion e.g. 13.0.1
** @param chromeUserAgent the Chrome that ran the test
** @param benchmarkIndex the host's benchmark index
** @param scores category column name to 0-100 score (only categories that ran)
** @param fcpMs First Contentful Paint
** @param lcpMs Largest Contentful Paint
** @param tbtMs Total Blocking Time
** @param cls Cumulative Layout Shift
** @param speedIndexMs Speed Index
** @param lcpElement the LCP element's selector (may be null)
** @param runtimeError "CODE: message" when Lighthouse could not test the page (else null)
** @param runWarnings Lighthouse's run warnings, joined (may be null)
** @param findings the findings
** @param fieldData Google field data PSI attached (may be empty)
*******************************************************************************/
public record ParsedLab(String finalUrl, String lighthouseVersion, String chromeUserAgent, Integer benchmarkIndex, Map<String, Integer> scores,
Integer fcpMs, Integer lcpMs, Integer tbtMs, Double cls, Integer speedIndexMs, String lcpElement, String runtimeError,
String runWarnings, List<Finding> findings, List<FieldData> fieldData)
{
/*******************************************************************************
** A category score.
**
** @param category PERFORMANCE, ACCESSIBILITY, BEST_PRACTICES or SEO
** @return 0-100 or null
*******************************************************************************/
public Integer score(String category)
{
return (scores.get(category));
}
}
/*******************************************************************************
** Parser only.
*******************************************************************************/
private LabResultParser()
{
}
/*******************************************************************************
** Parse a response body.
**
** @param json the PSI response, or a bare Lighthouse result
** @return the parsed values
** @throws IllegalArgumentException when the body has no Lighthouse result
*******************************************************************************/
public static ParsedLab parse(String json)
{
JSONObject root = new JSONObject(json);
JSONObject lhr = root.optJSONObject("lighthouseResult");
if(lhr == null && root.has("audits") && root.has("categories"))
{
lhr = root;
}
if(lhr == null)
{
throw (new IllegalArgumentException("The response has no Lighthouse result"));
}
JSONObject audits = lhr.optJSONObject("audits", new JSONObject());
JSONObject categories = lhr.optJSONObject("categories", new JSONObject());
Map<String, Integer> scores = new LinkedHashMap<>();
for(Map.Entry<String, String> category : CATEGORIES.entrySet())
{
JSONObject object = categories.optJSONObject(category.getKey());
if(object != null && object.has("score") && !object.isNull("score"))
{
scores.put(category.getValue(), (int) Math.round(object.getDouble("score") * 100));
}
}
String runtimeError = null;
JSONObject error = lhr.optJSONObject("runtimeError");
if(error != null && error.has("code"))
{
runtimeError = truncate(error.optString("code") + ": " + error.optString("message"), 2000);
}
JSONArray warnings = lhr.optJSONArray("runWarnings");
List<String> warningTexts = new ArrayList<>();
for(int i = 0; warnings != null && i < warnings.length(); i++)
{
warningTexts.add(String.valueOf(warnings.get(i)));
}
JSONObject environment = lhr.optJSONObject("environment", new JSONObject());
String finalUrl = lhr.optString("finalDisplayedUrl", lhr.optString("finalUrl", null));
return (new ParsedLab(
finalUrl,
lhr.optString("lighthouseVersion", null),
truncate(environment.optString("hostUserAgent", null), MAX_TEXT),
environment.has("benchmarkIndex") ? (int) Math.round(environment.getDouble("benchmarkIndex")) : null,
scores,
millis(audits, "first-contentful-paint"),
millis(audits, "largest-contentful-paint"),
millis(audits, "total-blocking-time"),
numeric(audits, "cumulative-layout-shift") == null ? null : Math.round(numeric(audits, "cumulative-layout-shift") * 10000) / 10000.0,
millis(audits, "speed-index"),
lcpElement(audits),
runtimeError,
warningTexts.isEmpty() ? null : truncate(String.join(" | ", warningTexts), 2000),
findings(categories, audits),
fieldData(root)));
}
/*******************************************************************************
** The findings of the categories that ran.
*******************************************************************************/
static List<Finding> findings(JSONObject categories, JSONObject audits)
{
Map<String, Finding> findings = new LinkedHashMap<>();
for(Map.Entry<String, String> category : CATEGORIES.entrySet())
{
JSONObject object = categories.optJSONObject(category.getKey());
JSONArray refs = object == null ? null : object.optJSONArray("auditRefs");
for(int i = 0; refs != null && i < refs.length(); i++)
{
JSONObject ref = refs.getJSONObject(i);
String id = ref.optString("id");
if("metrics".equals(ref.optString("group")) || findings.containsKey(id))
{
continue;
}
JSONObject audit = audits.optJSONObject(id);
if(audit == null || !audit.has("score") || audit.isNull("score") || UNSCORED.contains(audit.optString("scoreDisplayMode")))
{
continue;
}
double score = audit.getDouble("score");
if(score >= PASSING)
{
continue;
}
JSONObject details = audit.optJSONObject("details");
String kind;
if(id.endsWith("-insight"))
{
kind = "INSIGHT";
}
else if(details != null && "opportunity".equals(details.optString("type")))
{
kind = "OPPORTUNITY";
}
else if(!"PERFORMANCE".equals(category.getValue()))
{
kind = "FAILED_AUDIT";
}
else
{
kind = "DIAGNOSTIC";
}
Integer savingsMs = details != null && details.has("overallSavingsMs") ? (int) Math.round(details.getDouble("overallSavingsMs")) : null;
Long savingsBytes = details != null && details.has("overallSavingsBytes") ? Math.round(details.getDouble("overallSavingsBytes")) : null;
JSONObject metricSavings = audit.optJSONObject("metricSavings");
findings.put(id, new Finding(category.getValue(), id, kind, truncate(audit.optString("title", id), MAX_TEXT), score,
truncate(audit.optString("displayValue", null), 200), savingsMs, savingsBytes,
metricSavings == null ? null : metricSavings.toString(), topItems(details)));
}
}
return (new ArrayList<>(findings.values()));
}
/*******************************************************************************
** Up to five URLs or element selectors from an audit's details, as a JSON
** array, or null.
*******************************************************************************/
static String topItems(JSONObject details)
{
if(details == null)
{
return (null);
}
List<String> items = new ArrayList<>();
collectItems(details, items, 0);
if(items.isEmpty())
{
return (null);
}
return (new JSONArray(items).toString());
}
/*******************************************************************************
** Walk "items" arrays (insights nest them) collecting url or node selector.
*******************************************************************************/
private static void collectItems(JSONObject details, List<String> items, int depth)
{
JSONArray array = details.optJSONArray("items");
for(int i = 0; array != null && i < array.length() && items.size() < MAX_ITEMS && depth < 3; i++)
{
Object value = array.get(i);
if(!(value instanceof JSONObject item))
{
continue;
}
String found = null;
if(item.opt("url") instanceof String url && !url.isBlank())
{
found = url;
}
else if(item.optJSONObject("node") != null && item.getJSONObject("node").opt("selector") instanceof String selector)
{
found = selector;
}
if(found != null)
{
String text = truncate(found, MAX_TEXT);
if(!items.contains(text))
{
items.add(text);
}
}
else if(item.has("items"))
{
collectItems(item, items, depth + 1);
}
}
}
/*******************************************************************************
** The LCP element's selector, from the LCP breakdown insight (Lighthouse
** 13) or the older largest-contentful-paint-element audit.
*******************************************************************************/
static String lcpElement(JSONObject audits)
{
for(String id : List.of("lcp-breakdown-insight", "lcp-phases-insight", "largest-contentful-paint-element"))
{
JSONObject audit = audits.optJSONObject(id);
String found = audit == null ? null : firstSelector(audit.optJSONObject("details"), 0);
if(found != null)
{
return (truncate(found, MAX_TEXT));
}
}
return (null);
}
/*******************************************************************************
** The first node selector anywhere under a JSON value.
*******************************************************************************/
private static String firstSelector(Object value, int depth)
{
if(depth > 6 || value == null)
{
return (null);
}
if(value instanceof JSONObject object)
{
if("node".equals(object.optString("type")) && object.opt("selector") instanceof String selector)
{
return (selector);
}
for(String key : object.keySet())
{
String found = firstSelector(object.get(key), depth + 1);
if(found != null)
{
return (found);
}
}
}
else if(value instanceof JSONArray array)
{
for(int i = 0; i < array.length(); i++)
{
String found = firstSelector(array.get(i), depth + 1);
if(found != null)
{
return (found);
}
}
}
return (null);
}
/*******************************************************************************
** Google field data PSI attached for the URL and the origin.
*******************************************************************************/
static List<FieldData> fieldData(JSONObject root)
{
List<FieldData> result = new ArrayList<>();
for(String member : List.of("loadingExperience", "originLoadingExperience"))
{
JSONObject experience = root.optJSONObject(member);
JSONObject metrics = experience == null ? null : experience.optJSONObject("metrics");
if(metrics == null || metrics.isEmpty())
{
continue;
}
boolean fallback = experience.optBoolean("origin_fallback", false);
String scope = "originLoadingExperience".equals(member) || fallback ? "ORIGIN" : "URL";
Integer cls = percentile(metrics, "CUMULATIVE_LAYOUT_SHIFT_SCORE");
result.add(new FieldData(scope, experience.optString("id", null), fallback,
percentile(metrics, "LARGEST_CONTENTFUL_PAINT_MS"),
percentile(metrics, "INTERACTION_TO_NEXT_PAINT"),
cls == null ? null : cls / 100.0,
percentile(metrics, "FIRST_CONTENTFUL_PAINT_MS"),
percentile(metrics, "EXPERIMENTAL_TIME_TO_FIRST_BYTE"),
metrics.toString()));
}
return (result);
}
/*******************************************************************************
** A PSI field metric's percentile, or null.
*******************************************************************************/
private static Integer percentile(JSONObject metrics, String key)
{
JSONObject metric = metrics.optJSONObject(key);
return (metric != null && metric.has("percentile") ? metric.getInt("percentile") : null);
}
/*******************************************************************************
** An audit's numericValue, or null.
*******************************************************************************/
private static Double numeric(JSONObject audits, String id)
{
JSONObject audit = audits.optJSONObject(id);
return (audit != null && audit.has("numericValue") && !audit.isNull("numericValue") ? audit.getDouble("numericValue") : null);
}
/*******************************************************************************
** An audit's numericValue in whole milliseconds, or null.
*******************************************************************************/
private static Integer millis(JSONObject audits, String id)
{
Double value = numeric(audits, id);
return (value == null ? null : (int) Math.round(value));
}
/*******************************************************************************
** Cut text to a length.
*******************************************************************************/
static String truncate(String text, int max)
{
return (text == null || text.length() <= max ? text : text.substring(0, max));
}
}
@@ -0,0 +1,189 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.lab;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Locale;
import java.util.Set;
import com.kingsrook.qbits.webperf.spi.LabRunner;
/*******************************************************************************
** Runs a lab test through the PageSpeed Insights API v5 (design §2.1, D2):
** Lighthouse run by Google, from outside the site's network, so it measures
** what a visitor gets (and later the CDN). One call per URL and strategy,
** with every requested category (D13).
**
** The API key is sent as the key parameter and never appears in an
** exception, log line or stored value: messages name the URL and status
** only.
*******************************************************************************/
public class PsiLabRunner implements LabRunner
{
private static final long serialVersionUID = 1L;
public static final String DEFAULT_ENDPOINT = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
public static final String NAME = "PSI";
public static final Duration TIMEOUT = Duration.ofSeconds(150);
public static final int MAX_BYTES = 20 * 1024 * 1024;
private static final Set<String> STRATEGIES = Set.of("MOBILE", "DESKTOP");
private static final Set<String> CATEGORIES = Set.of("PERFORMANCE", "ACCESSIBILITY", "BEST_PRACTICES", "SEO");
private final String endpoint;
private final String apiKey;
/*******************************************************************************
** A runner on Google's endpoint.
**
** @param apiKey the Google API key
*******************************************************************************/
public PsiLabRunner(String apiKey)
{
this(DEFAULT_ENDPOINT, apiKey);
}
/*******************************************************************************
** A runner on an endpoint (tests point it at a local server).
**
** @param endpoint the runPagespeed URL
** @param apiKey the Google API key
*******************************************************************************/
public PsiLabRunner(String endpoint, String apiKey)
{
if(apiKey == null || apiKey.isBlank())
{
throw (new IllegalArgumentException("A PageSpeed Insights runner needs an API key"));
}
this.endpoint = endpoint;
this.apiKey = apiKey;
}
/*******************************************************************************
** The runner name stored on runs.
*******************************************************************************/
@Override
public String name()
{
return (NAME);
}
/*******************************************************************************
** Call the API. Any HTTP status is returned (the worker decides about
** retries); only transport failures throw.
*******************************************************************************/
@Override
public LabResponse run(LabRequest request) throws Exception
{
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
HttpRequest httpRequest = HttpRequest.newBuilder(URI.create(requestUrl(request)))
.timeout(TIMEOUT)
.header("Accept", "application/json")
.GET().build();
HttpResponse<byte[]> response;
try
{
response = client.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray());
}
catch(IOException e)
{
//////////////////////////////////////////////////////////////////
// the exception text can carry the request URL (with the key): //
// report the target only //
//////////////////////////////////////////////////////////////////
throw (new IOException("PageSpeed Insights call failed for " + request.url() + " (" + e.getClass().getSimpleName() + ")"));
}
if(response.body().length > MAX_BYTES)
{
throw (new IOException("PageSpeed Insights returned more than " + MAX_BYTES + " bytes for " + request.url()));
}
return (new LabResponse(response.statusCode(), new String(response.body(), StandardCharsets.UTF_8), Instant.now()));
}
/*******************************************************************************
** The request URL: url, strategy, every category, locale and key.
**
** @param request the test
** @return the URL
*******************************************************************************/
String requestUrl(LabRequest request)
{
String strategy = request.strategy() == null ? "" : request.strategy().toUpperCase(Locale.ROOT);
if(!STRATEGIES.contains(strategy))
{
throw (new IllegalArgumentException("Unknown strategy: " + request.strategy()));
}
StringBuilder url = new StringBuilder(endpoint)
.append("?url=").append(encode(request.url()))
.append("&strategy=").append(strategy)
.append("&locale=en");
for(String category : request.categories())
{
if(!CATEGORIES.contains(category))
{
throw (new IllegalArgumentException("Unknown category: " + category));
}
url.append("&category=").append(category);
}
url.append("&key=").append(encode(apiKey));
return (url.toString());
}
/*******************************************************************************
** URL-encode a value.
*******************************************************************************/
private static String encode(String value)
{
return (URLEncoder.encode(value, StandardCharsets.UTF_8));
}
/*******************************************************************************
** No key in the string form.
*******************************************************************************/
@Override
public String toString()
{
return ("PsiLabRunner{endpoint=" + endpoint + ", apiKey=<redacted>}");
}
}
@@ -0,0 +1,113 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.metadata;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.WebPerfSchema;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
/*******************************************************************************
** Validate manually entered URLs and derive the fields used by sync and tests.
*******************************************************************************/
public class TargetCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
** Derive site identity before the unique site/URL check.
*******************************************************************************/
@Override
public AbstractPreInsertCustomizer.WhenToRun whenToRunPreInsert(InsertInput input, boolean isPreview)
{
return (AbstractPreInsertCustomizer.WhenToRun.BEFORE_ALL_VALIDATIONS);
}
/*******************************************************************************
** Manual pages need only a URL, label, tier and enabled flag from the user.
*******************************************************************************/
@Override
public List<QRecord> preInsert(InsertInput input, List<QRecord> records, boolean isPreview)
{
validate(input.getTableName(), records, true);
return (records);
}
/*******************************************************************************
** Keep URL edits subject to the same validation and refresh their path.
*******************************************************************************/
@Override
public List<QRecord> preUpdate(UpdateInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecords)
{
validate(input.getTableName(), records, false);
return (records);
}
/*******************************************************************************
** Accept absolute HTTPS URLs without embedded credentials or fragments.
*******************************************************************************/
private void validate(String tableName, List<QRecord> records, boolean inserting)
{
String prefix = tableName.substring(0, tableName.length() - WebPerfSchema.TARGET.length());
WebPerfRuntime runtime = WebPerfRuntime.of(QContext.getQInstance(), prefix);
for(QRecord record : records)
{
if(!inserting && !record.getValues().containsKey("url"))
{
continue;
}
String url = record.getValueString("url");
try
{
URI uri = URI.create(url == null ? "" : url);
if(!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getUserInfo() != null || uri.getFragment() != null)
{
throw (new IllegalArgumentException("Invalid page URL"));
}
record.setValue("path", uri.getPath().isEmpty() ? "/" : uri.getPath());
if(inserting)
{
record.setValue("siteKey", runtime.service().siteKey());
record.setValue("source", "MANUAL");
record.setValue("inSitemap", false);
}
}
catch(IllegalArgumentException e)
{
record.addError(new BadInputStatusMessage("URL must be an absolute HTTPS URL without credentials or a fragment."));
}
}
}
}
@@ -27,7 +27,16 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.webperf.WebPerfQBitConfig;
import com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.WebPerfSchema;
import com.kingsrook.qbits.webperf.processes.WebPerfProcesses;
import com.kingsrook.qbits.webperf.widgets.WebPerfWidgets;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
@@ -39,7 +48,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
@@ -51,13 +62,13 @@ import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDe
/*******************************************************************************
** The QBit's tables, lookups and app (design §4, §5). Every table is a
** standard, visible QQQ table on the host's RDBMS backend, protected by the
** "webperf" permissions (webperf.read / webperf.write by default).
** "web_performance" permissions (web_performance.read / web_performance.write by default).
**
** - Measurements (lab runs, findings, reports, field snapshots,
** comparisons, change events) are written only by the QBit's processes:
** read-only in the UI and not audited.
** - Tested pages and budgets are edited by people (enable a page, change a
** budget): only those fields are editable, and both are field-audited.
** budget): URLs are validated on insert/edit, and both are field-audited.
** Regressions change status only through record actions (dismiss,
** resolve), so they are read-only in the UI but field-audited.
**
@@ -98,6 +109,28 @@ public final class WebPerfMetaData
{
qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(config.applyPrefix(base)));
}
addEnumSources(qInstance);
for(QProcessMetaData process : WebPerfProcesses.define(config, qInstance))
{
qInstance.addProcess(process);
}
addWidget(qInstance, WebPerfWidgets.SITE_SCORES, "Site Scores", WidgetType.HTML, WebPerfWidgets.SiteScores.class);
addWidget(qInstance, WebPerfWidgets.TREND, "Performance Trend", WidgetType.LINE_CHART, WebPerfWidgets.Trend.class);
addWidget(qInstance, WebPerfWidgets.SLOWEST_PAGES, "Slowest Pages", WidgetType.HTML, WebPerfWidgets.SlowestPages.class);
addWidget(qInstance, WebPerfWidgets.OPPORTUNITIES, "Top Opportunities", WidgetType.HTML, WebPerfWidgets.TopOpportunities.class);
addWidget(qInstance, WebPerfWidgets.FIELD_STATUS, "Google Field Data", WidgetType.HTML, WebPerfWidgets.FieldDataStatus.class);
addWidget(qInstance, WebPerfWidgets.RECENT_RUNS, "Recent Runs", WidgetType.HTML, WebPerfWidgets.RecentRuns.class);
addWidget(qInstance, WebPerfWidgets.RECORD_PANEL, "Performance", WidgetType.HTML, WebPerfWidgets.RecordPanel.class);
List<String> panelTables = new ArrayList<>(config.getContentTables());
panelTables.add(0, config.applyPrefix(WebPerfSchema.TARGET));
for(String tableName : panelTables)
{
qInstance.getTable(tableName).addSection(new QFieldSection(config.qualify("Performance"), new QIcon("speed"), Tier.T2)
.withLabel("Performance")
.withWidgetName(config.qualify(WebPerfWidgets.RECORD_PANEL)));
}
qInstance.addApp(app());
if(config.getParentAppName() != null)
{
@@ -108,8 +141,110 @@ public final class WebPerfMetaData
/*******************************************************************************
** The Performance app: one home (widgets arrive with phase P1) and the
** data tables. No process is in its nav.
** Enumerated values retain the stored IDs used by runners and seeded budgets.
*******************************************************************************/
private void addEnumSources(QInstance qInstance)
{
addEnumSource(qInstance, "tier",
new QPossibleValue<>("KEY", "Key page"),
new QPossibleValue<>("STANDARD", "Standard page"));
addEnumSource(qInstance, "target_source",
new QPossibleValue<>("SITEMAP", "Sitemap"),
new QPossibleValue<>("MANUAL", "Manual"),
new QPossibleValue<>("CONTENT", "Content"));
addEnumSource(qInstance, "strategy",
new QPossibleValue<>("MOBILE", "Mobile"),
new QPossibleValue<>("DESKTOP", "Desktop"));
addEnumSource(qInstance, "strategy_or_form_factor",
new QPossibleValue<>("MOBILE", "Mobile"),
new QPossibleValue<>("DESKTOP", "Desktop"),
new QPossibleValue<>("PHONE", "Phone"),
new QPossibleValue<>("TABLET", "Tablet"),
new QPossibleValue<>("ALL", "All devices"));
addEnumSource(qInstance, "field_source",
new QPossibleValue<>("CRUX_API", "CrUX API"),
new QPossibleValue<>("CRUX_HISTORY", "CrUX history"),
new QPossibleValue<>("PSI", "PageSpeed Insights"));
addEnumSource(qInstance, "field_scope",
new QPossibleValue<>("URL", "URL"),
new QPossibleValue<>("ORIGIN", "Origin"));
addEnumSource(qInstance, "budget_scope",
new QPossibleValue<>("SITE", "Site"),
new QPossibleValue<>("TIER", "Tier"),
new QPossibleValue<>("TARGET", "Page"),
new QPossibleValue<>("CONTENT_TABLE", "Content table"),
new QPossibleValue<>("PATH_PREFIX", "Path prefix"));
addEnumSource(qInstance, "measurement_source",
new QPossibleValue<>("LAB", "Lab"),
new QPossibleValue<>("RUM", "Real users"),
new QPossibleValue<>("CRUX", "Google field data"));
addEnumSource(qInstance, "comparison_source",
new QPossibleValue<>("LAB", "Lab"),
new QPossibleValue<>("RUM", "Real users"));
addEnumSource(qInstance, "comparison_scope",
new QPossibleValue<>("KEY", "Key pages"),
new QPossibleValue<>("TARGET", "Page"),
new QPossibleValue<>("SITE", "Site"));
addEnumSource(qInstance, "change_source",
new QPossibleValue<>("PROBE", "Probe"),
new QPossibleValue<>("WEBHOOK", "Webhook"),
new QPossibleValue<>("QBIT_CDN", "CDN"),
new QPossibleValue<>("USER", "User"));
addEnumSource(qInstance, "metric",
new QPossibleValue<>("LCP", "LCP"),
new QPossibleValue<>("INP", "INP"),
new QPossibleValue<>("CLS", "CLS"),
new QPossibleValue<>("FCP", "FCP"),
new QPossibleValue<>("TTFB", "TTFB"),
new QPossibleValue<>("TBT", "TBT"),
new QPossibleValue<>("PERFORMANCE_SCORE", "Performance score"),
new QPossibleValue<>("ACCESSIBILITY_SCORE", "Accessibility score"),
new QPossibleValue<>("BEST_PRACTICES_SCORE", "Best practices score"),
new QPossibleValue<>("SEO_SCORE", "SEO score"));
addEnumSource(qInstance, "comparator",
new QPossibleValue<>("<=", "At most"),
new QPossibleValue<>(">=", "At least"));
addEnumSource(qInstance, "severity",
new QPossibleValue<>("WARNING", "Warning"),
new QPossibleValue<>("CRITICAL", "Critical"));
}
/*******************************************************************************
** Register an install-scoped dropdown with plain labels.
*******************************************************************************/
private void addEnumSource(QInstance qInstance, String name, QPossibleValue<?>... values)
{
qInstance.addPossibleValueSource(new QPossibleValueSource().withName(config.applyPrefix(name))
.withType(QPossibleValueSourceType.ENUM).withEnumValues(List.of(values)));
}
/*******************************************************************************
** A card widget, full width, carrying the install's prefix so its
** renderer finds the runtime.
*******************************************************************************/
private void addWidget(QInstance qInstance, String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer)
{
qInstance.addWidget(new QWidgetMetaData()
.withName(config.qualify(name))
.withLabel(label)
.withType(type.getType())
.withCodeReference(new QCodeReference(renderer))
.withGridColumns(12)
.withIsCard(true)
.withDefaultValues(new java.util.HashMap<>(Map.of(WebPerfRuntime.INSTANCE_FIELD, config.getTableNamePrefix())))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withPermissionBaseName(config.getPermissionBaseName() + ".view")));
}
/*******************************************************************************
** The Performance app: its one home (the widgets) and the data tables.
** No process is in its nav.
**
** @return the app
*******************************************************************************/
@@ -125,6 +260,7 @@ public final class WebPerfMetaData
.withLabel(config.getAppLabel())
.withIcon(new QIcon("speed"))
.withPermissionRules(rules())
.withWidgets(WebPerfWidgets.HOME.stream().map(config::qualify).toList())
.withSection(new QAppSection()
.withName("data")
.withLabel("Data")
@@ -161,11 +297,11 @@ public final class WebPerfMetaData
f("siteKey", QFieldType.STRING, "Site").withMaxLength(100),
f("url", QFieldType.STRING, "URL").withMaxLength(1000).withIsRequired(true),
f("path", QFieldType.STRING, "Path").withMaxLength(500),
f("source", QFieldType.STRING, "Source").withMaxLength(20),
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("target_source")).withMaxLength(20),
f("contentTable", QFieldType.STRING, "Content Table").withMaxLength(100),
f("contentId", QFieldType.LONG, "Content Id"),
f("label", QFieldType.STRING, "Label").withMaxLength(200),
f("tier", QFieldType.STRING, "Tier").withMaxLength(20).withDefaultValue("STANDARD"),
f("tier", QFieldType.STRING, "Tier").withPossibleValueSourceName(config.applyPrefix("tier")).withMaxLength(20).withDefaultValue("STANDARD"),
f("enabled", QFieldType.BOOLEAN, "Enabled").withDefaultValue(true),
f("inSitemap", QFieldType.BOOLEAN, "In Sitemap"),
f("lastSeenInSitemapAt", QFieldType.DATE_TIME, "Last Seen in Sitemap"),
@@ -176,7 +312,9 @@ public final class WebPerfMetaData
f("openRegressionCount", QFieldType.INTEGER, "Open Regressions")));
QTableMetaData table = table(WebPerfSchema.TARGET, "Tested Pages", "travel_explore", fields, true,
Set.of("label", "tier", "enabled"))
Set.of("url", "label", "tier", "enabled"))
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(TargetCustomizer.class))
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(TargetCustomizer.class))
.withRecordLabelFormatAndFields("%s", "url")
.withUniqueKey(new UniqueKey("siteKey", "url"))
.withSection(section("page", "Page", "travel_explore", Tier.T1, "id", "url", "label", "tier", "enabled"))
@@ -199,7 +337,7 @@ public final class WebPerfMetaData
f("targetId", QFieldType.LONG, "Page").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.TARGET)),
f("url", QFieldType.STRING, "URL").withMaxLength(1000),
f("finalUrl", QFieldType.STRING, "Final URL").withMaxLength(1000),
f("strategy", QFieldType.STRING, "Strategy").withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy").withPossibleValueSourceName(config.applyPrefix("strategy")).withMaxLength(10),
f("runner", QFieldType.STRING, "Runner").withMaxLength(20),
f("triggerType", QFieldType.STRING, "Trigger").withMaxLength(20),
f("triggerRef", QFieldType.STRING, "Trigger Reference").withMaxLength(200),
@@ -250,7 +388,7 @@ public final class WebPerfMetaData
List<QFieldMetaData> fields = withCommon(List.of(
f("runId", QFieldType.LONG, "Run").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.LAB_RUN)),
f("targetId", QFieldType.LONG, "Page").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.TARGET)),
f("strategy", QFieldType.STRING, "Strategy").withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy").withPossibleValueSourceName(config.applyPrefix("strategy")).withMaxLength(10),
f("category", QFieldType.STRING, "Category").withMaxLength(20),
f("auditId", QFieldType.STRING, "Audit").withMaxLength(100),
f("lighthouseVersion", QFieldType.STRING, "Lighthouse Version").withMaxLength(20),
@@ -300,8 +438,8 @@ public final class WebPerfMetaData
QTableMetaData fieldSnapshot()
{
List<QFieldMetaData> fields = withCommon(List.of(
f("source", QFieldType.STRING, "Source").withMaxLength(20),
f("scope", QFieldType.STRING, "Scope").withMaxLength(10),
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("field_source")).withMaxLength(20),
f("scope", QFieldType.STRING, "Scope").withPossibleValueSourceName(config.applyPrefix("field_scope")).withMaxLength(10),
f("scopeKey", QFieldType.STRING, "URL or Origin").withMaxLength(1000),
f("formFactor", QFieldType.STRING, "Form Factor").withMaxLength(10),
f("status", QFieldType.STRING, "Status").withMaxLength(10),
@@ -332,14 +470,14 @@ public final class WebPerfMetaData
{
List<QFieldMetaData> fields = withCommon(List.of(
f("name", QFieldType.STRING, "Name").withMaxLength(200).withIsRequired(true),
f("scope", QFieldType.STRING, "Scope").withMaxLength(20),
f("scope", QFieldType.STRING, "Scope").withPossibleValueSourceName(config.applyPrefix("budget_scope")).withMaxLength(20),
f("scopeValue", QFieldType.STRING, "Scope Value").withMaxLength(500),
f("source", QFieldType.STRING, "Source").withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy").withMaxLength(10),
f("metric", QFieldType.STRING, "Metric").withMaxLength(30),
f("comparator", QFieldType.STRING, "Comparator").withMaxLength(5),
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("measurement_source")).withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy").withPossibleValueSourceName(config.applyPrefix("strategy")).withMaxLength(10),
f("metric", QFieldType.STRING, "Metric").withPossibleValueSourceName(config.applyPrefix("metric")).withMaxLength(30),
f("comparator", QFieldType.STRING, "Comparator").withPossibleValueSourceName(config.applyPrefix("comparator")).withMaxLength(5),
f("threshold", QFieldType.DECIMAL, "Threshold"),
f("severity", QFieldType.STRING, "Severity").withMaxLength(10),
f("severity", QFieldType.STRING, "Severity").withPossibleValueSourceName(config.applyPrefix("severity")).withMaxLength(10),
f("enabled", QFieldType.BOOLEAN, "Enabled"),
f("seeded", QFieldType.BOOLEAN, "Seeded")));
@@ -362,16 +500,16 @@ public final class WebPerfMetaData
f("targetId", QFieldType.LONG, "Page").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.TARGET)),
f("contentTable", QFieldType.STRING, "Content Table").withMaxLength(100),
f("contentId", QFieldType.LONG, "Content Id"),
f("source", QFieldType.STRING, "Source").withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy or Form Factor").withMaxLength(10),
f("metric", QFieldType.STRING, "Metric").withMaxLength(30),
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("measurement_source")).withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy or Form Factor").withPossibleValueSourceName(config.applyPrefix("strategy_or_form_factor")).withMaxLength(10),
f("metric", QFieldType.STRING, "Metric").withPossibleValueSourceName(config.applyPrefix("metric")).withMaxLength(30),
f("baselineValue", QFieldType.DECIMAL, "Baseline"),
f("baselineRef", QFieldType.STRING, "Baseline From").withMaxLength(500),
f("currentValue", QFieldType.DECIMAL, "Current"),
f("currentRef", QFieldType.STRING, "Current From").withMaxLength(500),
f("delta", QFieldType.DECIMAL, "Change"),
f("budgetId", QFieldType.LONG, "Budget").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.BUDGET)),
f("severity", QFieldType.STRING, "Severity").withMaxLength(10),
f("severity", QFieldType.STRING, "Severity").withPossibleValueSourceName(config.applyPrefix("severity")).withMaxLength(10),
f("status", QFieldType.STRING, "Status").withMaxLength(20),
f("causeChangeEventId", QFieldType.LONG, "Likely Cause").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.CHANGE_EVENT)),
f("detectedAt", QFieldType.DATE_TIME, "Detected"),
@@ -401,7 +539,7 @@ public final class WebPerfMetaData
f("fromValue", QFieldType.STRING, "From").withMaxLength(200),
f("toValue", QFieldType.STRING, "To").withMaxLength(200),
f("observedAt", QFieldType.DATE_TIME, "Observed"),
f("source", QFieldType.STRING, "Source").withMaxLength(20),
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("change_source")).withMaxLength(20),
f("note", QFieldType.STRING, "Note").withMaxLength(2000)));
return (table(WebPerfSchema.CHANGE_EVENT, "Site Changes", "change_circle", fields, false, Set.of())
@@ -420,10 +558,10 @@ public final class WebPerfMetaData
{
List<QFieldMetaData> fields = withCommon(List.of(
f("changeEventId", QFieldType.LONG, "Change").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.CHANGE_EVENT)),
f("scope", QFieldType.STRING, "Scope").withMaxLength(20),
f("source", QFieldType.STRING, "Source").withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy or Form Factor").withMaxLength(10),
f("metric", QFieldType.STRING, "Metric").withMaxLength(30),
f("scope", QFieldType.STRING, "Scope").withPossibleValueSourceName(config.applyPrefix("comparison_scope")).withMaxLength(20),
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("comparison_source")).withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy or Form Factor").withPossibleValueSourceName(config.applyPrefix("strategy_or_form_factor")).withMaxLength(10),
f("metric", QFieldType.STRING, "Metric").withPossibleValueSourceName(config.applyPrefix("metric")).withMaxLength(30),
f("segment", QFieldType.STRING, "Segment").withMaxLength(100),
f("beforeValue", QFieldType.DECIMAL, "Before"),
f("beforeN", QFieldType.INTEGER, "Before n"),
@@ -0,0 +1,208 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.processes;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.field.CruxClient;
import com.kingsrook.qbits.webperf.service.WebPerfService;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
/*******************************************************************************
** "Refresh Google field data" on Google Field Data: query the Chrome UX
** Report for the site's origin and every enabled tested page, on each form
** factor. Review says how many queries that is; Result says how many had
** data and how many Google has none for (NO_DATA, stored as such).
*******************************************************************************/
public final class FieldDataSteps
{
static final String ORIGIN = "webPerformanceOrigin";
static final String URLS = "webPerformanceUrls";
/*******************************************************************************
** Steps only.
*******************************************************************************/
private FieldDataSteps()
{
}
/*******************************************************************************
** The keys of a request record: ORIGIN and URL lists.
*******************************************************************************/
static Map<String, List<String>> keys(QRecord record)
{
Map<String, List<String>> keys = new LinkedHashMap<>();
keys.put("ORIGIN", List.of(record.getValueString(ORIGIN)));
String urls = record.getValueString(URLS);
keys.put("URL", urls == null || urls.isEmpty() ? List.of() : Arrays.asList(urls.split("\n")));
return (keys);
}
/*******************************************************************************
** Extract: one record naming the origin and the enabled pages.
*******************************************************************************/
public static class Extract extends AbstractExtractStep
{
/*******************************************************************************
** Build the request.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
WebPerfService service = WebPerfRuntime.forInput(input).service();
List<String> urls = new ArrayList<>();
try
{
service.enabledTargets(false).forEach(row -> urls.add((String) row.get("url")));
}
catch(java.sql.SQLException e)
{
throw (new QException("Could not read the tested pages", e));
}
getRecordPipe().addRecord(new QRecord().withValue("id", 1).withValue("scopeKey", service.origin()).withValue(ORIGIN, service.origin())
.withValue(URLS, String.join("\n", urls)));
}
}
/*******************************************************************************
** Review: count the queries. Never calls Google.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine queries = Lines.line(Status.OK, "Google field data query will be made (origin and pages, each form factor)",
"Google field data queries will be made (origin and pages, each form factor)", "query was made", "queries were made");
private ProcessSummaryLine noKey;
/*******************************************************************************
** Count one page (one record).
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
if(WebPerfRuntime.forInput(input).cruxClient() == null)
{
noKey = Lines.message(Status.ERROR, "No Google API key is configured, so Google field data cannot be checked");
return;
}
for(QRecord record : input.getRecords())
{
int keys = keys(record).values().stream().mapToInt(List::size).sum();
queries.incrementCount(keys * CruxClient.FORM_FACTORS.size());
output.addRecord(record);
}
}
/*******************************************************************************
** Review lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
Lines.addAll(lines, queries);
if(noKey != null)
{
lines.add(noKey);
}
return (lines);
}
}
/*******************************************************************************
** Execute: query and store.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine withData = Lines.line(Status.OK, "result will have data", "results will have data",
"query returned Google field data", "queries returned Google field data");
private final ProcessSummaryLine noData = Lines.line(Status.INFO, "result will have no data", "results will have no data",
"query found no Google field data (not enough Chrome traffic; recorded as NO_DATA)",
"queries found no Google field data (not enough Chrome traffic; recorded as NO_DATA)");
/*******************************************************************************
** Query and store.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
WebPerfService service = WebPerfRuntime.forInput(input).service();
for(QRecord record : input.getRecords())
{
try
{
Map<String, Integer> counts = service.refreshFieldData(keys(record), Instant.now());
withData.incrementCount(counts.getOrDefault("OK", 0));
noData.incrementCount(counts.getOrDefault("NO_DATA", 0));
}
catch(Exception e)
{
throw (new QException("Google field data could not be refreshed: " + e.getMessage(), e));
}
}
}
/*******************************************************************************
** Result lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
Lines.addAll(lines, withData, noData);
return (lines);
}
}
}
@@ -0,0 +1,101 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.processes;
import java.util.ArrayList;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
/*******************************************************************************
** Summary lines for the Review (future tense) and Result (past tense)
** screens; the frontend picks singular or plural by count.
*******************************************************************************/
final class Lines
{
/*******************************************************************************
** Helpers only.
*******************************************************************************/
private Lines()
{
}
/*******************************************************************************
** A counted line.
**
** @param status OK, INFO, WARNING or ERROR
** @param singularFuture e.g. "page will be tested"
** @param pluralFuture e.g. "pages will be tested"
** @param singularPast e.g. "page was queued"
** @param pluralPast e.g. "pages were queued"
** @return the line
*******************************************************************************/
static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast)
{
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
line.setSingularFutureMessage(singularFuture);
line.setPluralFutureMessage(pluralFuture);
line.setSingularPastMessage(singularPast);
line.setPluralPastMessage(pluralPast);
return (line);
}
/*******************************************************************************
** A single message line (no count), shown on both screens.
**
** @param status the status
** @param message the message
** @return the line
*******************************************************************************/
static ProcessSummaryLine message(Status status, String message)
{
return (new ProcessSummaryLine(status, null, message));
}
/*******************************************************************************
** Add lines that have a count (or no count at all: message lines).
**
** @param target the list
** @param lines the lines
*******************************************************************************/
static void addAll(ArrayList<ProcessSummaryLineInterface> target, ProcessSummaryLine... lines)
{
for(ProcessSummaryLine line : lines)
{
if(line.getCount() == null)
{
target.add(line);
}
else
{
line.addSelfToListIfAnyCount(target);
}
}
}
}
@@ -0,0 +1,341 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.processes;
import java.io.Serializable;
import java.sql.SQLException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.webperf.WebPerfQBitConfig;
import com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.WebPerfSchema;
import com.kingsrook.qbits.webperf.service.WebPerfService;
import com.kingsrook.qbits.webperf.store.WebPerfStore;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** "Test page speed" and "Run site audit": queue lab runs for the selected
** tested pages, or for the pages of the selected content records, on every
** configured strategy. Review counts what will be queued, merged into a run
** that is already waiting, or held back by today's run cap; nothing is
** written until Execute. Results arrive as the queue worker runs the tests.
*******************************************************************************/
public final class QueueTestsSteps
{
static final String TARGET_ID = "webPerformanceTargetId";
static final String URL = "webPerformanceUrl";
/*******************************************************************************
** Steps only.
*******************************************************************************/
private QueueTestsSteps()
{
}
/*******************************************************************************
** The tested pages of a page of input records: the records themselves on
** the tested-pages table, else the pages linked to the content records.
**
** @param runtime the runtime
** @param sourceTable the process's source table
** @param records the records
** @return record id to its pages (id, url, enabled)
** @throws SQLException on failure
*******************************************************************************/
static Map<Serializable, List<Map<String, Object>>> pagesOf(WebPerfRuntime runtime, String sourceTable, List<QRecord> records) throws SQLException
{
WebPerfQBitConfig config = runtime.getConfig();
WebPerfStore store = runtime.store();
Map<Serializable, List<Map<String, Object>>> pages = new LinkedHashMap<>();
boolean direct = config.applyPrefix(WebPerfSchema.TARGET).equals(sourceTable);
for(QRecord record : records)
{
Serializable id = record.getValue("id");
if(direct)
{
Map<String, Object> page = new LinkedHashMap<>();
page.put("id", record.getValue("id"));
page.put("url", record.getValueString("url"));
page.put("enabled", record.getValueBoolean("enabled"));
pages.put(id, List.of(page));
}
else
{
pages.put(id, store.query("SELECT id, url, enabled FROM {target} WHERE content_table = ? AND content_id = ? ORDER BY id", sourceTable,
Long.valueOf(String.valueOf(id))));
}
}
return (pages);
}
/*******************************************************************************
** Review: count what will be queued. Never writes.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine pages = Lines.line(Status.OK, "page will be tested", "pages will be tested",
"page was tested", "pages were tested");
private final ProcessSummaryLine runs = Lines.line(Status.OK, "run will be queued (mobile and desktop are separate runs)",
"runs will be queued (mobile and desktop are separate runs)", "run was queued", "runs were queued");
private final ProcessSummaryLine merged = Lines.line(Status.INFO, "run is already waiting for that page (merged, not queued twice)",
"runs are already waiting for those pages (merged, not queued twice)", "run was already waiting (merged)", "runs were already waiting (merged)");
private final ProcessSummaryLine overQuota = Lines.line(Status.WARNING, "run is over today's run cap and will be recorded as skipped (use Retry tomorrow)",
"runs are over today's run cap and will be recorded as skipped (use Retry tomorrow)", "run was over today's run cap (skipped)",
"runs were over today's run cap (skipped)");
private final ProcessSummaryLine noPage = Lines.line(Status.INFO, "record has no tested page (not in the sitemap, for example a draft)",
"records have no tested page (not in the sitemap, for example drafts)", "record had no tested page", "records had no tested page");
private final ProcessSummaryLine disabled = Lines.line(Status.INFO, "page is disabled and will be skipped", "pages are disabled and will be skipped",
"page was disabled (skipped)", "pages were disabled (skipped)");
private ProcessSummaryLine quotaNote;
private ProcessSummaryLine noKey;
private Set<String> open;
private int counted;
private int cap;
/*******************************************************************************
** Load the open runs and today's count once.
*******************************************************************************/
@Override
public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
WebPerfRuntime runtime = WebPerfRuntime.forInput(input);
WebPerfService service = runtime.service();
try
{
WebPerfService.QueuePlan none = service.planQueue(List.of(), Instant.now());
counted = none.runsToday();
cap = runtime.getConfig().getMaxRunsPerDay();
open = new HashSet<>();
for(Map<String, Object> row : runtime.store().query("SELECT target_id, strategy FROM {lab_run} WHERE status IN ('QUEUED', 'RUNNING')"))
{
open.add(row.get("target_id") + "|" + row.get("strategy"));
}
}
catch(SQLException e)
{
throw (new QException("Could not read the test queue", e));
}
quotaNote = Lines.message(Status.INFO, "Runs today before this: " + counted + " of " + cap + " (Google allows " + WebPerfQBitConfig.PSI_DAILY_QUOTA + ")");
if(runtime.labRunner() == null)
{
noKey = Lines.message(Status.WARNING, "No Google API key is configured, so the runs will wait in the queue until one is");
}
}
/*******************************************************************************
** Count one page of records.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
WebPerfRuntime runtime = WebPerfRuntime.forInput(input);
List<String> strategies = runtime.getConfig().getStrategies();
Map<Serializable, List<Map<String, Object>>> pagesById;
try
{
pagesById = pagesOf(runtime, input.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE), input.getRecords());
}
catch(SQLException e)
{
throw (new QException("Could not read the tested pages", e));
}
for(Map.Entry<Serializable, List<Map<String, Object>>> entry : pagesById.entrySet())
{
if(entry.getValue().isEmpty())
{
noPage.incrementCountAndAddPrimaryKey(entry.getKey());
continue;
}
for(Map<String, Object> page : entry.getValue())
{
if(Boolean.FALSE.equals(page.get("enabled")))
{
disabled.incrementCountAndAddPrimaryKey(entry.getKey());
continue;
}
boolean any = false;
for(String strategy : strategies)
{
String key = page.get("id") + "|" + strategy;
if(!open.add(key))
{
merged.incrementCount();
}
else if(counted >= cap)
{
overQuota.incrementCount();
any = true;
}
else
{
counted++;
runs.incrementCount();
any = true;
}
}
if(any)
{
pages.incrementCountAndAddPrimaryKey(entry.getKey());
output.addRecord(new QRecord().withValue(TARGET_ID, WebPerfStore.asLong(page.get("id"))).withValue(URL, (String) page.get("url")));
}
}
}
}
/*******************************************************************************
** Review lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
Lines.addAll(lines, pages, runs, merged, overQuota, noPage, disabled);
if(quotaNote != null)
{
lines.add(quotaNote);
}
if(noKey != null)
{
lines.add(noKey);
}
return (lines);
}
/*******************************************************************************
** Lines the Result screen repeats.
*******************************************************************************/
List<ProcessSummaryLine> skippedLines()
{
return (List.of(merged, noPage, disabled));
}
/*******************************************************************************
** The merged line (Execute adds runs merged at write time).
*******************************************************************************/
ProcessSummaryLine mergedLine()
{
return (merged);
}
}
/*******************************************************************************
** Execute: queue the runs (merging and capping again at write time).
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine queued = Lines.line(Status.OK, "run will be queued", "runs will be queued", "run was queued",
"runs were queued; results appear in Lab Runs as the tests finish (usually within minutes)");
private final ProcessSummaryLine overQuota = Lines.line(Status.WARNING, "run will be skipped", "runs will be skipped",
"run was over today's run cap (recorded as skipped; use Retry)", "runs were over today's run cap (recorded as skipped; use Retry)");
/*******************************************************************************
** Queue one page of pages.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
WebPerfRuntime runtime = WebPerfRuntime.forInput(input);
WebPerfService service = runtime.service();
List<Map<String, Object>> targets = new ArrayList<>();
for(QRecord record : input.getRecords())
{
Map<String, Object> target = new LinkedHashMap<>();
target.put("id", record.getValue(TARGET_ID));
target.put("url", record.getValueString(URL));
targets.add(target);
}
String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? null : QContext.getQSession().getUser().getIdReference();
String trigger = input.getValueString(WebPerfProcesses.TRIGGER_FIELD);
try
{
WebPerfService.QueuePlan plan = service.enqueue(service.requestsFor(targets, trigger == null ? "MANUAL" : trigger,
input.getProcessName() + (user == null ? "" : " by " + user), Instant.now()), Instant.now());
queued.incrementCount(plan.queue().size());
if(!plan.merged().isEmpty() && getTransformStep() instanceof Transform transform)
{
transform.mergedLine().incrementCount(plan.merged().size());
}
overQuota.incrementCount(plan.overQuota().size());
}
catch(SQLException e)
{
throw (new QException("Could not queue the runs", e));
}
}
/*******************************************************************************
** Result lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
Lines.addAll(lines, queued, overQuota);
if(getTransformStep() instanceof Transform transform)
{
Lines.addAll(lines, transform.skippedLines().toArray(new ProcessSummaryLine[0]));
}
return (lines);
}
}
}
@@ -0,0 +1,180 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.processes;
import java.sql.SQLException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.service.WebPerfService;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
/*******************************************************************************
** "Retry" on lab runs: runs that failed, hit a Lighthouse runtime error or
** were skipped by the run cap are queued again as new runs. The original
** rows are kept unchanged, so the history shows what happened.
*******************************************************************************/
public final class RetryRunsSteps
{
/** Statuses that can be retried. */
public static final Set<String> RETRYABLE = Set.of("FAILED", "RUNTIME_ERROR", "SKIPPED_QUOTA");
/*******************************************************************************
** Steps only.
*******************************************************************************/
private RetryRunsSteps()
{
}
/*******************************************************************************
** Review: which runs can be retried.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine retry = Lines.line(Status.OK, "run will be queued again", "runs will be queued again",
"run was queued again", "runs were queued again");
private final ProcessSummaryLine skip = Lines.line(Status.INFO, "run did not fail and will not be retried",
"runs did not fail and will not be retried", "run had not failed (not retried)", "runs had not failed (not retried)");
/*******************************************************************************
** Sort one page of runs.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord record : input.getRecords())
{
if(RETRYABLE.contains(record.getValueString("status")) && record.getValue("targetId") != null)
{
retry.incrementCountAndAddPrimaryKey(record.getValue("id"));
output.addRecord(record);
}
else
{
skip.incrementCountAndAddPrimaryKey(record.getValue("id"));
}
}
}
/*******************************************************************************
** Review lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
Lines.addAll(lines, retry, skip);
return (lines);
}
/*******************************************************************************
** The not-retried line, repeated on the Result screen.
*******************************************************************************/
ProcessSummaryLine skipLine()
{
return (skip);
}
}
/*******************************************************************************
** Execute: queue new runs for the retryable ones.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine queued = Lines.line(Status.OK, "run will be queued", "runs will be queued", "run was queued again",
"runs were queued again");
private final ProcessSummaryLine merged = Lines.line(Status.INFO, "run will merge", "runs will merge",
"run was already waiting for that page (merged)", "runs were already waiting for those pages (merged)");
private final ProcessSummaryLine capped = Lines.line(Status.WARNING, "run will be skipped", "runs will be skipped",
"run was over today's run cap (recorded as skipped)", "runs were over today's run cap (recorded as skipped)");
/*******************************************************************************
** Queue one page.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
WebPerfService service = WebPerfRuntime.forInput(input).service();
List<WebPerfService.RunRequest> requests = new ArrayList<>();
String batch = java.util.UUID.randomUUID().toString();
for(QRecord record : input.getRecords())
{
requests.add(new WebPerfService.RunRequest(record.getValueLong("targetId"), record.getValueString("url"), record.getValueString("strategy"),
"MANUAL", "retry of run " + record.getValue("id"), batch, Instant.now()));
}
try
{
WebPerfService.QueuePlan plan = service.enqueue(requests, Instant.now());
queued.incrementCount(plan.queue().size());
merged.incrementCount(plan.merged().size());
capped.incrementCount(plan.overQuota().size());
}
catch(SQLException e)
{
throw (new QException("Could not queue the runs", e));
}
}
/*******************************************************************************
** Result lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
Lines.addAll(lines, queued, merged, capped);
if(getTransformStep() instanceof Transform transform)
{
Lines.addAll(lines, transform.skipLine());
}
return (lines);
}
}
}
@@ -0,0 +1,196 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.processes;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.service.WebPerfService;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The hidden scheduled processes' steps (design §6.2, D6): the queue worker,
** the daily KEY-page tests, the weekly full audit, the daily sitemap sync and
** the daily Google field data refresh.
*******************************************************************************/
public final class ScheduledSteps
{
private static final QLogger LOG = QLogger.getLogger(ScheduledSteps.class);
/*******************************************************************************
** Steps only.
*******************************************************************************/
private ScheduledSteps()
{
}
/*******************************************************************************
** Every minute: run due queued tests.
*******************************************************************************/
public static class RunQueued implements BackendStep
{
/*******************************************************************************
** Run.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
try
{
WebPerfRuntime.forInput(input).service().runDue(Instant.now());
}
catch(Exception e)
{
throw (new QException("Running queued page speed tests failed", e));
}
}
}
/*******************************************************************************
** Daily: queue the KEY pages on every strategy.
*******************************************************************************/
public static class KeyTests implements BackendStep
{
/*******************************************************************************
** Run.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
queue(input, true, "daily key pages");
}
}
/*******************************************************************************
** Weekly: queue every enabled page on every strategy.
*******************************************************************************/
public static class SiteAudit implements BackendStep
{
/*******************************************************************************
** Run.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
queue(input, false, "weekly site audit");
}
}
/*******************************************************************************
** Daily: sync tested pages from the URL source (when one is configured).
*******************************************************************************/
public static class Sync implements BackendStep
{
/*******************************************************************************
** Run.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
WebPerfRuntime runtime = WebPerfRuntime.forInput(input);
if(runtime.getConfig().getUrlSource() == null)
{
return;
}
try
{
WebPerfService service = runtime.service();
WebPerfService.SyncPlan plan = service.planSync(runtime.getConfig().getUrlSource().urls());
service.applySync(plan, Instant.now());
LOG.info("Web performance sitemap sync", logPair("added", plan.newUrls().size()), logPair("removed", plan.removedIds().size()));
}
catch(Exception e)
{
throw (new QException("Sitemap sync failed: " + e.getMessage(), e));
}
}
}
/*******************************************************************************
** Daily: refresh Google field data (with a key).
*******************************************************************************/
public static class FieldData implements BackendStep
{
/*******************************************************************************
** Run.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
WebPerfRuntime runtime = WebPerfRuntime.forInput(input);
if(runtime.cruxClient() == null)
{
return;
}
try
{
WebPerfService service = runtime.service();
List<String> urls = service.enabledTargets(false).stream().map(row -> (String) row.get("url")).toList();
Map<String, Integer> counts = service.refreshFieldData(Map.of("ORIGIN", List.of(service.origin()), "URL", urls), Instant.now());
LOG.info("Web performance field data refreshed", logPair("counts", counts));
}
catch(Exception e)
{
throw (new QException("Google field data refresh failed: " + e.getMessage(), e));
}
}
}
/*******************************************************************************
** Queue enabled pages (KEY only or all) on every strategy.
*******************************************************************************/
static void queue(RunBackendStepInput input, boolean keyOnly, String reason) throws QException
{
try
{
WebPerfService service = WebPerfRuntime.forInput(input).service();
WebPerfService.QueuePlan plan = service.enqueue(service.requestsFor(service.enabledTargets(keyOnly), "SCHEDULE", reason, Instant.now()), Instant.now());
LOG.info("Web performance tests queued", logPair("reason", reason), logPair("queued", plan.queue().size()), logPair("merged", plan.merged().size()),
logPair("overCap", plan.overQuota().size()));
}
catch(java.sql.SQLException e)
{
throw (new QException("Queueing " + reason + " failed", e));
}
}
}
@@ -0,0 +1,230 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.processes;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.service.WebPerfService;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
/*******************************************************************************
** "Sync pages from sitemap" on Tested Pages: the URL source (the sitemap by
** default) is read once and compared with the tested pages. Review counts
** pages to add, pages still listed, and pages no longer listed (kept, with
** their history, and marked "not in sitemap"). Nothing is ever deleted.
*******************************************************************************/
public final class SyncPagesSteps
{
static final String URLS = "webPerformanceUrls";
/*******************************************************************************
** Steps only.
*******************************************************************************/
private SyncPagesSteps()
{
}
/*******************************************************************************
** The URLs of a sync record.
*******************************************************************************/
static List<String> urls(QRecord record)
{
String joined = record.getValueString(URLS);
return (joined == null || joined.isEmpty() ? List.of() : Arrays.asList(joined.split("\n")));
}
/*******************************************************************************
** Extract: one record holding every listed URL.
*******************************************************************************/
public static class Extract extends AbstractExtractStep
{
/*******************************************************************************
** Read the URL source.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
WebPerfRuntime runtime = WebPerfRuntime.forInput(input);
if(runtime.getConfig().getUrlSource() == null)
{
throw (new QUserFacingException("No URL source (sitemap) is configured for web performance"));
}
List<String> urls;
try
{
urls = runtime.getConfig().getUrlSource().urls();
}
catch(Exception e)
{
throw (new QUserFacingException("The sitemap could not be read: " + e.getMessage()));
}
getRecordPipe().addRecord(new QRecord().withValue("id", 1).withValue("source", "Sitemap").withValue("urlCount", urls.size())
.withValue(URLS, String.join("\n", urls)));
}
}
/*******************************************************************************
** Review: compare with the tested pages. Never writes.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine added = Lines.line(Status.OK, "new page will be added", "new pages will be added",
"new page was added", "new pages were added");
private final ProcessSummaryLine seen = Lines.line(Status.INFO, "page is still in the sitemap (no change)", "pages are still in the sitemap (no change)",
"page was still in the sitemap", "pages were still in the sitemap");
private final ProcessSummaryLine removed = Lines.line(Status.WARNING, "page is no longer in the sitemap and will be marked so (kept, with its history)",
"pages are no longer in the sitemap and will be marked so (kept, with their history)", "page was marked not in the sitemap (kept)",
"pages were marked not in the sitemap (kept)");
/*******************************************************************************
** Plan the sync.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
WebPerfService service = WebPerfRuntime.forInput(input).service();
for(QRecord record : input.getRecords())
{
try
{
WebPerfService.SyncPlan plan = service.planSync(urls(record));
added.incrementCount(plan.newUrls().size());
seen.incrementCount(plan.seenIds().size());
removed.incrementCount(plan.removedIds().size());
plan.removedIds().forEach(id -> addKey(removed, id));
}
catch(java.sql.SQLException e)
{
throw (new QException("Could not read the tested pages", e));
}
output.addRecord(record);
}
}
/*******************************************************************************
** Review lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
Lines.addAll(lines, added, seen, removed);
return (lines);
}
}
/*******************************************************************************
** Execute: apply the sync (planned again, so it is current).
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine added = Lines.line(Status.OK, "page will be added", "pages will be added", "new page was added",
"new pages were added");
private final ProcessSummaryLine seen = Lines.line(Status.INFO, "page will be kept", "pages will be kept", "page was still in the sitemap",
"pages were still in the sitemap");
private final ProcessSummaryLine removed = Lines.line(Status.WARNING, "page will be marked", "pages will be marked",
"page was marked not in the sitemap (kept)", "pages were marked not in the sitemap (kept)");
/*******************************************************************************
** Apply.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
WebPerfService service = WebPerfRuntime.forInput(input).service();
for(QRecord record : input.getRecords())
{
try
{
WebPerfService.SyncPlan plan = service.planSync(urls(record));
service.applySync(plan, Instant.now());
added.incrementCount(plan.newUrls().size());
seen.incrementCount(plan.seenIds().size());
removed.incrementCount(plan.removedIds().size());
plan.removedIds().forEach(id -> addKey(removed, id));
}
catch(java.sql.SQLException e)
{
throw (new QException("Could not update the tested pages", e));
}
}
}
/*******************************************************************************
** Result lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
Lines.addAll(lines, added, seen, removed);
return (lines);
}
}
/*******************************************************************************
** Link a page to a line (the count is kept separately).
*******************************************************************************/
static void addKey(ProcessSummaryLine line, Long id)
{
if(line.getPrimaryKeys() == null)
{
line.setPrimaryKeys(new ArrayList<>());
}
line.getPrimaryKeys().add(id);
}
}
@@ -0,0 +1,235 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.processes;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.webperf.WebPerfFeature;
import com.kingsrook.qbits.webperf.WebPerfQBitConfig;
import com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.WebPerfSchema;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** The QBit's processes (design §6.1). None is in any app's nav:
**
** - record and table actions, each a standard streamed ETL process (record
** input, Review summary lines, Result summary lines):
** "Test page speed" (tested pages, and every configured content table),
** "Run site audit" (tested pages), "Sync pages from sitemap" (tested
** pages), "Retry" (lab runs), "Refresh Google field data" (field data);
** - hidden scheduled processes: the queue worker (every minute), the daily
** KEY-page tests, the weekly full audit, the daily sitemap sync and the
** daily CrUX refresh. They exist without a scheduler and run only with one.
**
** Permissions: actions that queue tests need "&lt;base&gt;.test.hasAccess",
** sync and field data need "&lt;base&gt;.admin.hasAccess" (base "webPerformance").
*******************************************************************************/
public final class WebPerfProcesses
{
public static final String TEST_PAGE_SPEED = "TestPageSpeed";
public static final String RUN_SITE_AUDIT = "RunSiteAudit";
public static final String SYNC_TARGETS = "SyncPages";
public static final String RETRY_RUNS = "RetryRuns";
public static final String REFRESH_FIELD_DATA = "RefreshFieldData";
public static final String RUN_QUEUED_TESTS = "RunQueuedTests";
public static final String SCHEDULE_KEY_TESTS = "ScheduleKeyTests";
public static final String SCHEDULE_SITE_AUDIT = "ScheduleSiteAudit";
public static final String SCHEDULED_SYNC = "ScheduledSync";
public static final String SCHEDULED_FIELD = "ScheduledFieldData";
/** Process value naming the trigger recorded on queued runs. */
public static final String TRIGGER_FIELD = "webPerformanceTrigger";
public static final int WORKER_REPEAT_SECONDS = 60;
public static final String KEY_TESTS_CRON = "0 30 3 * * ?";
public static final String SITE_AUDIT_CRON = "0 0 4 ? * SUN";
public static final String SYNC_CRON = "0 45 2 * * ?";
public static final String FIELD_CRON = "0 0 6 * * ?";
/*******************************************************************************
** Processes only.
*******************************************************************************/
private WebPerfProcesses()
{
}
/*******************************************************************************
** Every process of an install.
**
** @param config the config
** @param qInstance the instance (for content table labels)
** @return the processes
*******************************************************************************/
public static List<QProcessMetaData> define(WebPerfQBitConfig config, QInstance qInstance)
{
List<QProcessMetaData> processes = new ArrayList<>();
String targets = config.applyPrefix(WebPerfSchema.TARGET);
if(config.isOn(WebPerfFeature.LAB))
{
processes.add(etl(config, TEST_PAGE_SPEED, "Test page speed", "speed", targets, targets, ExtractViaQueryStep.class, QueueTestsSteps.Transform.class,
QueueTestsSteps.Load.class, "MANUAL", "test", 1, "This is a preview of the pages that will be tested."));
for(String table : config.getContentTables())
{
processes.add(etl(config, TEST_PAGE_SPEED + capitalize(table), "Test page speed", "speed", table, targets, ExtractViaQueryStep.class,
QueueTestsSteps.Transform.class, QueueTestsSteps.Load.class, "MANUAL", "test", 1, "This is a preview of the records whose pages will be tested."));
}
processes.add(etl(config, RUN_SITE_AUDIT, "Run site audit", "fact_check", targets, targets, ExtractViaQueryStep.class, QueueTestsSteps.Transform.class,
QueueTestsSteps.Load.class, "SITE_AUDIT", "test", null, "This is a preview of the pages the audit will test (enabled pages only)."));
processes.add(etl(config, RETRY_RUNS, "Retry", "replay", config.applyPrefix(WebPerfSchema.LAB_RUN), config.applyPrefix(WebPerfSchema.LAB_RUN),
ExtractViaQueryStep.class, RetryRunsSteps.Transform.class, RetryRunsSteps.Load.class, "MANUAL", "test", 1,
"This is a preview of the runs to retry."));
}
processes.add(etl(config, SYNC_TARGETS, "Sync pages from sitemap", "sync", targets, targets, SyncPagesSteps.Extract.class, SyncPagesSteps.Transform.class,
SyncPagesSteps.Load.class, null, "admin", null, "This is the list of pages the URL source returned."));
if(config.isOn(WebPerfFeature.FIELD_CRUX))
{
String field = config.applyPrefix(WebPerfSchema.FIELD_SNAPSHOT);
processes.add(etl(config, REFRESH_FIELD_DATA, "Refresh Google field data", "public", field, field, FieldDataSteps.Extract.class,
FieldDataSteps.Transform.class, FieldDataSteps.Load.class, null, "admin", null, "These are the origin and pages Google field data will be checked for."));
}
if(config.isOn(WebPerfFeature.LAB))
{
processes.add(scheduled(config, RUN_QUEUED_TESTS, "Run queued page speed tests", ScheduledSteps.RunQueued.class,
new QScheduleMetaData().withRepeatSeconds(WORKER_REPEAT_SECONDS).withInitialDelaySeconds(WORKER_REPEAT_SECONDS)));
processes.add(scheduled(config, SCHEDULE_KEY_TESTS, "Test key pages", ScheduledSteps.KeyTests.class,
new QScheduleMetaData().withCronExpression(KEY_TESTS_CRON).withCronTimeZoneId(config.getTimeZone())));
processes.add(scheduled(config, SCHEDULE_SITE_AUDIT, "Weekly site audit", ScheduledSteps.SiteAudit.class,
new QScheduleMetaData().withCronExpression(SITE_AUDIT_CRON).withCronTimeZoneId(config.getTimeZone())));
}
processes.add(scheduled(config, SCHEDULED_SYNC, "Sync pages from sitemap (scheduled)", ScheduledSteps.Sync.class,
new QScheduleMetaData().withCronExpression(SYNC_CRON).withCronTimeZoneId(config.getTimeZone())));
if(config.isOn(WebPerfFeature.FIELD_CRUX))
{
processes.add(scheduled(config, SCHEDULED_FIELD, "Refresh Google field data (scheduled)", ScheduledSteps.FieldData.class,
new QScheduleMetaData().withCronExpression(FIELD_CRON).withCronTimeZoneId("UTC")));
}
return (processes);
}
/*******************************************************************************
** A streamed ETL action.
*******************************************************************************/
private static QProcessMetaData etl(WebPerfQBitConfig config, String name, String label, String icon, String sourceTable, String destinationTable,
Class<? extends AbstractExtractStep> extract, Class<? extends AbstractTransformStep> transform,
Class<? extends AbstractLoadStep> load, String trigger, String permission, Integer minRecords, String preview)
{
QProcessMetaData process = StreamedETLWithFrontendProcess.processMetaDataBuilder()
.withName(config.qualify(name))
.withLabel(label)
.withIcon(new QIcon(icon))
.withTableName(sourceTable)
.withSourceTable(sourceTable)
.withDestinationTable(destinationTable)
.withExtractStepClass(extract)
.withTransformStepClass(transform)
.withLoadStepClass(load)
.withSupportsFullValidation(true)
.withDoFullValidation(true)
.withPreviewMessage(preview)
.withMinInputRecords(minRecords)
.withFields(List.of(
new QFieldMetaData(WebPerfRuntime.INSTANCE_FIELD, QFieldType.STRING).withDefaultValue(config.getTableNamePrefix()),
new QFieldMetaData(TRIGGER_FIELD, QFieldType.STRING).withDefaultValue(trigger)))
.getProcessMetaData();
process.setPermissionRules(rules(config, permission));
return (process);
}
/*******************************************************************************
** A hidden process with one backend step and, with a scheduler, a schedule.
*******************************************************************************/
private static QProcessMetaData scheduled(WebPerfQBitConfig config, String name, String label, Class<?> step, QScheduleMetaData schedule)
{
QProcessMetaData process = new QProcessMetaData()
.withName(config.qualify(name))
.withLabel(label)
.withIsHidden(true)
.withIcon(new QIcon("schedule"))
.withPermissionRules(rules(config, "admin"))
.withStep(new QBackendStepMetaData()
.withName("run")
.withCode(new QCodeReference(step))
.withInputData(new QFunctionInputMetaData()
.withField(new QFieldMetaData(WebPerfRuntime.INSTANCE_FIELD, QFieldType.STRING).withDefaultValue(config.getTableNamePrefix()))));
if(config.getSchedulerName() != null)
{
process.withSchedule(schedule.withSchedulerName(config.getSchedulerName()));
}
return (process);
}
/*******************************************************************************
** HAS_ACCESS permission "&lt;base&gt;.&lt;suffix&gt;.hasAccess".
*******************************************************************************/
private static QPermissionRules rules(WebPerfQBitConfig config, String suffix)
{
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(config.getPermissionBaseName() + "." + suffix));
}
/*******************************************************************************
** "blog_post" to "BlogPost".
*******************************************************************************/
static String capitalize(String table)
{
StringBuilder result = new StringBuilder();
for(String part : table.split("[_\\-]"))
{
if(!part.isEmpty())
{
result.append(part.substring(0, 1).toUpperCase(Locale.ROOT)).append(part.substring(1));
}
}
return (result.toString());
}
}
@@ -0,0 +1,833 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.zip.GZIPOutputStream;
import com.kingsrook.qbits.webperf.WebPerfQBitConfig;
import com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.WebPerfSchema;
import com.kingsrook.qbits.webperf.field.CruxClient;
import com.kingsrook.qbits.webperf.lab.LabResultParser;
import com.kingsrook.qbits.webperf.spi.ContentResolver;
import com.kingsrook.qbits.webperf.spi.LabRunner;
import com.kingsrook.qbits.webperf.store.WebPerfStore;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The QBit's operations (design §6): syncing tested pages from the URL
** source, queueing lab runs (merging duplicates, honoring the daily cap),
** running due runs through the lab runner (claim, retry with backoff, store
** scores, findings, the full report and the page's latest results), and
** refreshing Google field data (CrUX, NO_DATA kept as a real result).
**
** Claims are a conditional update (QUEUED to RUNNING for one id), so any
** number of backend replicas never run the same test twice.
*******************************************************************************/
public class WebPerfService
{
private static final QLogger LOG = QLogger.getLogger(WebPerfService.class);
public static final int MAX_ATTEMPTS = 3;
public static final Duration[] BACKOFF = { Duration.ofMinutes(1), Duration.ofMinutes(5), Duration.ofMinutes(15) };
/** Statuses that still hold a place in the queue (a new request merges into them). */
public static final List<String> OPEN_STATUSES = List.of("QUEUED", "RUNNING");
private final WebPerfRuntime runtime;
private final WebPerfStore store;
private final WebPerfQBitConfig config;
/*******************************************************************************
** A service over a runtime's store.
**
** @param runtime the runtime
** @param store the store
*******************************************************************************/
public WebPerfService(WebPerfRuntime runtime, WebPerfStore store)
{
this.runtime = runtime;
this.store = store;
this.config = runtime.getConfig();
}
/*******************************************************************************
** The site key of the configured site ("host:www.example.com").
**
** @return the key
*******************************************************************************/
public String siteKey()
{
return ("host:" + URI.create(config.getSiteBaseUrl()).getHost().toLowerCase(Locale.ROOT));
}
/////////////////////////////////////////////////////////////////////////////
// tested pages //
/////////////////////////////////////////////////////////////////////////////
/*******************************************************************************
** What a sync will do.
**
** @param newUrls URLs to add
** @param seenIds existing pages still listed
** @param removedIds pages no longer listed (kept, marked not in the sitemap)
** @param pathsByUrl each listed URL's path
*******************************************************************************/
public record SyncPlan(List<String> newUrls, List<Long> seenIds, List<Long> removedIds, Map<String, String> pathsByUrl)
{
}
/*******************************************************************************
** Plan a sync against the listed URLs (reads only).
**
** @param urls the URLs from the URL source
** @return the plan
** @throws SQLException on failure
*******************************************************************************/
public SyncPlan planSync(List<String> urls) throws SQLException
{
Map<String, Map<String, Object>> existing = new LinkedHashMap<>();
for(Map<String, Object> row : store.query("SELECT id, url, source, in_sitemap FROM {target} WHERE site_key = ?", siteKey()))
{
existing.put((String) row.get("url"), row);
}
List<String> newUrls = new ArrayList<>();
List<Long> seen = new ArrayList<>();
Map<String, String> paths = new LinkedHashMap<>();
Set<String> listed = new HashSet<>();
for(String url : urls)
{
if(!listed.add(url))
{
continue;
}
paths.put(url, pathOf(url));
Map<String, Object> row = existing.get(url);
if(row == null)
{
newUrls.add(url);
}
else
{
seen.add(WebPerfStore.asLong(row.get("id")));
}
}
List<Long> removed = new ArrayList<>();
for(Map.Entry<String, Map<String, Object>> entry : existing.entrySet())
{
Map<String, Object> row = entry.getValue();
if(!listed.contains(entry.getKey()) && "SITEMAP".equals(row.get("source")) && !Boolean.FALSE.equals(row.get("in_sitemap")))
{
removed.add(WebPerfStore.asLong(row.get("id")));
}
}
return (new SyncPlan(newUrls, seen, removed, paths));
}
/*******************************************************************************
** Apply a sync plan: add new pages (linked to their records, KEY when
** their path is a key path), refresh listed ones, mark unlisted ones as
** not in the sitemap. Nothing is deleted.
**
** @param plan the plan
** @param now the time
** @throws SQLException on failure
*******************************************************************************/
public void applySync(SyncPlan plan, Instant now) throws SQLException
{
ContentResolver resolver = config.getContentResolver();
for(String url : plan.newUrls())
{
String path = plan.pathsByUrl().get(url);
ContentResolver.ContentRef content = resolver == null ? null : resolver.resolve(path);
Map<String, Object> values = new LinkedHashMap<>();
values.put("site_key", siteKey());
values.put("url", url);
values.put("path", path);
values.put("source", "SITEMAP");
values.put("content_table", content == null ? null : content.tableName());
values.put("content_id", content == null ? null : content.id());
values.put("tier", isKeyPath(path) ? "KEY" : "STANDARD");
values.put("enabled", true);
values.put("in_sitemap", true);
values.put("last_seen_in_sitemap_at", now);
values.put("open_regression_count", 0);
store.insert(WebPerfSchema.TARGET, values);
}
for(Long id : plan.seenIds())
{
store.update("UPDATE {target} SET in_sitemap = ?, last_seen_in_sitemap_at = ?, modify_date = ? WHERE id = ?", true, now, now, id);
}
for(Long id : plan.removedIds())
{
store.update("UPDATE {target} SET in_sitemap = ?, modify_date = ? WHERE id = ?", false, now, id);
}
}
/*******************************************************************************
** Whether a path is one of the configured key paths (a trailing slash is
** ignored, except for the root).
*******************************************************************************/
boolean isKeyPath(String path)
{
String normal = normalizePath(path);
return (config.getKeyPaths().stream().anyMatch(p -> normalizePath(p).equals(normal)));
}
/*******************************************************************************
** A URL's path ("/" when empty).
*******************************************************************************/
static String pathOf(String url)
{
String path = URI.create(url).getPath();
return (path == null || path.isEmpty() ? "/" : path);
}
/*******************************************************************************
** A path without its trailing slash (the root stays "/").
*******************************************************************************/
static String normalizePath(String path)
{
if(path == null || path.isEmpty())
{
return ("/");
}
return (path.length() > 1 && path.endsWith("/") ? path.substring(0, path.length() - 1) : path);
}
/////////////////////////////////////////////////////////////////////////////
// queue //
/////////////////////////////////////////////////////////////////////////////
/*******************************************************************************
** One run to queue.
**
** @param targetId the page
** @param url its URL
** @param strategy MOBILE or DESKTOP
** @param trigger SCHEDULE, PUBLISH, DEPLOY, MANUAL, SITE_AUDIT, CONFIRM or CHANGE_COMPARE
** @param triggerRef what caused it (may be null)
** @param batchId groups the runs of one action or schedule
** @param notBefore earliest start
*******************************************************************************/
public record RunRequest(Long targetId, String url, String strategy, String trigger, String triggerRef, String batchId, Instant notBefore)
{
}
/*******************************************************************************
** How a set of requests would be or was queued.
**
** @param queue requests queued
** @param merged requests already queued or running for that page and strategy
** @param overQuota requests past today's run cap (recorded as SKIPPED_QUOTA)
** @param runsToday runs already counted today
*******************************************************************************/
public record QueuePlan(List<RunRequest> queue, List<RunRequest> merged, List<RunRequest> overQuota, int runsToday)
{
}
/*******************************************************************************
** Plan queueing (reads only): merge into open runs, then apply the cap.
**
** @param requests the requests
** @param now the time
** @return the plan
** @throws SQLException on failure
*******************************************************************************/
public QueuePlan planQueue(List<RunRequest> requests, Instant now) throws SQLException
{
Set<String> open = new HashSet<>();
for(Map<String, Object> row : store.query("SELECT target_id, strategy FROM {lab_run} WHERE status IN ('QUEUED', 'RUNNING')"))
{
open.add(row.get("target_id") + "|" + row.get("strategy"));
}
int runsToday = runsSince(startOfToday(now));
List<RunRequest> queue = new ArrayList<>();
List<RunRequest> merged = new ArrayList<>();
List<RunRequest> overQuota = new ArrayList<>();
for(RunRequest request : requests)
{
String key = request.targetId() + "|" + request.strategy();
if(!open.add(key))
{
merged.add(request);
}
else if(runsToday + queue.size() >= config.getMaxRunsPerDay())
{
overQuota.add(request);
}
else
{
queue.add(request);
}
}
return (new QueuePlan(queue, merged, overQuota, runsToday));
}
/*******************************************************************************
** Queue requests: plan again at write time (so concurrent actions still
** merge), insert QUEUED runs, and record over-cap ones as SKIPPED_QUOTA.
**
** @param requests the requests
** @param now the time
** @return the plan that was applied
** @throws SQLException on failure
*******************************************************************************/
public QueuePlan enqueue(List<RunRequest> requests, Instant now) throws SQLException
{
QueuePlan plan = planQueue(requests, now);
for(RunRequest request : plan.queue())
{
store.insert(WebPerfSchema.LAB_RUN, runValues(request, "QUEUED", now, null));
}
for(RunRequest request : plan.overQuota())
{
store.insert(WebPerfSchema.LAB_RUN, runValues(request, "SKIPPED_QUOTA", now,
"Today's run cap (" + config.getMaxRunsPerDay() + ") was reached; use Retry to queue it again"));
}
return (plan);
}
/*******************************************************************************
** The column values of a new run.
*******************************************************************************/
private Map<String, Object> runValues(RunRequest request, String status, Instant now, String error)
{
Map<String, Object> values = new LinkedHashMap<>();
values.put("target_id", request.targetId());
values.put("url", request.url());
values.put("strategy", request.strategy());
values.put("runner", runtime.labRunner() == null ? null : runtime.labRunner().name());
values.put("trigger_type", request.trigger());
values.put("trigger_ref", request.triggerRef());
values.put("batch_id", request.batchId());
values.put("status", status);
values.put("not_before", request.notBefore() == null ? now : request.notBefore());
values.put("queued_at", now);
values.put("attempts", 0);
values.put("error_message", error);
return (values);
}
/*******************************************************************************
** Requests for pages: one per configured strategy.
**
** @param targets rows with id and url
** @param trigger the trigger
** @param triggerRef what caused it
** @param notBefore earliest start
** @return the requests
*******************************************************************************/
public List<RunRequest> requestsFor(List<Map<String, Object>> targets, String trigger, String triggerRef, Instant notBefore)
{
String batch = UUID.randomUUID().toString();
List<RunRequest> requests = new ArrayList<>();
for(Map<String, Object> target : targets)
{
for(String strategy : config.getStrategies())
{
requests.add(new RunRequest(WebPerfStore.asLong(target.get("id")), (String) target.get("url"), strategy, trigger, triggerRef, batch, notBefore));
}
}
return (requests);
}
/*******************************************************************************
** Enabled pages, optionally only the KEY tier.
**
** @param keyOnly only KEY pages
** @return rows with id and url
** @throws SQLException on failure
*******************************************************************************/
public List<Map<String, Object>> enabledTargets(boolean keyOnly) throws SQLException
{
return (store.query("SELECT id, url FROM {target} WHERE enabled = TRUE AND site_key = ?" + (keyOnly ? " AND tier = 'KEY'" : "") + " ORDER BY id",
siteKey()));
}
/*******************************************************************************
** Runs counted against today's cap (queued since the start of today,
** excluding cap skips).
*******************************************************************************/
int runsSince(Instant since) throws SQLException
{
return (WebPerfStore.asInt(store.query("SELECT COUNT(*) AS n FROM {lab_run} WHERE queued_at >= ? AND status <> 'SKIPPED_QUOTA'", since)
.get(0).get("n")));
}
/*******************************************************************************
** The start of today in the configured zone.
*******************************************************************************/
Instant startOfToday(Instant now)
{
ZoneId zone = ZoneId.of(config.getTimeZone());
return (now.atZone(zone).toLocalDate().atStartOfDay(zone).toInstant());
}
/////////////////////////////////////////////////////////////////////////////
// worker //
/////////////////////////////////////////////////////////////////////////////
/*******************************************************************************
** Run due queued tests: claim up to `concurrency` of them and run them in
** parallel. Without a lab runner (no Google API key) nothing is claimed;
** the runs wait, and the screens say why.
**
** @param now the time
** @return the runs finished (in any final or retry state)
** @throws Exception on a store failure
*******************************************************************************/
public int runDue(Instant now) throws Exception
{
LabRunner runner = runtime.labRunner();
if(runner == null)
{
LOG.info("Web performance: no lab runner (no Google API key); queued runs wait");
return (0);
}
List<Map<String, Object>> due = store.query("SELECT id FROM {lab_run} WHERE status = 'QUEUED' AND not_before <= ? ORDER BY not_before, id", now);
List<Long> claimed = new ArrayList<>();
for(Map<String, Object> row : due)
{
if(claimed.size() >= config.getConcurrency())
{
break;
}
Long id = WebPerfStore.asLong(row.get("id"));
if(store.update("UPDATE {lab_run} SET status = 'RUNNING', started_at = ?, modify_date = ? WHERE id = ? AND status = 'QUEUED'", now, now, id) == 1)
{
claimed.add(id);
}
}
if(claimed.isEmpty())
{
return (0);
}
ExecutorService executor = Executors.newFixedThreadPool(claimed.size());
try
{
List<Future<?>> futures = new ArrayList<>();
for(int i = 0; i < claimed.size(); i++)
{
long id = claimed.get(i);
long delay = (long) i * config.getMinSpacingMillis();
futures.add(executor.submit(() ->
{
if(delay > 0)
{
Thread.sleep(delay);
}
runOne(runner, id);
return (null);
}));
}
for(Future<?> future : futures)
{
future.get();
}
}
finally
{
executor.shutdownNow();
}
return (claimed.size());
}
/*******************************************************************************
** Run one claimed test and record what happened.
**
** @param runner the lab runner
** @param runId the claimed run
** @throws SQLException on a store failure
*******************************************************************************/
void runOne(LabRunner runner, long runId) throws SQLException
{
Map<String, Object> run = store.query("SELECT id, target_id, url, strategy, attempts FROM {lab_run} WHERE id = ?", runId).get(0);
int attempts = WebPerfStore.asInt(run.get("attempts")) + 1;
LabRunner.LabResponse response;
try
{
response = runner.run(new LabRunner.LabRequest((String) run.get("url"), (String) run.get("strategy"), Set.copyOf(config.getCategories())));
}
catch(Exception e)
{
retryOrFail(runId, attempts, null, e.getMessage());
return;
}
int status = response.httpStatus();
if(status == 429 || status >= 500)
{
retryOrFail(runId, attempts, status, apiError(response.rawJson(), status));
return;
}
if(status != 200)
{
finish(runId, attempts, "FAILED", status, apiError(response.rawJson(), status), response.completedAt());
return;
}
LabResultParser.ParsedLab parsed;
try
{
parsed = LabResultParser.parse(response.rawJson());
}
catch(RuntimeException e)
{
finish(runId, attempts, "FAILED", status, "The response could not be read: " + e.getMessage(), response.completedAt());
return;
}
store(runId, run, attempts, parsed, response);
}
/*******************************************************************************
** Store a successful response: the run's scores and metrics, its findings,
** the compressed report, and the page's latest results.
*******************************************************************************/
private void store(long runId, Map<String, Object> run, int attempts, LabResultParser.ParsedLab parsed, LabRunner.LabResponse response) throws SQLException
{
Instant completed = response.completedAt();
String status = parsed.runtimeError() == null ? "SUCCEEDED" : "RUNTIME_ERROR";
store.update("""
UPDATE {lab_run} SET status = ?, completed_at = ?, attempts = ?, http_status = ?, error_message = ?, final_url = ?, lighthouse_version = ?,
chrome_user_agent = ?, benchmark_index = ?, score_performance = ?, score_accessibility = ?, score_best_practices = ?, score_seo = ?,
fcp_ms = ?, lcp_ms = ?, tbt_ms = ?, cls = ?, speed_index_ms = ?, lcp_element = ?, run_warnings = ?, modify_date = ?
WHERE id = ?""",
status, completed, attempts, response.httpStatus(), parsed.runtimeError(), parsed.finalUrl(), parsed.lighthouseVersion(),
parsed.chromeUserAgent(), parsed.benchmarkIndex(), parsed.score("PERFORMANCE"), parsed.score("ACCESSIBILITY"), parsed.score("BEST_PRACTICES"),
parsed.score("SEO"), parsed.fcpMs(), parsed.lcpMs(), parsed.tbtMs(), parsed.cls(), parsed.speedIndexMs(), parsed.lcpElement(), parsed.runWarnings(),
completed, runId);
Long targetId = WebPerfStore.asLong(run.get("target_id"));
for(LabResultParser.Finding finding : parsed.findings())
{
Map<String, Object> values = new LinkedHashMap<>();
values.put("run_id", runId);
values.put("target_id", targetId);
values.put("strategy", run.get("strategy"));
values.put("category", finding.category());
values.put("audit_id", finding.auditId());
values.put("lighthouse_version", parsed.lighthouseVersion());
values.put("kind", finding.kind());
values.put("title", finding.title());
values.put("score", finding.score());
values.put("display_value", finding.displayValue());
values.put("savings_ms", finding.savingsMs());
values.put("savings_bytes", finding.savingsBytes());
values.put("metric_savings", finding.metricSavings());
values.put("top_items", finding.topItems());
store.insert(WebPerfSchema.LAB_FINDING, values);
}
byte[] raw = response.rawJson().getBytes(StandardCharsets.UTF_8);
Map<String, Object> report = new LinkedHashMap<>();
report.put("run_id", runId);
report.put("content", gzip(raw));
report.put("bytes", raw.length);
report.put("sha256", sha256(raw));
store.insert(WebPerfSchema.LAB_REPORT, report);
if("SUCCEEDED".equals(status) && targetId != null)
{
String column = "MOBILE".equals(run.get("strategy")) ? "latest_mobile_performance" : "latest_desktop_performance";
store.update("UPDATE {target} SET " + column + " = ?, latest_accessibility = ?, latest_tested_at = ?, modify_date = ? WHERE id = ?",
parsed.score("PERFORMANCE"), parsed.score("ACCESSIBILITY"), completed, completed, targetId);
}
LOG.info("Web performance run finished", logPair("runId", runId), logPair("status", status), logPair("performance", parsed.score("PERFORMANCE")));
}
/*******************************************************************************
** Queue a run again with backoff, or fail it after MAX_ATTEMPTS.
*******************************************************************************/
private void retryOrFail(long runId, int attempts, Integer httpStatus, String error) throws SQLException
{
Instant now = Instant.now();
if(attempts >= MAX_ATTEMPTS)
{
finish(runId, attempts, "FAILED", httpStatus, error, now);
return;
}
store.update("UPDATE {lab_run} SET status = 'QUEUED', attempts = ?, http_status = ?, error_message = ?, not_before = ?, modify_date = ? WHERE id = ?",
attempts, httpStatus, truncate(error), now.plus(BACKOFF[attempts - 1]), now, runId);
}
/*******************************************************************************
** Record a final status without results.
*******************************************************************************/
private void finish(long runId, int attempts, String status, Integer httpStatus, String error, Instant completed) throws SQLException
{
store.update("UPDATE {lab_run} SET status = ?, attempts = ?, http_status = ?, error_message = ?, completed_at = ?, modify_date = ? WHERE id = ?",
status, attempts, httpStatus, truncate(error), completed, completed, runId);
}
/*******************************************************************************
** The API's own error message, or the status.
*******************************************************************************/
static String apiError(String body, int status)
{
try
{
JSONObject error = new JSONObject(body).optJSONObject("error");
if(error != null && error.has("message"))
{
return ("HTTP " + status + ": " + error.getString("message"));
}
}
catch(RuntimeException e)
{
// not JSON
}
return ("HTTP " + status);
}
/////////////////////////////////////////////////////////////////////////////
// Google field data //
/////////////////////////////////////////////////////////////////////////////
/*******************************************************************************
** Query CrUX for keys and store the results. A NO_DATA result updates the
** existing NO_DATA row's last check (one row, not one a day); data is
** stored once per collection period.
**
** @param keys scope ("URL" or "ORIGIN") to the keys of that scope
** @param now the time
** @return result status to count
** @throws Exception on a store or transport failure
*******************************************************************************/
public Map<String, Integer> refreshFieldData(Map<String, List<String>> keys, Instant now) throws Exception
{
CruxClient client = runtime.cruxClient();
if(client == null)
{
throw (new IllegalStateException("No Google API key is configured, so Google field data cannot be checked"));
}
Map<String, Integer> counts = new LinkedHashMap<>();
boolean first = true;
for(Map.Entry<String, List<String>> scope : keys.entrySet())
{
for(String key : scope.getValue())
{
for(String formFactor : CruxClient.FORM_FACTORS)
{
//////////////////////////////////////////////////////////////
// pace the queries under the API's 150 a minute per project //
//////////////////////////////////////////////////////////////
if(!first && config.getCruxSpacingMillis() > 0)
{
Thread.sleep(config.getCruxSpacingMillis());
}
first = false;
CruxClient.CruxResult result = client.query(scope.getKey(), key, formFactor);
storeField(result, now);
counts.merge(result.status(), 1, Integer::sum);
}
}
}
return (counts);
}
/*******************************************************************************
** Upsert one CrUX result.
*******************************************************************************/
void storeField(CruxClient.CruxResult result, Instant now) throws SQLException
{
List<Map<String, Object>> existing = "NO_DATA".equals(result.status())
? store.query("SELECT id FROM {field_snapshot} WHERE source = 'CRUX_API' AND scope = ? AND scope_key = ? AND form_factor = ? AND status = 'NO_DATA'",
result.scope(), result.key(), result.formFactor())
: store.query("SELECT id FROM {field_snapshot} WHERE source = 'CRUX_API' AND scope = ? AND scope_key = ? AND form_factor = ? AND period_end = ?",
result.scope(), result.key(), result.formFactor(), result.periodEnd());
if(!existing.isEmpty())
{
store.update("UPDATE {field_snapshot} SET last_checked_at = ?, modify_date = ? WHERE id = ?", now, now, existing.get(0).get("id"));
return;
}
Map<String, Object> values = new LinkedHashMap<>();
values.put("source", "CRUX_API");
values.put("scope", result.scope());
values.put("scope_key", result.key());
values.put("form_factor", result.formFactor());
values.put("status", result.status());
values.put("period_start", result.periodStart());
values.put("period_end", result.periodEnd());
values.put("p75_lcp_ms", result.p75LcpMs());
values.put("p75_inp_ms", result.p75InpMs());
values.put("p75_cls", result.p75Cls());
values.put("p75_fcp_ms", result.p75FcpMs());
values.put("p75_ttfb_ms", result.p75TtfbMs());
values.put("distributions", result.distributions());
values.put("last_checked_at", now);
store.insert(WebPerfSchema.FIELD_SNAPSHOT, values);
}
/*******************************************************************************
** The site's origin ("https://www.example.com").
**
** @return the origin
*******************************************************************************/
public String origin()
{
URI uri = URI.create(config.getSiteBaseUrl());
return (uri.getScheme() + "://" + uri.getAuthority());
}
/////////////////////////////////////////////////////////////////////////////
// helpers //
/////////////////////////////////////////////////////////////////////////////
/*******************************************************************************
** Gzip bytes.
*******************************************************************************/
static byte[] gzip(byte[] bytes)
{
try(ByteArrayOutputStream out = new ByteArrayOutputStream(); GZIPOutputStream gzip = new GZIPOutputStream(out))
{
gzip.write(bytes);
gzip.finish();
return (out.toByteArray());
}
catch(IOException e)
{
throw (new IllegalStateException("gzip failed", e));
}
}
/*******************************************************************************
** Hex SHA-256.
*******************************************************************************/
static String sha256(byte[] bytes)
{
try
{
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)));
}
catch(Exception e)
{
throw (new IllegalStateException("SHA-256 unavailable", e));
}
}
/*******************************************************************************
** Cut an error message to the column size.
*******************************************************************************/
private static String truncate(String text)
{
return (text == null || text.length() <= 2000 ? text : text.substring(0, 2000));
}
/*******************************************************************************
** Getter for store.
**
** @return the store
*******************************************************************************/
public WebPerfStore getStore()
{
return (store);
}
}
@@ -29,7 +29,7 @@ import java.util.List;
/*******************************************************************************
** Reports changes that may move performance and are owned by another QBit,
** e.g. CDN hostname stage changes from qbit-cdn (design §6.5). They become
** webperf_change_event rows, drawn as markers on trends.
** web_performance_change_event rows, drawn as markers on trends.
*******************************************************************************/
public interface ChangeEventSource extends Serializable
{
@@ -26,7 +26,7 @@ import java.io.Serializable;
/*******************************************************************************
** Reports the running build of one component (design §6.3, D7). The QBit
** polls its probes; a changed value is recorded as a webperf_change_event and
** polls its probes; a changed value is recorded as a web_performance_change_event and
** re-tests the KEY pages after a settle delay.
*******************************************************************************/
public interface DeployProbe extends Serializable
@@ -31,7 +31,7 @@ import java.util.Set;
** phase P1 once the Google API key exists, calls the PageSpeed Insights API
** v5; a self-hosted Lighthouse runner is an optional later adapter. A runner
** returns the complete raw response, so the QBit stores it unchanged
** (webperf_lab_report) and parses scores and findings from it.
** (web_performance_lab_report) and parses scores and findings from it.
*******************************************************************************/
public interface LabRunner extends Serializable
{
@@ -0,0 +1,209 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.store;
import java.sql.SQLException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/*******************************************************************************
** The read queries behind the QBit's widgets. Every figure is a stored run,
** finding or field snapshot; nothing is estimated.
*******************************************************************************/
public class WebPerfQueries
{
/** The latest successful run of each page and strategy. */
static final String LATEST_RUNS = """
SELECT r.id, r.target_id, r.url, r.strategy, r.completed_at, r.lighthouse_version, r.score_performance, r.score_accessibility,
r.score_best_practices, r.score_seo, r.lcp_ms, r.cls, r.tbt_ms, r.fcp_ms, t.tier, t.label, t.content_table, t.content_id
FROM {lab_run} r JOIN {target} t ON t.id = r.target_id
WHERE r.status = 'SUCCEEDED' AND t.enabled = TRUE
AND r.id = (SELECT MAX(r2.id) FROM {lab_run} r2 WHERE r2.target_id = r.target_id AND r2.strategy = r.strategy AND r2.status = 'SUCCEEDED')""";
private final WebPerfStore store;
/*******************************************************************************
** Queries over a store.
**
** @param store the store
*******************************************************************************/
public WebPerfQueries(WebPerfStore store)
{
this.store = store;
}
/*******************************************************************************
** The latest successful run of each enabled page and strategy.
**
** @return the rows
** @throws SQLException on failure
*******************************************************************************/
public List<Map<String, Object>> latestRuns() throws SQLException
{
return (store.query(LATEST_RUNS + " ORDER BY r.score_performance, r.id"));
}
/*******************************************************************************
** Successful runs since an instant (for trends), oldest first.
**
** @param since the instant
** @return completed_at, strategy, score_performance
** @throws SQLException on failure
*******************************************************************************/
public List<Map<String, Object>> successfulRunsSince(Instant since) throws SQLException
{
return (store.query("SELECT completed_at, strategy, score_performance FROM {lab_run} WHERE status = 'SUCCEEDED' AND completed_at >= ? ORDER BY completed_at",
since));
}
/*******************************************************************************
** The findings of the latest successful runs, grouped by audit: title,
** pages affected, total estimated savings.
**
** @param limit rows
** @return audit_id, title, pages, savings_ms
** @throws SQLException on failure
*******************************************************************************/
public List<Map<String, Object>> topOpportunities(int limit) throws SQLException
{
return (store.query("""
SELECT f.audit_id, MAX(f.title) AS title, COUNT(DISTINCT f.target_id) AS pages, SUM(COALESCE(f.savings_ms, 0)) AS savings_ms
FROM {lab_finding} f
WHERE f.run_id IN (SELECT r.id FROM {lab_run} r WHERE r.status = 'SUCCEEDED'
AND r.id = (SELECT MAX(r2.id) FROM {lab_run} r2 WHERE r2.target_id = r.target_id AND r2.strategy = r.strategy AND r2.status = 'SUCCEEDED'))
GROUP BY f.audit_id
ORDER BY COUNT(DISTINCT f.target_id) DESC, SUM(COALESCE(f.savings_ms, 0)) DESC, f.audit_id
FETCH FIRST %d ROWS ONLY""".formatted(limit)));
}
/*******************************************************************************
** The newest runs.
**
** @param limit rows
** @return the rows
** @throws SQLException on failure
*******************************************************************************/
public List<Map<String, Object>> recentRuns(int limit) throws SQLException
{
return (store.query("""
SELECT id, target_id, url, strategy, status, trigger_type, queued_at, completed_at, score_performance, score_accessibility, score_best_practices,
score_seo, error_message
FROM {lab_run} WHERE status <> 'SKIPPED_QUOTA' ORDER BY id DESC FETCH FIRST %d ROWS ONLY""".formatted(limit)));
}
/*******************************************************************************
** The findings of a run, largest estimated savings first.
**
** @param runId the run
** @param limit rows
** @return the rows
** @throws SQLException on failure
*******************************************************************************/
public List<Map<String, Object>> findingsOf(long runId, int limit) throws SQLException
{
return (store.query("""
SELECT id, title, kind, category, display_value, savings_ms, savings_bytes, top_items FROM {lab_finding} WHERE run_id = ?
ORDER BY CASE WHEN savings_ms IS NULL THEN 1 ELSE 0 END, savings_ms DESC, score, id FETCH FIRST %d ROWS ONLY""".formatted(limit), runId));
}
/*******************************************************************************
** The tested pages of a record (or the page itself).
**
** @param tableName the record's table
** @param id the record id
** @param direct true when the record is a tested page
** @return id, url, enabled
** @throws SQLException on failure
*******************************************************************************/
public List<Map<String, Object>> pagesOf(String tableName, long id, boolean direct) throws SQLException
{
return (direct
? store.query("SELECT id, url, enabled FROM {target} WHERE id = ?", id)
: store.query("SELECT id, url, enabled FROM {target} WHERE content_table = ? AND content_id = ? ORDER BY id", tableName, id));
}
/*******************************************************************************
** A page's runs on a strategy, newest first.
**
** @param targetId the page
** @param strategy the strategy
** @param limit rows
** @return the rows
** @throws SQLException on failure
*******************************************************************************/
public List<Map<String, Object>> runsOf(long targetId, String strategy, int limit) throws SQLException
{
return (store.query("""
SELECT id, status, queued_at, completed_at, score_performance, score_accessibility, score_best_practices, score_seo, fcp_ms, lcp_ms, tbt_ms, cls,
lcp_element, error_message, lighthouse_version
FROM {lab_run} WHERE target_id = ? AND strategy = ? AND status <> 'SKIPPED_QUOTA' ORDER BY id DESC FETCH FIRST %d ROWS ONLY""".formatted(limit),
targetId, strategy));
}
/*******************************************************************************
** The latest Google field data rows for a scope key (one per form factor).
**
** @param scope URL or ORIGIN
** @param key the URL or origin
** @return the rows, newest check first
** @throws SQLException on failure
*******************************************************************************/
public List<Map<String, Object>> fieldData(String scope, String key) throws SQLException
{
return (store.query("""
SELECT form_factor, status, period_end, p75_lcp_ms, p75_inp_ms, p75_cls, p75_fcp_ms, p75_ttfb_ms, last_checked_at
FROM {field_snapshot} WHERE scope = ? AND scope_key = ? ORDER BY last_checked_at DESC""", scope, key));
}
/*******************************************************************************
** How many runs wait in the queue.
**
** @return the count
** @throws SQLException on failure
*******************************************************************************/
public int queuedCount() throws SQLException
{
return (WebPerfStore.asInt(store.query("SELECT COUNT(*) AS n FROM {lab_run} WHERE status IN ('QUEUED', 'RUNNING')").get(0).get("n")));
}
}
@@ -0,0 +1,309 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.store;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.sql.Types;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.webperf.WebPerfQBitConfig;
import com.kingsrook.qbits.webperf.WebPerfSchema;
/*******************************************************************************
** JDBC access to the QBit's tables for its processes and widgets. The
** measurement tables are read-only in the UI (no insert or update
** capability), so the QBit writes them here, not through QQQ table actions.
** The SQL is portable between PostgreSQL and H2; table names come from the
** config's prefix through {placeholders} (never user input).
**
** Timestamps are bound as UTC wall-clock Timestamps, the way the QQQ RDBMS
** module binds Instant fields, so QQQ screens read them back unchanged.
*******************************************************************************/
public class WebPerfStore
{
/*******************************************************************************
** Opens connections to the QBit's backend.
*******************************************************************************/
@FunctionalInterface
public interface ConnectionSupplier
{
/*******************************************************************************
** A new connection (closed by the caller).
**
** @return the connection
** @throws SQLException on failure
*******************************************************************************/
Connection get() throws SQLException;
}
private final WebPerfQBitConfig config;
private final ConnectionSupplier connections;
/*******************************************************************************
** A store over a config's tables.
**
** @param config the config (table prefix)
** @param connections the connection supplier
*******************************************************************************/
public WebPerfStore(WebPerfQBitConfig config, ConnectionSupplier connections)
{
this.config = config;
this.connections = connections;
}
/*******************************************************************************
** Replace {table} placeholders with prefixed table names.
**
** @param template SQL with placeholders
** @return the SQL
*******************************************************************************/
public String sql(String template)
{
String sql = template;
for(String base : WebPerfSchema.TABLES)
{
sql = sql.replace("{" + base + "}", config.applyPrefix(base));
}
return (sql);
}
/*******************************************************************************
** Run an update; returns the row count.
**
** @param template SQL with placeholders
** @param params parameters
** @return rows changed
** @throws SQLException on failure
*******************************************************************************/
public int update(String template, Object... params) throws SQLException
{
try(Connection connection = connections.get(); PreparedStatement statement = connection.prepareStatement(sql(template)))
{
bind(statement, params);
return (statement.executeUpdate());
}
}
/*******************************************************************************
** Insert one row and return its generated id.
**
** @param table the table base name
** @param values column name to value, in order
** @return the id
** @throws SQLException on failure
*******************************************************************************/
public long insert(String table, Map<String, Object> values) throws SQLException
{
Map<String, Object> row = new LinkedHashMap<>(values);
Instant now = Instant.now();
row.putIfAbsent("create_date", now);
row.putIfAbsent("modify_date", now);
String columns = String.join(", ", row.keySet());
String placeholders = String.join(", ", java.util.Collections.nCopies(row.size(), "?"));
try(Connection connection = connections.get();
PreparedStatement statement = connection.prepareStatement("INSERT INTO " + config.applyPrefix(table) + " (" + columns + ") VALUES (" + placeholders + ")",
Statement.RETURN_GENERATED_KEYS))
{
bind(statement, row.values().toArray());
statement.executeUpdate();
try(ResultSet keys = statement.getGeneratedKeys())
{
keys.next();
return (keys.getLong(1));
}
}
}
/*******************************************************************************
** Run a query; rows as column-name (lower case) to value maps.
**
** @param template SQL with placeholders
** @param params parameters
** @return the rows
** @throws SQLException on failure
*******************************************************************************/
public List<Map<String, Object>> query(String template, Object... params) throws SQLException
{
try(Connection connection = connections.get(); PreparedStatement statement = connection.prepareStatement(sql(template)))
{
bind(statement, params);
List<Map<String, Object>> rows = new ArrayList<>();
try(ResultSet resultSet = statement.executeQuery())
{
ResultSetMetaData metaData = resultSet.getMetaData();
while(resultSet.next())
{
Map<String, Object> row = new LinkedHashMap<>();
for(int i = 1; i <= metaData.getColumnCount(); i++)
{
Object value = resultSet.getObject(i);
if(value instanceof Timestamp timestamp)
{
value = timestamp.toLocalDateTime().toInstant(ZoneOffset.UTC);
}
else if(value instanceof Date date)
{
value = date.toLocalDate();
}
row.put(metaData.getColumnLabel(i).toLowerCase(java.util.Locale.ROOT), value);
}
rows.add(row);
}
}
return (rows);
}
}
/*******************************************************************************
** Bind parameters: Instants as UTC wall-clock Timestamps, LocalDates as
** Dates, byte arrays as bytes, nulls as NULL.
**
** @param statement the statement
** @param params the parameters
** @throws SQLException on failure
*******************************************************************************/
static void bind(PreparedStatement statement, Object... params) throws SQLException
{
for(int i = 0; i < params.length; i++)
{
Object param = params[i];
if(param == null)
{
statement.setNull(i + 1, Types.NULL);
}
else if(param instanceof Instant instant)
{
statement.setTimestamp(i + 1, utc(instant));
}
else if(param instanceof LocalDate localDate)
{
statement.setDate(i + 1, Date.valueOf(localDate));
}
else if(param instanceof byte[] bytes)
{
statement.setBytes(i + 1, bytes);
}
else if(param instanceof Double d)
{
statement.setBigDecimal(i + 1, BigDecimal.valueOf(d));
}
else
{
statement.setObject(i + 1, param);
}
}
}
/*******************************************************************************
** An Instant as a UTC wall-clock Timestamp.
**
** @param instant the instant
** @return the timestamp
*******************************************************************************/
public static Timestamp utc(Instant instant)
{
return (Timestamp.valueOf(LocalDateTime.ofInstant(instant, ZoneOffset.UTC)));
}
/*******************************************************************************
** A value as a Long, or null.
**
** @param value the value
** @return the long or null
*******************************************************************************/
public static Long asLong(Object value)
{
return (value instanceof Number number ? number.longValue() : null);
}
/*******************************************************************************
** A value as an Integer, or null.
**
** @param value the value
** @return the integer or null
*******************************************************************************/
public static Integer asInt(Object value)
{
return (value instanceof Number number ? number.intValue() : null);
}
/*******************************************************************************
** A value as a Double, or null.
**
** @param value the value
** @return the double or null
*******************************************************************************/
public static Double asDouble(Object value)
{
return (value instanceof Number number ? number.doubleValue() : null);
}
/*******************************************************************************
** Getter for config.
**
** @return the config
*******************************************************************************/
public WebPerfQBitConfig getConfig()
{
return (config);
}
}
@@ -0,0 +1,273 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.widgets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
/*******************************************************************************
** HTML building blocks for the QBit's widgets. Next renders widget HTML
** through a sanitizer that keeps links and inline styles but drops style
** elements, so styling is inline and theme-neutral (inherited text color,
** translucent borders), and layouts are intrinsic: tiles wrap with
** flex-wrap, tables are full width with no declared column widths, long
** text wraps, nothing sets a fixed width or height or its own scrolling.
** Every dynamic value goes through escape().
*******************************************************************************/
public final class Html
{
public static final String GOOD = "#16a34a";
public static final String MEDIUM = "#d97706";
public static final String POOR = "#dc2626";
public static final String MUTED = "opacity:.75";
private static final String WRAP = "overflow-wrap:anywhere;min-width:0";
private static final String TILE_ROW = "display:flex;flex-wrap:wrap;gap:12px";
private static final String TILE = "flex:1 1 150px;min-width:0;border:1px solid rgba(127,127,127,.25);border-radius:10px;padding:10px 12px";
private static final String CELL = "padding:6px 8px;border-bottom:1px solid rgba(127,127,127,.2);vertical-align:top;text-align:left";
/*******************************************************************************
** Helpers only.
*******************************************************************************/
private Html()
{
}
/*******************************************************************************
** Escape text for HTML.
**
** @param value the value (null is empty)
** @return the escaped text
*******************************************************************************/
public static String escape(Object value)
{
return (Objects.toString(value, "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
/*******************************************************************************
** A link.
**
** @param href the target
** @param text the text
** @return the HTML
*******************************************************************************/
public static String link(String href, Object text)
{
return ("<a href=\"" + escape(href) + "\" style=\"color:inherit;text-decoration:underline;text-underline-offset:2px;" + WRAP + "\">" + escape(text)
+ "</a>");
}
/*******************************************************************************
** Muted explanatory text.
**
** @param text the text
** @return the HTML
*******************************************************************************/
public static String muted(String text)
{
return ("<div style=\"" + MUTED + ";font-size:13px;" + WRAP + "\">" + escape(text) + "</div>");
}
/*******************************************************************************
** The color of a 0-100 Lighthouse score (0-49 poor, 50-89 needs
** improvement, 90-100 good).
**
** @param score the score
** @return the color
*******************************************************************************/
public static String scoreColor(int score)
{
return (score >= 90 ? GOOD : score >= 50 ? MEDIUM : POOR);
}
/*******************************************************************************
** A score with a colored dot and its band in words (color is never the
** only signal), or a dash.
**
** @param score the score or null
** @return the HTML
*******************************************************************************/
public static String score(Integer score)
{
if(score == null)
{
return ("<span style=\"" + MUTED + "\">&ndash;</span>");
}
String band = score >= 90 ? "good" : score >= 50 ? "needs work" : "poor";
return ("<span style=\"white-space:nowrap;font-variant-numeric:tabular-nums\"><span style=\"display:inline-block;width:.6em;height:.6em;border-radius:50%;"
+ "margin-right:.35em;background:" + scoreColor(score) + "\"></span><b>" + score + "</b> <span style=\"" + MUTED + ";font-size:12px\">" + band
+ "</span></span>");
}
/*******************************************************************************
** One tile: label, big value HTML, optional note.
*******************************************************************************/
public record Tile(String label, String valueHtml, String note)
{
}
/*******************************************************************************
** A wrapping row of tiles.
**
** @param tiles the tiles
** @return the HTML
*******************************************************************************/
public static String tiles(List<Tile> tiles)
{
StringBuilder html = new StringBuilder("<div style=\"" + TILE_ROW + "\">");
for(Tile tile : tiles)
{
html.append("<div style=\"").append(TILE).append("\">")
.append("<div style=\"font-size:12px;").append(MUTED).append(";text-transform:uppercase;letter-spacing:.04em\">").append(escape(tile.label()))
.append("</div><div style=\"font-size:22px;line-height:1.4\">").append(tile.valueHtml()).append("</div>");
if(tile.note() != null)
{
html.append("<div style=\"font-size:12px;").append(MUTED).append(";").append(WRAP).append("\">").append(escape(tile.note())).append("</div>");
}
html.append("</div>");
}
return (html.append("</div>").toString());
}
/*******************************************************************************
** A full-width table (header texts escaped, cells are HTML).
**
** @param headers the column headers (at most four, per the readability rules)
** @param rows the cells, as HTML
** @return the HTML
*******************************************************************************/
public static String table(List<String> headers, List<List<String>> rows)
{
StringBuilder html = new StringBuilder("<table style=\"width:100%;border-collapse:collapse;font-size:14px\"><thead><tr>");
for(String header : headers)
{
html.append("<th style=\"").append(CELL).append(";font-size:12px;").append(MUTED).append(";font-weight:600\">").append(escape(header)).append("</th>");
}
html.append("</tr></thead><tbody>");
for(List<String> row : rows)
{
html.append("<tr>");
for(String cell : row)
{
html.append("<td style=\"").append(CELL).append(";").append(WRAP).append("\">").append(cell).append("</td>");
}
html.append("</tr>");
}
return (html.append("</tbody></table>").toString());
}
/*******************************************************************************
** "3 h ago" style age.
**
** @param when the instant
** @param now now
** @return the text
*******************************************************************************/
public static String ago(Instant when, Instant now)
{
if(when == null)
{
return ("never");
}
long seconds = Math.max(0, Duration.between(when, now).getSeconds());
if(seconds < 120)
{
return ("just now");
}
if(seconds < 7200)
{
return ((seconds / 60) + " min ago");
}
if(seconds < 172800)
{
return ((seconds / 3600) + " h ago");
}
return ((seconds / 86400) + " days ago");
}
/*******************************************************************************
** Milliseconds as "1.84 s" or "320 ms", or a dash.
**
** @param ms the value
** @return the text
*******************************************************************************/
public static String millis(Integer ms)
{
if(ms == null)
{
return ("–");
}
return (ms >= 1000 ? String.format(java.util.Locale.US, "%.2f s", ms / 1000.0) : ms + " ms");
}
/*******************************************************************************
** A server-drawn sparkline of 0-100 values (oldest first), as inline SVG
** that scales to its container.
**
** @param values the values
** @return the SVG, or empty for fewer than two values
*******************************************************************************/
public static String sparkline(List<Integer> values)
{
if(values.size() < 2)
{
return ("");
}
StringBuilder points = new StringBuilder();
for(int i = 0; i < values.size(); i++)
{
double x = 100.0 * i / (values.size() - 1);
double y = 28 - 26.0 * values.get(i) / 100.0;
points.append(String.format(java.util.Locale.US, "%.1f,%.1f ", x, y));
}
return ("<svg viewBox=\"0 0 100 30\" preserveAspectRatio=\"none\" style=\"width:100%;height:36px;display:block\" role=\"img\" aria-label=\"Trend of the last "
+ values.size() + " scores\"><polyline fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" vector-effect=\"non-scaling-stroke\" points=\""
+ points.toString().trim() + "\"/></svg>");
}
}
@@ -0,0 +1,585 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.widgets;
import java.io.Serializable;
import java.sql.SQLException;
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import com.kingsrook.qbits.webperf.WebPerfQBitConfig;
import com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.WebPerfSchema;
import com.kingsrook.qbits.webperf.store.WebPerfQueries;
import com.kingsrook.qbits.webperf.store.WebPerfStore;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
/*******************************************************************************
** The Performance home's widgets and the record "Performance" panel
** (design §5). Real data only: every number is a stored Lighthouse run,
** finding or Google field data row, labelled with its source; with nothing
** to show a widget says why (never tested, no API key, Google has no data).
*******************************************************************************/
public final class WebPerfWidgets
{
public static final String SITE_SCORES = "SiteScores";
public static final String TREND = "Trend";
public static final String SLOWEST_PAGES = "SlowestPages";
public static final String OPPORTUNITIES = "TopOpportunities";
public static final String RECENT_RUNS = "RecentRuns";
public static final String FIELD_STATUS = "FieldDataStatus";
public static final String RECORD_PANEL = "RecordPanel";
/** The home widgets, in order. */
public static final List<String> HOME = List.of(SITE_SCORES, TREND, SLOWEST_PAGES, OPPORTUNITIES, FIELD_STATUS, RECENT_RUNS);
static final int TREND_WEEKS = 26;
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("MMM d", Locale.US);
/*******************************************************************************
** Widgets only.
*******************************************************************************/
private WebPerfWidgets()
{
}
/*******************************************************************************
** The runtime a widget belongs to (its webPerformanceInstance default value).
*******************************************************************************/
static WebPerfRuntime runtime(RenderWidgetInput input)
{
Map<String, Serializable> defaults = input.getWidgetMetaData() == null ? null : input.getWidgetMetaData().getDefaultValues();
Object prefix = defaults == null ? null : defaults.get(WebPerfRuntime.INSTANCE_FIELD);
return (WebPerfRuntime.of(QContext.getQInstance(), String.valueOf(prefix)));
}
/*******************************************************************************
** HTML output.
*******************************************************************************/
static RenderWidgetOutput html(String html)
{
return (new RenderWidgetOutput(new RawHTML(null, html)));
}
/*******************************************************************************
** A record link.
*******************************************************************************/
static String recordHref(String table, Object id)
{
return ("/app/" + table + "/" + id);
}
/*******************************************************************************
** A page cell: its label or path, linking to the tested page.
*******************************************************************************/
static String pageCell(WebPerfQBitConfig config, Map<String, Object> row)
{
Object label = row.get("label");
String url = (String) row.get("url");
String text = label != null ? label.toString() : url == null ? "" : url.replaceFirst("^https?://[^/]+", "");
return (Html.link(recordHref(config.applyPrefix(WebPerfSchema.TARGET), row.get("target_id")), text.isEmpty() ? "/" : text));
}
/*******************************************************************************
** The note shown when lab tests cannot run.
*******************************************************************************/
static String noKeyNote(WebPerfRuntime runtime)
{
return (runtime.labRunner() == null ? Html.muted("No Google API key is configured, so lab tests are off. Queued runs wait until one is.") : "");
}
/*******************************************************************************
** The median of values (null when empty).
*******************************************************************************/
static Integer median(List<Integer> values)
{
List<Integer> sorted = values.stream().filter(java.util.Objects::nonNull).sorted().toList();
if(sorted.isEmpty())
{
return (null);
}
int middle = sorted.size() / 2;
return (sorted.size() % 2 == 1 ? sorted.get(middle) : (int) Math.round((sorted.get(middle - 1) + sorted.get(middle)) / 2.0));
}
/*******************************************************************************
** Site Scores: for each strategy, the median of the latest score of each
** KEY page (all enabled pages when none is KEY), per category.
*******************************************************************************/
public static class SiteScores extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
WebPerfRuntime runtime = runtime(input);
try
{
List<Map<String, Object>> latest = new WebPerfQueries(runtime.store()).latestRuns();
if(latest.isEmpty())
{
return (html(Html.muted("No page has been tested yet. Use “Sync pages from sitemap”, then “Run site audit”, on Tested Pages.")
+ noKeyNote(runtime)));
}
boolean anyKey = latest.stream().anyMatch(r -> "KEY".equals(r.get("tier")));
StringBuilder html = new StringBuilder();
Instant newest = null;
String version = null;
for(String strategy : runtime.getConfig().getStrategies())
{
List<Map<String, Object>> runs = latest.stream().filter(r -> strategy.equals(r.get("strategy")) && (!anyKey || "KEY".equals(r.get("tier")))).toList();
if(runs.isEmpty())
{
continue;
}
List<Html.Tile> tiles = new ArrayList<>();
for(String[] category : new String[][] { { "Performance", "score_performance" }, { "Accessibility", "score_accessibility" },
{ "Best Practices", "score_best_practices" }, { "SEO", "score_seo" } })
{
tiles.add(new Html.Tile(category[0], Html.score(median(runs.stream().map(r -> WebPerfStore.asInt(r.get(category[1]))).toList())), null));
}
html.append("<div style=\"margin:4px 0 6px;font-weight:600\">").append(Html.escape(strategy.charAt(0) + strategy.substring(1).toLowerCase(Locale.ROOT)))
.append(" <span style=\"").append(Html.MUTED).append(";font-weight:400;font-size:13px\">median of ").append(runs.size())
.append(anyKey ? " key page" : " page").append(runs.size() == 1 ? "" : "s").append("</span></div>").append(Html.tiles(tiles));
for(Map<String, Object> run : runs)
{
Instant completed = (Instant) run.get("completed_at");
if(newest == null || (completed != null && completed.isAfter(newest)))
{
newest = completed;
version = (String) run.get("lighthouse_version");
}
}
}
html.append(Html.muted("Lab data (Lighthouse " + (version == null ? "?" : version) + ", latest test " + Html.ago(newest, Instant.now())
+ "). Lab scores vary from run to run; trends and regressions use medians."));
return (html(html + noKeyNote(runtime)));
}
catch(SQLException e)
{
throw (new QException("Error loading site scores", e));
}
}
}
/*******************************************************************************
** Trend: weekly median Performance score per strategy, from the first week
** with a test (no week before testing began is drawn).
*******************************************************************************/
public static class Trend extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
WebPerfRuntime runtime = runtime(input);
ZoneId zone = ZoneId.of(runtime.getConfig().getTimeZone());
try
{
List<Map<String, Object>> runs = new WebPerfQueries(runtime.store()).successfulRunsSince(Instant.now().minus(Duration.ofDays(7L * TREND_WEEKS)));
Map<LocalDate, Map<String, List<Integer>>> weeks = new TreeMap<>();
for(Map<String, Object> run : runs)
{
Integer score = WebPerfStore.asInt(run.get("score_performance"));
if(score == null)
{
continue;
}
LocalDate week = ((Instant) run.get("completed_at")).atZone(zone).toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
weeks.computeIfAbsent(week, w -> new TreeMap<>()).computeIfAbsent((String) run.get("strategy"), s -> new ArrayList<>()).add(score);
}
List<String> labels = new ArrayList<>();
List<ChartData.Data.Dataset> datasets = new ArrayList<>();
Map<String, List<Number>> series = new TreeMap<>();
for(String strategy : runtime.getConfig().getStrategies())
{
series.put(strategy, new ArrayList<>());
}
for(Map.Entry<LocalDate, Map<String, List<Integer>>> week : weeks.entrySet())
{
labels.add(DAY.format(week.getKey()));
for(Map.Entry<String, List<Number>> entry : series.entrySet())
{
entry.getValue().add(median(week.getValue().getOrDefault(entry.getKey(), List.of())));
}
}
String[] colors = { "#0062FF", "#10B8A6" };
int i = 0;
for(Map.Entry<String, List<Number>> entry : series.entrySet())
{
datasets.add(new ChartData.Data.Dataset().withLabel(entry.getKey().charAt(0) + entry.getKey().substring(1).toLowerCase(Locale.ROOT) + " Performance")
.withData(entry.getValue()).withColor(colors[i++ % colors.length]));
}
ChartData chart = new ChartData().withChartData(new ChartData.Data().withLabels(labels).withDatasets(datasets));
chart.setSublabel(labels.isEmpty() ? "No tests yet" : "Weekly median of every successful lab run (Lighthouse), from the first tested week");
return (new RenderWidgetOutput(chart));
}
catch(SQLException e)
{
throw (new QException("Error loading the performance trend", e));
}
}
}
/*******************************************************************************
** Slowest Pages: the ten lowest latest mobile (else first strategy)
** Performance scores, with LCP and the top finding.
*******************************************************************************/
public static class SlowestPages extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
WebPerfRuntime runtime = runtime(input);
WebPerfQBitConfig config = runtime.getConfig();
String strategy = config.getStrategies().contains("MOBILE") ? "MOBILE" : config.getStrategies().get(0);
try
{
WebPerfQueries queries = new WebPerfQueries(runtime.store());
List<Map<String, Object>> rows = queries.latestRuns().stream().filter(r -> strategy.equals(r.get("strategy"))).limit(10).toList();
if(rows.isEmpty())
{
return (html(Html.muted("No page has been tested on " + strategy.toLowerCase(Locale.ROOT) + " yet.")));
}
List<List<String>> cells = new ArrayList<>();
for(Map<String, Object> row : rows)
{
List<Map<String, Object>> findings = queries.findingsOf(WebPerfStore.asLong(row.get("id")), 1);
cells.add(List.of(pageCell(config, row), Html.score(WebPerfStore.asInt(row.get("score_performance"))),
Html.escape(Html.millis(WebPerfStore.asInt(row.get("lcp_ms")))),
findings.isEmpty() ? "<span style=\"" + Html.MUTED + "\">none</span>" : Html.escape(findings.get(0).get("title"))));
}
return (html(Html.table(List.of("Page", "Performance (" + strategy.toLowerCase(Locale.ROOT) + ")", "LCP (lab)", "Top finding"), cells)
+ Html.muted("Latest lab run of each page. ") + Html.link("/app/" + config.applyPrefix(WebPerfSchema.TARGET), "All tested pages")));
}
catch(SQLException e)
{
throw (new QException("Error loading the slowest pages", e));
}
}
}
/*******************************************************************************
** Top Opportunities: findings across the latest runs of every page,
** grouped by audit, by pages affected.
*******************************************************************************/
public static class TopOpportunities extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
WebPerfRuntime runtime = runtime(input);
try
{
List<Map<String, Object>> rows = new WebPerfQueries(runtime.store()).topOpportunities(10);
if(rows.isEmpty())
{
return (html(Html.muted("No findings: either nothing has been tested yet, or every audit passed in the latest runs.")));
}
String findings = runtime.getConfig().applyPrefix(WebPerfSchema.LAB_FINDING);
List<List<String>> cells = new ArrayList<>();
for(Map<String, Object> row : rows)
{
long savings = WebPerfStore.asLong(row.get("savings_ms")) == null ? 0 : WebPerfStore.asLong(row.get("savings_ms"));
cells.add(List.of(Html.escape(row.get("title")) + "<div style=\"" + Html.MUTED + ";font-size:12px\">" + Html.escape(row.get("audit_id")) + "</div>",
Html.escape(row.get("pages")), savings > 0 ? Html.escape(Html.millis((int) savings)) : "<span style=\"" + Html.MUTED + "\">&ndash;</span>"));
}
return (html(Html.table(List.of("Finding", "Pages", "Est. savings (total)"), cells)
+ Html.muted("From the latest lab run of each page and strategy. ") + Html.link("/app/" + findings, "All findings")));
}
catch(SQLException e)
{
throw (new QException("Error loading the top opportunities", e));
}
}
}
/*******************************************************************************
** Recent Runs: the newest five, with status and scores.
*******************************************************************************/
public static class RecentRuns extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
WebPerfRuntime runtime = runtime(input);
WebPerfQBitConfig config = runtime.getConfig();
try
{
WebPerfQueries queries = new WebPerfQueries(runtime.store());
List<Map<String, Object>> rows = queries.recentRuns(5);
String runs = config.applyPrefix(WebPerfSchema.LAB_RUN);
if(rows.isEmpty())
{
return (html(Html.muted("No runs yet.") + noKeyNote(runtime)));
}
Instant now = Instant.now();
List<List<String>> cells = new ArrayList<>();
for(Map<String, Object> row : rows)
{
String status = (String) row.get("status");
String scores = "SUCCEEDED".equals(status)
? Html.score(WebPerfStore.asInt(row.get("score_performance"))) + " <span style=\"" + Html.MUTED + ";font-size:12px;white-space:nowrap\">A11y "
+ Html.escape(row.get("score_accessibility")) + " · BP " + Html.escape(row.get("score_best_practices")) + " · SEO "
+ Html.escape(row.get("score_seo")) + "</span>"
: "<span style=\"" + Html.MUTED + "\">" + Html.escape(row.get("error_message") == null ? "" : row.get("error_message")) + "</span>";
Instant when = row.get("completed_at") != null ? (Instant) row.get("completed_at") : (Instant) row.get("queued_at");
cells.add(List.of(pageCell(config, row) + "<div style=\"" + Html.MUTED + ";font-size:12px\">" + Html.escape(row.get("strategy")) + "</div>",
Html.link(recordHref(runs, row.get("id")), status), scores, Html.escape(Html.ago(when, now))));
}
int queued = queries.queuedCount();
return (html(Html.table(List.of("Page", "Status", "Scores", "When"), cells)
+ Html.muted(queued + " run" + (queued == 1 ? "" : "s") + " waiting or running. ") + Html.link("/app/" + runs, "All lab runs") + noKeyNote(runtime)));
}
catch(SQLException e)
{
throw (new QException("Error loading recent runs", e));
}
}
}
/*******************************************************************************
** Field Data Status: what Google's field data (CrUX) says about the site,
** honestly NO_DATA when Google has none.
*******************************************************************************/
public static class FieldDataStatus extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
WebPerfRuntime runtime = runtime(input);
try
{
String origin = runtime.service().origin();
List<Map<String, Object>> rows = new WebPerfQueries(runtime.store()).fieldData("ORIGIN", origin);
Instant now = Instant.now();
if(rows.isEmpty())
{
return (html(Html.muted(runtime.cruxClient() == null
? "Google field data (CrUX): not checked. No Google API key is configured."
: "Google field data (CrUX): not checked yet. It is checked daily, or use “Refresh Google field data”.")));
}
Map<String, Object> all = rows.stream().filter(r -> "ALL".equals(r.get("form_factor"))).findFirst().orElse(rows.get(0));
if("NO_DATA".equals(all.get("status")))
{
return (html("<div style=\"font-weight:600\">Google field data (CrUX): not available</div>" + Html.muted(
"Google publishes field data only for pages and sites with enough Chrome traffic; it has none for " + origin + ". Last checked "
+ Html.ago((Instant) all.get("last_checked_at"), now) + ". Lab results above are Lighthouse runs; real-user figures come from this site's own "
+ "measurements.")));
}
List<Html.Tile> tiles = List.of(
new Html.Tile("LCP p75", Html.escape(Html.millis(WebPerfStore.asInt(all.get("p75_lcp_ms")))), "good ≤ 2.5 s"),
new Html.Tile("INP p75", Html.escape(Html.millis(WebPerfStore.asInt(all.get("p75_inp_ms")))), "good ≤ 200 ms"),
new Html.Tile("CLS p75", Html.escape(all.get("p75_cls") == null ? "–" : all.get("p75_cls")), "good ≤ 0.1"),
new Html.Tile("TTFB p75", Html.escape(Html.millis(WebPerfStore.asInt(all.get("p75_ttfb_ms")))), "good ≤ 0.8 s"));
return (html(Html.tiles(tiles) + Html.muted("Google field data (CrUX) for " + origin + ", all devices, 28 days to " + all.get("period_end")
+ ". Last checked " + Html.ago((Instant) all.get("last_checked_at"), now) + ".")));
}
catch(SQLException e)
{
throw (new QException("Error loading Google field data", e));
}
}
}
/*******************************************************************************
** The record "Performance" panel: this record's page, its latest scores per
** strategy, lab metrics, what to fix, and a trend of its mobile score.
*******************************************************************************/
public static class RecordPanel extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render for the tableName / id parameters.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
WebPerfRuntime runtime = runtime(input);
WebPerfQBitConfig config = runtime.getConfig();
Map<String, String> params = input.getQueryParams();
String table = params == null ? null : params.get("tableName");
String id = params == null ? null : params.get("id");
if(table == null || id == null || !id.matches("\\d{1,18}"))
{
return (html(Html.muted("Open a record to see its page speed.")));
}
boolean direct = config.applyPrefix(WebPerfSchema.TARGET).equals(table);
try
{
WebPerfQueries queries = new WebPerfQueries(runtime.store());
List<Map<String, Object>> pages = queries.pagesOf(table, Long.parseLong(id), direct);
if(pages.isEmpty())
{
return (html(Html.muted("This record has no tested page: it is not in the sitemap (for example a draft), so it is not tested.")));
}
StringBuilder html = new StringBuilder();
Instant now = Instant.now();
for(Map<String, Object> page : pages)
{
long pageId = WebPerfStore.asLong(page.get("id"));
if(!direct)
{
html.append("<div style=\"margin-bottom:6px\">").append(Html.link(recordHref(config.applyPrefix(WebPerfSchema.TARGET), pageId), page.get("url")))
.append("</div>");
}
if(Boolean.FALSE.equals(page.get("enabled")))
{
html.append(Html.muted("This page is disabled, so it is not tested."));
}
List<Html.Tile> tiles = new ArrayList<>();
Map<String, Object> mobileRun = null;
List<Integer> mobileTrend = new ArrayList<>();
String waiting = null;
for(String strategy : config.getStrategies())
{
List<Map<String, Object>> runs = queries.runsOf(pageId, strategy, 12);
Map<String, Object> latest = runs.stream().filter(r -> "SUCCEEDED".equals(r.get("status"))).findFirst().orElse(null);
if(!runs.isEmpty() && ("QUEUED".equals(runs.get(0).get("status")) || "RUNNING".equals(runs.get(0).get("status"))))
{
waiting = "A test is " + runs.get(0).get("status").toString().toLowerCase(Locale.ROOT) + " (queued " + Html.ago((Instant) runs.get(0).get("queued_at"), now) + ").";
}
String label = strategy.charAt(0) + strategy.substring(1).toLowerCase(Locale.ROOT);
if(latest == null)
{
tiles.add(new Html.Tile(label, "<span style=\"" + Html.MUTED + "\">not tested</span>", null));
continue;
}
tiles.add(new Html.Tile(label, Html.score(WebPerfStore.asInt(latest.get("score_performance"))),
"A11y " + latest.get("score_accessibility") + " · BP " + latest.get("score_best_practices") + " · SEO " + latest.get("score_seo") + " · "
+ Html.ago((Instant) latest.get("completed_at"), now)));
if("MOBILE".equals(strategy) || mobileRun == null)
{
mobileRun = latest;
mobileTrend = new ArrayList<>(runs.stream().filter(r -> "SUCCEEDED".equals(r.get("status")))
.map(r -> WebPerfStore.asInt(r.get("score_performance"))).filter(java.util.Objects::nonNull).toList());
Collections.reverse(mobileTrend);
}
}
html.append(Html.tiles(tiles));
if(waiting != null)
{
html.append(Html.muted(waiting));
}
if(mobileRun == null)
{
html.append(Html.muted("Never tested. Use “Test page speed” in this record's actions."));
continue;
}
html.append(Html.muted("Lab metrics (Lighthouse " + mobileRun.get("lighthouse_version") + "): LCP " + Html.millis(WebPerfStore.asInt(mobileRun.get("lcp_ms")))
+ " · FCP " + Html.millis(WebPerfStore.asInt(mobileRun.get("fcp_ms"))) + " · TBT " + Html.millis(WebPerfStore.asInt(mobileRun.get("tbt_ms")))
+ " · CLS " + (mobileRun.get("cls") == null ? "–" : mobileRun.get("cls"))
+ (mobileRun.get("lcp_element") == null ? "" : " · LCP element " + mobileRun.get("lcp_element"))));
if(mobileTrend.size() >= 2)
{
html.append("<div style=\"margin:6px 0\">").append(Html.sparkline(mobileTrend)).append("</div>");
}
List<Map<String, Object>> findings = queries.findingsOf(WebPerfStore.asLong(mobileRun.get("id")), 5);
if(findings.isEmpty())
{
html.append(Html.muted("Every scored audit passed in the latest run."));
}
else
{
List<List<String>> cells = new ArrayList<>();
for(Map<String, Object> finding : findings)
{
Integer savings = WebPerfStore.asInt(finding.get("savings_ms"));
cells.add(List.of(Html.escape(finding.get("title")) + (finding.get("display_value") == null ? ""
: " <span style=\"" + Html.MUTED + ";font-size:12px\">" + Html.escape(finding.get("display_value")) + "</span>"),
savings == null ? "<span style=\"" + Html.MUTED + "\">&ndash;</span>" : Html.escape(Html.millis(savings))));
}
html.append("<div style=\"margin-top:8px;font-weight:600\">What to fix</div>").append(Html.table(List.of("Finding", "Est. savings"), cells));
}
}
return (html(html.toString() + noKeyNote(runtime)));
}
catch(SQLException e)
{
throw (new QException("Error loading this record's page speed", e));
}
}
}
}
@@ -1,12 +1,12 @@
-- liquibase formatted sql
-- qbit-web-performance schema (QRun-IO/qqq docs/specs/2026-10-12-web-performance-design.md §4).
-- Table names carry the prefix in the webperf.tablePrefix changelog property
-- (default webperf_), declared by the host's master changelog, which must
-- Table names carry the prefix in the web_performance.tablePrefix changelog property
-- (default web_performance_), declared by the host's master changelog, which must
-- equal WebPerfQBitConfig's tableNamePrefix:
--
-- - property: { name: webperf.tablePrefix, value: webperf_ }
-- - include: { file: db/liquibase/qbit-web-performance/webperf.changelog.sql }
-- - property: { name: web_performance.tablePrefix, value: web_performance_ }
-- - include: { file: db/liquibase/qbit-web-performance/web_performance.changelog.sql }
--
-- Rules for this file: the host runs it, the QBit never runs DDL; additive
-- only (nothing is ever dropped or rewritten); every statement is idempotent
@@ -14,7 +14,7 @@
-- changeset qbit-web-performance:1
-- comment: Tested Pages. One public URL under test, synced from the sitemap (in_sitemap false keeps the history of pages that left it) or added by hand, linked to the record it renders. The latest_* columns are maintained by the QBit for list sorting.
CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}target (
CREATE TABLE IF NOT EXISTS ${web_performance.tablePrefix}target (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
site_key VARCHAR(100),
url VARCHAR(1000) NOT NULL,
@@ -34,15 +34,15 @@ CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}target (
open_regression_count INTEGER,
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${webperf.tablePrefix}target PRIMARY KEY (id),
CONSTRAINT uq_${webperf.tablePrefix}target_url UNIQUE (site_key, url)
CONSTRAINT pk_${web_performance.tablePrefix}target PRIMARY KEY (id),
CONSTRAINT uq_${web_performance.tablePrefix}target_url UNIQUE (site_key, url)
);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}target_content ON ${webperf.tablePrefix}target (content_table, content_id);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}target_tier ON ${webperf.tablePrefix}target (enabled, tier);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}target_content ON ${web_performance.tablePrefix}target (content_table, content_id);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}target_tier ON ${web_performance.tablePrefix}target (enabled, tier);
-- changeset qbit-web-performance:2
-- comment: Lab Runs. One Lighthouse run of one URL and strategy, from QUEUED to a final status. Scores are 0-100 (null when the category was not run); lab metrics in ms, CLS unitless. trigger_type says why it ran (SCHEDULE, PUBLISH, DEPLOY, MANUAL, SITE_AUDIT, CONFIRM, CHANGE_COMPARE).
CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}lab_run (
CREATE TABLE IF NOT EXISTS ${web_performance.tablePrefix}lab_run (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
target_id BIGINT,
url VARCHAR(1000),
@@ -78,16 +78,16 @@ CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}lab_run (
cdn_stage VARCHAR(20),
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${webperf.tablePrefix}lab_run PRIMARY KEY (id)
CONSTRAINT pk_${web_performance.tablePrefix}lab_run PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}lab_run_target ON ${webperf.tablePrefix}lab_run (target_id, strategy, completed_at);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}lab_run_queue ON ${webperf.tablePrefix}lab_run (status, not_before);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}lab_run_batch ON ${webperf.tablePrefix}lab_run (batch_id);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}lab_run_completed ON ${webperf.tablePrefix}lab_run (completed_at);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}lab_run_target ON ${web_performance.tablePrefix}lab_run (target_id, strategy, completed_at);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}lab_run_queue ON ${web_performance.tablePrefix}lab_run (status, not_before);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}lab_run_batch ON ${web_performance.tablePrefix}lab_run (batch_id);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}lab_run_completed ON ${web_performance.tablePrefix}lab_run (completed_at);
-- changeset qbit-web-performance:3
-- comment: Findings. Each non-passing or informative Lighthouse audit of a run. audit_id is kept with lighthouse_version, because Lighthouse 13 replaced most performance audits with insight audits (new ids); trends never compare audit ids across major versions.
CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}lab_finding (
CREATE TABLE IF NOT EXISTS ${web_performance.tablePrefix}lab_finding (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
run_id BIGINT NOT NULL,
target_id BIGINT,
@@ -105,15 +105,15 @@ CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}lab_finding (
top_items TEXT,
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${webperf.tablePrefix}lab_finding PRIMARY KEY (id)
CONSTRAINT pk_${web_performance.tablePrefix}lab_finding PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}lab_finding_run ON ${webperf.tablePrefix}lab_finding (run_id);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}lab_finding_audit ON ${webperf.tablePrefix}lab_finding (audit_id, create_date);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}lab_finding_target ON ${webperf.tablePrefix}lab_finding (target_id, audit_id);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}lab_finding_run ON ${web_performance.tablePrefix}lab_finding (run_id);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}lab_finding_audit ON ${web_performance.tablePrefix}lab_finding (audit_id, create_date);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}lab_finding_target ON ${web_performance.tablePrefix}lab_finding (target_id, audit_id);
-- changeset qbit-web-performance:4
-- comment: Lighthouse Reports. The complete PageSpeed Insights response of a run, gzip-compressed (about 100-300 KB), kept forever by default (D8) and downloadable from the record.
CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}lab_report (
CREATE TABLE IF NOT EXISTS ${web_performance.tablePrefix}lab_report (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
run_id BIGINT NOT NULL,
content BYTEA,
@@ -121,13 +121,13 @@ CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}lab_report (
sha256 VARCHAR(64),
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${webperf.tablePrefix}lab_report PRIMARY KEY (id),
CONSTRAINT uq_${webperf.tablePrefix}lab_report_run UNIQUE (run_id)
CONSTRAINT pk_${web_performance.tablePrefix}lab_report PRIMARY KEY (id),
CONSTRAINT uq_${web_performance.tablePrefix}lab_report_run UNIQUE (run_id)
);
-- changeset qbit-web-performance:5
-- comment: Google Field Data. A CrUX API, CrUX History or PSI loadingExperience result for a URL or origin. status NO_DATA records honestly that Google has no data (low-traffic pages and sites are not in CrUX); an unchanged NO_DATA result only moves last_checked_at.
CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}field_snapshot (
CREATE TABLE IF NOT EXISTS ${web_performance.tablePrefix}field_snapshot (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
source VARCHAR(20),
scope VARCHAR(10),
@@ -145,13 +145,13 @@ CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}field_snapshot (
last_checked_at TIMESTAMP WITHOUT TIME ZONE,
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${webperf.tablePrefix}field_snapshot PRIMARY KEY (id),
CONSTRAINT uq_${webperf.tablePrefix}field_snapshot UNIQUE (source, scope, scope_key, form_factor, period_end)
CONSTRAINT pk_${web_performance.tablePrefix}field_snapshot PRIMARY KEY (id),
CONSTRAINT uq_${web_performance.tablePrefix}field_snapshot UNIQUE (source, scope, scope_key, form_factor, period_end)
);
-- changeset qbit-web-performance:6
-- comment: Budgets. The thresholds that define a regression, by scope (site, tier, page, content table, path prefix), source (lab, RUM, CrUX), metric and severity.
CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}budget (
CREATE TABLE IF NOT EXISTS ${web_performance.tablePrefix}budget (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(200) NOT NULL,
scope VARCHAR(20),
@@ -166,13 +166,13 @@ CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}budget (
seeded BOOLEAN DEFAULT FALSE NOT NULL,
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${webperf.tablePrefix}budget PRIMARY KEY (id)
CONSTRAINT pk_${web_performance.tablePrefix}budget PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}budget_enabled ON ${webperf.tablePrefix}budget (enabled, source);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}budget_enabled ON ${web_performance.tablePrefix}budget (enabled, source);
-- changeset qbit-web-performance:7
-- comment: Regressions. A confirmed drop or budget breach kept as a record (SUSPECTED, CONFIRMED, NOT_CONFIRMED, RESOLVED, DISMISSED), linked to the page, its record, the budget and the likely cause.
CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}regression (
CREATE TABLE IF NOT EXISTS ${web_performance.tablePrefix}regression (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
target_id BIGINT,
content_table VARCHAR(100),
@@ -195,14 +195,14 @@ CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}regression (
resolution_note VARCHAR(2000),
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${webperf.tablePrefix}regression PRIMARY KEY (id)
CONSTRAINT pk_${web_performance.tablePrefix}regression PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}regression_status ON ${webperf.tablePrefix}regression (status, severity);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}regression_target ON ${webperf.tablePrefix}regression (target_id, metric, status);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}regression_status ON ${web_performance.tablePrefix}regression (status, severity);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}regression_target ON ${web_performance.tablePrefix}regression (target_id, metric, status);
-- changeset qbit-web-performance:8
-- comment: Site Changes. Deploys, CDN stage changes, Lighthouse version changes and manual notes that may move performance, drawn as markers on trends.
CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}change_event (
CREATE TABLE IF NOT EXISTS ${web_performance.tablePrefix}change_event (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
kind VARCHAR(30),
component VARCHAR(200),
@@ -213,14 +213,14 @@ CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}change_event (
note VARCHAR(2000),
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${webperf.tablePrefix}change_event PRIMARY KEY (id)
CONSTRAINT pk_${web_performance.tablePrefix}change_event PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}change_event_observed ON ${webperf.tablePrefix}change_event (observed_at);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}change_event_kind ON ${webperf.tablePrefix}change_event (kind, observed_at);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}change_event_observed ON ${web_performance.tablePrefix}change_event (observed_at);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}change_event_kind ON ${web_performance.tablePrefix}change_event (kind, observed_at);
-- changeset qbit-web-performance:9
-- comment: Before and After. The result of comparing lab medians and real-user p75 around a change, per metric, strategy or form factor, and segment (e.g. country), with sample counts and windows.
CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}comparison (
CREATE TABLE IF NOT EXISTS ${web_performance.tablePrefix}comparison (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
change_event_id BIGINT,
scope VARCHAR(20),
@@ -238,13 +238,13 @@ CREATE TABLE IF NOT EXISTS ${webperf.tablePrefix}comparison (
delta_pct NUMERIC(8,2),
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${webperf.tablePrefix}comparison PRIMARY KEY (id)
CONSTRAINT pk_${web_performance.tablePrefix}comparison PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}comparison_change ON ${webperf.tablePrefix}comparison (change_event_id);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}comparison_change ON ${web_performance.tablePrefix}comparison (change_event_id);
-- changeset qbit-web-performance:10
-- comment: Seeded reference budgets (owner decision D12): Google's "good" thresholds for each Core Web Vital, lab and real-user, and the Lighthouse category bands for KEY pages. Reference rows, not content; editable and audited. Inserted only when no seeded budget exists, so a re-run or an edited budget is never overwritten.
INSERT INTO ${webperf.tablePrefix}budget (name, scope, scope_value, source, strategy, metric, comparator, threshold, severity, enabled, seeded)
INSERT INTO ${web_performance.tablePrefix}budget (name, scope, scope_value, source, strategy, metric, comparator, threshold, severity, enabled, seeded)
SELECT name, scope, scope_value, source, strategy, metric, comparator, threshold, severity, TRUE, TRUE FROM (
SELECT 'LCP good (lab, mobile)' AS name, 'SITE' AS scope, CAST(NULL AS VARCHAR(500)) AS scope_value, 'LAB' AS source, 'MOBILE' AS strategy, 'LCP' AS metric, '<=' AS comparator, CAST(2500 AS NUMERIC(12,4)) AS threshold, 'WARNING' AS severity
UNION ALL SELECT 'CLS good (lab, mobile)', 'SITE', NULL, 'LAB', 'MOBILE', 'CLS', '<=', 0.1, 'WARNING'
@@ -260,4 +260,4 @@ SELECT name, scope, scope_value, source, strategy, metric, comparator, threshold
UNION ALL SELECT 'SEO at least 90 (KEY pages)', 'TIER', 'KEY', 'LAB', NULL, 'SEO_SCORE', '>=', 90, 'WARNING'
UNION ALL SELECT 'Performance at least 50 (KEY pages)', 'TIER', 'KEY', 'LAB', NULL, 'PERFORMANCE_SCORE', '>=', 50, 'CRITICAL'
) seeds
WHERE NOT EXISTS (SELECT 1 FROM ${webperf.tablePrefix}budget WHERE seeded = TRUE);
WHERE NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}budget WHERE seeded = TRUE);
@@ -0,0 +1,160 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import com.sun.net.httpserver.HttpServer;
/*******************************************************************************
** A local HTTP server standing in for the Google APIs in tests: it answers
** with queued responses (the last one repeats), and records every request's
** query string and body.
*******************************************************************************/
public class FixtureServer implements AutoCloseable
{
/*******************************************************************************
** One response.
*******************************************************************************/
public record Response(int status, String body)
{
}
/*******************************************************************************
** One recorded request.
*******************************************************************************/
public record Request(String method, String query, String body)
{
}
private final HttpServer server;
private final Deque<Response> responses = new ArrayDeque<>();
private final List<Request> requests = new CopyOnWriteArrayList<>();
/*******************************************************************************
** Start a server.
*******************************************************************************/
public FixtureServer() throws IOException
{
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", exchange ->
{
String body;
try(InputStream in = exchange.getRequestBody())
{
body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
requests.add(new Request(exchange.getRequestMethod(), exchange.getRequestURI().getRawQuery(), body));
Response response;
synchronized(responses)
{
response = responses.size() > 1 ? responses.pollFirst() : responses.peekFirst();
}
if(response == null)
{
response = new Response(500, "{\"error\":{\"message\":\"no response configured\"}}");
}
byte[] bytes = response.body().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(response.status(), bytes.length);
try(OutputStream out = exchange.getResponseBody())
{
out.write(bytes);
}
exchange.close();
});
server.start();
}
/*******************************************************************************
** Queue responses (the last one repeats).
*******************************************************************************/
public FixtureServer respond(Response... queued)
{
synchronized(responses)
{
responses.clear();
responses.addAll(List.of(queued));
}
return (this);
}
/*******************************************************************************
** A fixture file's content.
*******************************************************************************/
public static String fixture(String name) throws IOException
{
try(InputStream in = FixtureServer.class.getClassLoader().getResourceAsStream("fixtures/" + name))
{
return (new String(in.readAllBytes(), StandardCharsets.UTF_8));
}
}
/*******************************************************************************
** The server's base URL.
*******************************************************************************/
public String url(String path)
{
return ("http://127.0.0.1:" + server.getAddress().getPort() + path);
}
/*******************************************************************************
** Requests so far.
*******************************************************************************/
public List<Request> getRequests()
{
return (requests);
}
/*******************************************************************************
** Stop.
*******************************************************************************/
@Override
public void close()
{
server.stop(0);
}
}
@@ -0,0 +1,459 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf;
import java.io.ByteArrayInputStream;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.GZIPInputStream;
import com.kingsrook.qbits.webperf.field.CruxClient;
import com.kingsrook.qbits.webperf.lab.PsiLabRunner;
import com.kingsrook.qbits.webperf.processes.WebPerfProcesses;
import com.kingsrook.qbits.webperf.service.WebPerfService;
import com.kingsrook.qbits.webperf.spi.ContentResolver;
import com.kingsrook.qbits.webperf.spi.UrlSource;
import com.kingsrook.qbits.webperf.store.WebPerfStore;
import com.kingsrook.qbits.webperf.widgets.WebPerfWidgets;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Phase P1 end to end on H2 with the real changelog and a local server
** standing in for Google: sitemap sync, the queue (merge, daily cap), the
** worker (success, retry with backoff, failure, no key), Google field data
** (NO_DATA kept honestly), every action through QQQ's RunProcessAction, and
** every widget through RenderWidgetAction, with and without data.
*******************************************************************************/
class WebPerfP1Test
{
static final String SITE = "https://www.example.com";
private QInstance qInstance;
private WebPerfQBitConfig config;
private FixtureServer google;
private List<String> sitemap;
/*******************************************************************************
** An instance with a content table "page", the QBit wired to it, and the
** local Google stand-in.
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
google = new FixtureServer();
sitemap = new ArrayList<>(List.of(SITE + "/", SITE + "/about", SITE + "/blog/hello"));
List<String> source = sitemap;
qInstance = WebPerfTestSupport.newInstance();
qInstance.addTable(new QTableMetaData().withName("page").withBackendName(WebPerfTestSupport.BACKEND).withPrimaryKeyField("id")
.withBackendDetails(new RDBMSTableBackendDetails().withTableName("page"))
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
.withField(new QFieldMetaData("slug", QFieldType.STRING))
.withSection(new com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection("main", new com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon("description"),
com.kingsrook.qqq.backend.core.model.metadata.tables.Tier.T1, List.of("id", "slug"))));
config = WebPerfTestSupport.config()
.withSiteBaseUrl(SITE)
.withContentTables(List.of("page"))
.withKeyPaths(List.of("/"))
.withMinSpacingMillis(0)
.withCruxSpacingMillis(0)
.withUrlSource((UrlSource) () -> new ArrayList<>(source))
.withContentResolver((ContentResolver) path -> "/about".equals(path) ? new ContentResolver.ContentRef("page", 1L) : null)
.withLabRunner(new PsiLabRunner(google.url("/runPagespeed"), "AIzaTestKey"))
.withCruxClient(new CruxClient(google.url("/queryRecord"), "AIzaTestKey"));
new WebPerfQBitProducer().withQBitConfig(config).produce(qInstance);
new QInstanceValidator().validate(qInstance);
WebPerfTestSupport.applyChangelog(qInstance, "web_performance_");
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(WebPerfTestSupport.BACKEND));
Statement statement = connection.createStatement())
{
statement.execute("CREATE TABLE page (id INTEGER PRIMARY KEY, slug VARCHAR(100))");
statement.execute("INSERT INTO page (id, slug) VALUES (1, 'about'), (2, 'draft')");
}
WebPerfTestSupport.login(qInstance, "web_performance.read", "web_performance.write", "web_performance.test.hasAccess", "web_performance.admin.hasAccess", "web_performance.view.hasAccess");
}
/*******************************************************************************
** Stop the stand-in.
*******************************************************************************/
@AfterEach
void tearDown()
{
google.close();
QContext.clear();
}
/*******************************************************************************
** The service and store.
*******************************************************************************/
private WebPerfService service()
{
return (WebPerfRuntime.of(qInstance, "web_performance_").service());
}
private WebPerfStore store()
{
return (service().getStore());
}
/*******************************************************************************
** Sync adds pages (KEY by path, linked to records), and marks pages that
** left the sitemap without deleting them.
*******************************************************************************/
@Test
void testSync() throws Exception
{
WebPerfService service = service();
service.applySync(service.planSync(sitemap), Instant.now());
List<Map<String, Object>> rows = store().query("SELECT url, tier, content_table, content_id, in_sitemap FROM {target} ORDER BY id");
assertEquals(3, rows.size());
assertEquals("KEY", rows.get(0).get("tier"));
assertEquals("STANDARD", rows.get(1).get("tier"));
assertEquals("page", rows.get(1).get("content_table"));
assertEquals(1L, WebPerfStore.asLong(rows.get(1).get("content_id")));
WebPerfService.SyncPlan again = service.planSync(List.of(SITE + "/", SITE + "/about"));
assertEquals(0, again.newUrls().size());
assertEquals(2, again.seenIds().size());
assertEquals(1, again.removedIds().size());
service.applySync(again, Instant.now());
assertEquals(3, store().query("SELECT id FROM {target}").size(), "nothing is deleted");
assertEquals(Boolean.FALSE, store().query("SELECT in_sitemap FROM {target} WHERE url = ?", SITE + "/blog/hello").get(0).get("in_sitemap"));
}
/*******************************************************************************
** Queueing merges into waiting runs and records runs past the cap.
*******************************************************************************/
@Test
void testQueueMergeAndCap() throws Exception
{
config.withMaxRunsPerDay(3);
WebPerfService service = service();
service.applySync(service.planSync(sitemap), Instant.now());
WebPerfService.QueuePlan plan = service.enqueue(service.requestsFor(service.enabledTargets(false), "MANUAL", "test", Instant.now()), Instant.now());
assertEquals(3, plan.queue().size());
assertEquals(3, plan.overQuota().size());
assertEquals(3, store().query("SELECT id FROM {lab_run} WHERE status = 'QUEUED'").size());
assertEquals(3, store().query("SELECT id FROM {lab_run} WHERE status = 'SKIPPED_QUOTA'").size());
WebPerfService.QueuePlan again = service.enqueue(service.requestsFor(service.enabledTargets(true), "MANUAL", "test", Instant.now()), Instant.now());
assertEquals(2, again.merged().size(), "the KEY page's mobile and desktop runs are already waiting");
}
/*******************************************************************************
** The worker runs a test, stores scores, findings, the report and the
** page's latest results.
*******************************************************************************/
@Test
void testWorkerSuccess() throws Exception
{
google.respond(new FixtureServer.Response(200, FixtureServer.fixture("psi-kof22-home-mobile.json")));
WebPerfService service = service();
service.applySync(service.planSync(List.of(SITE + "/")), Instant.now());
service.enqueue(service.requestsFor(service.enabledTargets(false), "MANUAL", "test", Instant.now()), Instant.now());
assertEquals(2, service.runDue(Instant.now()));
List<Map<String, Object>> runs = store().query("SELECT id, status, score_performance, score_seo, lcp_ms, lighthouse_version, attempts FROM {lab_run} ORDER BY id");
assertThat(runs).allMatch(r -> "SUCCEEDED".equals(r.get("status")) && Integer.valueOf(81).equals(WebPerfStore.asInt(r.get("score_performance"))));
assertEquals("13.5.0", runs.get(0).get("lighthouse_version"));
assertEquals(1, WebPerfStore.asInt(runs.get(0).get("attempts")));
assertThat(store().query("SELECT id FROM {lab_finding} WHERE run_id = ?", runs.get(0).get("id"))).hasSizeGreaterThan(5);
Map<String, Object> report = store().query("SELECT content, bytes, sha256 FROM {lab_report} WHERE run_id = ?", runs.get(0).get("id")).get(0);
byte[] raw;
try(GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream((byte[]) report.get("content"))))
{
raw = in.readAllBytes();
}
assertEquals(FixtureServer.fixture("psi-kof22-home-mobile.json"), new String(raw, StandardCharsets.UTF_8), "the full response is kept unchanged");
assertEquals(raw.length, WebPerfStore.asInt(report.get("bytes")));
Map<String, Object> target = store().query("SELECT latest_mobile_performance, latest_desktop_performance, latest_accessibility FROM {target}").get(0);
assertEquals(81, WebPerfStore.asInt(target.get("latest_mobile_performance")));
assertEquals(81, WebPerfStore.asInt(target.get("latest_desktop_performance")));
assertEquals(82, WebPerfStore.asInt(target.get("latest_accessibility")));
assertEquals(0, service.runDue(Instant.now()), "nothing left");
assertThat(google.getRequests().get(0).query()).contains("key=AIzaTestKey");
}
/*******************************************************************************
** Server errors retry with backoff then fail; client errors fail at once;
** without a runner nothing is claimed.
*******************************************************************************/
@Test
void testWorkerRetriesAndFailures() throws Exception
{
config.withStrategies(List.of("MOBILE"));
WebPerfService service = service();
service.applySync(service.planSync(List.of(SITE + "/")), Instant.now());
service.enqueue(service.requestsFor(service.enabledTargets(false), "MANUAL", "test", Instant.now()), Instant.now());
google.respond(new FixtureServer.Response(500, "{\"error\":{\"message\":\"Backend error\"}}"));
for(int attempt = 1; attempt <= WebPerfService.MAX_ATTEMPTS; attempt++)
{
assertEquals(1, service.runDue(Instant.now().plus(Duration.ofHours(1))));
}
Map<String, Object> run = store().query("SELECT status, attempts, http_status, error_message FROM {lab_run}").get(0);
assertEquals("FAILED", run.get("status"));
assertEquals(3, WebPerfStore.asInt(run.get("attempts")));
assertEquals("HTTP 500: Backend error", run.get("error_message"));
store().update("DELETE FROM {lab_run}");
service.enqueue(service.requestsFor(service.enabledTargets(false), "MANUAL", "test", Instant.now()), Instant.now());
google.respond(new FixtureServer.Response(500, "{}"));
service.runDue(Instant.now());
Map<String, Object> waiting = store().query("SELECT status, attempts, not_before FROM {lab_run}").get(0);
assertEquals("QUEUED", waiting.get("status"));
assertTrue(((Instant) waiting.get("not_before")).isAfter(Instant.now()), "backed off");
assertEquals(0, service.runDue(Instant.now()), "not due yet");
store().update("DELETE FROM {lab_run}");
service.enqueue(service.requestsFor(service.enabledTargets(false), "MANUAL", "test", Instant.now()), Instant.now());
google.respond(new FixtureServer.Response(400, "{\"error\":{\"message\":\"Invalid URL\"}}"));
service.runDue(Instant.now());
assertEquals("HTTP 400: Invalid URL", store().query("SELECT error_message FROM {lab_run}").get(0).get("error_message"));
config.withLabRunner(null);
store().update("DELETE FROM {lab_run}");
service.enqueue(service.requestsFor(service.enabledTargets(false), "MANUAL", "test", Instant.now()), Instant.now());
assertEquals(0, WebPerfRuntime.of(qInstance, "web_performance_").service().runDue(Instant.now()), "no key: runs wait");
assertEquals("QUEUED", store().query("SELECT status FROM {lab_run}").get(0).get("status"));
}
/*******************************************************************************
** Google field data: NO_DATA is one row per key and form factor whose last
** check moves; data is stored per collection period.
*******************************************************************************/
@Test
void testFieldData() throws Exception
{
google.respond(new FixtureServer.Response(404, "{\"error\":{\"code\":404}}"));
WebPerfService service = service();
Map<String, Integer> first = service.refreshFieldData(Map.of("ORIGIN", List.of(SITE)), Instant.now());
assertEquals(Map.of("NO_DATA", 3), first);
service.refreshFieldData(Map.of("ORIGIN", List.of(SITE)), Instant.now().plusSeconds(60));
assertEquals(3, store().query("SELECT id FROM {field_snapshot}").size(), "an unchanged NO_DATA result is not stored twice");
google.respond(new FixtureServer.Response(200, FixtureServer.fixture("crux-origin-phone.json")));
service.refreshFieldData(Map.of("ORIGIN", List.of(SITE)), Instant.now());
assertEquals(3, store().query("SELECT id FROM {field_snapshot} WHERE status = 'OK'").size());
assertEquals(2367, WebPerfStore.asInt(store().query("SELECT p75_lcp_ms FROM {field_snapshot} WHERE status = 'OK'").get(0).get("p75_lcp_ms")));
}
/*******************************************************************************
** The actions through RunProcessAction: sync, test page speed (pages and a
** content record), retry, field data; then the scheduled worker.
*******************************************************************************/
@Test
void testActions() throws Exception
{
RunProcessOutput sync = run(WebPerfProcesses.SYNC_TARGETS);
assertThat(summary(sync)).anyMatch(l -> l.contains("3") && l.contains("added"));
assertEquals(3, store().query("SELECT id FROM {target}").size());
RunProcessOutput test = run(WebPerfProcesses.TEST_PAGE_SPEED);
assertThat(summary(test)).anyMatch(l -> l.contains("6") && l.contains("queued"));
assertEquals(6, store().query("SELECT id FROM {lab_run} WHERE status = 'QUEUED'").size());
RunProcessOutput again = run(WebPerfProcesses.TEST_PAGE_SPEED + "Page");
assertThat(summary(again)).anyMatch(l -> l.contains("merged")).anyMatch(l -> l.contains("no tested page"));
google.respond(new FixtureServer.Response(200, FixtureServer.fixture("psi-kof22-home-mobile.json")));
for(int i = 0; i < 3; i++)
{
runScheduled(WebPerfProcesses.RUN_QUEUED_TESTS);
}
assertEquals(6, store().query("SELECT id FROM {lab_run} WHERE status = 'SUCCEEDED'").size());
store().update("UPDATE {lab_run} SET status = 'FAILED' WHERE id = (SELECT MIN(id) FROM {lab_run})");
RunProcessOutput retry = run(WebPerfProcesses.RETRY_RUNS);
assertThat(summary(retry)).anyMatch(l -> l.contains("1") && l.contains("queued again")).anyMatch(l -> l.contains("not retried"));
google.respond(new FixtureServer.Response(404, "{}"));
RunProcessOutput field = run(WebPerfProcesses.REFRESH_FIELD_DATA);
assertThat(summary(field)).anyMatch(l -> l.contains("NO_DATA"));
assertEquals(12, store().query("SELECT id FROM {field_snapshot}").size(), "origin and 3 pages, 3 form factors each");
runScheduled(WebPerfProcesses.SCHEDULE_KEY_TESTS);
assertTrue(store().query("SELECT id FROM {lab_run} WHERE trigger_type = 'SCHEDULE'").size() >= 1);
}
/*******************************************************************************
** Every widget renders, empty and with data, with honest wording.
*******************************************************************************/
@Test
void testWidgets() throws Exception
{
assertThat(html(WebPerfWidgets.SITE_SCORES, Map.of())).contains("No page has been tested yet");
assertThat(html(WebPerfWidgets.FIELD_STATUS, Map.of())).contains("not checked yet");
assertThat(html(WebPerfWidgets.RECORD_PANEL, Map.of("tableName", "page", "id", "2"))).contains("no tested page");
google.respond(new FixtureServer.Response(200, FixtureServer.fixture("psi-kof22-home-mobile.json")));
WebPerfService service = service();
service.applySync(service.planSync(sitemap), Instant.now());
service.enqueue(service.requestsFor(service.enabledTargets(false), "MANUAL", "test", Instant.now()), Instant.now());
while(service.runDue(Instant.now()) > 0)
{
// run everything
}
google.respond(new FixtureServer.Response(404, "{}"));
service.refreshFieldData(Map.of("ORIGIN", List.of(service.origin())), Instant.now());
assertThat(html(WebPerfWidgets.SITE_SCORES, Map.of())).contains("Performance").contains("<b>81</b>").contains("Lighthouse 13.5.0").contains("key page");
assertThat(html(WebPerfWidgets.SLOWEST_PAGES, Map.of())).contains("/about").contains("2.27 s");
assertThat(html(WebPerfWidgets.OPPORTUNITIES, Map.of())).contains("unused-javascript");
assertThat(html(WebPerfWidgets.RECENT_RUNS, Map.of())).contains("SUCCEEDED").contains("0 runs waiting");
assertThat(html(WebPerfWidgets.FIELD_STATUS, Map.of())).contains("not available").contains("enough Chrome traffic");
assertThat(html(WebPerfWidgets.RECORD_PANEL, Map.of("tableName", "page", "id", "1"))).contains("What to fix").contains("<b>81</b>")
.doesNotContain("<script");
ChartData chart = (ChartData) new RenderWidgetAction().execute(new RenderWidgetInput()
.withWidgetMetaData(qInstance.getWidget(config.qualify(WebPerfWidgets.TREND)))).getWidgetData();
assertNotNull(chart.getChartData());
assertEquals(1, chart.getChartData().getLabels().size(), "one tested week");
}
/*******************************************************************************
** Run an action with every frontend step skipped.
*******************************************************************************/
private RunProcessOutput run(String name) throws Exception
{
RunProcessInput input = new RunProcessInput();
input.setProcessName(config.qualify(name));
input.setValues(new HashMap<>(Map.of()));
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
input.setCallback(new com.kingsrook.qqq.backend.core.actions.processes.QProcessCallback()
{
@Override
public com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter getQueryFilter()
{
return (new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter());
}
});
RunProcessOutput output = new RunProcessAction().execute(input);
assertTrue(output.getException().isEmpty(), () -> String.valueOf(output.getException().orElse(null)));
return (output);
}
/*******************************************************************************
** Run a scheduled (hidden) process.
*******************************************************************************/
private void runScheduled(String name) throws Exception
{
run(name);
}
/*******************************************************************************
** The Result summary lines as text.
*******************************************************************************/
@SuppressWarnings("unchecked")
private static List<String> summary(RunProcessOutput output)
{
Serializable value = output.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
List<String> lines = new ArrayList<>();
for(ProcessSummaryLineInterface line : (List<ProcessSummaryLineInterface>) value)
{
if(line instanceof com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine summaryLine)
{
lines.add(summaryLine.getCount() + " " + summaryLine.getMessage() + " " + summaryLine.getPluralPastMessage() + " " + summaryLine.getSingularPastMessage());
}
else
{
lines.add(String.valueOf(line.getMessage()));
}
}
return (lines);
}
/*******************************************************************************
** Render an HTML widget.
*******************************************************************************/
private String html(String widget, Map<String, String> params) throws Exception
{
RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(qInstance.getWidget(config.qualify(widget)));
input.setQueryParams(new HashMap<>(params));
return (((RawHTML) new RenderWidgetAction().execute(input).getWidgetData()).getHtml());
}
}
@@ -23,12 +23,16 @@ package com.kingsrook.qbits.webperf;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.webperf.sources.HttpBuildIdProbe;
import com.kingsrook.qbits.webperf.sources.SitemapUrlSource;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
@@ -80,7 +84,7 @@ class WebPerfQBitProducerTest
assertThat(qInstance.getQBits()).containsKey("com.kingsrook.qbits:web-performance");
for(String base : WebPerfSchema.TABLES)
{
assertNotNull(qInstance.getTable("webperf_" + base), base);
assertNotNull(qInstance.getTable("web_performance_" + base), base);
}
QAppMetaData app = qInstance.getApp(WebPerfQBitConfig.DEFAULT_APP_NAME);
@@ -94,18 +98,65 @@ class WebPerfQBitProducerTest
for(String base : List.of(WebPerfSchema.LAB_RUN, WebPerfSchema.LAB_FINDING, WebPerfSchema.LAB_REPORT, WebPerfSchema.FIELD_SNAPSHOT,
WebPerfSchema.CHANGE_EVENT, WebPerfSchema.COMPARISON, WebPerfSchema.REGRESSION))
{
QTableMetaData table = qInstance.getTable("webperf_" + base);
QTableMetaData table = qInstance.getTable("web_performance_" + base);
assertFalse(table.isCapabilityEnabled(qInstance.getBackend(WebPerfTestSupport.BACKEND), Capability.TABLE_INSERT), base);
assertTrue(table.getFields().values().stream().noneMatch(f -> Boolean.TRUE.equals(f.getIsEditable())), base);
}
for(String base : List.of(WebPerfSchema.TARGET, WebPerfSchema.BUDGET, WebPerfSchema.REGRESSION))
{
assertEquals(AuditLevel.FIELD, qInstance.getTable("webperf_" + base).getAuditRules().getAuditLevel(), base);
assertEquals(AuditLevel.FIELD, qInstance.getTable("web_performance_" + base).getAuditRules().getAuditLevel(), base);
}
assertEquals(AuditLevel.NONE, qInstance.getTable("webperf_lab_run").getAuditRules().getAuditLevel());
assertTrue(qInstance.getTable("webperf_target").getField("enabled").getIsEditable());
assertFalse(qInstance.getTable("webperf_target").getField("latestMobilePerformance").getIsEditable());
assertEquals("webperf", qInstance.getTable("webperf_target").getPermissionRules().getPermissionBaseName());
assertEquals(AuditLevel.NONE, qInstance.getTable("web_performance_lab_run").getAuditRules().getAuditLevel());
assertTrue(qInstance.getTable("web_performance_target").getField("enabled").getIsEditable());
assertFalse(qInstance.getTable("web_performance_target").getField("latestMobilePerformance").getIsEditable());
assertEquals("web_performance", qInstance.getTable("web_performance_target").getPermissionRules().getPermissionBaseName());
}
/*******************************************************************************
** Every enum field resolves to an install-scoped ENUM with stable stored IDs.
*******************************************************************************/
@Test
void testEnumFields() throws Exception
{
QInstance instance = WebPerfTestSupport.newInstance();
new WebPerfQBitProducer().withQBitConfig(WebPerfTestSupport.config().withTableNamePrefix("perf_")).produce(instance);
Map<String, List<String>> expected = Map.ofEntries(
Map.entry("tier", List.of("KEY", "STANDARD")),
Map.entry("target_source", List.of("SITEMAP", "MANUAL", "CONTENT")),
Map.entry("strategy", List.of("MOBILE", "DESKTOP")),
Map.entry("strategy_or_form_factor", List.of("MOBILE", "DESKTOP", "PHONE", "TABLET", "ALL")),
Map.entry("field_source", List.of("CRUX_API", "CRUX_HISTORY", "PSI")),
Map.entry("field_scope", List.of("URL", "ORIGIN")),
Map.entry("budget_scope", List.of("SITE", "TIER", "TARGET", "CONTENT_TABLE", "PATH_PREFIX")),
Map.entry("measurement_source", List.of("LAB", "RUM", "CRUX")),
Map.entry("comparison_source", List.of("LAB", "RUM")),
Map.entry("comparison_scope", List.of("KEY", "TARGET", "SITE")),
Map.entry("change_source", List.of("PROBE", "WEBHOOK", "QBIT_CDN", "USER")),
Map.entry("metric", List.of("LCP", "INP", "CLS", "FCP", "TTFB", "TBT", "PERFORMANCE_SCORE", "ACCESSIBILITY_SCORE", "BEST_PRACTICES_SCORE", "SEO_SCORE")),
Map.entry("comparator", List.of("<=", ">=")),
Map.entry("severity", List.of("WARNING", "CRITICAL")));
int count = 0;
for(QTableMetaData table : instance.getTables().values())
{
for(QFieldMetaData field : table.getFields().values())
{
if(List.of("tier", "scope", "source", "strategy", "metric", "comparator", "severity").contains(field.getName()))
{
count++;
assertThat(field.getPossibleValueSourceName()).as(table.getName() + "." + field.getName()).startsWith("perf_");
QPossibleValueSource source = instance.getPossibleValueSource(field.getPossibleValueSourceName());
assertEquals(QPossibleValueSourceType.ENUM, source.getType());
assertThat(source.getEnumValues()).extracting(v -> (String) v.getId()).containsExactlyElementsOf(expected.get(source.getName().substring(5)));
assertThat(source.getEnumValues()).allMatch(v -> v.getLabel() != null && !v.getLabel().isBlank());
}
}
}
assertEquals(21, count);
assertEquals("Key page", instance.getPossibleValueSource("perf_tier").getEnumValues().get(0).getLabel());
assertTrue(instance.getTable("perf_target").getField("url").getIsEditable());
assertTrue(instance.getTable("perf_target").isCapabilityEnabled(instance.getBackend(WebPerfTestSupport.BACKEND), Capability.TABLE_INSERT));
}
@@ -136,7 +187,7 @@ class WebPerfQBitProducerTest
new WebPerfQBitProducer().withQBitConfig(WebPerfTestSupport.config()).produce(qInstance);
new WebPerfQBitProducer().withNamespace("shop").withQBitConfig(WebPerfTestSupport.config().withAppName("shopPerformance")).produce(qInstance);
assertNotNull(qInstance.getTable("shop_target"));
assertNotNull(qInstance.getTable("webperf_target"));
assertNotNull(qInstance.getTable("web_performance_target"));
assertThatThrownBy(() -> new WebPerfQBitProducer().withQBitConfig(WebPerfTestSupport.config()).produce(qInstance))
.isInstanceOf(QException.class).hasMessageContaining("already exists");
@@ -196,7 +247,7 @@ class WebPerfQBitProducerTest
assertTrue(config.hasGoogleApiKey());
assertThat(config.toString()).doesNotContain("AIzaSecretValue").contains("<redacted>");
assertThat(WebPerfTestSupport.config().toString()).contains("googleApiKey=<none>");
assertEquals("webperfTestPageSpeed", config.qualify("TestPageSpeed"));
assertEquals("webPerformanceTestPageSpeed", config.qualify("TestPageSpeed"));
config.withFeatures(EnumSet.of(WebPerfFeature.LAB));
assertTrue(config.isOn(WebPerfFeature.LAB));
@@ -31,6 +31,9 @@ import java.util.Locale;
import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
@@ -41,6 +44,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.junit.jupiter.api.AfterEach;
@@ -76,7 +80,7 @@ class WebPerfSchemaTest
QInstance qInstance = WebPerfTestSupport.newInstance();
new WebPerfQBitProducer().withQBitConfig(WebPerfTestSupport.config().withTableNamePrefix(prefix)).produce(qInstance);
WebPerfTestSupport.applyChangelog(qInstance, prefix);
WebPerfTestSupport.login(qInstance, "webperf.read", "webperf.write");
WebPerfTestSupport.login(qInstance, "web_performance.read", "web_performance.write");
return (qInstance);
}
@@ -88,13 +92,13 @@ class WebPerfSchemaTest
@Test
void testEveryFieldHasItsColumn() throws Exception
{
QInstance qInstance = installed("webperf_");
QInstance qInstance = installed("web_performance_");
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(WebPerfTestSupport.BACKEND)))
{
DatabaseMetaData metaData = connection.getMetaData();
for(String base : WebPerfSchema.TABLES)
{
QTableMetaData table = qInstance.getTable("webperf_" + base);
QTableMetaData table = qInstance.getTable("web_performance_" + base);
Set<String> columns = new HashSet<>();
try(ResultSet resultSet = metaData.getColumns(null, null, table.getName(), null))
{
@@ -125,25 +129,37 @@ class WebPerfSchemaTest
@Test
void testSeededBudgets() throws Exception
{
QInstance qInstance = installed("webperf_");
List<QRecord> budgets = new QueryAction().execute(new QueryInput("webperf_budget").withFilter(new QQueryFilter())).getRecords();
QInstance qInstance = installed("web_performance_");
List<QRecord> budgets = new QueryAction().execute(new QueryInput("web_performance_budget").withFilter(new QQueryFilter())).getRecords();
assertEquals(13, budgets.size());
assertThat(budgets).allMatch(r -> Boolean.TRUE.equals(r.getValueBoolean("seeded")) && Boolean.TRUE.equals(r.getValueBoolean("enabled")));
for(QRecord budget : budgets)
{
for(String field : List.of("scope", "source", "strategy", "metric", "comparator", "severity"))
{
if(budget.getValue(field) != null)
{
QPossibleValueSource source = qInstance.getPossibleValueSource(qInstance.getTable("web_performance_budget").getField(field).getPossibleValueSourceName());
assertThat(source.getEnumValues()).extracting(v -> (String) v.getId()).contains(budget.getValueString(field));
}
}
}
QRecord inp = budgets.stream().filter(r -> "INP good (real users, p75)".equals(r.getValueString("name"))).findFirst().orElseThrow();
assertEquals("RUM", inp.getValueString("source"));
assertEquals(0, new BigDecimal("200").compareTo(inp.getValueBigDecimal("threshold")));
QRecord critical = budgets.stream().filter(r -> "CRITICAL".equals(r.getValueString("severity"))).findFirst().orElseThrow();
assertEquals("KEY", critical.getValueString("scopeValue"));
WebPerfTestSupport.applyChangelog(qInstance, "webperf_");
assertEquals(13, new QueryAction().execute(new QueryInput("webperf_budget").withFilter(new QQueryFilter())).getRecords().size());
WebPerfTestSupport.applyChangelog(qInstance, "web_performance_");
assertEquals(13, new QueryAction().execute(new QueryInput("web_performance_budget").withFilter(new QQueryFilter())).getRecords().size());
}
/*******************************************************************************
** A tested page and a budget can be added by a user with webperf.write,
** A tested page and a budget can be added by a user with web_performance.write,
** under any prefix; the unique key keeps one row per site and URL.
*******************************************************************************/
@Test
@@ -151,16 +167,68 @@ class WebPerfSchemaTest
{
installed("perf_");
InsertOutput output = new InsertAction().execute(new InsertInput("perf_target").withRecords(List.of(
new QRecord().withValue("siteKey", "site:1").withValue("url", "https://www.example.com/about").withValue("path", "/about")
.withValue("source", "MANUAL").withValue("tier", "KEY").withValue("enabled", true))));
new QRecord().withValue("url", "https://www.example.com/about").withValue("label", "About")
.withValue("tier", "KEY").withValue("enabled", true))));
assertThat(output.getRecords().get(0).getErrors()).isEmpty();
QRecord stored = new QueryAction().execute(new QueryInput("perf_target").withFilter(new QQueryFilter())).getRecords().get(0);
assertEquals("https://www.example.com/about", stored.getValueString("url"));
assertEquals("KEY", stored.getValueString("tier"));
assertEquals("host:www.example.com", stored.getValueString("siteKey"));
assertEquals("/about", stored.getValueString("path"));
assertEquals("MANUAL", stored.getValueString("source"));
assertEquals(false, stored.getValueBoolean("inSitemap"));
InsertOutput duplicate = new InsertAction().execute(new InsertInput("perf_target").withRecords(List.of(
new QRecord().withValue("siteKey", "site:1").withValue("url", "https://www.example.com/about"))));
assertThat(duplicate.getRecords().get(0).getErrors()).isNotEmpty();
}
/*******************************************************************************
** Invalid manual URLs return a clear error and never become tested pages.
*******************************************************************************/
@Test
void testManualPageUrlValidation() throws Exception
{
installed("perf_");
for(String url : List.of("", "/about", "http://www.example.com/about", "https:///about", "https://user:pass@example.com/", "https://www.example.com/#section", "not a URL"))
{
InsertOutput output = new InsertAction().execute(new InsertInput("perf_target").withRecords(List.of(new QRecord().withValue("url", url))));
assertThat(output.getRecords().get(0).getErrors()).isNotEmpty();
assertThat(output.getRecords().get(0).getErrors().toString()).contains("absolute HTTPS URL");
}
assertThat(new QueryAction().execute(new QueryInput("perf_target").withFilter(new QQueryFilter())).getRecords()).isEmpty();
}
/*******************************************************************************
** Editing a URL refreshes its path; invalid edits leave the stored URL intact.
*******************************************************************************/
@Test
void testManualPageUrlUpdates() throws Exception
{
installed("perf_");
QRecord inserted = new InsertAction().execute(new InsertInput("perf_target").withRecords(List.of(
new QRecord().withValue("url", "https://www.example.com")))).getRecords().get(0);
assertThat(inserted.getErrors()).isEmpty();
assertEquals("/", inserted.getValueString("path"));
UpdateOutput valid = new UpdateAction().execute(new UpdateInput("perf_target").withRecords(List.of(
new QRecord().withValue("id", inserted.getValue("id")).withValue("url", "https://www.example.com/new"))));
assertThat(valid.getRecords().get(0).getErrors()).isEmpty();
UpdateOutput invalid = new UpdateAction().execute(new UpdateInput("perf_target").withRecords(List.of(
new QRecord().withValue("id", inserted.getValue("id")).withValue("url", "/bad"))));
assertThat(invalid.getRecords().get(0).getErrors()).isNotEmpty();
UpdateOutput labelOnly = new UpdateAction().execute(new UpdateInput("perf_target").withRecords(List.of(
new QRecord().withValue("id", inserted.getValue("id")).withValue("label", "New page"))));
assertThat(labelOnly.getRecords().get(0).getErrors()).isEmpty();
QRecord stored = new QueryAction().execute(new QueryInput("perf_target").withFilter(new QQueryFilter())).getRecords().get(0);
assertEquals("https://www.example.com/new", stored.getValueString("url"));
assertEquals("/new", stored.getValueString("path"));
assertEquals("New page", stored.getValueString("label"));
}
}
@@ -68,9 +68,9 @@ public final class WebPerfTestSupport
.withName(BACKEND)
.withVendor("h2")
.withHostName("mem")
.withDatabaseName("webperf_" + UUID.randomUUID().toString().replace("-", ""))
.withDatabaseName("web_performance_" + UUID.randomUUID().toString().replace("-", ""))
.withUsername("sa");
backend.setJdbcUrl(ConnectionManager.getJdbcUrl(backend) + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1");
backend.setJdbcUrl("jdbc:h2:mem:" + backend.getDatabaseName() + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1");
QInstance qInstance = new QInstance();
qInstance.addBackend(backend);
@@ -110,7 +110,7 @@ public final class WebPerfTestSupport
/*******************************************************************************
** Apply the QBit's changelog with a prefix: each changeset's statements in
** order, comment lines skipped, ${webperf.tablePrefix} substituted.
** order, comment lines skipped, ${web_performance.tablePrefix} substituted.
**
** @param qInstance the instance
** @param prefix the table prefix
@@ -144,6 +144,11 @@ public final class WebPerfTestSupport
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND));
Statement statement = connection.createStatement())
{
//////////////////////////////////////////////////////////////////////
// the RDBMS module pools connections per backend name, so the in- //
// memory database outlives one test: start every test from empty //
//////////////////////////////////////////////////////////////////////
statement.execute("DROP ALL OBJECTS");
for(String sql : statements)
{
statement.execute(sql);
@@ -0,0 +1,198 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.lab;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.webperf.FixtureServer;
import com.kingsrook.qbits.webperf.field.CruxClient;
import com.kingsrook.qbits.webperf.spi.LabRunner;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The PSI parser on a real Lighthouse 13 result of www.kof22.com, the PSI
** runner and the CrUX client over real HTTP (a local server standing in for
** Google), including that the API key never leaks into errors or strings.
*******************************************************************************/
class LabClientsTest
{
/*******************************************************************************
** Scores, metrics, LCP element and findings of the real fixture.
*******************************************************************************/
@Test
void testParseRealLighthouseResult() throws Exception
{
LabResultParser.ParsedLab parsed = LabResultParser.parse(FixtureServer.fixture("psi-kof22-home-mobile.json"));
assertEquals("13.5.0", parsed.lighthouseVersion());
assertEquals("https://www.kof22.com/", parsed.finalUrl());
assertEquals(81, parsed.score("PERFORMANCE"));
assertEquals(82, parsed.score("ACCESSIBILITY"));
assertEquals(100, parsed.score("BEST_PRACTICES"));
assertEquals(92, parsed.score("SEO"));
assertEquals(1074, parsed.fcpMs());
assertEquals(2268, parsed.lcpMs());
assertEquals(82, parsed.tbtMs());
assertEquals(0.3507, parsed.cls());
assertEquals(1312, parsed.speedIndexMs());
assertEquals("div.mx-auto > div.grid > div.flex > p.mt-4", parsed.lcpElement());
assertNull(parsed.runtimeError());
assertTrue(parsed.fieldData().isEmpty(), "Google has no CrUX data for the site, and the parser invents none");
assertThat(parsed.chromeUserAgent()).contains("HeadlessChrome");
List<String> ids = parsed.findings().stream().map(LabResultParser.Finding::auditId).toList();
assertThat(ids).contains("unused-javascript", "cls-culprits-insight", "render-blocking-insight", "color-contrast", "button-name", "meta-description")
.doesNotContain("cumulative-layout-shift", "largest-contentful-paint")
.doesNotHaveDuplicates();
LabResultParser.Finding unused = finding(parsed, "unused-javascript");
assertEquals("OPPORTUNITY", unused.kind());
assertEquals("PERFORMANCE", unused.category());
assertEquals(150, unused.savingsMs());
assertEquals(22121L, unused.savingsBytes());
assertThat(unused.metricSavings()).contains("LCP");
assertThat(unused.topItems()).contains("https://www.kof22.com/_next/static/chunks/");
assertEquals("INSIGHT", finding(parsed, "cls-culprits-insight").kind());
LabResultParser.Finding contrast = finding(parsed, "color-contrast");
assertEquals("FAILED_AUDIT", contrast.kind());
assertEquals("ACCESSIBILITY", contrast.category());
assertThat(contrast.topItems()).contains("button.font-mono");
}
/*******************************************************************************
** A runtime error and PSI field data (loadingExperience).
*******************************************************************************/
@Test
void testRuntimeErrorAndFieldData()
{
LabResultParser.ParsedLab parsed = LabResultParser.parse("""
{"loadingExperience": {"id": "https://www.example.com/a", "origin_fallback": true, "metrics": {
"LARGEST_CONTENTFUL_PAINT_MS": {"percentile": 2100}, "CUMULATIVE_LAYOUT_SHIFT_SCORE": {"percentile": 5},
"INTERACTION_TO_NEXT_PAINT": {"percentile": 120}}},
"lighthouseResult": {"lighthouseVersion": "13.0.1", "runtimeError": {"code": "NO_FCP", "message": "The page did not paint any content."},
"runWarnings": ["The page loaded too slowly"], "categories": {"performance": {"score": null}}, "audits": {}}}""");
assertEquals("NO_FCP: The page did not paint any content.", parsed.runtimeError());
assertEquals("The page loaded too slowly", parsed.runWarnings());
assertNull(parsed.score("PERFORMANCE"));
assertEquals(1, parsed.fieldData().size());
LabResultParser.FieldData field = parsed.fieldData().get(0);
assertEquals("ORIGIN", field.scope(), "an origin fallback is labelled as origin data");
assertEquals(2100, field.p75LcpMs());
assertEquals(0.05, field.p75Cls());
assertEquals(120, field.p75InpMs());
assertThatThrownBy(() -> LabResultParser.parse("{\"kind\": \"x\"}")).hasMessageContaining("no Lighthouse result");
}
/*******************************************************************************
** The PSI runner sends url, strategy, every category and the key, returns
** any status, and never shows the key.
*******************************************************************************/
@Test
void testPsiRunner() throws Exception
{
try(FixtureServer google = new FixtureServer())
{
google.respond(new FixtureServer.Response(200, FixtureServer.fixture("psi-kof22-home-mobile.json")));
PsiLabRunner runner = new PsiLabRunner(google.url("/pagespeedonline/v5/runPagespeed"), "AIzaSecretKey");
LabRunner.LabResponse response = runner.run(new LabRunner.LabRequest("https://www.kof22.com/?a=b", "MOBILE",
Set.of("PERFORMANCE", "ACCESSIBILITY", "BEST_PRACTICES", "SEO")));
assertEquals(200, response.httpStatus());
assertEquals(81, LabResultParser.parse(response.rawJson()).score("PERFORMANCE"));
String query = google.getRequests().get(0).query();
assertThat(query).contains("url=https%3A%2F%2Fwww.kof22.com%2F%3Fa%3Db", "strategy=MOBILE", "category=PERFORMANCE", "category=ACCESSIBILITY",
"category=BEST_PRACTICES", "category=SEO", "key=AIzaSecretKey", "locale=en");
assertEquals("PSI", runner.name());
assertThat(runner.toString()).doesNotContain("AIzaSecretKey");
google.respond(new FixtureServer.Response(429, "{\"error\":{\"code\":429,\"message\":\"Quota exceeded\"}}"));
assertEquals(429, runner.run(new LabRunner.LabRequest("https://www.kof22.com/", "DESKTOP", Set.of("SEO"))).httpStatus());
}
PsiLabRunner unreachable = new PsiLabRunner("http://127.0.0.1:1/runPagespeed", "AIzaSecretKey");
assertThatThrownBy(() -> unreachable.run(new LabRunner.LabRequest("https://www.kof22.com/", "MOBILE", Set.of("SEO"))))
.hasMessageContaining("https://www.kof22.com/").hasMessageNotContaining("AIzaSecretKey");
assertThatThrownBy(() -> new PsiLabRunner("")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> unreachable.requestUrl(new LabRunner.LabRequest("https://x.example/", "TABLET", Set.of())))
.isInstanceOf(IllegalArgumentException.class);
}
/*******************************************************************************
** CrUX: data is parsed (CLS from its string form), 404 is NO_DATA, other
** errors fail, the key never shows.
*******************************************************************************/
@Test
void testCruxClient() throws Exception
{
try(FixtureServer google = new FixtureServer())
{
CruxClient client = new CruxClient(google.url("/v1/records:queryRecord"), "AIzaSecretKey");
google.respond(new FixtureServer.Response(200, FixtureServer.fixture("crux-origin-phone.json")));
CruxClient.CruxResult result = client.query("ORIGIN", "https://www.example.com", "PHONE");
assertEquals("OK", result.status());
assertEquals(2367, result.p75LcpMs());
assertEquals(147, result.p75InpMs());
assertEquals(0.05, result.p75Cls());
assertEquals(1906, result.p75FcpMs());
assertEquals(918, result.p75TtfbMs());
assertEquals(LocalDate.of(2026, 9, 13), result.periodStart());
assertEquals(LocalDate.of(2026, 10, 10), result.periodEnd());
assertThat(result.distributions()).contains("largest_contentful_paint");
assertThat(google.getRequests().get(0).body()).contains("\"origin\":\"https://www.example.com\"").contains("\"formFactor\":\"PHONE\"");
assertThat(google.getRequests().get(0).query()).contains("key=AIzaSecretKey");
google.respond(new FixtureServer.Response(404, "{\"error\":{\"code\":404,\"message\":\"chrome ux report data not found\",\"status\":\"NOT_FOUND\"}}"));
CruxClient.CruxResult none = client.query("URL", "https://www.example.com/a", "ALL");
assertEquals("NO_DATA", none.status());
assertNull(none.p75LcpMs());
assertThat(google.getRequests().get(1).body()).contains("\"url\"").doesNotContain("formFactor");
google.respond(new FixtureServer.Response(403, "{}"));
assertThatThrownBy(() -> client.query("ORIGIN", "https://www.example.com", "ALL")).hasMessageContaining("HTTP 403")
.hasMessageNotContaining("AIzaSecretKey");
assertThat(client.toString()).doesNotContain("AIzaSecretKey");
}
}
/*******************************************************************************
** A finding by audit id.
*******************************************************************************/
private static LabResultParser.Finding finding(LabResultParser.ParsedLab parsed, String id)
{
return (parsed.findings().stream().filter(f -> f.auditId().equals(id)).findFirst().orElseThrow());
}
}
+4
View File
@@ -0,0 +1,4 @@
# Test fixtures
- `psi-kof22-home-mobile.json`: a real Lighthouse 13.5.0 result for https://www.kof22.com/ (mobile, all four categories), run with the Lighthouse CLI on 2026-10-12 and wrapped in the PageSpeed Insights v5 response envelope (`kind`, `id`, `lighthouseResult`, `analysisUTCTimestamp`). Screenshots and i18n strings were removed to keep it small. There is no `loadingExperience`, because Google has no CrUX data for the site. Replace it with a response captured from the PSI API once the key is live.
- `crux-origin-phone.json`: a CrUX API `queryRecord` response with the documented shape (p75 per metric, CLS as a string, histograms, collection period). Its values are illustrative and used only in tests.
@@ -0,0 +1,31 @@
{
"record": {
"key": { "origin": "https://www.example.com", "formFactor": "PHONE" },
"metrics": {
"cumulative_layout_shift": {
"histogram": [ { "start": "0.00", "end": "0.10", "density": 0.8611 }, { "start": "0.10", "end": "0.25", "density": 0.0864 }, { "start": "0.25", "density": 0.0525 } ],
"percentiles": { "p75": "0.05" }
},
"experimental_time_to_first_byte": {
"histogram": [ { "start": 0, "end": 800, "density": 0.6543 }, { "start": 800, "end": 1800, "density": 0.2877 }, { "start": 1800, "density": 0.058 } ],
"percentiles": { "p75": 918 }
},
"first_contentful_paint": {
"histogram": [ { "start": 0, "end": 1800, "density": 0.7307 }, { "start": 1800, "end": 3000, "density": 0.1697 }, { "start": 3000, "density": 0.0996 } ],
"percentiles": { "p75": 1906 }
},
"interaction_to_next_paint": {
"histogram": [ { "start": 0, "end": 200, "density": 0.8899 }, { "start": 200, "end": 500, "density": 0.0819 }, { "start": 500, "density": 0.0282 } ],
"percentiles": { "p75": 147 }
},
"largest_contentful_paint": {
"histogram": [ { "start": 0, "end": 2500, "density": 0.7911 }, { "start": 2500, "end": 4000, "density": 0.1314 }, { "start": 4000, "density": 0.0775 } ],
"percentiles": { "p75": 2367 }
}
},
"collectionPeriod": {
"firstDate": { "year": 2026, "month": 9, "day": 13 },
"lastDate": { "year": 2026, "month": 10, "day": 10 }
}
}
}
@@ -0,0 +1 @@
{"captchaResult":"CAPTCHA_NOT_NEEDED","kind":"pagespeedonline#result","id":"https://www.kof22.com/","lighthouseResult":{"lighthouseVersion":"13.5.0","requestedUrl":"https://www.kof22.com/","mainDocumentUrl":"https://www.kof22.com/","finalDisplayedUrl":"https://www.kof22.com/","finalUrl":"https://www.kof22.com/","fetchTime":"2026-10-12T18:18:02.075Z","gatherMode":"navigation","runWarnings":[],"userAgent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36","environment":{"networkUserAgent":"Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Mobile Safari/537.36","hostUserAgent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36","benchmarkIndex":2588.5,"credits":{"axe-core":"4.14.0"}},"audits":{"is-on-https":{"id":"is-on-https","title":"Uses HTTPS","description":"All sites should be protected with HTTPS, even ones that don't handle sensitive data. This includes avoiding [mixed content](https://developers.google.com/web/fundamentals/security/prevent-mixed-content/what-is-mixed-content), where some resources are loaded over HTTP despite the initial request being served over HTTPS. HTTPS prevents intruders from tampering with or passively listening in on the communications between your app and your users, and is a prerequisite for HTTP/2 and many new web platform APIs. [Learn more about HTTPS](https://developer.chrome.com/docs/lighthouse/pwa/is-on-https/).","score":1,"scoreDisplayMode":"binary","details":{"type":"table","headings":[{"key":"url","valueType":"url","label":"Insecure URL"},{"key":"resolution","valueType":"text","label":"Request Resolution"}],"items":[]}},"redirects-http":{"id":"redirects-http","title":"Redirects HTTP traffic to HTTPS","description":"Make sure that you redirect all HTTP traffic to HTTPS in order to enable secure web features for all your users. [Learn more](https://developer.chrome.com/docs/lighthouse/pwa/redirects-http/).","score":null,"scoreDisplayMode":"notApplicable"},"first-contentful-paint":{"id":"first-contentful-paint","title":"First Contentful Paint","description":"First Contentful Paint marks the time at which the first text or image is painted. [Learn more about the First Contentful Paint metric](https://developer.chrome.com/docs/lighthouse/performance/first-contentful-paint/).","score":0.99,"scoreDisplayMode":"numeric","numericValue":1073.712,"numericUnit":"millisecond","displayValue":"1.1 s","scoringOptions":{"p10":1800,"median":3000}},"largest-contentful-paint":{"id":"largest-contentful-paint","title":"Largest Contentful Paint","description":"Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn more about the Largest Contentful Paint metric](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-largest-contentful-paint/)","score":0.94,"scoreDisplayMode":"numeric","numericValue":2267.712,"numericUnit":"millisecond","displayValue":"2.3 s","scoringOptions":{"p10":2500,"median":4000}},"speed-index":{"id":"speed-index","title":"Speed Index","description":"Speed Index shows how quickly the contents of a page are visibly populated. [Learn more about the Speed Index metric](https://developer.chrome.com/docs/lighthouse/performance/speed-index/).","score":1,"scoreDisplayMode":"numeric","numericValue":1311.9405829736213,"numericUnit":"millisecond","displayValue":"1.3 s","scoringOptions":{"p10":3387,"median":5800}},"screenshot-thumbnails":{"id":"screenshot-thumbnails","title":"Screenshot Thumbnails","description":"This is what the load of your site looked like.","score":1,"scoreDisplayMode":"informative"},"final-screenshot":{"id":"final-screenshot","title":"Final Screenshot","description":"The last screenshot captured of the pageload.","score":1,"scoreDisplayMode":"informative"},"total-blocking-time":{"id":"total-blocking-time","title":"Total Blocking Time","description":"Sum of all time periods between FCP and Time to Interactive, when task length exceeded 50ms, expressed in milliseconds. [Learn more about the Total Blocking Time metric](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-total-blocking-time/).","score":0.99,"scoreDisplayMode":"numeric","numericValue":82,"numericUnit":"millisecond","displayValue":"80 ms","scoringOptions":{"p10":200,"median":600}},"max-potential-fid":{"id":"max-potential-fid","title":"Max Potential First Input Delay","description":"The maximum potential First Input Delay that your users could experience is the duration of the longest task. [Learn more about the Maximum Potential First Input Delay metric](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-max-potential-fid/).","score":0.92,"scoreDisplayMode":"numeric","numericValue":122,"numericUnit":"millisecond","displayValue":"120 ms"},"cumulative-layout-shift":{"id":"cumulative-layout-shift","title":"CumulatiLine truncated