Compare commits

...
77 Commits
Author SHA1 Message Date
CI be80ba5715 Bump version to 0.1.0-RC.8 [skip ci] 2026-11-07 18:33:24 +00:00
james.maes 05a175a35d Merge pull request 'Merge develop into release/0.1 (Gitea-aligned RC)' (#39) from align/develop-into-release-0.1 into release/0.1
build / maven (push) Successful in 3m46s
maven / build (push) Successful in 3m46s
2026-11-07 18:22:51 +00:00
james.maesandClaude Opus 5.5 a5025b063d Merge develop into release/0.1 (Gitea-aligned RC)
build / maven (push) Canceled after 0s
maven / build (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:22:28 -06:00
james.maes 4624ae6461 Merge pull request 'build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)' (#38) from deps/align-gitea into develop
build / maven (push) Successful in 2m56s
maven / build (push) Successful in 2m56s
2026-11-07 18:22:07 +00:00
james.maesandClaude Opus 5.5 d5288c55a6 build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)
build / maven (push) Successful in 4m14s
maven / build (push) Successful in 4m14s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.7 -> 4.1.0-RC.26
pom.xml: added qqq.version=4.1.0-RC.26 (parent BOM import)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:12:05 +00:00
james.maes 8784d913e6 Merge pull request 'ci: pin shared workflow to v1' (#37) from ci/pin-v1-develop into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 13s
QRun-IO/ci-policy: native / native-result (push) Successful in 11s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 11s
build / maven (push) Successful in 3m26s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 3m26s
2026-11-06 16:11:55 +00:00
james.maesandClaude Opus 5.5 25a24f1bc8 ci: pin shared workflow to v1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
build / maven (push) Successful in 2m31s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 2m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:15:30 +00:00
james.maes f68d370e92 Merge pull request 'ci: use shared ci-workflows Maven workflow' (#36) from ci/ci-workflows into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
build / maven (push) Successful in 2m20s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 6s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 2m20s
2026-11-05 23:18:32 +00:00
CI afe9b54831 ci: rerun shared workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
build / maven (push) Successful in 1m56s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Failing after 1s
maven / build (push) Successful in 1m56s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 1m48s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Failing after 19m14s
2026-11-05 16:28:06 -06:00
james.maesandClaude Opus 5.5 3741e90059 ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
build / maven (push) Failing after 2s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Failing after 2s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 2s
maven / build (push) Failing after 2s
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 2s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:09:42 +00:00
forge-recovery 257788ec60 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 32m44s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
qrun-release / postpublish (push) Failing after 10m16s
Shared Java Maven CI with Docker / verify (push) Successful in 2m10s
Shared Java Maven CI with Docker / secrets (push) Successful in 19s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 20s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 1m33s
Shared Java Maven CI with Docker / gate (push) Successful in 16s
qrun-release / release (push) Successful in 32m40s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 15s
Shared QRun source version / version (push) Failing after 10m16s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
2026-11-02 22:24:45 +00:00
forge-recovery 40bc96ba7c ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 34s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 11s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 21m43s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 1m39s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 16s
2026-11-02 21:36:42 +00:00
forge-recoveryandCodex a80c090ad3 ci: enroll shared Gitea release-candidate lifecycle
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 8s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 2m11s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 21s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 1h6m17s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:22:20 +00:00
311de2c433 ci: add the isolated postpublish job to the QRun release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 14s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
qrun-release / postpublish (push) Canceled after 0s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h25m15s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 1m52s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 3h8m5s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:55:41 +00:00
d79b413626 ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Canceled after 7m59s
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 6s
Shared Java Maven CI with Docker / secrets (push) Successful in 10s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Canceled after 0s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 8m10s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 15s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:22:10 +00:00
forge-recoveryandCodex 10fe5215b6 ci: add the identical QRun release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Successful in 59m33s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-8 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 1m45s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 13s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:17:09 +00:00
james.maesandCodex ebb2ba18fb ci: adopt managed orb 0.8 patch track (#30)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:28:13 -05:00
james.maesandCodex 7803c5577b ci: adopt shared orb 0.7.18 (#29)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:43:40 -05:00
james.maesandCodex f1d8916d9f ci: adopt shared orb 0.7.17 (#28)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:15:56 -05:00
james.maesandCodex 97def4ce37 ci: centralize release branch verification and workflows (#27)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 13:06:23 -05:00
james.maesandCodex d79ec6a105 ci: centralize QBit source scanning (#25)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:42:15 -05:00
james.maesandCodex fb64a3ecb0 ci: adopt central orb 0.7.12 and stable gates (#24)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:56:13 -05:00
james.maesandCodex a0aef77ae4 ci: centralize rc release completion in qqq orb (#23)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:25:29 -05:00
james.maesandCodex 909652f1f2 ci: adopt shared orb 0.7.11 (#22)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:59:25 -05:00
james.maes 2da172a50e Merge pull request #21 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:06:55 -05:00
james.maesandCodex 6a05a82221 ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:02:54 -05:00
CircleCI 4c5c32573a Bump version to 0.1.0-RC.7 [skip ci] 2026-10-19 11:56:24 +00:00
james.maes bfe4beb90e Merge pull request #19 from QRun-IO/feature/audit-quota-rc-prep
chore(release): prepare scheduled audit quota rotation on 0.1
2026-10-19 06:54:40 -05:00
james.maesandOpenAI GPT-6 4ea4af2426 chore(release): prepare audit quota rotation on 0.1 line
Merge verified develop ec2e40b into the isolated release preparation branch. Preserve the current RC revision for the release automation to advance; help bundle and producer metadata remain build-version filtered.

Co-Authored-By: OpenAI GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 06:50:27 -05:00
james.maes ec2e40ba41 Merge pull request #18 from QRun-IO/fix/audit-quota-rotation
fix(scheduling): rotate audit requests across the daily quota
2026-10-19 06:44:10 -05:00
james.maesandOpenAI GPT-6 9ca5f13874 fix(scheduling): rotate audit requests across daily quota
Prioritize never-queued and least-recently-queued page/device pairs using persisted non-quota-skipped history. Scheduled key-page and full audits continue sharing the existing daily allowance with manual requests. Cover odd caps, repeated scheduling, shared allowance and recreated services.

Co-Authored-By: OpenAI GPT-6 <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 06:41:31 -05:00
CircleCI 3002dd4472 Bump version to 0.1.0-RC.6 [skip ci] 2026-10-16 16:05:16 +00:00
github-actions[bot] 86efefbe6a Merge develop into release/0.1 for the next 0.1.0 RC 2026-10-16 16:02:50 +00:00
james.maes b00fb256b6 Merge pull request #17 from QRun-IO/feature/page-speed-business-home
feat(home): business-only Page Speed home; setup and run queue become admin widgets
2026-10-16 10:19:44 -05:00
james.maesandClaude Opus 5.5 a6c6c99e11 feat(home): business-only Page Speed home; provider setup and run queue become admin widgets
Replace the provider Setup card on the home with a business "not set up"
empty state (no config key names, empty once configured). Setup and Recent
Runs leave the home and become WebPerfWidgets.ADMIN, gated to
<base>.admin.hasAccess with DenyBehavior.HIDDEN, for a host Admin area.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:12:51 -05:00
james.maes 0b8b9f8214 Merge pull request #16 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:05:25 -05:00
james.maesandClaude Opus 5.5 483c3d48b3 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:28 -05:00
james.maes 6e6deca794 Merge pull request #15 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:58 -05:00
james.maesandClaude Opus 5.5 9003d1dabe fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-web-performance.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:33:04 -05:00
james.maes c937d753df Merge pull request #14 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
james.maesandClaude Opus 5.5 ffa50f6964 fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:10:27 -05:00
CircleCI ea9a7b916d Bump version to 0.1.0-RC.5 [skip ci] 2026-10-15 02:44:36 +00:00
james.maesandClaude Opus 5.5 30f7adfa10 Merge develop into release/0.1 for 0.1.0-RC.5 (#13 help bundle)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:41:29 -05:00
james.maes 8e2ecb3b3f Merge pull request #13 from QRun-IO/feature/help-bundle
feat(help): qbit-web-performance help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:45:30 -05:00
james.maesandClaude Opus 5.5 1e71915f7b feat(help): ship the qbit-web-performance help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:37:23 -05:00
CircleCI cfce62b3ed Bump version to 0.1.0-RC.4 [skip ci] 2026-10-14 23:25:29 +00:00
james.maesandClaude Opus 5.5 89b4102833 Merge develop into release/0.1 for 0.1.0-RC.4 (#11 indexes and FKs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:22:24 -05:00
james.maes 8b5b6fde6b Merge pull request #11 from QRun-IO/feature/foreign-keys
feat(schema): foreign keys for every table reference, with orphan quarantine
2026-10-14 18:00:28 -05:00
james.maesandClaude Opus 5.5 588ae52cd9 test(reports): give the old retention report a real run
The foreign key on lab_report.run_id rejects develop's placeholder run 999999,
so the 40-day-old report now belongs to an inserted FAILED run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:51:18 -05:00
james.maesandClaude Opus 5.5 cad99545b2 Merge origin/develop into feature/foreign-keys
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:49:47 -05:00
CircleCI 805d2e336f Bump version to 0.1.0-RC.3 [skip ci] 2026-10-14 22:34:27 +00:00
james.maesandClaude Opus 5.5 7d5582266d Merge develop into release/0.1 for 0.1.0-RC.3 (#12 setup card, seeds, help)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:31:43 -05:00
james.maes 13eebd9d9c Merge pull request #12 from QRun-IO/feature/tables-populated-defaults
Web performance: table help, Setup card, defaults test, enforce reportRetentionDays
2026-10-14 17:19:51 -05:00
james.maesandClaude Sonnet 5.5 4565a12cd4 fix(reports): prune after commit, hourly and batched; guardrail and test additions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 17:17:17 -05:00
james.maesandClaude Opus 5.5 dff3077f50 feat(help): table help, Setup card, guardrail tests, enforce reportRetentionDays
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:06:48 -05:00
james.maesandClaude Opus 5.5 fb856fdb39 test(schema): take a join's referencing side from its key field
A join's child column is on the side whose field is not the primary key,
so a many-to-one join (child -> parent) is checked on the child too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:35:13 -05:00
james.maesandClaude Opus 5.5 7ed9d0b81e feat(schema): foreign keys for every table reference, with orphan quarantine
Changesets 12-26 add a foreign key for each possible-value reference between
the QBit's tables, named fk_<child table>_<column>:

- lab_finding.run_id, lab_report.run_id -> lab_run: CASCADE (owned detail)
- comparison.change_event_id -> change_event: CASCADE (computed for the change)
- regression.cause_change_event_id, lab_run.change_event_id -> change_event,
  regression.first_run_id / checkpoint_run_id -> lab_run: SET NULL (pointers)
- lab_run / lab_finding / regression / comparison .target_id -> target and
  regression.budget_id -> budget: RESTRICT (history is kept, D8; pages and
  budgets are disabled, not deleted; the QBit never deletes either)

Each changeset first records parent-less rows in qqq_fk_quarantine and nulls
them (or, for the NOT NULL run_id columns, copies the row as JSON and deletes
it, PostgreSQL only), and is marked ran when the constraint already exists.
Changeset 12 indexes the foreign key columns that had no leading index.

WebPerfForeignKeyTest runs the changelog with Liquibase on PostgreSQL
(Testcontainers), checks the orphan path and re-run idempotency, and fails
when a table reference in the meta-data has no constraint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:16:35 -05:00
CircleCI 51b8608896 Bump version to 0.1.0-RC.2 [skip ci] 2026-10-14 16:08:01 +00:00
james.maesandClaude Opus 5.5 1521906bca Merge develop into release/0.1 for 0.1.0-RC.2 (#9 #10)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:04:15 -05:00
james.maes 5a9ee80b49 Merge pull request #10 from QRun-IO/feature/regressions-budgets
feat(evaluation): write regressions, budget checks, site changes and comparisons (#8)
2026-10-14 11:03:58 -05:00
james.maes e8d226bd69 Merge remote-tracking branch 'origin/develop' into feature/regressions-budgets
# Conflicts:
#	src/main/java/com/kingsrook/qbits/webperf/metadata/WebPerfMetaData.java
2026-10-14 11:01:38 -05:00
james.maesandClaude Opus 5.5 0745f331f4 feat(evaluation): write regressions, budget checks, site changes and comparisons
The regression, budget, change event and comparison tables had list
screens but nothing wrote them; the open regression count was a
hard-coded 0 and budgets were never evaluated.

- Change events: new build ids from the DeployProbe and ChangeEventSource
  events become durable, idempotent change_event rows (event_key);
  lab runs record the deployment (change event) and CDN stage.
- Budgets are evaluated after lab runs and field imports; breaches open
  or refresh regressions.
- Regression detection compares new results with the baseline per page,
  strategy and metric as the design describes, links the likely change,
  confirms before opening, and resolves on recovery. The open regression
  count is real.
- Before/after comparisons are written per change event.
- Dismiss and Mark resolved are standard record actions on regressions.
- Optional alerts through core QAlerts (logging sink without the Alerts
  qbit). QQQ BOM moves to the released 4.1.0-RC.6 for core alerts.
- Changeset 11 adds the progress and identity columns.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:00:41 -05:00
james.maes 78dc868cf4 Merge pull request #7 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:57:13 -05:00
james.maes 48ad6f6504 Merge pull request #9 from QRun-IO/fix/enum-dropdowns-tested-pages
fix(metadata): dropdowns for enum fields; manual Tested Pages insert works
2026-10-14 10:50:30 -05:00
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.maesandClaude Opus 5.5 fd0abfcb6f ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:05:31 -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
CircleCI 1cd3c0e883 Bump version to 0.1.0-RC.1 [skip ci] 2026-10-13 01:16:11 +00: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
69 changed files with 11579 additions and 428 deletions

No files matched your search

+11 -68
View File
@@ -1,71 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*|hotfix\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
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\/.*/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
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
+7
View File
@@ -0,0 +1,7 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
+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: ['*']
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+74
View File
@@ -0,0 +1,74 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+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.
+33
View File
@@ -0,0 +1,33 @@
# Changelog
All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning.
## [0.1.0] - Unreleased
### Changed
- The Performance home is business-only. The provider Setup readout and Recent Runs left the home's widget list (`WebPerfWidgets.HOME`); they are `WebPerfWidgets.ADMIN` (`Setup`, `RecentRuns`), gated to `<base>.admin.hasAccess` and hidden when denied, for a host to place on its Admin area. A new `NotConfigured` widget leads the home: while no Google API key is set it says "Page speed testing is not set up. Ask an admin to add a Google PageSpeed API key." (no config key names), and renders nothing once configured.
### Fixed
- Read the QBit metadata version from the build so release artifacts report their actual version, matching the help bundle.
- 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
- Ship the host-neutral `help/qbit-web-performance.yaml` QBIT bundle, register it with QQQ help bundles, and enforce metadata coverage and live-reference validity with `WebPerfHelpBundleTest`. Keep app and table empty-state help available without Help installed.
- Foreign keys for every reference between the QBit's tables (changesets 12-26): findings and reports cascade with their run, comparisons with their site change; optional pointers (likely cause, first and confirmation run, a run's site change) are set to NULL; tested pages and budgets with history cannot be deleted (disable them instead). Rows whose parent is already missing are recorded in `qqq_fk_quarantine`, then nulled or (NOT NULL columns, PostgreSQL) deleted, so the upgrade never fails. Foreign key columns are indexed. A PostgreSQL guardrail test fails when a new reference lacks its constraint.
- Every `web_performance_*` table and the Performance app carry help saying what the table is for and how its rows get created (`help/qbit-web-performance.yaml`, applied by `WebPerfHelp`; host help is never overridden). A Setup card at the top of the home lists tested pages, the lab runner, budgets (reference defaults or your own) and Google field data, each with its state and next step, using the runtime's own resolution. `WebPerfTablesGuardrailTest` fails when a table is unused, unexplained or lacks that help; `WebPerfDefaultsChangelogTest` runs the shipped changelog and its seeded budgets through real Liquibase.
- `reportRetentionDays` is now enforced: with a value above 0, Lighthouse Reports older than that many days are deleted after a result is stored, in batches, at most once an hour per process (runs, scores and findings stay). It was documented but nothing read it; the default 0 still keeps everything.
- Record deploy and external site changes once, queue settled retests, and attribute lab runs to the observed build and CDN stage.
- Evaluate enabled lab and URL CrUX budgets; retain breaches, confirmation evidence, automatic recovery and accurate open counts in the standard regression records.
- Confirm statistical lab regressions with a median of three, reset baselines on Lighthouse major changes, and detect deterministic category/audit failures.
- Add permission-gated Dismiss, Mark resolved and Compare before and after record actions with standard Review/Result summaries.
- Store before/after lab medians, sample counts and windows, including measured TTFB; schedule CDN samples before launch and at one and 24 hours after launch.
- Raise and resolve optional alerts through QQQ core, with logging fallback when no Alerts QBit is installed.
### Changed
- Pin QQQ 4.1.0-RC.7 for the released help-bundle API.
- Use QQQ 4.1.0-RC.6 for the core alerts API; add migration fields for durable evaluation and source-event identity.
- Store complete run results atomically and retain confirmation/comparison identities when retrying samples.
### Previously 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).
+48 -20
View File
@@ -13,15 +13,19 @@ Design and owner decisions: QRun-IO/qqq `docs/specs/2026-10-12-web-performance-d
## Status
This is a **skeleton (0.1.0-SNAPSHOT)**. It is local only, and the GitHub repository does not exist yet. It contains:
The lab and Google field-data runners, queue, schedules, record actions and performance widgets are implemented. This change adds durable site changes, budget evaluation, confirmed lab regressions and before/after lab comparisons.
- the producer and config, with validation and secret redaction
- the nine `webperf_` 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`
- Deploy probes run at startup through the scheduler and every five minutes. New build ids are recorded once per component, and KEY pages are queued after `deploySettleSeconds`. External `ChangeEventSource` events are deduplicated by source registration and their complete event tuple. Keep source registration order stable across restarts. Content events queue enabled pages after `publishSettleSeconds`, because the current SPI does not identify affected target ids.
- Each run records the latest observed frontend build, hostname CDN stage and preceding site change when it starts. A rollback to an already recorded build updates its last observation without duplicating its change row.
- Enabled LAB budgets match site, tier, target, content table or literal path prefix, plus strategy and metric. Comparators describe acceptable values. Breaches are recorded in Regressions with the budget id; there is no separate budget-check table in the design. The strongest matching severity wins. CRUX budgets evaluate URL p75 snapshots; NO_DATA, errors and origin fallback are never treated as page measurements.
- Statistical lab baselines require five successful SCHEDULE, CONFIRM or SITE_AUDIT runs in the preceding 30 days on the same Lighthouse major version. Performance drops of 10 points, LCP increases of both 25% and 500 ms, CLS increases of 0.05, TBT increases of 200 ms, and budget breaches create suspected regressions. Two independent confirmation runs provide a median of three. Category drops of 5 points and newly failing Accessibility, Best Practices or SEO audits confirm immediately. Recovery requires a median of three later runs. Lighthouse major changes record a marker and reset incompatible baselines.
- The page's open count includes suspected and confirmed regressions. **Dismiss** and **Mark resolved** require a note and `web_performance.manage.hasAccess`, use standard Review/Result summary lines, and preserve history.
- **Compare before and after** uses three successful same-version lab samples on each side of a change, with page, sample counts and actual time windows. It stores Performance, LCP, FCP and measured TTFB when available. Results are also updated after attributed runs complete. CDN HEADERS_ONLY queues three samples per strategy; LIVE queues three at +1 hour and three at +24 hours. Insufficient history produces no invented comparison.
- Confirmed regressions raise keyed `webperf.regression` alerts through QQQ core `QAlerts`; recovery, dismissal and manual resolution clear them. `ALERTS` can be disabled. Without an Alerts QBit, core uses its logging sink. No Alerts QBit dependency is required.
Not built yet: the PSI and CrUX runners (they need the key), the queue worker, the schedules, the actions, the widgets, and the Alerts qbit producer integration.
Evaluation progress and related writes commit together. Retrying an evaluation does not duplicate incidents, and retrying a failed confirmation preserves its sample reference. The additive changelog must be applied before running the new worker. This version requires QQQ **4.1.0-RC.6** for the core alerts API.
RUM drift and RUM/country before-and-after comparisons remain separate work: the existing `RumSource` exposes fixed daily/28-day aggregates, not arbitrary before/after windows. Enum field metadata and Tested Pages insertion are also outside this change.
## Install
@@ -40,27 +44,49 @@ 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 |
### Starter defaults and empty-state help
The changelog's changeset 10 seeds 13 reference budgets (Google's "good" thresholds and the Lighthouse category bands); nothing else is seeded. The target table is filled by Sync pages from sitemap, and the lab, finding, report, field, regression, change and comparison tables are filled by the qbit as it runs (empty is a normal start). Every table's help (`help/qbit-web-performance.yaml`, shown on the table screen) says how its rows get created, and the Setup card on the home shows what still needs doing. Budgets with source RUM are not evaluated yet (no real-user source is wired); LAB and CRUX budgets are.
## Scheduled audit quota
Set `withMaxRunsPerDay(300)` in the host configuration to reserve headroom below the default 500-run cap. Key-page schedules, full audits and manually queued runs share that daily allowance in the configured time zone. Scheduled requests prioritize page/device pairs never queued before, then those queued least recently. Quota-skipped rows do not advance that rotation, so large sitemaps continue through the remaining pairs on later days instead of repeatedly testing the first pages. Existing queued/running pairs still merge; manual selection keeps its requested order.
## In-app help
The QBit registers [`help/qbit-web-performance.yaml`](src/main/resources/help/qbit-web-performance.yaml) with QQQ help bundles (RC.7 or later). Registration is metadata-only; core skips startup synchronization when the host has no `helpContent` table. App and table empty-state help remains available without Help installed.
To override text, register your own uniquely named `layer: HOST` bundle with entries using the same keys and roles. HOST entries take precedence over QBIT entries; startup synchronization preserves administrator edits. Keep deployment-specific URLs, schedules and content-table actions in the host bundle. The shipped keys use the default metadata names; hosts that rename or namespace objects should provide help matching those names.
`WebPerfHelpBundleTest` checks owned metadata, live references and summary length with `HelpBundleAssertions`. Run it as part of `./mvnw verify`. The bundle version is the literal Maven project version; update it when changing the project version.
## 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 +94,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").
+9
View File
@@ -0,0 +1,9 @@
# Upgrading
## Business-only home; admin widgets (unreleased)
No DDL change. The Setup and Recent Runs widgets are no longer on the Performance home. They are still registered (`webPerformanceSetup`, `webPerformanceRecentRuns`), gated to `web_performance.admin.hasAccess` and hidden without it; add `WebPerfWidgets.ADMIN` (qualified with `config.qualify`) to your Admin app's widgets to keep them. The home gains a `webPerformanceNotConfigured` widget that shows only while no Google API key is set.
## Table help, Setup card and `reportRetentionDays` (unreleased)
No DDL change, no new changeset and no host include change. The qbit now fills empty `help` slots on its app and tables (help a host set is kept) and adds a Setup widget to the Performance home. `reportRetentionDays` (default 0, keep forever) is now enforced: set it above 0 only if you want Lighthouse Reports older than that many days deleted.
+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.
+68 -5
View File
@@ -18,16 +18,19 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.1.0-SNAPSHOT</revision>
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
<revision>0.1.0-RC.8</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<liquibase.version>4.33.0</liquibase.version>
<testcontainers.version>1.21.4</testcontainers.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -70,7 +73,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>
@@ -82,6 +85,13 @@
<version>2.2.220</version>
<scope>test</scope>
</dependency>
<!-- WebPerfDefaultsChangelogTest runs the shipped changelog (with its seeded budgets) through real Liquibase. -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>4.33.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -93,10 +103,63 @@
<version>3.27.7</version>
<scope>test</scope>
</dependency>
<!-- The foreign key guardrail runs the changelog with Liquibase on PostgreSQL (Docker). -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>${liquibase.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.14</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<resources>
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-web-performance.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-web-performance.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables combine.children="append">
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
@@ -143,14 +206,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.4 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -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.
*******************************************************************************/
@@ -21,13 +21,17 @@
package com.kingsrook.qbits.webperf;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import com.kingsrook.qbits.webperf.metadata.WebPerfMetaData;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -51,8 +55,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>
@@ -61,13 +65,41 @@ public class WebPerfQBitProducer implements QBitMetaDataProducer<WebPerfQBitConf
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "web-performance";
public static final String VERSION = "0.1.0";
public static final String VERSION = loadVersion();
private WebPerfQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Read the artifact version from the Maven-filtered properties resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = WebPerfQBitProducer.class.getClassLoader().getResourceAsStream("qbit-web-performance.properties"))
{
if(inputStream == null)
{
throw (new IllegalStateException("Missing qbit-web-performance.properties; build with Maven to generate the artifact version"));
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw (new IllegalStateException("Missing or unfiltered version in qbit-web-performance.properties; build with Maven"));
}
return (version);
}
catch(IOException e)
{
throw (new IllegalStateException("Could not read artifact version from qbit-web-performance.properties", e));
}
}
/*******************************************************************************
** Fluent setter for the config.
**
@@ -161,6 +193,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 +207,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,7 +221,11 @@ public class WebPerfQBitProducer implements QBitMetaDataProducer<WebPerfQBitConf
}
qInstance.addQBit(getQBitMetaData());
new WebPerfRuntime(qBitConfig).addSelfToInstance(qInstance);
new WebPerfMetaData(qBitConfig).addTo(qInstance);
com.kingsrook.qbits.webperf.metadata.WebPerfHelp.apply(qInstance, qBitConfig);
// Registration is metadata-only; core guards startup sync when helpContent is absent.
HelpBundles.register(qInstance, "help/qbit-web-performance.yaml");
LOG.info("Web performance registered", logPair("tablePrefix", qBitConfig.getTableNamePrefix()), logPair("app", qBitConfig.getAppName()),
logPair("siteBaseUrl", qBitConfig.getSiteBaseUrl()), logPair("features", qBitConfig.getFeatures()),
@@ -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;
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";
/** At most one report-retention pass an hour per process; the first one waits so restarts and deploys do not each trigger a pass. */
public static final long REPORT_PRUNE_INTERVAL_MILLIS = 60L * 60 * 1000;
public static final long REPORT_PRUNE_FIRST_DELAY = 5L * 60 * 1000;
private final WebPerfQBitConfig config;
private final java.util.concurrent.atomic.AtomicLong lastReportPruneMillis = new java.util.concurrent.atomic.AtomicLong(
System.currentTimeMillis() - REPORT_PRUNE_INTERVAL_MILLIS + REPORT_PRUNE_FIRST_DELAY);
/*******************************************************************************
** 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()));
}
/*******************************************************************************
** Whether a report-retention pass is due; true for exactly one caller per
** hour (shared by every worker of this process).
*******************************************************************************/
public boolean reportPruneDue()
{
long now = System.currentTimeMillis();
long last = lastReportPruneMillis.get();
return (now - last >= REPORT_PRUNE_INTERVAL_MILLIS && lastReportPruneMillis.compareAndSet(last, now));
}
/*******************************************************************************
** For tests: make the next reportPruneDue() true.
*******************************************************************************/
public void allowReportPruneNow()
{
lastReportPruneMillis.set(0);
}
/*******************************************************************************
** 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,520 @@
/*
* 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));
}
}
/*******************************************************************************
** Read a reported TTFB subpart from Lighthouse's LCP insight, never savings.
*******************************************************************************/
public static Integer ttfb(String json)
{
JSONObject root = new JSONObject(json);
JSONObject lhr = root.optJSONObject("lighthouseResult");
JSONObject audits = (lhr == null ? root : lhr).optJSONObject("audits");
if(audits == null)
{
return (null);
}
for(String name : List.of("lcp-phases-insight", "lcp-breakdown-insight"))
{
JSONObject audit = audits.optJSONObject(name);
JSONObject details = audit == null ? null : audit.optJSONObject("details");
JSONArray items = details == null ? null : details.optJSONArray("items");
if(items != null)
{
for(int i = 0; i < items.length(); i++)
{
JSONObject item = items.optJSONObject(i);
if(item == null)
{
continue;
}
JSONArray subparts = item.optJSONArray("items");
if(subparts == null)
{
subparts = new JSONArray().put(item);
}
for(int j = 0; j < subparts.length(); j++)
{
JSONObject part = subparts.optJSONObject(j);
if(part != null && "timeToFirstByte".equals(part.optString("subpart")) && part.opt("duration") instanceof Number duration)
{
return ((int) Math.round(duration.doubleValue()));
}
}
}
}
}
return (null);
}
/*******************************************************************************
** 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."));
}
}
}
}
@@ -0,0 +1,138 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qbits.webperf.WebPerfQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-web-performance.yaml (the QQQ help-bundle format) to the
** Performance app and its web_performance_* tables: what each table is for and
** how its rows get created, shown on the table screen and when it is empty.
** Keeps table and app empty-state help available even without the Help tables.
** The producer also registers this resource with the core bundle registry.
*******************************************************************************/
public final class WebPerfHelp
{
public static final String RESOURCE = "/help/qbit-web-performance.yaml";
public static final String SLOT = "help";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private WebPerfHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = WebPerfHelp.class.getResourceAsStream(RESOURCE))
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-web-performance".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
if(!key.endsWith(";slot:help") || !(key.startsWith("table:") || key.startsWith("app:")))
{
continue;
}
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
}
}
/*******************************************************************************
** Apply the help to the app and every web_performance_* table in the instance
** (names carry the config's prefix, if any). A host's own help on a table
** is kept (the bundle only fills the slot).
*******************************************************************************/
public static void apply(QInstance qInstance, WebPerfQBitConfig config) throws QException
{
for(Map.Entry<String, JsonNode> entry : entries().entrySet())
{
String key = entry.getKey();
String name = key.substring(key.indexOf(':') + 1, key.indexOf(';'));
if(key.startsWith("table:"))
{
QTableMetaData table = qInstance.getTable(config.applyPrefix(name.startsWith(WebPerfQBitConfig.DEFAULT_TABLE_PREFIX) ? name.substring(WebPerfQBitConfig.DEFAULT_TABLE_PREFIX.length()) : name));
if(table != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry.getValue()));
}
}
else if(key.startsWith("app:"))
{
QAppMetaData app = qInstance.getApp(config.getAppName());
if(app != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(entry.getValue()));
}
}
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
}
}
@@ -26,20 +26,37 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
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.qbits.webperf.processes.WebPerfProcesses;
import com.kingsrook.qbits.webperf.service.WebPerfEvaluationService;
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.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
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.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
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.DenyBehavior;
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.processes.QProcessMetaData;
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 +68,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.
**
@@ -90,6 +107,16 @@ public final class WebPerfMetaData
*******************************************************************************/
public void addTo(QInstance qInstance)
{
if(config.isOn(WebPerfFeature.ALERTS))
{
AlertsInstanceMetaData.ofOrWithNew(qInstance).withAlertType(
new QAlertTypeMetaData()
.withName(WebPerfEvaluationService.ALERT_TYPE)
.withLabel("Web performance regression").withSourceQBitName("web-performance")
.withDefaultSeverity(AlertSeverity.WARNING)
.withSubjectTableName(config.applyPrefix(WebPerfSchema.REGRESSION))
.withVisibilityPermission(config.getPermissionBaseName() + ".read"));
}
for(QTableMetaData table : tables().values())
{
qInstance.addTable(table);
@@ -98,6 +125,31 @@ 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.NOT_CONFIGURED, "Page Speed Setup", WidgetType.HTML, WebPerfWidgets.NotConfigured.class);
qInstance.getWidget(config.qualify(WebPerfWidgets.NOT_CONFIGURED)).setIsCard(false);
addAdminWidget(qInstance, WebPerfWidgets.SETUP, "Page Speed Setup (Admin)", WebPerfWidgets.Setup.class);
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);
addAdminWidget(qInstance, WebPerfWidgets.RECENT_RUNS, "Recent Runs", 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 +160,124 @@ 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")));
}
/*******************************************************************************
** A system widget for a host's Admin area (WebPerfWidgets.ADMIN): gated to
** "<base>.admin.hasAccess" and HIDDEN, never a locked shell, when denied.
*******************************************************************************/
private void addAdminWidget(QInstance qInstance, String name, String label, Class<? extends AbstractWidgetRenderer> renderer)
{
addWidget(qInstance, name, label, WidgetType.HTML, renderer);
qInstance.getWidget(config.qualify(name)).setPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withPermissionBaseName(config.getPermissionBaseName() + ".admin")
.withDenyBehavior(DenyBehavior.HIDDEN));
}
/*******************************************************************************
** The Performance app: its one home (the widgets) and the data tables.
** No process is in its nav.
**
** @return the app
*******************************************************************************/
@@ -125,6 +293,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 +330,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 +345,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 +370,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),
@@ -227,7 +398,10 @@ public final class WebPerfMetaData
f("lcpElement", QFieldType.STRING, "LCP Element").withMaxLength(300),
f("runWarnings", QFieldType.STRING, "Warnings").withMaxLength(2000),
f("deploymentId", QFieldType.STRING, "Frontend Build").withMaxLength(64),
f("cdnStage", QFieldType.STRING, "CDN Stage").withMaxLength(20)));
f("cdnStage", QFieldType.STRING, "CDN Stage").withMaxLength(20),
f("ttfbMs", QFieldType.INTEGER, "TTFB (ms)"),
f("evaluatedAt", QFieldType.DATE_TIME, "Evaluated"),
f("changeEventId", QFieldType.LONG, "Site Change").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.CHANGE_EVENT))));
return (table(WebPerfSchema.LAB_RUN, "Lab Runs", "speed", fields, false, Set.of())
.withRecordLabelFormatAndFields("%s %s", "strategy", "url")
@@ -236,7 +410,7 @@ public final class WebPerfMetaData
.withSection(section("metrics", "Lab Metrics", "timer", Tier.T2, "fcpMs", "lcpMs", "tbtMs", "cls", "speedIndexMs", "lcpElement"))
.withSection(section("details", "Details", "info", Tier.T2, "targetId", "finalUrl", "runner", "triggerRef", "batchId", "notBefore",
"queuedAt", "startedAt", "attempts", "httpStatus", "errorMessage", "lighthouseVersion", "chromeUserAgent", "benchmarkIndex",
"runWarnings", "deploymentId", "cdnStage"))
"runWarnings", "deploymentId", "cdnStage", "ttfbMs", "evaluatedAt", "changeEventId"))
.withSection(dates()));
}
@@ -250,7 +424,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 +474,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),
@@ -313,11 +487,12 @@ public final class WebPerfMetaData
f("p75FcpMs", QFieldType.INTEGER, "FCP p75 (ms)"),
f("p75TtfbMs", QFieldType.INTEGER, "TTFB p75 (ms)"),
f("distributions", QFieldType.TEXT, "Distributions"),
f("lastCheckedAt", QFieldType.DATE_TIME, "Last Checked")));
f("lastCheckedAt", QFieldType.DATE_TIME, "Last Checked"),
f("evaluatedAt", QFieldType.DATE_TIME, "Evaluated")));
return (table(WebPerfSchema.FIELD_SNAPSHOT, "Google Field Data", "public", fields, false, Set.of())
.withRecordLabelFormatAndFields("%s %s", "scopeKey", "formFactor")
.withSection(section("snapshot", "Snapshot", "public", Tier.T1, "id", "scopeKey", "scope", "formFactor", "status", "lastCheckedAt"))
.withSection(section("snapshot", "Snapshot", "public", Tier.T1, "id", "scopeKey", "scope", "formFactor", "status", "lastCheckedAt", "evaluatedAt"))
.withSection(section("p75", "p75 (28 days)", "insights", Tier.T2, "p75LcpMs", "p75InpMs", "p75Cls", "p75FcpMs", "p75TtfbMs",
"periodStart", "periodEnd", "source", "distributions"))
.withSection(dates()));
@@ -332,14 +507,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,29 +537,33 @@ 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"),
f("confirmedAt", QFieldType.DATE_TIME, "Confirmed"),
f("resolvedAt", QFieldType.DATE_TIME, "Resolved"),
f("resolutionNote", QFieldType.STRING, "Resolution Note").withMaxLength(2000)));
f("resolutionNote", QFieldType.STRING, "Resolution Note").withMaxLength(2000),
f("firstRunId", QFieldType.LONG, "First Run").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.LAB_RUN)),
f("checkpointRunId", QFieldType.LONG, "Confirmation Run").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.LAB_RUN)),
f("lighthouseMajor", QFieldType.STRING, "Lighthouse Major").withMaxLength(20),
f("failedAudits", QFieldType.STRING, "Newly Failing Audits").withMaxLength(2000)));
return (table(WebPerfSchema.REGRESSION, "Regressions", "trending_down", fields, true, Set.of())
.withRecordLabelFormatAndFields("%s %s", "metric", "status")
.withSection(section("regression", "Regression", "trending_down", Tier.T1, "id", "targetId", "metric", "strategy", "baselineValue",
"currentValue", "delta", "severity", "status"))
.withSection(section("details", "Details", "info", Tier.T2, "source", "contentTable", "contentId", "baselineRef", "currentRef",
"budgetId", "causeChangeEventId", "detectedAt", "confirmedAt", "resolvedAt", "resolutionNote"))
"budgetId", "causeChangeEventId", "detectedAt", "confirmedAt", "resolvedAt", "resolutionNote", "firstRunId", "checkpointRunId", "lighthouseMajor", "failedAudits"))
.withSection(dates()));
}
@@ -401,13 +580,16 @@ 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("note", QFieldType.STRING, "Note").withMaxLength(2000)));
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("change_source")).withMaxLength(20),
f("note", QFieldType.STRING, "Note").withMaxLength(2000),
f("lastSeenAt", QFieldType.DATE_TIME, "Last Observed"),
f("eventKey", QFieldType.STRING, "Source Event Key").withMaxLength(64),
f("siteKey", QFieldType.STRING, "Site").withMaxLength(200)));
return (table(WebPerfSchema.CHANGE_EVENT, "Site Changes", "change_circle", fields, false, Set.of())
.withRecordLabelFormatAndFields("%s %s", "kind", "toValue")
.withSection(section("change", "Change", "change_circle", Tier.T1, "id", "kind", "component", "fromValue", "toValue", "observedAt",
"source", "note"))
"source", "note", "lastSeenAt", "eventKey", "siteKey"))
.withSection(dates()));
}
@@ -420,10 +602,11 @@ 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("targetId", QFieldType.LONG, "Page").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.TARGET)),
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"),
@@ -436,7 +619,7 @@ public final class WebPerfMetaData
return (table(WebPerfSchema.COMPARISON, "Before and After", "compare_arrows", fields, false, Set.of())
.withRecordLabelFormatAndFields("%s %s", "metric", "segment")
.withSection(section("comparison", "Comparison", "compare_arrows", Tier.T1, "id", "changeEventId", "metric", "segment", "beforeValue",
.withSection(section("comparison", "Comparison", "compare_arrows", Tier.T1, "id", "changeEventId", "targetId", "metric", "segment", "beforeValue",
"afterValue", "delta", "deltaPct"))
.withSection(section("details", "Details", "info", Tier.T2, "scope", "source", "strategy", "beforeN", "beforeWindow", "afterN",
"afterWindow"))
@@ -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,276 @@
/*
* 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 com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.service.WebPerfComparisonService;
import com.kingsrook.qbits.webperf.service.WebPerfEvaluationService;
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;
/*******************************************************************************
** Standard streamed Review/Result actions for regression status and comparisons.
*******************************************************************************/
public final class RegressionSteps
{
public static final String NOTE = "resolutionNote";
/*******************************************************************************
** Steps only.
*******************************************************************************/
private RegressionSteps()
{
}
/*******************************************************************************
** Review open records; closed records are explicitly skipped.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private ProcessSummaryLine change;
private final ProcessSummaryLine skip = Lines.line(Status.INFO, "closed regression will be skipped", "closed regressions will be skipped",
"closed regression was skipped", "closed regressions were skipped");
/*******************************************************************************
** Validate the note and classify this page without writing anything.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String status = input.getValueString(WebPerfProcesses.TRIGGER_FIELD);
String note = input.getValueString(NOTE);
if(note == null || note.isBlank() || note.length() > 2000)
{
throw (new QException("Enter a resolution note of 1 to 2000 characters"));
}
if(change == null)
{
String verb = "DISMISSED".equals(status) ? "dismissed" : "marked resolved";
change = Lines.line(Status.OK, "regression will be " + verb, "regressions will be " + verb,
"regression was " + verb, "regressions were " + verb);
}
for(QRecord record : input.getRecords())
{
if(List.of("SUSPECTED", "CONFIRMED").contains(record.getValueString("status")))
{
change.incrementCountAndAddPrimaryKey(record.getValue("id"));
output.addRecord(record);
}
else
{
skip.incrementCountAndAddPrimaryKey(record.getValue("id"));
}
}
}
/*******************************************************************************
** Review summary.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
if(change != null)
{
Lines.addAll(lines, change);
}
Lines.addAll(lines, skip);
return (lines);
}
}
/*******************************************************************************
** Apply the requested state under a database lock, rechecking eligibility.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private ProcessSummaryLine changed;
private final ProcessSummaryLine skipped = Lines.line(Status.INFO, "regression will be skipped", "regressions will be skipped",
"regression was already closed", "regressions were already closed");
/*******************************************************************************
** Apply one page and report only the rows actually changed.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String status = input.getValueString(WebPerfProcesses.TRIGGER_FIELD);
if(changed == null)
{
String verb = "DISMISSED".equals(status) ? "dismissed" : "marked resolved";
changed = Lines.line(Status.OK, "regression will be " + verb, "regressions will be " + verb,
"regression was " + verb, "regressions were " + verb);
}
WebPerfEvaluationService service = new WebPerfEvaluationService(WebPerfRuntime.forInput(input).store());
try
{
for(QRecord record : input.getRecords())
{
boolean updated = service.finishRegression(record.getValueLong("id"), status, input.getValueString(NOTE), Instant.now());
(updated ? changed : skipped).incrementCountAndAddPrimaryKey(record.getValue("id"));
}
}
catch(SQLException | IllegalArgumentException e)
{
throw (new QException("Could not update the regressions", e));
}
}
/*******************************************************************************
** Result summary.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
if(changed != null)
{
Lines.addAll(lines, changed);
}
Lines.addAll(lines, skipped);
if(getTransformStep() instanceof Transform transform)
{
Lines.addAll(lines, transform.skip);
}
return (lines);
}
}
/*******************************************************************************
** Review comparisons using recorded lab history.
*******************************************************************************/
public static class CompareTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine changes = Lines.line(Status.OK, "site change will be compared using recorded lab samples",
"site changes will be compared using recorded lab samples", "site change was compared", "site changes were compared");
/*******************************************************************************
** Pass through a bounded page of selected changes.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord record : input.getRecords())
{
changes.incrementCountAndAddPrimaryKey(record.getValue("id"));
output.addRecord(record);
}
}
/*******************************************************************************
** Review summary.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
Lines.addAll(lines, changes);
return (lines);
}
}
/*******************************************************************************
** Write comparisons when both sides have three comparable measurements.
*******************************************************************************/
public static class CompareLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine compared = Lines.line(Status.OK, "comparison will be recorded", "comparisons will be recorded",
"comparison was recorded", "comparisons were recorded");
private final ProcessSummaryLine insufficient = Lines.line(Status.INFO, "change needs more samples", "changes need more samples",
"change had fewer than three comparable samples on each side", "changes had fewer than three comparable samples on each side");
/*******************************************************************************
** Compute and persist this page of selected changes.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
try
{
WebPerfComparisonService service = new WebPerfComparisonService(WebPerfRuntime.forInput(input).store());
for(QRecord record : input.getRecords())
{
int count = service.compare(record.getValueLong("id"));
compared.incrementCount(count);
if(count == 0)
{
insufficient.incrementCountAndAddPrimaryKey(record.getValue("id"));
}
}
}
catch(SQLException e)
{
throw (new QException("Could not compare the site changes", e));
}
}
/*******************************************************************************
** Result summary, including an honest insufficient-history line.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
Lines.addAll(lines, compared, insufficient);
return (lines);
}
}
}
@@ -0,0 +1,195 @@
/*
* 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())
{
if(List.of("CONFIRM", "CHANGE_COMPARE").contains(record.getValueString("triggerType")))
{
try
{
String status = service.retrySample(record.getValueLong("id"), Instant.now());
("QUEUED".equals(status) ? queued : "MERGED".equals(status) ? merged : capped).incrementCount(1);
}
catch(SQLException e)
{
throw (new QException("Could not retry the measurement sample", e));
}
}
else
{
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,222 @@
/*
* 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.WebPerfChangeService;
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()
{
}
/*******************************************************************************
** Poll deploy probes and external change sources on the existing scheduler.
*******************************************************************************/
public static class ProbeChanges implements BackendStep
{
/*******************************************************************************
** Store changes and queue the affected pages.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
try
{
new WebPerfChangeService(WebPerfRuntime.forInput(input).store()).poll(Instant.now());
}
catch(Exception e)
{
throw (new QException("Checking site changes failed", e));
}
}
}
/*******************************************************************************
** 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();
Instant now = Instant.now();
WebPerfService.QueuePlan plan = service.enqueue(service.scheduledRequests(keyOnly, reason, now), 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,263 @@
/*
* 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.QFrontendStepMetaData;
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 DISMISS_REGRESSIONS = "DismissRegressions";
public static final String RESOLVE_REGRESSIONS = "ResolveRegressions";
public static final String COMPARE_CHANGE = "CompareChange";
public static final String PROBE_DEPLOYS = "ProbeDeploys";
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")));
}
String regressions = config.applyPrefix(WebPerfSchema.REGRESSION);
for(String status : List.of("DISMISSED", "RESOLVED"))
{
boolean dismiss = "DISMISSED".equals(status);
QProcessMetaData process = etl(config, dismiss ? DISMISS_REGRESSIONS : RESOLVE_REGRESSIONS, dismiss ? "Dismiss" : "Mark resolved", "done",
regressions, regressions, ExtractViaQueryStep.class, RegressionSteps.Transform.class, RegressionSteps.Load.class, status, "manage", 1,
"Open regressions will be updated. Closed regressions will be skipped.");
process.withStep(0, new QFrontendStepMetaData().withName("note").withLabel("Resolution note")
.withFormField(new QFieldMetaData(RegressionSteps.NOTE, QFieldType.STRING).withLabel("Resolution note").withIsRequired(true).withMaxLength(2000)));
processes.add(process);
}
if(config.isOn(WebPerfFeature.CDN_COMPARE))
{
String changes = config.applyPrefix(WebPerfSchema.CHANGE_EVENT);
processes.add(etl(config, COMPARE_CHANGE, "Compare before and after", "compare", changes, config.applyPrefix(WebPerfSchema.COMPARISON),
ExtractViaQueryStep.class, RegressionSteps.CompareTransform.class, RegressionSteps.CompareLoad.class, null, "view", 1,
"Lab comparisons require three successful samples on each side, on the same Lighthouse major version."));
}
if(config.isOn(WebPerfFeature.DEPLOY_PROBES) || !config.getChangeEventSources().isEmpty())
{
processes.add(scheduled(config, PROBE_DEPLOYS, "Check site changes", ScheduledSteps.ProbeChanges.class,
new QScheduleMetaData().withRepeatSeconds(300).withInitialDelaySeconds(1)));
}
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,246 @@
/*
* 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.net.URI;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
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.qbits.webperf.spi.ChangeEventSource;
import com.kingsrook.qbits.webperf.spi.DeployProbe;
import com.kingsrook.qbits.webperf.store.WebPerfStore;
import org.json.JSONArray;
/*******************************************************************************
** Records source changes and attributes runs to the state observed at start.
** All identities and queue writes are durable; polling can safely repeat.
*******************************************************************************/
public class WebPerfChangeService
{
private final WebPerfStore store;
private final WebPerfQBitConfig config;
/*******************************************************************************
** Changes for one installation.
*******************************************************************************/
public WebPerfChangeService(WebPerfStore store)
{
this.store = store;
this.config = store.getConfig();
}
/*******************************************************************************
** Poll configured sources. Failed probes propagate so the schedule retries.
*******************************************************************************/
public void poll(Instant now) throws Exception
{
if(config.isOn(WebPerfFeature.DEPLOY_PROBES))
{
for(DeployProbe probe : config.getDeployProbes())
{
String build = probe.currentBuildId();
if(build != null && !build.isBlank())
{
String kind = "BACKEND".equalsIgnoreCase(probe.component()) ? "BACKEND_DEPLOY" : "FRONTEND_DEPLOY";
Map<String, Object> previous = latest(kind, probe.component(), now);
record(new ChangeEventSource.Change(kind, probe.component(), previous == null ? null : (String) previous.get("to_value"), build, now),
"PROBE", probe.component() + ":" + build, true);
}
}
}
for(int i = 0; i < config.getChangeEventSources().size(); i++)
{
String sourceKey = "source:" + i;
List<Map<String, Object>> last = store.query("SELECT MAX(observed_at) AS observed_at FROM {change_event} WHERE site_key = ? AND note = ?",
siteKey(), sourceKey);
Instant since = (Instant) last.get(0).get("observed_at");
for(ChangeEventSource.Change change : config.getChangeEventSources().get(i).changesSince(since == null ? Instant.EPOCH : since))
{
if(!"CDN_STAGE".equals(change.kind()) || config.isOn(WebPerfFeature.CDN_COMPARE))
{
record(change, "CDN_STAGE".equals(change.kind()) ? "QBIT_CDN" : "WEBHOOK", sourceKey, true);
}
}
}
}
/*******************************************************************************
** Insert once per build or source event and queue affected pages atomically.
** The source SPI has no event id, so its complete tuple is its identity.
*******************************************************************************/
public long record(ChangeEventSource.Change change, String source, String sourceKey, boolean queue) throws SQLException
{
String identity = new JSONArray(List.of(siteKey(), source, sourceKey, change.kind(), change.component(), change.toValue(),
"PROBE".equals(source) ? "" : change.observedAt().toString(), "PROBE".equals(source) ? "" : String.valueOf(change.fromValue()))).toString();
String key = WebPerfService.sha256(identity.getBytes(StandardCharsets.UTF_8));
try
{
return (store.transaction(tx ->
{
List<Map<String, Object>> existing = tx.query("SELECT id FROM {change_event} WHERE event_key = ?", key);
if(!existing.isEmpty())
{
if("PROBE".equals(source))
{
tx.update("UPDATE {change_event} SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)",
change.observedAt(), existing.get(0).get("id"), change.observedAt());
}
return (WebPerfStore.asLong(existing.get(0).get("id")));
}
Map<String, Object> values = new LinkedHashMap<>();
values.put("kind", change.kind());
values.put("component", change.component());
values.put("from_value", change.fromValue());
values.put("to_value", change.toValue());
values.put("observed_at", change.observedAt());
values.put("last_seen_at", change.observedAt());
values.put("source", source);
values.put("note", sourceKey);
values.put("event_key", key);
values.put("site_key", siteKey());
long id = tx.insert(WebPerfSchema.CHANGE_EVENT, values);
if(queue && config.isOn(WebPerfFeature.LAB))
{
queueChange(tx, id, change);
}
return (id);
}));
}
catch(SQLException e)
{
/////////////////////////////////////////////////////////////////////
// A competing poll may have committed this exact identity first. //
/////////////////////////////////////////////////////////////////////
if("23505".equals(e.getSQLState()))
{
List<Map<String, Object>> existing = store.query("SELECT id FROM {change_event} WHERE event_key = ?", key);
if(!existing.isEmpty())
{
return (WebPerfStore.asLong(existing.get(0).get("id")));
}
}
throw (e);
}
}
/*******************************************************************************
** Page through targets; CDN sampling bypasses the ordinary queue merge.
*******************************************************************************/
private void queueChange(WebPerfStore tx, long eventId, ChangeEventSource.Change change) throws SQLException
{
WebPerfService service = new WebPerfService(new WebPerfRuntime(config), tx);
boolean cdn = "CDN_STAGE".equals(change.kind());
boolean deploy = change.kind().endsWith("DEPLOY");
long after = 0;
while(true)
{
List<Map<String, Object>> pages = tx.query("SELECT id, url FROM {target} WHERE site_key = ? AND enabled = TRUE AND id > ?"
+ (cdn || deploy ? " AND tier = 'KEY'" : "") + " ORDER BY id FETCH FIRST 100 ROWS ONLY", siteKey(), after);
if(pages.isEmpty())
{
return;
}
for(Map<String, Object> page : pages)
{
after = WebPerfStore.asLong(page.get("id"));
for(String strategy : config.getStrategies())
{
if(cdn && ("HEADERS_ONLY".equals(change.toValue()) || "LIVE".equals(change.toValue())))
{
for(int sample = 0; sample < 3; sample++)
{
service.enqueueSample(page, strategy, "CHANGE_COMPARE", "change:" + eventId + ":1:" + sample,
change.observedAt().plusSeconds("LIVE".equals(change.toValue()) ? 3600 : 0));
if("LIVE".equals(change.toValue()))
{
service.enqueueSample(page, strategy, "CHANGE_COMPARE", "change:" + eventId + ":24:" + sample, change.observedAt().plusSeconds(86400));
}
}
}
else
{
Instant due = change.observedAt().plusSeconds(deploy ? config.getDeploySettleSeconds() : config.getPublishSettleSeconds());
service.enqueue(List.of(new WebPerfService.RunRequest(after, (String) page.get("url"), strategy,
deploy ? "DEPLOY" : "PUBLISH", "change:" + eventId, null, due)), change.observedAt());
}
}
}
}
}
/*******************************************************************************
** Latest observed state at a time, optionally for a specific component.
*******************************************************************************/
public Map<String, Object> latest(String kind, String component, Instant at) throws SQLException
{
List<Map<String, Object>> rows = component == null
? store.query("SELECT * FROM {change_event} WHERE site_key = ? AND kind = ? AND observed_at <= ? ORDER BY last_seen_at DESC, id DESC FETCH FIRST 1 ROWS ONLY",
siteKey(), kind, at)
: store.query("SELECT * FROM {change_event} WHERE site_key = ? AND kind = ? AND component = ? AND observed_at <= ? ORDER BY last_seen_at DESC, id DESC FETCH FIRST 1 ROWS ONLY",
siteKey(), kind, component, at);
return (rows.isEmpty() ? null : rows.get(0));
}
/*******************************************************************************
** Attribute at execution, not queue time (a deployment may happen meanwhile).
*******************************************************************************/
public void attribute(long runId, Instant now) throws SQLException
{
Map<String, Object> build = latest("FRONTEND_DEPLOY", null, now);
Map<String, Object> cdn = latest("CDN_STAGE", URI.create(config.getSiteBaseUrl()).getHost(), now);
List<Map<String, Object>> cause = store.query("SELECT id FROM {change_event} WHERE site_key = ? AND observed_at <= ? ORDER BY observed_at DESC, id DESC FETCH FIRST 1 ROWS ONLY",
siteKey(), now);
store.update("UPDATE {lab_run} SET deployment_id = ?, cdn_stage = ?, change_event_id = ? WHERE id = ?",
build == null ? null : build.get("to_value"), cdn == null ? null : cdn.get("to_value"), cause.isEmpty() ? null : cause.get(0).get("id"), runId);
}
/*******************************************************************************
** Same site identity as the tested pages.
*******************************************************************************/
String siteKey()
{
return ("host:" + URI.create(config.getSiteBaseUrl()).getHost().toLowerCase(Locale.ROOT));
}
}
@@ -0,0 +1,188 @@
/*
* 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.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.webperf.WebPerfSchema;
import com.kingsrook.qbits.webperf.store.WebPerfStore;
/*******************************************************************************
** Reproducible before/after lab medians, with counts and actual sample windows.
** Three successful samples on each side and one Lighthouse major are required.
*******************************************************************************/
public class WebPerfComparisonService
{
private final WebPerfStore store;
/*******************************************************************************
** Comparisons for one installation.
*******************************************************************************/
public WebPerfComparisonService(WebPerfStore store)
{
this.store = store;
}
/*******************************************************************************
** Recompute the run's attributed change after its results have been stored.
*******************************************************************************/
public void compareForRun(Map<String, Object> run) throws SQLException
{
if(run.get("change_event_id") != null)
{
List<Map<String, Object>> events = store.query("SELECT * FROM {change_event} WHERE id = ?", run.get("change_event_id"));
if(!events.isEmpty())
{
compareTarget(events.get(0), WebPerfStore.asLong(run.get("target_id")), (String) run.get("strategy"));
}
}
}
/*******************************************************************************
** Record action: page through enabled targets and compare existing evidence.
** Sparse history stays absent instead of being filled with estimated values.
*******************************************************************************/
public int compare(long eventId) throws SQLException
{
return (store.transaction(tx ->
{
Map<String, Object> event = tx.query("SELECT * FROM {change_event} WHERE id = ? FOR UPDATE", eventId).get(0);
WebPerfComparisonService service = new WebPerfComparisonService(tx);
int count = 0;
long after = 0;
while(true)
{
List<Map<String, Object>> pages = tx.query("SELECT id FROM {target} WHERE enabled = TRUE AND site_key = ? AND id > ? ORDER BY id FETCH FIRST 100 ROWS ONLY",
event.get("site_key"), after);
if(pages.isEmpty())
{
return (count);
}
for(Map<String, Object> page : pages)
{
after = WebPerfStore.asLong(page.get("id"));
for(String strategy : store.getConfig().getStrategies())
{
count += service.compareTarget(event, after, strategy);
}
}
}
}));
}
/*******************************************************************************
** Serialize comparison upserts on the change row, shared by all callers.
*******************************************************************************/
private int compareTarget(Map<String, Object> event, long targetId, String strategy) throws SQLException
{
store.query("SELECT id FROM {change_event} WHERE id = ? FOR UPDATE", event.get("id"));
Instant at = (Instant) event.get("observed_at");
List<Map<String, Object>> after = store.query("SELECT * FROM {lab_run} WHERE target_id = ? AND strategy = ? AND status = 'SUCCEEDED'"
+ " AND started_at >= ? AND change_event_id = ? ORDER BY completed_at DESC, id DESC FETCH FIRST 3 ROWS ONLY", targetId, strategy, at, event.get("id"));
if(after.size() != 3)
{
return (0);
}
String major = WebPerfEvaluationService.major(after.get(0));
if(after.stream().anyMatch(row -> !major.equals(WebPerfEvaluationService.major(row))))
{
return (0);
}
List<Map<String, Object>> before = store.query("SELECT * FROM {lab_run} WHERE target_id = ? AND strategy = ? AND status = 'SUCCEEDED'"
+ " AND completed_at < ? AND completed_at >= ? AND (lighthouse_version = ? OR lighthouse_version LIKE ?)"
+ " ORDER BY completed_at DESC, id DESC FETCH FIRST 3 ROWS ONLY", targetId, strategy, at, at.minus(Duration.ofDays(30)), major, major + ".%");
if(before.size() != 3)
{
return (0);
}
int count = 0;
for(String metric : List.of("PERFORMANCE_SCORE", "LCP", "FCP", "TTFB"))
{
String column = WebPerfEvaluationService.LAB_METRICS.get(metric);
if(before.stream().anyMatch(row -> row.get(column) == null) || after.stream().anyMatch(row -> row.get(column) == null))
{
continue;
}
double beforeValue = WebPerfEvaluationService.median(before, column);
double afterValue = WebPerfEvaluationService.median(after, column);
double delta = afterValue - beforeValue;
Double percentage = beforeValue == 0 ? null : delta * 100 / beforeValue;
if(percentage != null && Math.abs(percentage) >= 1000000)
{
percentage = null;
}
List<Map<String, Object>> existing = store.query("SELECT id FROM {comparison} WHERE change_event_id = ? AND target_id = ? AND source = 'LAB'"
+ " AND strategy = ? AND metric = ? AND segment = 'ALL'", event.get("id"), targetId, strategy, metric);
if(existing.isEmpty())
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("change_event_id", event.get("id"));
row.put("target_id", targetId);
row.put("scope", "TARGET");
row.put("source", "LAB");
row.put("strategy", strategy);
row.put("metric", metric);
row.put("segment", "ALL");
row.put("before_value", beforeValue);
row.put("before_n", 3);
row.put("before_window", window(before));
row.put("after_value", afterValue);
row.put("after_n", 3);
row.put("after_window", window(after));
row.put("delta", delta);
row.put("delta_pct", percentage);
store.insert(WebPerfSchema.COMPARISON, row);
}
else
{
store.update("UPDATE {comparison} SET before_value = ?, before_n = 3, before_window = ?, after_value = ?, after_n = 3, after_window = ?,"
+ " delta = ?, delta_pct = ?, modify_date = ? WHERE id = ?", beforeValue, window(before), afterValue, window(after), delta, percentage,
Instant.now(), existing.get(0).get("id"));
}
count++;
}
return (count);
}
/*******************************************************************************
** The actual sample times, not an implied continuous measurement window.
*******************************************************************************/
private static String window(List<Map<String, Object>> samples)
{
return (samples.get(samples.size() - 1).get("completed_at") + " / " + samples.get(0).get("completed_at"));
}
}
@@ -0,0 +1,611 @@
/*
* 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.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.webperf.WebPerfFeature;
import com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.WebPerfSchema;
import com.kingsrook.qbits.webperf.spi.ChangeEventSource;
import com.kingsrook.qbits.webperf.store.WebPerfStore;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
/*******************************************************************************
** Budget breaches and confirmed lab regressions, backed by ordinary records.
** Target row locks serialize evaluation across workers. Baselines and samples
** are bounded, and evaluation progress commits with the resulting records.
*******************************************************************************/
public class WebPerfEvaluationService
{
public static final String ALERT_TYPE = "webperf.regression";
public static final Map<String, String> LAB_METRICS = Map.of(
"PERFORMANCE_SCORE", "score_performance", "ACCESSIBILITY_SCORE", "score_accessibility",
"BEST_PRACTICES_SCORE", "score_best_practices", "SEO_SCORE", "score_seo",
"LCP", "lcp_ms", "FCP", "fcp_ms", "CLS", "cls", "TBT", "tbt_ms", "TTFB", "ttfb_ms");
private final WebPerfStore store;
/*******************************************************************************
** Evaluation for one installation.
*******************************************************************************/
public WebPerfEvaluationService(WebPerfStore store)
{
this.store = store;
}
/*******************************************************************************
** Bounded work per worker tick, including results left by an interrupted tick.
*******************************************************************************/
public void evaluatePending() throws SQLException
{
for(Map<String, Object> run : store.query("SELECT id FROM {lab_run} WHERE status = 'SUCCEEDED' AND evaluated_at IS NULL ORDER BY completed_at, id FETCH FIRST 100 ROWS ONLY"))
{
evaluateLab(WebPerfStore.asLong(run.get("id")));
}
for(Map<String, Object> field : store.query("SELECT id FROM {field_snapshot} WHERE evaluated_at IS NULL ORDER BY id FETCH FIRST 100 ROWS ONLY"))
{
evaluateField(WebPerfStore.asLong(field.get("id")));
}
}
/*******************************************************************************
** Evaluate a stored successful result once; publish alerts after commit.
*******************************************************************************/
public void evaluateLab(long runId) throws SQLException
{
List<Long> changed = store.transaction(tx ->
{
List<Map<String, Object>> runs = tx.query("SELECT * FROM {lab_run} WHERE id = ? AND status = 'SUCCEEDED'", runId);
if(runs.isEmpty())
{
return (List.of());
}
Map<String, Object> run = runs.get(0);
List<Map<String, Object>> targets = tx.query("SELECT * FROM {target} WHERE id = ? FOR UPDATE", run.get("target_id"));
if(targets.isEmpty() || !tx.query("SELECT id FROM {lab_run} WHERE id = ? AND evaluated_at IS NOT NULL", runId).isEmpty())
{
return (List.of());
}
Map<String, Object> target = targets.get(0);
WebPerfEvaluationService evaluator = new WebPerfEvaluationService(tx);
List<Long> transitions = new ArrayList<>();
evaluator.versionChange(run, transitions);
List<Map<String, Object>> baseline = evaluator.baseline(run);
for(Map.Entry<String, String> metric : LAB_METRICS.entrySet())
{
if(run.get(metric.getValue()) != null)
{
evaluator.evaluateMetric(target, run, metric.getKey(), metric.getValue(), baseline, transitions);
}
}
evaluator.updateCount(target.get("id"));
if(store.getConfig().isOn(WebPerfFeature.CDN_COMPARE))
{
new WebPerfComparisonService(tx).compareForRun(run);
}
tx.update("UPDATE {lab_run} SET evaluated_at = ? WHERE id = ?", Instant.now(), runId);
return (transitions);
});
changed.forEach(this::sendAlert);
}
/*******************************************************************************
** CrUX URL budgets use real p75 values, never NO_DATA, errors or origin
** fallback as page measurements. A new collection period can resolve a breach.
*******************************************************************************/
public void evaluateField(long snapshotId) throws SQLException
{
List<Long> changed = store.transaction(tx ->
{
Map<String, Object> field = tx.query("SELECT * FROM {field_snapshot} WHERE id = ? FOR UPDATE", snapshotId).get(0);
List<Long> transitions = new ArrayList<>();
if(field.get("evaluated_at") != null)
{
return (transitions);
}
if("OK".equals(field.get("status")) && "URL".equals(field.get("scope")))
{
List<Map<String, Object>> targets = tx.query("SELECT * FROM {target} WHERE site_key = ? AND url = ? FOR UPDATE",
new WebPerfChangeService(tx).siteKey(), field.get("scope_key"));
WebPerfEvaluationService evaluator = new WebPerfEvaluationService(tx);
for(Map<String, Object> target : targets)
{
for(String metric : List.of("LCP", "INP", "CLS", "FCP", "TTFB"))
{
String column = "p75_" + metric.toLowerCase(Locale.ROOT) + ("CLS".equals(metric) ? "" : "_ms");
Double value = WebPerfStore.asDouble(field.get(column));
if(value == null)
{
continue;
}
String strategy = "PHONE".equals(field.get("form_factor")) ? "MOBILE" : (String) field.get("form_factor");
Map<String, Object> budget = evaluator.breachedBudget(target, "CRUX", strategy, metric, value);
List<Map<String, Object>> open = tx.query("SELECT * FROM {regression} WHERE target_id = ? AND source = 'CRUX' AND strategy = ? AND metric = ?"
+ " AND status = 'CONFIRMED' FETCH FIRST 1 ROWS ONLY", target.get("id"), field.get("form_factor"), metric);
Instant at = (Instant) field.get("last_checked_at");
if(budget != null && open.isEmpty())
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("target_id", target.get("id"));
row.put("content_table", target.get("content_table"));
row.put("content_id", target.get("content_id"));
row.put("source", "CRUX");
row.put("strategy", field.get("form_factor"));
row.put("metric", metric);
row.put("current_value", value);
row.put("current_ref", "field:" + snapshotId);
row.put("budget_id", budget.get("id"));
row.put("severity", budget.get("severity"));
row.put("status", "CONFIRMED");
row.put("detected_at", at);
row.put("confirmed_at", at);
transitions.add(tx.insert(WebPerfSchema.REGRESSION, row));
}
else if(!open.isEmpty())
{
Map<String, Object> regression = open.get(0);
tx.update("UPDATE {regression} SET current_value = ?, current_ref = ?, modify_date = ? WHERE id = ?",
value, "field:" + snapshotId, at, regression.get("id"));
if(budget == null)
{
evaluator.close(regression, "RESOLVED", "Google field data is within its budget", at);
transitions.add(WebPerfStore.asLong(regression.get("id")));
}
}
}
evaluator.updateCount(target.get("id"));
}
}
tx.update("UPDATE {field_snapshot} SET evaluated_at = ? WHERE id = ?", Instant.now(), snapshotId);
return (transitions);
});
changed.forEach(this::sendAlert);
}
/*******************************************************************************
** Five eligible results in the preceding 30 days, excluding the new result.
*******************************************************************************/
private List<Map<String, Object>> baseline(Map<String, Object> run) throws SQLException
{
return (store.query("SELECT * FROM {lab_run} WHERE target_id = ? AND strategy = ? AND status = 'SUCCEEDED'"
+ " AND trigger_type IN ('SCHEDULE', 'CONFIRM', 'SITE_AUDIT') AND completed_at >= ?"
+ " AND (completed_at < ? OR (completed_at = ? AND id < ?)) AND (lighthouse_version = ? OR lighthouse_version LIKE ?)"
+ " ORDER BY completed_at DESC, id DESC FETCH FIRST 5 ROWS ONLY", run.get("target_id"), run.get("strategy"),
((Instant) run.get("completed_at")).minus(Duration.ofDays(30)), run.get("completed_at"), run.get("completed_at"), run.get("id"),
major(run), major(run) + ".%"));
}
/*******************************************************************************
** Major-version changes are markers and retire incompatible open baselines.
*******************************************************************************/
private void versionChange(Map<String, Object> run, List<Long> changed) throws SQLException
{
List<Map<String, Object>> previous = store.query("SELECT lighthouse_version FROM {lab_run} WHERE target_id = ? AND strategy = ?"
+ " AND status = 'SUCCEEDED' AND id < ? ORDER BY id DESC FETCH FIRST 1 ROWS ONLY", run.get("target_id"), run.get("strategy"), run.get("id"));
if(!previous.isEmpty() && !major(previous.get(0)).equals(major(run)))
{
new WebPerfChangeService(store).record(new ChangeEventSource.Change("LIGHTHOUSE_VERSION", "LIGHTHOUSE", major(previous.get(0)), major(run),
(Instant) run.get("completed_at")), "PROBE", "lighthouse:" + major(run), false);
}
for(Map<String, Object> open : store.query("SELECT * FROM {regression} WHERE target_id = ? AND strategy = ? AND source = 'LAB'"
+ " AND status IN ('SUSPECTED', 'CONFIRMED') AND lighthouse_major <> ?", run.get("target_id"), run.get("strategy"), major(run)))
{
close(open, "CONFIRMED".equals(open.get("status")) ? "RESOLVED" : "NOT_CONFIRMED", "Lighthouse major version changed; baseline reset",
(Instant) run.get("completed_at"));
changed.add(WebPerfStore.asLong(open.get("id")));
}
}
/*******************************************************************************
** Keep a single open row per page, source, strategy and metric. Its baseline
** stays fixed through confirmation and recovery rather than drifting.
*******************************************************************************/
private void evaluateMetric(Map<String, Object> target, Map<String, Object> run, String metric, String column,
List<Map<String, Object>> baseline, List<Long> changed) throws SQLException
{
List<Map<String, Object>> existing = store.query("SELECT * FROM {regression} WHERE target_id = ? AND strategy = ? AND source = 'LAB' AND metric = ?"
+ " AND status IN ('SUSPECTED', 'CONFIRMED') FETCH FIRST 1 ROWS ONLY", target.get("id"), run.get("strategy"), metric);
if(!existing.isEmpty())
{
advance(target, run, metric, column, existing.get(0), changed);
return;
}
Double value = WebPerfStore.asDouble(run.get(column));
Double reference = baseline.size() == 5 && baseline.stream().allMatch(row -> row.get(column) != null) ? median(baseline, column) : null;
Map<String, Object> budget = breachedBudget(target, "LAB", (String) run.get("strategy"), metric, value);
List<String> failedAudits = newFailures(run, metric);
if(!drift(metric, reference, value) && budget == null && failedAudits.isEmpty())
{
return;
}
boolean deterministic = metric.endsWith("_SCORE") && !"PERFORMANCE_SCORE".equals(metric);
Instant at = (Instant) run.get("completed_at");
Map<String, Object> row = new LinkedHashMap<>();
row.put("target_id", target.get("id"));
row.put("content_table", target.get("content_table"));
row.put("content_id", target.get("content_id"));
row.put("source", "LAB");
row.put("strategy", run.get("strategy"));
row.put("metric", metric);
row.put("baseline_value", reference);
row.put("baseline_ref", reference == null ? null : refs(baseline));
row.put("current_value", value);
row.put("current_ref", run.get("id").toString());
row.put("delta", reference == null ? null : value - reference);
row.put("budget_id", budget == null ? null : budget.get("id"));
row.put("severity", budget == null ? "WARNING" : budget.get("severity"));
row.put("status", deterministic ? "CONFIRMED" : "SUSPECTED");
row.put("detected_at", at);
row.put("confirmed_at", deterministic ? at : null);
row.put("cause_change_event_id", run.get("change_event_id"));
row.put("first_run_id", run.get("id"));
row.put("checkpoint_run_id", run.get("id"));
row.put("lighthouse_major", major(run));
row.put("failed_audits", String.join(",", failedAudits));
long id = store.insert(WebPerfSchema.REGRESSION, row);
if(deterministic)
{
changed.add(id);
}
else
{
WebPerfService service = new WebPerfService(new WebPerfRuntime(store.getConfig()), store);
for(int sample = 0; sample < 2; sample++)
{
service.enqueueSample(target, (String) run.get("strategy"), "CONFIRM", "confirm:" + run.get("id") + ":" + sample, at);
}
}
}
/*******************************************************************************
** Confirm with the original plus two explicit samples. Recover only after
** three later successful runs on the same major version.
*******************************************************************************/
private void advance(Map<String, Object> target, Map<String, Object> run, String metric, String column,
Map<String, Object> open, List<Long> changed) throws SQLException
{
boolean suspected = "SUSPECTED".equals(open.get("status"));
List<Map<String, Object>> samples = suspected
? store.query("SELECT * FROM {lab_run} WHERE target_id = ? AND strategy = ? AND status = 'SUCCEEDED' AND id <= ?"
+ " AND (id = ? OR trigger_ref IN (?, ?)) AND (lighthouse_version = ? OR lighthouse_version LIKE ?) ORDER BY id FETCH FIRST 3 ROWS ONLY",
target.get("id"), run.get("strategy"), run.get("id"), open.get("first_run_id"), "confirm:" + open.get("first_run_id") + ":0",
"confirm:" + open.get("first_run_id") + ":1", major(run), major(run) + ".%")
: store.query("SELECT * FROM {lab_run} WHERE target_id = ? AND strategy = ? AND status = 'SUCCEEDED' AND id > ? AND id <= ?"
+ " AND (lighthouse_version = ? OR lighthouse_version LIKE ?) ORDER BY id DESC FETCH FIRST 3 ROWS ONLY",
target.get("id"), run.get("strategy"), open.get("checkpoint_run_id"), run.get("id"), major(run), major(run) + ".%");
if(samples.size() < 3 || samples.stream().anyMatch(row -> row.get(column) == null))
{
return;
}
double value = median(samples, column);
Double reference = WebPerfStore.asDouble(open.get("baseline_value"));
Map<String, Object> budget = breachedBudget(target, "LAB", (String) run.get("strategy"), metric, value);
boolean breached = drift(metric, reference, value) || budget != null || auditsStillFail(open, samples);
Instant at = (Instant) run.get("completed_at");
store.update("UPDATE {regression} SET current_value = ?, current_ref = ?, delta = ?, budget_id = ?, severity = ?, modify_date = ? WHERE id = ?",
value, refs(samples), reference == null ? null : value - reference, budget == null ? null : budget.get("id"),
budget == null ? "WARNING" : budget.get("severity"), at, open.get("id"));
if(suspected && breached)
{
store.update("UPDATE {regression} SET status = 'CONFIRMED', confirmed_at = ?, checkpoint_run_id = ? WHERE id = ?", at, run.get("id"), open.get("id"));
changed.add(WebPerfStore.asLong(open.get("id")));
}
else if(!breached)
{
close(open, suspected ? "NOT_CONFIRMED" : "RESOLVED", suspected ? "Median of three did not confirm the regression" : "Recovered: median of three later runs", at);
changed.add(WebPerfStore.asLong(open.get("id")));
}
}
/*******************************************************************************
** Match enabled budgets by source, strategy and every supported scope.
** The comparator describes acceptable values; a breach is its inverse.
*******************************************************************************/
public Map<String, Object> breachedBudget(Map<String, Object> target, String source, String strategy, String metric, double value) throws SQLException
{
Map<String, Object> winner = null;
long after = 0;
while(true)
{
List<Map<String, Object>> budgets = store.query("SELECT * FROM {budget} WHERE enabled = TRUE AND source = ? AND metric = ? AND id > ?"
+ " AND (strategy IS NULL OR strategy = '' OR strategy = ?) ORDER BY id FETCH FIRST 100 ROWS ONLY", source, metric, after, strategy);
if(budgets.isEmpty())
{
return (winner);
}
for(Map<String, Object> budget : budgets)
{
after = WebPerfStore.asLong(budget.get("id"));
if(matches(target, budget) && budget.get("threshold") != null && !acceptable(value, (String) budget.get("comparator"), WebPerfStore.asDouble(budget.get("threshold"))))
{
if(winner == null || "CRITICAL".equals(budget.get("severity")))
{
winner = budget;
}
}
}
}
}
/*******************************************************************************
** Scope values are literal values, never SQL or wildcard expressions.
*******************************************************************************/
private static boolean matches(Map<String, Object> target, Map<String, Object> budget)
{
String value = (String) budget.get("scope_value");
return (switch(String.valueOf(budget.get("scope")))
{
case "SITE" -> value == null || value.isBlank() || value.equals(target.get("site_key"));
case "TIER" -> Objects.equals(value, target.get("tier"));
case "TARGET" -> Objects.equals(value, String.valueOf(target.get("id")));
case "CONTENT_TABLE" -> Objects.equals(value, target.get("content_table"));
case "PATH_PREFIX" -> value != null && target.get("path") instanceof String path && path.startsWith(value);
default -> false;
});
}
/*******************************************************************************
** Supported budget comparators. Invalid records fail visibly on evaluation.
*******************************************************************************/
static boolean acceptable(double value, String comparator, double threshold)
{
return (switch(String.valueOf(comparator))
{
case "<=", "LE" -> value <= threshold;
case "<", "LT" -> value < threshold;
case ">=", "GE" -> value >= threshold;
case ">", "GT" -> value > threshold;
case "=", "==", "EQ" -> value == threshold;
case "!=", "<>", "NE" -> value != threshold;
default -> throw new IllegalArgumentException("Unsupported performance budget comparator: " + comparator);
});
}
/*******************************************************************************
** Design section 6.4: relative and absolute LCP limits must both be met.
*******************************************************************************/
static boolean drift(String metric, Double baseline, double value)
{
if(baseline == null)
{
return (false);
}
double delta = value - baseline;
return (switch(metric)
{
case "PERFORMANCE_SCORE" -> delta <= -10;
case "ACCESSIBILITY_SCORE", "BEST_PRACTICES_SCORE", "SEO_SCORE" -> delta <= -5;
case "LCP" -> delta >= 500 && value >= baseline * 1.25;
case "CLS" -> delta >= 0.05 - 0.0000001;
case "TBT" -> delta >= 200;
default -> false;
});
}
/*******************************************************************************
** Newly failing deterministic audits, compared only within the same major.
*******************************************************************************/
private List<String> newFailures(Map<String, Object> run, String metric) throws SQLException
{
if(!metric.endsWith("_SCORE") || "PERFORMANCE_SCORE".equals(metric))
{
return (List.of());
}
String category = metric.replace("_SCORE", "");
List<Map<String, Object>> previous = store.query("SELECT id FROM {lab_run} WHERE target_id = ? AND strategy = ? AND id < ?"
+ " AND status = 'SUCCEEDED' AND (lighthouse_version = ? OR lighthouse_version LIKE ?) ORDER BY id DESC FETCH FIRST 1 ROWS ONLY",
run.get("target_id"), run.get("strategy"), run.get("id"), major(run), major(run) + ".%");
if(previous.isEmpty())
{
return (List.of());
}
return (store.query("SELECT audit_id FROM {lab_finding} f WHERE run_id = ? AND category = ? AND score < 1"
+ " AND NOT EXISTS (SELECT 1 FROM {lab_finding} old WHERE old.run_id = ? AND old.audit_id = f.audit_id AND old.score < 1)"
+ " ORDER BY audit_id FETCH FIRST 19 ROWS ONLY", run.get("id"), category, previous.get(0).get("id"))
.stream().map(row -> (String) row.get("audit_id")).toList());
}
/*******************************************************************************
** An audit-caused regression recovers when a majority no longer fail it.
*******************************************************************************/
private boolean auditsStillFail(Map<String, Object> open, List<Map<String, Object>> samples) throws SQLException
{
String audits = (String) open.get("failed_audits");
if(audits == null || audits.isBlank())
{
return (false);
}
for(String audit : audits.split(","))
{
int failed = 0;
for(Map<String, Object> sample : samples)
{
if(!store.query("SELECT id FROM {lab_finding} WHERE run_id = ? AND audit_id = ? AND score < 1 FETCH FIRST 1 ROWS ONLY", sample.get("id"), audit).isEmpty())
{
failed++;
}
}
if(failed >= 2)
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** Record the terminal state without discarding evidence.
*******************************************************************************/
private void close(Map<String, Object> regression, String status, String note, Instant at) throws SQLException
{
store.update("UPDATE {regression} SET status = ?, resolved_at = ?, resolution_note = ?, modify_date = ? WHERE id = ?",
status, at, note, at, regression.get("id"));
}
/*******************************************************************************
** Record action; rechecks state under the same target lock as evaluation.
*******************************************************************************/
public boolean finishRegression(long id, String status, String note, Instant at) throws SQLException
{
if(!List.of("DISMISSED", "RESOLVED").contains(status) || note == null || note.isBlank() || note.length() > 2000)
{
throw (new IllegalArgumentException("A resolution note of 1 to 2000 characters is required"));
}
boolean changed = store.transaction(tx ->
{
List<Map<String, Object>> rows = tx.query("SELECT * FROM {regression} WHERE id = ?", id);
if(rows.isEmpty())
{
return (false);
}
Map<String, Object> row = rows.get(0);
tx.query("SELECT id FROM {target} WHERE id = ? FOR UPDATE", row.get("target_id"));
int updated = tx.update("UPDATE {regression} SET status = ?, resolved_at = ?, resolution_note = ?, modify_date = ? WHERE id = ? AND status IN ('SUSPECTED', 'CONFIRMED')",
status, at, note, at, id);
new WebPerfEvaluationService(tx).updateCount(row.get("target_id"));
return (updated == 1);
});
if(changed)
{
sendAlert(id);
}
return (changed);
}
/*******************************************************************************
** The list count includes suspected and confirmed open regressions.
*******************************************************************************/
private void updateCount(Object targetId) throws SQLException
{
store.update("UPDATE {target} SET open_regression_count = (SELECT COUNT(*) FROM {regression} WHERE target_id = ? AND status IN ('SUSPECTED', 'CONFIRMED')) WHERE id = ?",
targetId, targetId);
}
/*******************************************************************************
** Optional core producer API; without an Alerts QBit core uses its log sink.
*******************************************************************************/
private void sendAlert(long id)
{
if(!store.getConfig().isOn(WebPerfFeature.ALERTS))
{
return;
}
try
{
Map<String, Object> row = store.query("SELECT * FROM {regression} WHERE id = ?", id).get(0);
String key = store.getConfig().getTableNamePrefix() + ":" + row.get("target_id") + ":" + row.get("source") + ":" + row.get("strategy") + ":" + row.get("metric");
if("CONFIRMED".equals(row.get("status")))
{
QAlerts.raise(new AlertSignal(ALERT_TYPE).withDedupKey(key).withSeverity(AlertSeverity.valueOf((String) row.get("severity")))
.withTitle("Confirmed web performance regression").withSummary(row.get("strategy") + " " + row.get("metric"))
.withRecordRef(AlertRecordRef.of(store.getConfig().applyPrefix(WebPerfSchema.REGRESSION), id)));
}
else if(List.of("RESOLVED", "DISMISSED").contains(row.get("status")))
{
QAlerts.resolve(ALERT_TYPE, key, "Performance regression " + row.get("status").toString().toLowerCase(Locale.ROOT));
}
}
catch(SQLException e)
{
throw (new IllegalStateException("Reading a committed performance regression failed", e));
}
}
/*******************************************************************************
** Median of bounded, non-null measurement values.
*******************************************************************************/
static Double median(List<Map<String, Object>> rows, String column)
{
List<Double> values = rows.stream().map(row -> WebPerfStore.asDouble(row.get(column))).filter(Objects::nonNull).sorted().toList();
int size = values.size();
return (size == 0 ? null : size % 2 == 1 ? values.get(size / 2) : (values.get(size / 2 - 1) + values.get(size / 2)) / 2);
}
/*******************************************************************************
** Stored ids make a statistical result reproducible.
*******************************************************************************/
static String refs(List<Map<String, Object>> rows)
{
return (String.join(",", rows.stream().map(row -> row.get("id").toString()).toList()));
}
/*******************************************************************************
** Unknown versions never mix with a known Lighthouse baseline.
*******************************************************************************/
static String major(Map<String, Object> run)
{
return (String.valueOf(run.get("lighthouse_version")).split("\\.")[0]);
}
}
@@ -0,0 +1,962 @@
/*
* 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.Comparator;
import java.util.HashMap;
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");
static final int REPORT_BATCH = 500;
static final int REPORT_MAX_BATCHES = 20;
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);
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<>();
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) || !store.query("SELECT id FROM {lab_run} WHERE target_id = ? AND strategy = ? AND status IN ('QUEUED', 'RUNNING') FETCH FIRST 1 ROWS ONLY",
request.targetId(), request.strategy()).isEmpty())
{
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);
}
/*******************************************************************************
** Queue an independent confirmation or comparison sample. Its reference is
** stable across retries; deliberate samples must not merge with each other.
*******************************************************************************/
public String enqueueSample(Map<String, Object> target, String strategy, String trigger, String reference, Instant due) throws SQLException
{
if(!store.query("SELECT id FROM {lab_run} WHERE target_id = ? AND strategy = ? AND trigger_ref = ? AND status NOT IN ('FAILED', 'RUNTIME_ERROR', 'SKIPPED_QUOTA') FETCH FIRST 1 ROWS ONLY",
target.get("id"), strategy, reference).isEmpty())
{
return ("MERGED");
}
Instant now = Instant.now();
boolean capped = runsSince(startOfToday(now)) >= config.getMaxRunsPerDay();
RunRequest request = new RunRequest(WebPerfStore.asLong(target.get("id")), (String) target.get("url"), strategy, trigger, reference, null, due);
store.insert(WebPerfSchema.LAB_RUN, runValues(request, capped ? "SKIPPED_QUOTA" : "QUEUED", now,
capped ? "Daily run cap reached; use Retry to collect this sample" : null));
return (capped ? "SKIPPED_QUOTA" : "QUEUED");
}
/*******************************************************************************
** Retry a deliberate sample without losing its confirmation/comparison slot.
** Keep the failed row as history; simultaneous retries share the target lock.
*******************************************************************************/
public String retrySample(long runId, Instant now) throws SQLException
{
return (store.transaction(tx ->
{
Map<String, Object> run = tx.query("SELECT * FROM {lab_run} WHERE id = ?", runId).get(0);
Map<String, Object> target = tx.query("SELECT id, url FROM {target} WHERE id = ? FOR UPDATE", run.get("target_id")).get(0);
if(!List.of("FAILED", "RUNTIME_ERROR", "SKIPPED_QUOTA").contains(run.get("status")))
{
return ("MERGED");
}
return (new WebPerfService(runtime, tx).enqueueSample(target, (String) run.get("strategy"), (String) run.get("trigger_type"),
(String) run.get("trigger_ref"), now));
}));
}
/*******************************************************************************
** 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);
}
/*******************************************************************************
** Scheduled requests, least recently queued page/strategy first. Quota skips
** do not advance the rotation; never-tested pairs precede historical runs.
** Manual requests keep the caller's ordering.
**
** @param keyOnly only KEY pages
** @param reason schedule description
** @param now earliest start
** @return requests ordered fairly across daily quota boundaries
** @throws SQLException on a history read failure
*******************************************************************************/
public List<RunRequest> scheduledRequests(boolean keyOnly, String reason, Instant now) throws SQLException
{
List<RunRequest> requests = requestsFor(enabledTargets(keyOnly), "SCHEDULE", reason, now);
Map<String, Instant> lastQueued = new HashMap<>();
for(Map<String, Object> row : store.query("SELECT r.target_id, r.strategy, MAX(r.queued_at) AS last_queued FROM {lab_run} r JOIN {target} t ON t.id = r.target_id WHERE t.site_key = ? AND r.status <> 'SKIPPED_QUOTA' GROUP BY r.target_id, r.strategy", siteKey()))
{
lastQueued.put(WebPerfStore.asLong(row.get("target_id")) + "|" + row.get("strategy"), (Instant) row.get("last_queued"));
}
requests.sort(Comparator.comparing(request -> lastQueued.get(request.targetId() + "|" + request.strategy()),
Comparator.nullsFirst(Comparator.naturalOrder())));
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
{
new WebPerfEvaluationService(store).evaluatePending();
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 FETCH FIRST " + config.getConcurrency() + " ROWS ONLY", 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();
}
new WebPerfEvaluationService(store).evaluatePending();
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;
new WebPerfChangeService(store).attribute(runId, Instant.now());
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);
}
/*******************************************************************************
** Apply reportRetentionDays: delete Lighthouse Reports older than that many
** days (0 keeps them forever). The run, its scores and findings stay; only
** the full compressed response goes. Runs after a result is committed (a
** failure here never loses a run's evidence), at most once an hour per
** process (WebPerfRuntime.reportPruneDue, shared by the workers), in batches
** of REPORT_BATCH rows, at most REPORT_MAX_BATCHES per pass. There is no
** table for state shared across replicas, so each replica keeps its own
** hourly limit; passes are bounded and idempotent, so overlapping replicas
** only repeat one cheap query.
*******************************************************************************/
void pruneReports()
{
int days = runtime.getConfig().getReportRetentionDays();
if(days <= 0 || !runtime.reportPruneDue())
{
return;
}
try
{
java.sql.Timestamp cutoff = WebPerfStore.utc(Instant.now().minus(java.time.Duration.ofDays(days)));
for(int batch = 0; batch < REPORT_MAX_BATCHES; batch++)
{
int deleted = store.update("DELETE FROM {lab_report} WHERE id IN (SELECT id FROM {lab_report} WHERE create_date < ? ORDER BY id FETCH FIRST " + REPORT_BATCH + " ROWS ONLY)", cutoff);
if(deleted < REPORT_BATCH)
{
return;
}
}
}
catch(SQLException | RuntimeException e)
{
LOG.warn("Could not prune old Lighthouse Reports", e);
}
}
/*******************************************************************************
** 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
{
store.transaction(tx ->
{
new WebPerfService(runtime, tx).storeResult(runId, run, attempts, parsed, response);
return (null);
});
pruneReports();
}
/*******************************************************************************
** Commit the result, findings, report and latest values as one unit, so an
** evaluator never sees a successful run with only some of its evidence.
*******************************************************************************/
private void storeResult(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);
store.update("UPDATE {lab_run} SET ttfb_ms = ? WHERE id = ?", LabResultParser.ttfb(response.rawJson()), 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);
}
}
}
new WebPerfEvaluationService(store).evaluatePending();
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,389 @@
/*
* 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;
private Connection transactionConnection;
/*******************************************************************************
** Work sharing one database transaction.
*******************************************************************************/
@FunctionalInterface
public interface TransactionWork<T>
{
/*******************************************************************************
** Execute against the transaction's store.
*******************************************************************************/
T run(WebPerfStore store) throws SQLException;
}
/*******************************************************************************
** Commit related writes together; roll back every write on failure.
*******************************************************************************/
public <T> T transaction(TransactionWork<T> work) throws SQLException
{
if(transactionConnection != null)
{
return (work.run(this));
}
try(Connection connection = connections.get())
{
boolean autoCommit = connection.getAutoCommit();
connection.setAutoCommit(false);
WebPerfStore transactional = new WebPerfStore(config, connections);
transactional.transactionConnection = connection;
try
{
T result = work.run(transactional);
connection.commit();
return (result);
}
catch(SQLException | RuntimeException e)
{
connection.rollback();
throw (e);
}
finally
{
connection.setAutoCommit(autoCommit);
}
}
}
/*******************************************************************************
** Own a connection only outside an enclosing transaction.
*******************************************************************************/
private ConnectionLease lease() throws SQLException
{
return (new ConnectionLease(transactionConnection == null ? connections.get() : transactionConnection, transactionConnection == null));
}
/*******************************************************************************
** A statement's connection lifetime.
*******************************************************************************/
private record ConnectionLease(Connection connection, boolean owned) implements AutoCloseable
{
/*******************************************************************************
** Leave an enclosing transaction's connection open.
*******************************************************************************/
@Override
public void close() throws SQLException
{
if(owned)
{
connection.close();
}
}
}
/*******************************************************************************
** 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(ConnectionLease lease = lease(); PreparedStatement statement = lease.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(ConnectionLease lease = lease();
PreparedStatement statement = lease.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(ConnectionLease lease = lease(); PreparedStatement statement = lease.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,183 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
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.qbits.webperf.store.WebPerfStore;
/*******************************************************************************
** What is set up on the Performance home and what is next: whether pages have
** been synced, whether budgets exist (and whether they are only the seeded
** defaults), and whether the lab runner and Google field data are configured.
** The checks use the runtime's own resolution (WebPerfRuntime.labRunner() and
** cruxClient(), the url source), so the card never says "needs setup" for
** something the runtime would in fact run.
*******************************************************************************/
public final class WebPerfSetupStatus
{
public static final String SET_UP = "Set up";
public static final String NEEDS_SETUP = "Needs setup";
public static final String DEFAULTS = "Using defaults";
public static final String OPTIONAL = "Optional";
public static final String OFF = "Off";
/*******************************************************************************
** One line of the card.
**
** @param name what is being set up
** @param state one of the state constants
** @param detail what the state is (counts, and the next step)
** @param tableName the table to open (prefixed name), or null
*******************************************************************************/
public record Item(String name, String state, String detail, String tableName)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private WebPerfSetupStatus()
{
}
/*******************************************************************************
** The card's lines, in setup order.
*******************************************************************************/
public static List<Item> items(WebPerfRuntime runtime) throws SQLException
{
WebPerfQBitConfig config = runtime.getConfig();
WebPerfStore store = runtime.store();
List<Item> items = new ArrayList<>();
//////////////////////////////////////////////////////////////////////////
// tested pages: synced from the url source (sitemap) or added by hand //
//////////////////////////////////////////////////////////////////////////
long pages = count(store, "SELECT COUNT(*) AS n FROM {target}");
String target = config.applyPrefix(WebPerfSchema.TARGET);
if(pages > 0)
{
items.add(new Item("Tested pages", SET_UP, pages + " page" + (pages == 1 ? "" : "s") + " are tracked.", target));
}
else if(config.getUrlSource() == null)
{
items.add(new Item("Tested pages", NEEDS_SETUP, "No pages yet and no page source is configured. Add a page by hand, or have the host set a URL source (the sitemap) in the qbit config.", target));
}
else
{
items.add(new Item("Tested pages", NEEDS_SETUP, "No pages yet. Run “Sync pages from sitemap” on Tested Pages.", target));
}
//////////////////////////////////////////////////////////////////////////
// lab runner: the configured one, else PageSpeed Insights with a key //
//////////////////////////////////////////////////////////////////////////
if(!config.isOn(WebPerfFeature.LAB))
{
items.add(new Item("Lab tests (Lighthouse)", OFF, "The LAB feature is turned off in the qbit config.", null));
}
else if(runtime.labRunner() != null)
{
items.add(new Item("Lab tests (Lighthouse)", SET_UP, "A lab runner is configured. Use “Run site audit” on Tested Pages, or wait for the schedule.", config.applyPrefix(WebPerfSchema.LAB_RUN)));
}
else
{
items.add(new Item("Lab tests (Lighthouse)", NEEDS_SETUP, "No Google API key is configured, so lab tests cannot run. The host sets googleApiKey in the qbit config (read from an environment variable).", null));
}
//////////////////////////////////////////////////////////////////////////
// budgets: the seeded reference budgets, or the admin's own //
//////////////////////////////////////////////////////////////////////////
long budgets = count(store, "SELECT COUNT(*) AS n FROM {budget} WHERE enabled = TRUE");
long custom = count(store, "SELECT COUNT(*) AS n FROM {budget} WHERE enabled = TRUE AND (seeded IS NULL OR seeded = FALSE)");
String budget = config.applyPrefix(WebPerfSchema.BUDGET);
if(budgets == 0)
{
items.add(new Item("Budgets", NEEDS_SETUP, "No budget is enabled, so nothing is flagged as too slow. Add one, or enable the reference budgets.", budget));
}
else if(custom == 0)
{
items.add(new Item("Budgets", DEFAULTS, budgets + " reference budgets (Google's “good” thresholds) are enabled. Edit them or add your own.", budget));
}
else
{
items.add(new Item("Budgets", SET_UP, budgets + " budgets are enabled, " + custom + " of them your own.", budget));
}
//////////////////////////////////////////////////////////////////////////
// Google field data (CrUX) //
//////////////////////////////////////////////////////////////////////////
if(!config.isOn(WebPerfFeature.FIELD_CRUX))
{
items.add(new Item("Google field data", OFF, "The FIELD_CRUX feature is turned off in the qbit config.", null));
}
else if(runtime.cruxClient() != null)
{
items.add(new Item("Google field data", SET_UP, "Checked daily; use “Refresh Google field data” to check now.", config.applyPrefix(WebPerfSchema.FIELD_SNAPSHOT)));
}
else
{
items.add(new Item("Google field data", NEEDS_SETUP, "No Google API key is configured, so Google field data is not checked.", null));
}
items.add(new Item("Regressions, changes, comparisons", OPTIONAL, "Filled in by the qbit as tests run and deploys are detected. Nothing to set up.", config.applyPrefix(WebPerfSchema.REGRESSION)));
return (items);
}
/*******************************************************************************
** The card's HTML.
*******************************************************************************/
public static String html(WebPerfRuntime runtime) throws SQLException
{
List<List<String>> rows = new ArrayList<>();
for(Item item : items(runtime))
{
String color = NEEDS_SETUP.equals(item.state()) ? Html.POOR : SET_UP.equals(item.state()) ? Html.GOOD : Html.MEDIUM;
rows.add(List.of(
item.tableName() == null ? Html.escape(item.name()) : Html.link("/app/" + item.tableName(), item.name()),
"<b style=\"color:" + color + "\">" + Html.escape(item.state()) + "</b>",
Html.escape(item.detail())));
}
return (Html.table(List.of("Part", "State", "What is next"), rows));
}
/*******************************************************************************
**
*******************************************************************************/
private static long count(WebPerfStore store, String sql) throws SQLException
{
Long n = WebPerfStore.asLong(store.query(sql).get(0).get("n"));
return (n == null ? 0 : n);
}
}
@@ -0,0 +1,645 @@
/*
* 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 SETUP = "Setup";
public static final String NOT_CONFIGURED = "NotConfigured";
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 business home's widgets, in order. No provider or run-queue detail (standard A-0). */
public static final List<String> HOME = List.of(NOT_CONFIGURED, SITE_SCORES, TREND, SLOWEST_PAGES, OPPORTUNITIES, FIELD_STATUS);
/**
* The system widgets (provider setup readout, run queue). Not on the home: a host places them on its
* Admin area. Each is gated to "&lt;base&gt;.admin.hasAccess" and hidden (not shown locked) without it.
*/
public static final List<String> ADMIN = List.of(SETUP, RECENT_RUNS);
/** The business empty state shown on the home while no Google API key is set. */
public static final String NOT_CONFIGURED_TITLE = "Page speed testing is not set up";
public static final String NOT_CONFIGURED_BODY = "Ask an admin to add a Google PageSpeed API key.";
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));
}
}
}
/*******************************************************************************
** Not Configured: the home's business empty state. Shown only while lab tests
** cannot run because no Google API key is set; says who can fix it and names
** no configuration key. Renders nothing once the provider is configured.
*******************************************************************************/
public static class NotConfigured extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
if(runtime(input).labRunner() != null)
{
return (html(""));
}
return (html("<div style=\"font-weight:600\">" + Html.escape(NOT_CONFIGURED_TITLE) + "</div>" + Html.muted(NOT_CONFIGURED_BODY)));
}
}
/*******************************************************************************
** Setup: what is configured (pages, lab runner, budgets, field data) and
** the next step for each (WebPerfSetupStatus).
*******************************************************************************/
public static class Setup extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
try
{
return (html(WebPerfSetupStatus.html(runtime(input))));
}
catch(SQLException e)
{
throw (new QException("Error loading setup status", 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));
}
}
}
}
@@ -0,0 +1,433 @@
-- 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 web_performance.tablePrefix changelog property
-- (default web_performance_), declared by the host's master changelog, which must
-- equal WebPerfQBitConfig's tableNamePrefix:
--
-- - 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
-- (IF NOT EXISTS). Everything is kept forever by default (owner decision D8).
-- 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 ${web_performance.tablePrefix}target (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
site_key VARCHAR(100),
url VARCHAR(1000) NOT NULL,
path VARCHAR(500),
source VARCHAR(20),
content_table VARCHAR(100),
content_id BIGINT,
label VARCHAR(200),
tier VARCHAR(20) DEFAULT 'STANDARD' NOT NULL,
enabled BOOLEAN DEFAULT TRUE NOT NULL,
in_sitemap BOOLEAN,
last_seen_in_sitemap_at TIMESTAMP WITHOUT TIME ZONE,
latest_mobile_performance INTEGER,
latest_desktop_performance INTEGER,
latest_accessibility INTEGER,
latest_tested_at TIMESTAMP WITHOUT TIME ZONE,
open_regression_count INTEGER,
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
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_${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 ${web_performance.tablePrefix}lab_run (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
target_id BIGINT,
url VARCHAR(1000),
final_url VARCHAR(1000),
strategy VARCHAR(10),
runner VARCHAR(20),
trigger_type VARCHAR(20),
trigger_ref VARCHAR(200),
batch_id VARCHAR(64),
status VARCHAR(20),
not_before TIMESTAMP WITHOUT TIME ZONE,
queued_at TIMESTAMP WITHOUT TIME ZONE,
started_at TIMESTAMP WITHOUT TIME ZONE,
completed_at TIMESTAMP WITHOUT TIME ZONE,
attempts INTEGER,
http_status INTEGER,
error_message VARCHAR(2000),
lighthouse_version VARCHAR(20),
chrome_user_agent VARCHAR(300),
benchmark_index INTEGER,
score_performance INTEGER,
score_accessibility INTEGER,
score_best_practices INTEGER,
score_seo INTEGER,
fcp_ms INTEGER,
lcp_ms INTEGER,
tbt_ms INTEGER,
cls NUMERIC(8,4),
speed_index_ms INTEGER,
lcp_element VARCHAR(300),
run_warnings VARCHAR(2000),
deployment_id VARCHAR(64),
cdn_stage VARCHAR(20),
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${web_performance.tablePrefix}lab_run PRIMARY KEY (id)
);
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 ${web_performance.tablePrefix}lab_finding (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
run_id BIGINT NOT NULL,
target_id BIGINT,
strategy VARCHAR(10),
category VARCHAR(20),
audit_id VARCHAR(100),
lighthouse_version VARCHAR(20),
kind VARCHAR(20),
title VARCHAR(300),
score NUMERIC(5,4),
display_value VARCHAR(200),
savings_ms INTEGER,
savings_bytes BIGINT,
metric_savings TEXT,
top_items TEXT,
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${web_performance.tablePrefix}lab_finding PRIMARY KEY (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 ${web_performance.tablePrefix}lab_report (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
run_id BIGINT NOT NULL,
content BYTEA,
bytes INTEGER,
sha256 VARCHAR(64),
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
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 ${web_performance.tablePrefix}field_snapshot (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
source VARCHAR(20),
scope VARCHAR(10),
scope_key VARCHAR(1000),
form_factor VARCHAR(10),
status VARCHAR(10),
period_start DATE,
period_end DATE,
p75_lcp_ms INTEGER,
p75_inp_ms INTEGER,
p75_cls NUMERIC(8,4),
p75_fcp_ms INTEGER,
p75_ttfb_ms INTEGER,
distributions TEXT,
last_checked_at TIMESTAMP WITHOUT TIME ZONE,
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
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 ${web_performance.tablePrefix}budget (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(200) NOT NULL,
scope VARCHAR(20),
scope_value VARCHAR(500),
source VARCHAR(10),
strategy VARCHAR(10),
metric VARCHAR(30),
comparator VARCHAR(5),
threshold NUMERIC(12,4),
severity VARCHAR(10),
enabled BOOLEAN DEFAULT TRUE NOT NULL,
seeded BOOLEAN DEFAULT FALSE NOT NULL,
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${web_performance.tablePrefix}budget PRIMARY KEY (id)
);
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 ${web_performance.tablePrefix}regression (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
target_id BIGINT,
content_table VARCHAR(100),
content_id BIGINT,
source VARCHAR(10),
strategy VARCHAR(10),
metric VARCHAR(30),
baseline_value NUMERIC(12,4),
baseline_ref VARCHAR(500),
current_value NUMERIC(12,4),
current_ref VARCHAR(500),
delta NUMERIC(12,4),
budget_id BIGINT,
severity VARCHAR(10),
status VARCHAR(20),
cause_change_event_id BIGINT,
detected_at TIMESTAMP WITHOUT TIME ZONE,
confirmed_at TIMESTAMP WITHOUT TIME ZONE,
resolved_at TIMESTAMP WITHOUT TIME ZONE,
resolution_note VARCHAR(2000),
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${web_performance.tablePrefix}regression PRIMARY KEY (id)
);
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 ${web_performance.tablePrefix}change_event (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
kind VARCHAR(30),
component VARCHAR(200),
from_value VARCHAR(200),
to_value VARCHAR(200),
observed_at TIMESTAMP WITHOUT TIME ZONE,
source VARCHAR(20),
note VARCHAR(2000),
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${web_performance.tablePrefix}change_event PRIMARY KEY (id)
);
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 ${web_performance.tablePrefix}comparison (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
change_event_id BIGINT,
scope VARCHAR(20),
source VARCHAR(10),
strategy VARCHAR(10),
metric VARCHAR(30),
segment VARCHAR(100),
before_value NUMERIC(12,4),
before_n INTEGER,
before_window VARCHAR(100),
after_value NUMERIC(12,4),
after_n INTEGER,
after_window VARCHAR(100),
delta NUMERIC(12,4),
delta_pct NUMERIC(8,2),
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${web_performance.tablePrefix}comparison PRIMARY KEY (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 ${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'
UNION ALL SELECT 'FCP good (lab, mobile)', 'SITE', NULL, 'LAB', 'MOBILE', 'FCP', '<=', 1800, 'WARNING'
UNION ALL SELECT 'LCP good (real users, p75)', 'SITE', NULL, 'RUM', NULL, 'LCP', '<=', 2500, 'WARNING'
UNION ALL SELECT 'INP good (real users, p75)', 'SITE', NULL, 'RUM', NULL, 'INP', '<=', 200, 'WARNING'
UNION ALL SELECT 'CLS good (real users, p75)', 'SITE', NULL, 'RUM', NULL, 'CLS', '<=', 0.1, 'WARNING'
UNION ALL SELECT 'FCP good (real users, p75)', 'SITE', NULL, 'RUM', NULL, 'FCP', '<=', 1800, 'WARNING'
UNION ALL SELECT 'TTFB good (real users, p75)', 'SITE', NULL, 'RUM', NULL, 'TTFB', '<=', 800, 'WARNING'
UNION ALL SELECT 'Performance at least 90 (KEY pages)', 'TIER', 'KEY', 'LAB', NULL, 'PERFORMANCE_SCORE', '>=', 90, 'WARNING'
UNION ALL SELECT 'Accessibility at least 90 (KEY pages)', 'TIER', 'KEY', 'LAB', NULL, 'ACCESSIBILITY_SCORE', '>=', 90, 'WARNING'
UNION ALL SELECT 'Best Practices at least 90 (KEY pages)', 'TIER', 'KEY', 'LAB', NULL, 'BEST_PRACTICES_SCORE', '>=', 90, 'WARNING'
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 ${web_performance.tablePrefix}budget WHERE seeded = TRUE);
-- changeset qbit-web-performance:11
-- comment: Durable evaluation progress, event identity and comparison page identity. Existing history is preserved.
ALTER TABLE ${web_performance.tablePrefix}lab_run ADD COLUMN evaluated_at TIMESTAMP WITHOUT TIME ZONE;
ALTER TABLE ${web_performance.tablePrefix}lab_run ADD COLUMN change_event_id BIGINT;
ALTER TABLE ${web_performance.tablePrefix}regression ADD COLUMN first_run_id BIGINT;
ALTER TABLE ${web_performance.tablePrefix}regression ADD COLUMN checkpoint_run_id BIGINT;
ALTER TABLE ${web_performance.tablePrefix}regression ADD COLUMN lighthouse_major VARCHAR(20);
ALTER TABLE ${web_performance.tablePrefix}change_event ADD COLUMN event_key VARCHAR(64);
ALTER TABLE ${web_performance.tablePrefix}change_event ADD COLUMN site_key VARCHAR(200);
ALTER TABLE ${web_performance.tablePrefix}comparison ADD COLUMN target_id BIGINT;
CREATE UNIQUE INDEX uq_${web_performance.tablePrefix}change_event_key ON ${web_performance.tablePrefix}change_event (event_key);
CREATE UNIQUE INDEX uq_${web_performance.tablePrefix}comparison_page ON ${web_performance.tablePrefix}comparison (change_event_id, target_id, source, strategy, metric, segment);
CREATE INDEX idx_${web_performance.tablePrefix}lab_evaluation ON ${web_performance.tablePrefix}lab_run (evaluated_at, status, id);
ALTER TABLE ${web_performance.tablePrefix}regression ADD COLUMN failed_audits VARCHAR(2000);
ALTER TABLE ${web_performance.tablePrefix}lab_run ADD COLUMN ttfb_ms INTEGER;
ALTER TABLE ${web_performance.tablePrefix}field_snapshot ADD COLUMN evaluated_at TIMESTAMP WITHOUT TIME ZONE;
ALTER TABLE ${web_performance.tablePrefix}change_event ADD COLUMN last_seen_at TIMESTAMP WITHOUT TIME ZONE;
-- changeset qbit-web-performance:12
-- comment: Referential integrity (foreign keys), part 1: every foreign key column leads an index, so a parent delete checks its children by index. The other foreign key columns already lead one (target_id, run_id, change_event_id on comparison).
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}lab_run_change_event ON ${web_performance.tablePrefix}lab_run (change_event_id);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}regression_budget ON ${web_performance.tablePrefix}regression (budget_id);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}regression_cause ON ${web_performance.tablePrefix}regression (cause_change_event_id);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}regression_first_run ON ${web_performance.tablePrefix}regression (first_run_id);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}regression_checkpoint_run ON ${web_performance.tablePrefix}regression (checkpoint_run_id);
CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}comparison_target ON ${web_performance.tablePrefix}comparison (target_id);
-- Referential integrity (foreign keys), part 2: one changeset per foreign key, named
-- fk_<child table>_<column>. Each first records and repairs rows whose parent is
-- missing (a nullable column is set to NULL; a NOT NULL row is copied in full to
-- qqq_fk_quarantine and deleted, PostgreSQL only), then adds the constraint. Each
-- is marked ran when its constraint already exists (a host that added it itself).
-- ON DELETE: owned details CASCADE (findings and the report of a run,
-- comparisons of a site change); optional pointers SET NULL; tested pages and
-- budgets RESTRICT, because history is kept (D8): disable a page or budget instead
-- of deleting it.
-- changeset qbit-web-performance:13
-- comment: Lab runs keep their tested page (D8, history is kept): a page with runs cannot be deleted, disable it instead (enabled = false). Orphan target_id values are recorded in qqq_fk_quarantine and set to NULL.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_run_target_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}lab_run', 'target_id', CAST(c.id AS VARCHAR(100)), CAST(c.target_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:13' FROM ${web_performance.tablePrefix}lab_run c WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id);
UPDATE ${web_performance.tablePrefix}lab_run c SET target_id = NULL WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id);
ALTER TABLE ${web_performance.tablePrefix}lab_run ADD CONSTRAINT fk_${web_performance.tablePrefix}lab_run_target_id FOREIGN KEY (target_id) REFERENCES ${web_performance.tablePrefix}target (id) ON DELETE RESTRICT;
-- rollback ALTER TABLE ${web_performance.tablePrefix}lab_run DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}lab_run_target_id;
-- changeset qbit-web-performance:14 dbms:postgresql
-- comment: Orphan cleanup before fk_${web_performance.tablePrefix}lab_finding_run_id: ${web_performance.tablePrefix}lab_finding rows whose run_id names a missing ${web_performance.tablePrefix}lab_run row are copied in full to qqq_fk_quarantine, then deleted (run_id is NOT NULL).
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_finding_run_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT '${web_performance.tablePrefix}lab_finding', 'run_id', CAST(c.id AS VARCHAR(100)), CAST(c.run_id AS VARCHAR(100)), 'DELETED', row_to_json(c)::text, 'qbit-web-performance:14' FROM ${web_performance.tablePrefix}lab_finding c WHERE c.run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.run_id);
DELETE FROM ${web_performance.tablePrefix}lab_finding c WHERE c.run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.run_id);
-- rollback SELECT 1;
-- changeset qbit-web-performance:15
-- comment: Findings belong to their run: deleting a run deletes its findings.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_finding_run_id'
ALTER TABLE ${web_performance.tablePrefix}lab_finding ADD CONSTRAINT fk_${web_performance.tablePrefix}lab_finding_run_id FOREIGN KEY (run_id) REFERENCES ${web_performance.tablePrefix}lab_run (id) ON DELETE CASCADE;
-- rollback ALTER TABLE ${web_performance.tablePrefix}lab_finding DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}lab_finding_run_id;
-- changeset qbit-web-performance:16
-- comment: A finding's page (copied from its run) is kept like the run's. Orphan target_id values are recorded in qqq_fk_quarantine and set to NULL.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_finding_target_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}lab_finding', 'target_id', CAST(c.id AS VARCHAR(100)), CAST(c.target_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:16' FROM ${web_performance.tablePrefix}lab_finding c WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id);
UPDATE ${web_performance.tablePrefix}lab_finding c SET target_id = NULL WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id);
ALTER TABLE ${web_performance.tablePrefix}lab_finding ADD CONSTRAINT fk_${web_performance.tablePrefix}lab_finding_target_id FOREIGN KEY (target_id) REFERENCES ${web_performance.tablePrefix}target (id) ON DELETE RESTRICT;
-- rollback ALTER TABLE ${web_performance.tablePrefix}lab_finding DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}lab_finding_target_id;
-- changeset qbit-web-performance:17 dbms:postgresql
-- comment: Orphan cleanup before fk_${web_performance.tablePrefix}lab_report_run_id: ${web_performance.tablePrefix}lab_report rows whose run_id names a missing ${web_performance.tablePrefix}lab_run row are copied in full to qqq_fk_quarantine, then deleted (run_id is NOT NULL).
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_report_run_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT '${web_performance.tablePrefix}lab_report', 'run_id', CAST(c.id AS VARCHAR(100)), CAST(c.run_id AS VARCHAR(100)), 'DELETED', row_to_json(c)::text, 'qbit-web-performance:17' FROM ${web_performance.tablePrefix}lab_report c WHERE c.run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.run_id);
DELETE FROM ${web_performance.tablePrefix}lab_report c WHERE c.run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.run_id);
-- rollback SELECT 1;
-- changeset qbit-web-performance:18
-- comment: The Lighthouse report belongs to its run: deleting a run deletes its report.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_report_run_id'
ALTER TABLE ${web_performance.tablePrefix}lab_report ADD CONSTRAINT fk_${web_performance.tablePrefix}lab_report_run_id FOREIGN KEY (run_id) REFERENCES ${web_performance.tablePrefix}lab_run (id) ON DELETE CASCADE;
-- rollback ALTER TABLE ${web_performance.tablePrefix}lab_report DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}lab_report_run_id;
-- changeset qbit-web-performance:19
-- comment: Regressions keep their page: a page with regressions cannot be deleted, disable it instead. Orphan target_id values are recorded in qqq_fk_quarantine and set to NULL.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}regression_target_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}regression', 'target_id', CAST(c.id AS VARCHAR(100)), CAST(c.target_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:19' FROM ${web_performance.tablePrefix}regression c WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id);
UPDATE ${web_performance.tablePrefix}regression c SET target_id = NULL WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id);
ALTER TABLE ${web_performance.tablePrefix}regression ADD CONSTRAINT fk_${web_performance.tablePrefix}regression_target_id FOREIGN KEY (target_id) REFERENCES ${web_performance.tablePrefix}target (id) ON DELETE RESTRICT;
-- rollback ALTER TABLE ${web_performance.tablePrefix}regression DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}regression_target_id;
-- changeset qbit-web-performance:20
-- comment: A regression keeps the budget it breached: a budget with regressions cannot be deleted, disable it instead (enabled = false). Orphan budget_id values are recorded in qqq_fk_quarantine and set to NULL.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}regression_budget_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}regression', 'budget_id', CAST(c.id AS VARCHAR(100)), CAST(c.budget_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:20' FROM ${web_performance.tablePrefix}regression c WHERE c.budget_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}budget p WHERE p.id = c.budget_id);
UPDATE ${web_performance.tablePrefix}regression c SET budget_id = NULL WHERE c.budget_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}budget p WHERE p.id = c.budget_id);
ALTER TABLE ${web_performance.tablePrefix}regression ADD CONSTRAINT fk_${web_performance.tablePrefix}regression_budget_id FOREIGN KEY (budget_id) REFERENCES ${web_performance.tablePrefix}budget (id) ON DELETE RESTRICT;
-- rollback ALTER TABLE ${web_performance.tablePrefix}regression DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}regression_budget_id;
-- changeset qbit-web-performance:21
-- comment: The likely cause is an optional pointer: deleting the site change clears it. Orphan values are recorded in qqq_fk_quarantine and set to NULL.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}regression_cause_change_event_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}regression', 'cause_change_event_id', CAST(c.id AS VARCHAR(100)), CAST(c.cause_change_event_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:21' FROM ${web_performance.tablePrefix}regression c WHERE c.cause_change_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}change_event p WHERE p.id = c.cause_change_event_id);
UPDATE ${web_performance.tablePrefix}regression c SET cause_change_event_id = NULL WHERE c.cause_change_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}change_event p WHERE p.id = c.cause_change_event_id);
ALTER TABLE ${web_performance.tablePrefix}regression ADD CONSTRAINT fk_${web_performance.tablePrefix}regression_cause_change_event_id FOREIGN KEY (cause_change_event_id) REFERENCES ${web_performance.tablePrefix}change_event (id) ON DELETE SET NULL;
-- rollback ALTER TABLE ${web_performance.tablePrefix}regression DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}regression_cause_change_event_id;
-- changeset qbit-web-performance:22
-- comment: A before-and-after comparison is computed for its site change: deleting the change deletes its comparisons. Orphan values are recorded in qqq_fk_quarantine and set to NULL.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}comparison_change_event_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}comparison', 'change_event_id', CAST(c.id AS VARCHAR(100)), CAST(c.change_event_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:22' FROM ${web_performance.tablePrefix}comparison c WHERE c.change_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}change_event p WHERE p.id = c.change_event_id);
UPDATE ${web_performance.tablePrefix}comparison c SET change_event_id = NULL WHERE c.change_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}change_event p WHERE p.id = c.change_event_id);
ALTER TABLE ${web_performance.tablePrefix}comparison ADD CONSTRAINT fk_${web_performance.tablePrefix}comparison_change_event_id FOREIGN KEY (change_event_id) REFERENCES ${web_performance.tablePrefix}change_event (id) ON DELETE CASCADE;
-- rollback ALTER TABLE ${web_performance.tablePrefix}comparison DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}comparison_change_event_id;
-- changeset qbit-web-performance:23
-- comment: The site change a run was attributed to is an optional pointer: deleting the change clears it. Orphan values are recorded in qqq_fk_quarantine and set to NULL.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_run_change_event_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}lab_run', 'change_event_id', CAST(c.id AS VARCHAR(100)), CAST(c.change_event_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:23' FROM ${web_performance.tablePrefix}lab_run c WHERE c.change_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}change_event p WHERE p.id = c.change_event_id);
UPDATE ${web_performance.tablePrefix}lab_run c SET change_event_id = NULL WHERE c.change_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}change_event p WHERE p.id = c.change_event_id);
ALTER TABLE ${web_performance.tablePrefix}lab_run ADD CONSTRAINT fk_${web_performance.tablePrefix}lab_run_change_event_id FOREIGN KEY (change_event_id) REFERENCES ${web_performance.tablePrefix}change_event (id) ON DELETE SET NULL;
-- rollback ALTER TABLE ${web_performance.tablePrefix}lab_run DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}lab_run_change_event_id;
-- changeset qbit-web-performance:24
-- comment: Evidence pointer to the run that first showed the regression: deleting the run clears it. Orphan values are recorded in qqq_fk_quarantine and set to NULL.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}regression_first_run_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}regression', 'first_run_id', CAST(c.id AS VARCHAR(100)), CAST(c.first_run_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:24' FROM ${web_performance.tablePrefix}regression c WHERE c.first_run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.first_run_id);
UPDATE ${web_performance.tablePrefix}regression c SET first_run_id = NULL WHERE c.first_run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.first_run_id);
ALTER TABLE ${web_performance.tablePrefix}regression ADD CONSTRAINT fk_${web_performance.tablePrefix}regression_first_run_id FOREIGN KEY (first_run_id) REFERENCES ${web_performance.tablePrefix}lab_run (id) ON DELETE SET NULL;
-- rollback ALTER TABLE ${web_performance.tablePrefix}regression DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}regression_first_run_id;
-- changeset qbit-web-performance:25
-- comment: Evidence pointer to the confirmation run: deleting the run clears it. Orphan values are recorded in qqq_fk_quarantine and set to NULL.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}regression_checkpoint_run_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}regression', 'checkpoint_run_id', CAST(c.id AS VARCHAR(100)), CAST(c.checkpoint_run_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:25' FROM ${web_performance.tablePrefix}regression c WHERE c.checkpoint_run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.checkpoint_run_id);
UPDATE ${web_performance.tablePrefix}regression c SET checkpoint_run_id = NULL WHERE c.checkpoint_run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.checkpoint_run_id);
ALTER TABLE ${web_performance.tablePrefix}regression ADD CONSTRAINT fk_${web_performance.tablePrefix}regression_checkpoint_run_id FOREIGN KEY (checkpoint_run_id) REFERENCES ${web_performance.tablePrefix}lab_run (id) ON DELETE SET NULL;
-- rollback ALTER TABLE ${web_performance.tablePrefix}regression DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}regression_checkpoint_run_id;
-- changeset qbit-web-performance:26
-- comment: A per-page comparison keeps its page: a page with comparisons cannot be deleted, disable it instead. Orphan target_id values are recorded in qqq_fk_quarantine and set to NULL.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}comparison_target_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}comparison', 'target_id', CAST(c.id AS VARCHAR(100)), CAST(c.target_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:26' FROM ${web_performance.tablePrefix}comparison c WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id);
UPDATE ${web_performance.tablePrefix}comparison c SET target_id = NULL WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id);
ALTER TABLE ${web_performance.tablePrefix}comparison ADD CONSTRAINT fk_${web_performance.tablePrefix}comparison_target_id FOREIGN KEY (target_id) REFERENCES ${web_performance.tablePrefix}target (id) ON DELETE RESTRICT;
-- rollback ALTER TABLE ${web_performance.tablePrefix}comparison DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}comparison_target_id;
@@ -1,263 +0,0 @@
-- 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
-- equal WebPerfQBitConfig's tableNamePrefix:
--
-- - property: { name: webperf.tablePrefix, value: webperf_ }
-- - include: { file: db/liquibase/qbit-web-performance/webperf.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
-- (IF NOT EXISTS). Everything is kept forever by default (owner decision D8).
-- 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 (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
site_key VARCHAR(100),
url VARCHAR(1000) NOT NULL,
path VARCHAR(500),
source VARCHAR(20),
content_table VARCHAR(100),
content_id BIGINT,
label VARCHAR(200),
tier VARCHAR(20) DEFAULT 'STANDARD' NOT NULL,
enabled BOOLEAN DEFAULT TRUE NOT NULL,
in_sitemap BOOLEAN,
last_seen_in_sitemap_at TIMESTAMP WITHOUT TIME ZONE,
latest_mobile_performance INTEGER,
latest_desktop_performance INTEGER,
latest_accessibility INTEGER,
latest_tested_at TIMESTAMP WITHOUT TIME ZONE,
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)
);
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);
-- 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 (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
target_id BIGINT,
url VARCHAR(1000),
final_url VARCHAR(1000),
strategy VARCHAR(10),
runner VARCHAR(20),
trigger_type VARCHAR(20),
trigger_ref VARCHAR(200),
batch_id VARCHAR(64),
status VARCHAR(20),
not_before TIMESTAMP WITHOUT TIME ZONE,
queued_at TIMESTAMP WITHOUT TIME ZONE,
started_at TIMESTAMP WITHOUT TIME ZONE,
completed_at TIMESTAMP WITHOUT TIME ZONE,
attempts INTEGER,
http_status INTEGER,
error_message VARCHAR(2000),
lighthouse_version VARCHAR(20),
chrome_user_agent VARCHAR(300),
benchmark_index INTEGER,
score_performance INTEGER,
score_accessibility INTEGER,
score_best_practices INTEGER,
score_seo INTEGER,
fcp_ms INTEGER,
lcp_ms INTEGER,
tbt_ms INTEGER,
cls NUMERIC(8,4),
speed_index_ms INTEGER,
lcp_element VARCHAR(300),
run_warnings VARCHAR(2000),
deployment_id VARCHAR(64),
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)
);
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);
-- 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 (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
run_id BIGINT NOT NULL,
target_id BIGINT,
strategy VARCHAR(10),
category VARCHAR(20),
audit_id VARCHAR(100),
lighthouse_version VARCHAR(20),
kind VARCHAR(20),
title VARCHAR(300),
score NUMERIC(5,4),
display_value VARCHAR(200),
savings_ms INTEGER,
savings_bytes BIGINT,
metric_savings TEXT,
top_items TEXT,
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${webperf.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);
-- 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 (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
run_id BIGINT NOT NULL,
content BYTEA,
bytes INTEGER,
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)
);
-- 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 (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
source VARCHAR(20),
scope VARCHAR(10),
scope_key VARCHAR(1000),
form_factor VARCHAR(10),
status VARCHAR(10),
period_start DATE,
period_end DATE,
p75_lcp_ms INTEGER,
p75_inp_ms INTEGER,
p75_cls NUMERIC(8,4),
p75_fcp_ms INTEGER,
p75_ttfb_ms INTEGER,
distributions TEXT,
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)
);
-- 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 (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(200) NOT NULL,
scope VARCHAR(20),
scope_value VARCHAR(500),
source VARCHAR(10),
strategy VARCHAR(10),
metric VARCHAR(30),
comparator VARCHAR(5),
threshold NUMERIC(12,4),
severity VARCHAR(10),
enabled BOOLEAN DEFAULT TRUE NOT NULL,
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)
);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}budget_enabled ON ${webperf.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 (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
target_id BIGINT,
content_table VARCHAR(100),
content_id BIGINT,
source VARCHAR(10),
strategy VARCHAR(10),
metric VARCHAR(30),
baseline_value NUMERIC(12,4),
baseline_ref VARCHAR(500),
current_value NUMERIC(12,4),
current_ref VARCHAR(500),
delta NUMERIC(12,4),
budget_id BIGINT,
severity VARCHAR(10),
status VARCHAR(20),
cause_change_event_id BIGINT,
detected_at TIMESTAMP WITHOUT TIME ZONE,
confirmed_at TIMESTAMP WITHOUT TIME ZONE,
resolved_at TIMESTAMP WITHOUT TIME ZONE,
resolution_note VARCHAR(2000),
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${webperf.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);
-- 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 (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
kind VARCHAR(30),
component VARCHAR(200),
from_value VARCHAR(200),
to_value VARCHAR(200),
observed_at TIMESTAMP WITHOUT TIME ZONE,
source VARCHAR(20),
note VARCHAR(2000),
create_date TIMESTAMP WITHOUT TIME ZONE,
modify_date TIMESTAMP WITHOUT TIME ZONE,
CONSTRAINT pk_${webperf.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);
-- 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 (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
change_event_id BIGINT,
scope VARCHAR(20),
source VARCHAR(10),
strategy VARCHAR(10),
metric VARCHAR(30),
segment VARCHAR(100),
before_value NUMERIC(12,4),
before_n INTEGER,
before_window VARCHAR(100),
after_value NUMERIC(12,4),
after_n INTEGER,
after_window VARCHAR(100),
delta NUMERIC(12,4),
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)
);
CREATE INDEX IF NOT EXISTS idx_${webperf.tablePrefix}comparison_change ON ${webperf.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)
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'
UNION ALL SELECT 'FCP good (lab, mobile)', 'SITE', NULL, 'LAB', 'MOBILE', 'FCP', '<=', 1800, 'WARNING'
UNION ALL SELECT 'LCP good (real users, p75)', 'SITE', NULL, 'RUM', NULL, 'LCP', '<=', 2500, 'WARNING'
UNION ALL SELECT 'INP good (real users, p75)', 'SITE', NULL, 'RUM', NULL, 'INP', '<=', 200, 'WARNING'
UNION ALL SELECT 'CLS good (real users, p75)', 'SITE', NULL, 'RUM', NULL, 'CLS', '<=', 0.1, 'WARNING'
UNION ALL SELECT 'FCP good (real users, p75)', 'SITE', NULL, 'RUM', NULL, 'FCP', '<=', 1800, 'WARNING'
UNION ALL SELECT 'TTFB good (real users, p75)', 'SITE', NULL, 'RUM', NULL, 'TTFB', '<=', 800, 'WARNING'
UNION ALL SELECT 'Performance at least 90 (KEY pages)', 'TIER', 'KEY', 'LAB', NULL, 'PERFORMANCE_SCORE', '>=', 90, 'WARNING'
UNION ALL SELECT 'Accessibility at least 90 (KEY pages)', 'TIER', 'KEY', 'LAB', NULL, 'ACCESSIBILITY_SCORE', '>=', 90, 'WARNING'
UNION ALL SELECT 'Best Practices at least 90 (KEY pages)', 'TIER', 'KEY', 'LAB', NULL, 'BEST_PRACTICES_SCORE', '>=', 90, 'WARNING'
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);
@@ -0,0 +1,666 @@
bundle: qbit-web-performance
version: ${project.version}
layer: QBIT
entries:
- key: app:webPerformance;slot:help
summary: Page speed tests, Google field data, budgets and regressions for your site.
content: |
Sync your pages and review your budgets, then run tests. The dashboard shows site scores, trends, slow pages, opportunities and field data. If page speed testing is not set up, it says so and tells you to ask an admin.
Run **{{label:webPerformanceTestPageSpeed}}** for selected pages or **{{label:webPerformanceRunSiteAudit}}** for all enabled pages. Mobile and desktop are separate runs by default. Your host controls the enabled features, device strategies, daily run limit and scheduler. Without a configured runner, lab tests wait in the queue.
Review **{{label:web_performance_regression}}** for budget breaches and performance changes. Testing requires the testing permission; syncing pages and refreshing field data require administrator permission.
- key: table:web_performance_target;slot:help
summary: Every public page that is tested for speed, with its latest scores.
content: |
**How rows get created:** Sync pages from your URL source, or add an HTTPS URL with New.
Use this list to choose which pages you test and review their latest scores. **{{label:webPerformanceSyncPages}}** imports URLs from your configured URL source. Pages that disappear from the source are retained with their history. You can also add an HTTPS URL with **New**.
Edit the URL, friendly label, tier or enabled setting as needed. Disable a page to skip it when queuing tests. Mark important pages as key pages to include them in key-page tests and site scores.
Use **{{label:webPerformanceTestPageSpeed}}** for selected pages or **{{label:webPerformanceRunSiteAudit}}** for all enabled pages. Results update after successful tests; the Performance section shows the latest results.
- key: table:web_performance_target;section:page
summary: 'The page address and the settings you can change: label, tier and whether it is tested.'
- key: table:web_performance_target;section:latest
summary: The most recent successful test scores for this page and how many regressions are open.
- key: table:web_performance_target;section:source
summary: Where the page came from and which content record it belongs to.
- key: table:web_performance_target;section:dates
summary: When the record was created and last changed.
- key: table:web_performance_target;section:webPerformancePerformance
summary: Latest mobile and desktop test results for this page, with a note when a test is waiting.
- key: table:web_performance_target;field:siteKey
summary: Which site the page belongs to. Filled in automatically.
- key: table:web_performance_target;field:url
summary: The full address that is tested. Together with the site it is unique.
- key: table:web_performance_target;field:path
summary: The page address without the domain, such as /contact. Filled in automatically from the URL.
- key: table:web_performance_target;field:source
summary: How the page was added. Pages from the sitemap show SITEMAP.
- key: table:web_performance_target;field:contentTable
summary: The kind of content record this page shows, such as a page or blog post, when one could be matched.
content: |
Your configured URL source can link a page to a content record. Empty when no content record is linked.
- key: table:web_performance_target;field:contentId
summary: The id of the content record this page shows. Filled in automatically with the content kind.
- key: table:web_performance_target;field:label
summary: A friendly name for the page, shown in lists and charts instead of the address. Optional.
- key: table:web_performance_target;field:tier
summary: Choose key pages for frequent tests and site scores, or standard pages for site audits.
content: |
Choose from {{values:web_performance_tier}}. New pages use **{{default:web_performance_target.tier}}** unless the configured URL source assigns another tier. Scheduled tests require your host to configure a scheduler.
- key: table:web_performance_target;field:enabled
summary: Turn off to stop testing this page.
content: |
New pages start enabled. Disabled pages are skipped by the daily tests, the site audit and **{{label:webPerformanceTestPageSpeed}}**.
- key: table:web_performance_target;field:inSitemap
summary: Whether the page was in the sitemap the last time it was synced.
content: |
Turned off, not deleted, when a page leaves the sitemap. Its test history stays.
- key: table:web_performance_target;field:lastSeenInSitemapAt
summary: The last time a sitemap sync found this page listed.
- key: table:web_performance_target;field:latestMobilePerformance
summary: Performance score (0 to 100) of the latest successful mobile test. Higher is better.
- key: table:web_performance_target;field:latestDesktopPerformance
summary: Performance score (0 to 100) of the latest successful desktop test. Higher is better.
- key: table:web_performance_target;field:latestAccessibility
summary: Accessibility score (0 to 100) of the latest successful test. Higher is better.
- key: table:web_performance_target;field:latestTestedAt
summary: When the latest successful test of this page finished.
- key: table:web_performance_target;field:openRegressionCount
summary: How many unresolved regressions the page has. Filled in automatically.
content: |
Counts suspected and confirmed regressions for this page. Review **{{label:web_performance_regression}}** for details.
- key: table:web_performance_lab_run;slot:help
summary: 'Every Lighthouse test: one page, one device type, from waiting in line to its final result.'
content: |
**How rows get created:** Queue tests with Test page speed or Run site audit, or let configured schedules queue them.
Each row is a single test of one page on either mobile or desktop. Runs are created when you use **{{label:webPerformanceTestPageSpeed}}**, **{{label:webPerformanceRunSiteAudit}}**, or configured schedules. With a scheduler, the worker picks up waiting runs every minute by default.
Scores are 0 to 100. Lab metrics are in milliseconds except **{{label:web_performance_lab_run.cls}}**, which has no unit. If Google is busy or fails, the run is tried up to 3 times in total, waiting 1 minute and then 5 minutes between tries.
A run that ends as **FAILED**, **RUNTIME_ERROR** or **SKIPPED_QUOTA** can be re-queued with the **{{label:webPerformanceRetryRuns}}** action, which makes a new run and keeps the old one.
Nothing here can be edited. The full Lighthouse report for each successful run is kept under **{{label:web_performance_lab_report}}**, and its problems are listed under **{{label:web_performance_lab_finding}}**. Lab scores vary a little from run to run, so look at trends rather than single runs.
- key: table:web_performance_lab_run;section:run
summary: Which page was tested, on which device type, why, and how it ended.
- key: table:web_performance_lab_run;section:scores
summary: The four Lighthouse category scores, from 0 to 100.
- key: table:web_performance_lab_run;section:metrics
summary: Measured load times in milliseconds, plus layout shift (no unit).
- key: table:web_performance_lab_run;section:details
summary: Queue timing, attempts, errors and the test environment.
- key: table:web_performance_lab_run;section:dates
summary: When the record was created and last changed.
- key: table:web_performance_lab_run;field:targetId
summary: The tested page this run belongs to.
- key: table:web_performance_lab_run;field:url
summary: The address that was tested.
- key: table:web_performance_lab_run;field:finalUrl
summary: The address the page ended up on after any redirects.
content: |
Filled in from the test result. If it differs from **{{label:web_performance_lab_run.url}}**, the page redirects.
- key: table:web_performance_lab_run;field:strategy
summary: 'Which device the test simulated: MOBILE (phone) or DESKTOP.'
content: |
Mobile and desktop are always separate runs.
- key: table:web_performance_lab_run;field:runner
summary: The service that ran the test. Currently PSI, Google PageSpeed Insights.
- key: table:web_performance_lab_run;field:triggerType
summary: Why the test was queued.
content: |
Why the test was queued: MANUAL, SCHEDULE or SITE_AUDIT.
**MANUAL** means someone used **{{label:webPerformanceTestPageSpeed}}** or **{{label:webPerformanceRetryRuns}}**. **SCHEDULE** is a scheduled key-page test. **SITE_AUDIT** is the site audit of every enabled page.
- key: table:web_performance_lab_run;field:triggerRef
summary: A short note on what caused the run, such as the action and who used it.
- key: table:web_performance_lab_run;field:batchId
summary: Identifier shared by runs queued together, such as both devices of one page.
- key: table:web_performance_lab_run;field:status
summary: Where the run is, from queued through completion, failure or being skipped.
content: |
Where the run is: QUEUED, RUNNING, SUCCEEDED, RUNTIME_ERROR, FAILED or SKIPPED_QUOTA.
- **QUEUED**: waiting its turn (or waiting for a retry, or for a Google API key).
- **RUNNING**: being tested now.
- **SUCCEEDED**: finished with scores.
- **RUNTIME_ERROR**: Lighthouse could not test the page; see **{{label:web_performance_lab_run.errorMessage}}**.
- **FAILED**: the test failed after its attempts; see **{{label:web_performance_lab_run.errorMessage}}**.
- **SKIPPED_QUOTA**: not run because the daily cap was reached. Use **{{label:webPerformanceRetryRuns}}**.
- key: table:web_performance_lab_run;field:notBefore
summary: The earliest time the run may start. Used for retry waits.
- key: table:web_performance_lab_run;field:queuedAt
summary: When the run was added to the queue.
- key: table:web_performance_lab_run;field:startedAt
summary: When the worker last picked the run up.
- key: table:web_performance_lab_run;field:completedAt
summary: When the run reached its final result.
- key: table:web_performance_lab_run;field:attempts
summary: How many times the test has been tried. A run is tried at most 3 times.
- key: table:web_performance_lab_run;field:httpStatus
summary: The HTTP status Google answered with. 200 means the test was returned.
- key: table:web_performance_lab_run;field:errorMessage
summary: Why the run failed or could not test the page. Empty on success.
- key: table:web_performance_lab_run;field:lighthouseVersion
summary: The Lighthouse version that scored the page.
content: |
Scores and audits from different major versions are not directly comparable.
- key: table:web_performance_lab_run;field:chromeUserAgent
summary: The browser Google used for the test.
- key: table:web_performance_lab_run;field:benchmarkIndex
summary: Google's measure of how fast the testing machine was at the time.
content: |
Lower numbers mean a slower machine, which can make scores look worse.
- key: table:web_performance_lab_run;field:scorePerformance
summary: Lighthouse Performance score, 0 to 100. Higher is better.
content: |
90 and above is good, 50 to 89 needs improvement and below 50 is poor.
- key: table:web_performance_lab_run;field:scoreAccessibility
summary: Lighthouse Accessibility score, 0 to 100. Higher is better.
- key: table:web_performance_lab_run;field:scoreBestPractices
summary: Lighthouse Best Practices score, 0 to 100. Higher is better.
- key: table:web_performance_lab_run;field:scoreSeo
summary: Lighthouse SEO score, 0 to 100. Higher is better.
- key: table:web_performance_lab_run;field:fcpMs
summary: 'First Contentful Paint in milliseconds: when anything first appears on screen.'
- key: table:web_performance_lab_run;field:lcpMs
summary: 'Largest Contentful Paint in milliseconds: when the biggest element on screen has loaded.'
content: |
Google considers 2,500 ms or less good.
- key: table:web_performance_lab_run;field:tbtMs
summary: 'Total Blocking Time in milliseconds: how long the page was unresponsive while loading.'
- key: table:web_performance_lab_run;field:cls
summary: 'Cumulative Layout Shift: how much the page jumps around while loading. No unit.'
content: |
Google considers 0.1 or less good.
- key: table:web_performance_lab_run;field:speedIndexMs
summary: 'Speed Index in milliseconds: how quickly the visible part of the page fills in.'
- key: table:web_performance_lab_run;field:lcpElement
summary: The largest element painted, as a page selector. Useful for finding what to speed up.
- key: table:web_performance_lab_run;field:runWarnings
summary: Warnings Lighthouse reported about the test itself, joined together.
- key: table:web_performance_lab_run;field:deploymentId
summary: The observed frontend build associated with this run, when available.
content: |
Filled from recorded site changes when deployment information is available.
- key: table:web_performance_lab_run;field:cdnStage
summary: The observed CDN stage associated with this run, when available.
content: |
Filled from recorded site changes when CDN stage information is available.
- key: table:web_performance_lab_finding;slot:help
summary: The problems and tips Lighthouse reported for each test, with how much time or data fixing them could save.
content: |
**How rows get created:** Successful lab tests create findings for audits that did not fully pass.
Each row is one audit from one run that did not fully pass (scored below 0.9). Use it to find what to fix: sort by **{{label:web_performance_lab_finding.savingsMs}}** or **{{label:web_performance_lab_finding.savingsBytes}}** to see the biggest wins.
Findings are grouped by **{{label:web_performance_lab_finding.category}}** and **{{label:web_performance_lab_finding.kind}}**. **{{label:web_performance_lab_finding.auditId}}** is Lighthouse's own id for the check. Lighthouse 13 replaced most performance audits with new "insight" audits, so audit ids are kept with the Lighthouse version and should not be compared across major versions.
Nothing here can be edited. Findings are created when a run succeeds. The **{{label:web_performance_lab_finding.topItems}}** column lists up to five example files or page elements involved.
- key: table:web_performance_lab_finding;section:finding
summary: What was found, its kind and category, and the estimated saving.
- key: table:web_performance_lab_finding;section:details
summary: The run and page it came from, the audit id, the raw score and supporting detail.
- key: table:web_performance_lab_finding;section:dates
summary: When the record was created and last changed.
- key: table:web_performance_lab_finding;field:runId
summary: The lab run this finding came from.
- key: table:web_performance_lab_finding;field:targetId
summary: The tested page this finding applies to.
- key: table:web_performance_lab_finding;field:strategy
summary: 'The device type of the run: MOBILE or DESKTOP.'
- key: table:web_performance_lab_finding;field:category
summary: Which Lighthouse category the audit belongs to.
content: |
Which Lighthouse category the audit belongs to: PERFORMANCE, ACCESSIBILITY, BEST_PRACTICES or SEO.
- key: table:web_performance_lab_finding;field:auditId
summary: Lighthouse's own name for the check, such as a short hyphenated id.
content: |
Useful for searching Google's documentation. Ids change between Lighthouse major versions.
- key: table:web_performance_lab_finding;field:lighthouseVersion
summary: The Lighthouse version that produced the finding.
- key: table:web_performance_lab_finding;field:kind
summary: The type of finding returned by Lighthouse.
content: |
The type of finding: INSIGHT, OPPORTUNITY, DIAGNOSTIC or FAILED_AUDIT.
- **INSIGHT**: a performance audit from Lighthouse 13 (audit ids ending in "-insight").
- **OPPORTUNITY**: a performance fix with an estimated saving.
- **DIAGNOSTIC**: other performance information worth reviewing.
- **FAILED_AUDIT**: a failed check in Accessibility, Best Practices or SEO.
- key: table:web_performance_lab_finding;field:title
summary: The plain-language name of the problem, as Lighthouse words it.
- key: table:web_performance_lab_finding;field:score
summary: The audit's own score from 0 to 1. Only audits below 0.9 are kept.
- key: table:web_performance_lab_finding;field:displayValue
summary: A short result Lighthouse shows with the audit, such as a size or time.
- key: table:web_performance_lab_finding;field:savingsMs
summary: Estimated time saved in milliseconds if this is fixed. Empty when Lighthouse gives no estimate.
- key: table:web_performance_lab_finding;field:savingsBytes
summary: Estimated data saved in bytes if this is fixed. Empty when Lighthouse gives no estimate.
- key: table:web_performance_lab_finding;field:metricSavings
summary: Estimated improvement to each metric, as raw Lighthouse data.
- key: table:web_performance_lab_finding;field:topItems
summary: Up to five example addresses or page elements involved, as a list.
- key: table:web_performance_regression;slot:help
summary: Drops in speed or breaches of a budget, kept as records to review and resolve.
content: |
**How rows get created:** Evaluation of lab and field results creates regressions for budget breaches and performance changes.
Review pages that breached a budget or changed from their baseline. Evaluation creates suspected regressions and uses further evidence to confirm or reject them; recovery can resolve them automatically.
Use **{{label:webPerformanceResolveRegressions}}** when you have fixed a problem, or **{{label:webPerformanceDismissRegressions}}** to close a report you choose not to act on. Both require a resolution note and management permission. You cannot edit rows directly.
Check **{{label:web_performance_budget}}** for thresholds and **{{label:web_performance_change_event}}** for possible causes. An empty list can also mean there is not enough test data yet.
- key: table:web_performance_regression;section:regression
summary: Which page and metric regressed, from what to what, and its severity and status.
- key: table:web_performance_regression;section:details
summary: Sources of the two values, the budget and likely cause, key dates and any note.
- key: table:web_performance_regression;section:dates
summary: When the record was created and last changed.
- key: table:web_performance_regression;field:targetId
summary: The tested page that regressed.
- key: table:web_performance_regression;field:contentTable
summary: The kind of content record the page shows, when known.
- key: table:web_performance_regression;field:contentId
summary: The id of the content record the page shows, when known.
- key: table:web_performance_regression;field:source
summary: 'Where the measurement came from: LAB (Lighthouse), RUM (real visitors) or CRUX (Google field data).'
- key: table:web_performance_regression;field:strategy
summary: 'The device type: MOBILE or DESKTOP for lab results, or a form factor for field data.'
- key: table:web_performance_regression;field:metric
summary: Which measurement regressed.
content: |
Which measurement regressed, such as LCP, CLS or PERFORMANCE_SCORE.
- key: table:web_performance_regression;field:baselineValue
summary: The earlier, healthy value the current one is compared with.
- key: table:web_performance_regression;field:baselineRef
summary: Where the baseline value came from, such as a run or date range.
- key: table:web_performance_regression;field:currentValue
summary: The latest measured value.
- key: table:web_performance_regression;field:currentRef
summary: Where the current value came from, such as a run or date range.
- key: table:web_performance_regression;field:delta
summary: 'Current minus baseline. Units follow the metric: milliseconds, score points or no unit for CLS.'
- key: table:web_performance_regression;field:budgetId
summary: The budget that was breached, if the regression came from one.
- key: table:web_performance_regression;field:severity
summary: 'How serious it is: WARNING or CRITICAL, taken from the budget.'
- key: table:web_performance_regression;field:status
summary: Where the regression stands, from suspected through confirmation, resolution or dismissal.
content: |
Where it stands: SUSPECTED, CONFIRMED, NOT_CONFIRMED, RESOLVED or DISMISSED.
- **SUSPECTED**: spotted, waiting for re-tests.
- **CONFIRMED**: re-tests agreed it is real.
- **NOT_CONFIRMED**: re-tests did not reproduce it; the record stays visible.
- **RESOLVED**: the value recovered or was fixed.
- **DISMISSED**: someone decided to ignore it.
- key: table:web_performance_regression;field:causeChangeEventId
summary: The site change that most likely caused it, such as a deploy.
- key: table:web_performance_regression;field:detectedAt
summary: When the regression was first noticed.
- key: table:web_performance_regression;field:confirmedAt
summary: When a re-test confirmed it.
- key: table:web_performance_regression;field:resolvedAt
summary: When it was resolved or dismissed.
- key: table:web_performance_regression;field:resolutionNote
summary: A note on how it was resolved or why it was dismissed.
- key: table:web_performance_budget;slot:help
summary: The thresholds that define when a speed measurement is good enough, by scope, metric and device type.
content: |
**How rows get created:** The shipped database changelog adds reference budgets; you can add your own with New.
Set the limits you want your pages to meet. Choose a scope, measurement source, device type, metric, comparison, threshold and severity. Times use milliseconds, scores use points and layout shift has no unit.
The shipped database changelog supplies reference budgets. You can edit them, add your own or turn them off with **{{label:web_performance_budget.enabled}}**. Changes are audited.
Enabled lab and URL Google field-data budgets are evaluated as results arrive. Breaches appear in **{{label:web_performance_regression}}**. Real-user budgets are not evaluated until a real-user source is supported.
- key: table:web_performance_budget;section:budget
summary: 'The rule itself: the metric, comparison, threshold, severity and whether it is on.'
- key: table:web_performance_budget;section:scope
summary: 'What the budget applies to: scope, scope value, source and device type.'
- key: table:web_performance_budget;section:dates
summary: When the record was created and last changed.
- key: table:web_performance_budget;field:name
summary: A short description of the rule, such as "LCP good (lab, mobile)".
- key: table:web_performance_budget;field:scope
summary: What the budget covers.
content: |
Choose the whole site, a tier, a page, a content table or a path prefix. Scope and scope value work together: Site needs no value; Tier takes the stored tier value, such as KEY.
- key: table:web_performance_budget;field:scopeValue
summary: The specific tier, page, content kind or path the scope points at. Leave empty for the whole site.
- key: table:web_performance_budget;field:source
summary: 'Which data the budget checks: LAB (Lighthouse), RUM (real visitors) or CRUX (Google field data).'
- key: table:web_performance_budget;field:strategy
summary: Choose a lab device or field-data form factor; leave empty to apply to all devices.
- key: table:web_performance_budget;field:metric
summary: The measurement to check against the budget.
content: |
The measurement to check, such as LCP, CLS, FCP, INP, TTFB or PERFORMANCE_SCORE.
Lab score metrics are PERFORMANCE_SCORE, ACCESSIBILITY_SCORE, BEST_PRACTICES_SCORE and SEO_SCORE. Times are in milliseconds; CLS has no unit; scores are 0 to 100.
- key: table:web_performance_budget;field:comparator
summary: Whether the measurement must be at or below the threshold, or at or above it.
content: |
How to compare: <= means the value must be at or below the threshold, >= at or above.
- key: table:web_performance_budget;field:threshold
summary: The limit the metric is compared with. Use the metric's own unit, such as milliseconds or score points.
- key: table:web_performance_budget;field:severity
summary: 'How serious a breach is: WARNING or CRITICAL.'
- key: table:web_performance_budget;field:enabled
summary: Turn off to ignore this budget without deleting it.
- key: table:web_performance_budget;field:seeded
summary: On for the reference budgets that shipped with the app. Filled in automatically; you can still edit those
rows.
- key: table:web_performance_change_event;slot:help
summary: Things that may have changed speed, such as a deploy, a CDN change or a note, used as markers on trends.
content: |
**How rows get created:** Configured probes and change sources record observed changes; lab evaluation also records Lighthouse version changes.
Review deployments and other observed site changes to understand changes in performance. Configured probes and change sources create these records; repeated observations update the existing event. Lighthouse major-version changes are also recorded.
You cannot edit these rows directly. Use **{{label:webPerformanceCompareChange}}** to compare measurements around a selected change when comparisons are enabled. See **{{label:web_performance_comparison}}** for the results.
- key: table:web_performance_change_event;section:change
summary: What changed, in which component, from what to what, and when it was seen.
- key: table:web_performance_change_event;section:dates
summary: When the record was created and last changed.
- key: table:web_performance_change_event;field:kind
summary: The type of change that may affect performance.
content: |
The type of change: FRONTEND_DEPLOY, BACKEND_DEPLOY, CDN_STAGE, LIGHTHOUSE_VERSION or MANUAL.
- key: table:web_performance_change_event;field:component
summary: The part of the site that changed, such as FRONTEND.
- key: table:web_performance_change_event;field:fromValue
summary: The value before the change, such as the old build id.
- key: table:web_performance_change_event;field:toValue
summary: The value after the change, such as the new build id.
- key: table:web_performance_change_event;field:observedAt
summary: When the change was first noticed.
- key: table:web_performance_change_event;field:source
summary: What recorded the change.
content: |
What recorded the change: PROBE (a build check), WEBHOOK, QBIT_CDN (the CDN setup) or USER (a person).
- key: table:web_performance_change_event;field:note
summary: Free text describing the change.
- key: table:web_performance_comparison;slot:help
summary: Measurements compared before and after a site change, per metric, device type and segment.
content: |
**How rows get created:** Run Compare before and after on a site change when enough samples are available.
Use **{{label:webPerformanceCompareChange}}** on a site change to create before-and-after comparisons. Lab comparisons need at least three successful samples on each side using the same Lighthouse major version.
Review the metric, device type, sample counts and time windows alongside the before and after values. **{{label:web_performance_comparison.deltaPct}}** shows the percentage change. You cannot edit these results directly; an empty list can mean no comparison has run or there are too few samples.
- key: table:web_performance_comparison;section:comparison
summary: The change, the metric and segment, the before and after values and how far they moved.
- key: table:web_performance_comparison;section:details
summary: Scope, data source, device type, sample counts and the time windows compared.
- key: table:web_performance_comparison;section:dates
summary: When the record was created and last changed.
- key: table:web_performance_comparison;field:changeEventId
summary: The site change this comparison is around.
- key: table:web_performance_comparison;field:scope
summary: 'What was compared: SITE, TARGET (one page) or KEY (the key page tier).'
- key: table:web_performance_comparison;field:source
summary: The measurement source used for the comparison. Lab comparisons are currently implemented.
- key: table:web_performance_comparison;field:strategy
summary: The device type or form factor compared, such as MOBILE or DESKTOP.
- key: table:web_performance_comparison;field:metric
summary: The measurement compared.
content: |
The measurement compared, such as LCP or PERFORMANCE_SCORE.
- key: table:web_performance_comparison;field:segment
summary: The slice of visitors or pages compared, such as a country. Empty for everything.
- key: table:web_performance_comparison;field:beforeValue
summary: The value before the change, as a median or 75th percentile.
- key: table:web_performance_comparison;field:beforeN
summary: How many measurements went into the before value. More samples mean a more reliable number.
- key: table:web_performance_comparison;field:beforeWindow
summary: The time span of the before measurements.
- key: table:web_performance_comparison;field:afterValue
summary: The value after the change, as a median or 75th percentile.
- key: table:web_performance_comparison;field:afterN
summary: How many measurements went into the after value. More samples mean a more reliable number.
- key: table:web_performance_comparison;field:afterWindow
summary: The time span of the after measurements.
- key: table:web_performance_comparison;field:delta
summary: After minus before, in the metric's own unit.
- key: table:web_performance_comparison;field:deltaPct
summary: The same difference as a percentage of the before value.
- key: table:web_performance_field_snapshot;slot:help
summary: What real Chrome users experienced on the site over the last 28 days, as reported by Google (CrUX).
content: |
**How rows get created:** Refresh Google field data, manually or through a configured schedule, to store snapshots.
Google collects speed data from Chrome users and publishes the 75th percentile (p75) of each Core Web Vital over a rolling 28-day period. Each row is one check for the whole site (origin) or one page (URL), and one form factor: ALL, PHONE or DESKTOP.
If Google has no data (common for low-traffic pages), the row is recorded as **NO_DATA** instead of left out, and re-checking only moves **{{label:web_performance_field_snapshot.lastCheckedAt}}**. Rows with data are stored once per 28-day period.
With a scheduler configured, data refreshes daily by default, or on demand with **{{label:webPerformanceRefreshFieldData}}** (admin permission). It needs a Google API key. Nothing here can be edited. See also **{{label:web_performance_lab_run}}**, which are lab tests, not real users.
- key: table:web_performance_field_snapshot;section:snapshot
summary: 'What was checked: the site or page, the form factor, the result and when it was last checked.'
- key: table:web_performance_field_snapshot;section:p75
summary: The 75th percentile of each Core Web Vital over the 28-day period, plus the period dates.
- key: table:web_performance_field_snapshot;section:dates
summary: When the record was created and last changed.
- key: table:web_performance_field_snapshot;field:source
summary: Which Google service provided the data.
content: |
Which Google service provided the data. Currently CRUX_API.
- key: table:web_performance_field_snapshot;field:scope
summary: Whether the row covers the whole site (ORIGIN) or a single page (URL).
- key: table:web_performance_field_snapshot;field:scopeKey
summary: The site origin or the page address that was checked.
- key: table:web_performance_field_snapshot;field:formFactor
summary: 'The devices covered: ALL, PHONE or DESKTOP.'
- key: table:web_performance_field_snapshot;field:status
summary: Whether Google returned data for this snapshot.
content: |
OK when Google returned data, NO_DATA when it had none.
- key: table:web_performance_field_snapshot;field:periodStart
summary: First day of Google's 28-day collection period. Empty when there is no data.
content: |
First day of Google's 28-day collection period. Empty for NO_DATA.
- key: table:web_performance_field_snapshot;field:periodEnd
summary: Last day of Google's 28-day collection period. Empty when there is no data.
content: |
Last day of Google's 28-day collection period. Empty for NO_DATA.
- key: table:web_performance_field_snapshot;field:p75LcpMs
summary: Largest Contentful Paint at the 75th percentile, in milliseconds. 2,500 or less is good.
- key: table:web_performance_field_snapshot;field:p75InpMs
summary: Interaction to Next Paint at the 75th percentile, in milliseconds. 200 or less is good.
- key: table:web_performance_field_snapshot;field:p75Cls
summary: Cumulative Layout Shift at the 75th percentile, with no unit. 0.1 or less is good.
- key: table:web_performance_field_snapshot;field:p75FcpMs
summary: First Contentful Paint at the 75th percentile, in milliseconds. 1,800 or less is good.
- key: table:web_performance_field_snapshot;field:p75TtfbMs
summary: Time to First Byte at the 75th percentile, in milliseconds. 800 or less is good.
- key: table:web_performance_field_snapshot;field:distributions
summary: How users split between good, needs improvement and poor for each metric, as raw data.
- key: table:web_performance_field_snapshot;field:lastCheckedAt
summary: The last time Google was asked about this page or site, even if nothing changed.
- key: table:web_performance_lab_report;slot:help
summary: The complete Lighthouse report of each successful test, kept compressed for later download.
content: |
**How rows get created:** Successful lab tests store their complete compressed responses here.
Open a report when you need the complete Lighthouse response behind a run. Each successful run has at most one compressed report. Download and decompress its content to inspect the JSON.
**{{label:web_performance_lab_report.sha256}}** lets you verify the uncompressed copy. Reports are kept indefinitely by default; your host can configure retention to delete older reports while keeping run scores and findings. You cannot edit reports directly. See **{{label:web_performance_lab_run}}** and **{{label:web_performance_lab_finding}}** for readable results.
- key: table:web_performance_lab_report;section:report
summary: Which run the report belongs to, its size and checksum, and the downloadable content.
- key: table:web_performance_lab_report;section:dates
summary: When the record was created and last changed.
- key: table:web_performance_lab_report;field:runId
summary: The lab run this report belongs to. Each run has at most one.
- key: table:web_performance_lab_report;field:content
summary: The full report as compressed JSON. Download it to read it in a Lighthouse viewer or a text editor after unzipping.
- key: table:web_performance_lab_report;field:bytes
summary: The size of the report before compression, in bytes.
- key: table:web_performance_lab_report;field:sha256
summary: A checksum of the uncompressed report, to verify a copy is complete.
- key: process:webPerformanceTestPageSpeed;slot:help
summary: Queues Google PageSpeed (Lighthouse) tests for the selected pages, for each configured device strategy.
content: |
Select tested pages, then run this action. A review screen shows how many runs will be queued before anything is queued.
- Each configured device strategy creates a separate run; mobile and desktop are enabled by default.
- With a scheduler configured, tests run in the background about once a minute by default, and results appear under **{{label:web_performance_lab_run}}** and the Performance dashboard.
- If a page already has a waiting run, it is merged rather than queued twice.
- Your host configures a daily run cap. Runs over the cap are recorded as skipped; use **{{label:webPerformanceRetryRuns}}** the next day.
- Content records with no tested page (a draft, or not in the sitemap) and disabled pages are skipped.
- Without a Google API key the runs wait in the queue until one is configured.
You need the page speed testing permission.
- key: process:webPerformanceRunSiteAudit;slot:help
summary: Queues a speed test for every enabled tested page, for each configured device strategy.
content: |
Use it to get a fresh picture of the whole site, for example after a release. The review screen lists the enabled pages that will be tested; disabled pages are left out. Run it from **{{label:web_performance_target}}**.
Tests then run in the background. A site audit can use much of your configured daily run cap, so avoid running it repeatedly. Runs that do not fit are recorded as skipped and can be re-queued with **{{label:webPerformanceRetryRuns}}**.
You need the page speed testing permission. See also **{{label:webPerformanceScheduleSiteAudit}}**, which queues a weekly audit by default when a scheduler is configured.
- key: process:webPerformanceRetryRuns;slot:help
summary: Queues a new test for lab runs that failed, hit an error or were skipped for the daily cap.
content: |
Select runs in **{{label:web_performance_lab_run}}** and run this action. Only runs that failed, ended with a runtime error or were skipped because of the daily cap are retried; successful and waiting runs are listed as not retried. Each retry is a new run for the same page and device type.
Transient failures are tried up to three times in total, with one-minute and five-minute waits; use this action for runs that still ended failed or skipped.
You need the page speed testing permission.
- key: process:webPerformanceSyncPages;slot:help
summary: Updates tested pages from your configured URL source.
content: |
Review the pages returned by your configured URL source before applying the changes. New pages are added and enabled; existing pages are kept. Pages that leave the source are marked as absent but retain their history.
Use this after publishing pages when you want to test them sooner. Automatic syncing requires a configured scheduler. You need web performance administrator permission.
- key: process:webPerformanceRefreshFieldData;slot:help
summary: Asks Google for its real-user speed data about the site and its tested pages.
content: |
This queries Google's Chrome User Experience Report for the whole site and for each enabled page, once per device type. Results with no data are stored as no data, because Google only publishes figures for sites and pages with enough Chrome traffic. The review screen shows how many queries will be made without calling Google.
It also runs automatically when your host configures a scheduler. It needs a Google API key; without one it reports that field data cannot be checked.
You need the web performance administrator permission. Results show in **{{label:web_performance_field_snapshot}}**.
- key: process:webPerformanceRunQueuedTests;slot:help
summary: Runs due page-speed tests in the background.
content: |
With a scheduler configured, the worker checks the queue every minute by default. Transient failures are tried up to three times in total, with one-minute and five-minute waits. You normally do not need to run this yourself.
- key: process:webPerformanceScheduleKeyTests;slot:help
summary: Queues tests for enabled key pages on the configured schedule.
content: |
Set a page to the key-page tier to include it. With a scheduler configured, the default is daily in your host's configured time zone. Your host can change the schedule. Runs wait for the queue worker.
- key: process:webPerformanceScheduleSiteAudit;slot:help
summary: Queues tests for all enabled pages on the configured site-audit schedule.
content: |
With a scheduler configured, the default site audit runs weekly in your host's configured time zone. Your host can change the schedule. Runs count toward the daily limit; excess runs are recorded as skipped.
- key: process:webPerformanceScheduledSync;slot:help
summary: Updates tested pages from the configured URL source on a schedule.
content: |
With a scheduler configured, this runs daily by default. Like **{{label:webPerformanceSyncPages}}**, it adds pages and marks pages that left the source without deleting their history. Your host can change the schedule.
- key: process:webPerformanceScheduledFieldData;slot:help
summary: Refreshes Google field data on the configured schedule.
content: |
With a scheduler configured, this runs daily by default. Like **{{label:webPerformanceRefreshFieldData}}**, it checks your origin and enabled pages. It skips the refresh when no field-data client is configured.
- key: widget:webPerformanceSiteScores;slot:label
summary: Median Lighthouse scores (Performance, Accessibility, Best Practices, SEO) for the key pages, mobile and desktop.
- key: widget:webPerformanceTrend;slot:label
summary: Weekly median Performance score for mobile and desktop, from the first week with a successful test.
- key: widget:webPerformanceSlowestPages;slot:label
summary: The ten tested pages with the lowest latest mobile Performance score, with their lab LCP and top finding.
- key: widget:webPerformanceTopOpportunities;slot:label
summary: The audit findings affecting the most pages in each page's latest test, with the estimated time savings.
- key: widget:webPerformanceFieldDataStatus;slot:label
summary: Google's real-user LCP, INP, CLS and TTFB for the site over the last 28 days, or why none is available.
- key: widget:webPerformanceRecentRuns;slot:label
summary: The five most recent page speed test runs with status and scores, and how many runs are waiting.
- key: widget:webPerformanceRecordPanel;slot:label
summary: This page's latest speed scores for mobile and desktop, key lab metrics, what to fix and a mobile score trend.
- key: table:web_performance_lab_run;field:ttfbMs
summary: Time to first byte measured by the lab run, in milliseconds.
- key: table:web_performance_lab_run;field:evaluatedAt
summary: When this run was processed by the regression evaluator; blank until evaluated.
- key: table:web_performance_lab_run;field:changeEventId
summary: The site change associated with this lab run.
content: |
See **{{label:web_performance_change_event}}** for the deployment or other change being measured.
- key: table:web_performance_regression;field:firstRunId
summary: The lab run that first raised this regression.
- key: table:web_performance_regression;field:checkpointRunId
summary: The lab run used as the latest confirmation checkpoint.
content: |
Initially the first run; updated when the regression is confirmed. See **{{label:web_performance_lab_run}}** for its measurements.
- key: table:web_performance_regression;field:lighthouseMajor
summary: The Lighthouse major version used to keep regression comparisons compatible.
- key: table:web_performance_regression;field:failedAudits
summary: Comma-separated audit identifiers that newly failed when this regression was raised.
- key: table:web_performance_change_event;field:lastSeenAt
summary: The latest time the change source reported this same event.
- key: table:web_performance_change_event;field:eventKey
summary: Generated key that prevents repeated observations from creating duplicate change events.
- key: table:web_performance_change_event;field:siteKey
summary: The configured site this change belongs to.
- key: table:web_performance_comparison;field:targetId
summary: The page whose measurements are compared before and after the change.
- key: table:web_performance_field_snapshot;field:evaluatedAt
summary: When this field-data snapshot was processed by the regression evaluator.
- key: process:webPerformanceDismissRegressions;slot:help
summary: Dismiss selected open regressions with a required resolution note.
content: |
Review the preview before continuing. Closed regressions are skipped. Use **{{label:webPerformanceResolveRegressions}}** when the problem has been fixed.
- key: process:webPerformanceResolveRegressions;slot:help
summary: Mark selected open regressions resolved with a required resolution note.
content: |
Review the preview before continuing. Closed regressions are skipped; this records your decision without running another page-speed test.
- key: process:webPerformanceCompareChange;slot:help
summary: Compare page performance before and after selected site changes.
content: |
Creates **{{label:web_performance_comparison}}** records. Lab comparisons need at least three successful samples on each side using the same Lighthouse major version.
- key: process:webPerformanceProbeDeploys;slot:help
summary: Check configured sources for deployments and other site changes.
content: |
The scheduled check records observations in **{{label:web_performance_change_event}}**. Repeated observations update the existing event instead of creating duplicates.
- key: widget:webPerformanceSetup;slot:label
summary: Admin only. Check your tested pages, lab runner, budgets and field-data setup, with the next step for each.
- key: widget:webPerformanceNotConfigured;slot:label
summary: Says when page speed testing is not set up and who can fix it.
- key: possibleValueSource:web_performance_target;slot:help
summary: Choose a tested page.
- key: possibleValueSource:web_performance_lab_run;slot:help
summary: Choose a lab test run.
- key: possibleValueSource:web_performance_budget;slot:help
summary: Choose a performance budget.
- key: possibleValueSource:web_performance_change_event;slot:help
summary: Choose an observed site change.
- key: possibleValueSource:web_performance_regression;slot:help
summary: Choose a performance regression.
- key: possibleValueSource:web_performance_tier;slot:help
summary: Choose how frequently a page should be tested.
- key: possibleValueSource:web_performance_target_source;slot:help
summary: How a tested page was discovered.
- key: possibleValueSource:web_performance_strategy;slot:help
summary: Choose a device strategy for lab testing.
- key: possibleValueSource:web_performance_strategy_or_form_factor;slot:help
summary: Choose a lab device strategy or field-data form factor.
- key: possibleValueSource:web_performance_field_source;slot:help
summary: The Google service that supplied field data.
- key: possibleValueSource:web_performance_field_scope;slot:help
summary: Choose field data for one page or an entire origin.
- key: possibleValueSource:web_performance_budget_scope;slot:help
summary: Choose which pages a budget applies to.
- key: possibleValueSource:web_performance_measurement_source;slot:help
summary: Choose the source of performance measurements.
- key: possibleValueSource:web_performance_comparison_source;slot:help
summary: Choose the source of before-and-after measurements.
- key: possibleValueSource:web_performance_comparison_scope;slot:help
summary: Choose which pages to compare.
- key: possibleValueSource:web_performance_change_source;slot:help
summary: How a site change was reported.
- key: possibleValueSource:web_performance_metric;slot:help
summary: Choose the performance measurement to evaluate.
- key: possibleValueSource:web_performance_comparator;slot:help
summary: Choose whether a value must be at most or at least the threshold.
- key: possibleValueSource:web_performance_severity;slot:help
summary: Choose how serious a budget breach is.
@@ -0,0 +1 @@
version=${project.version}
@@ -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,139 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import liquibase.Liquibase;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The shipped changelog, run through real Liquibase on H2 (PostgreSQL mode):
** it creates the nine tables, changeset 10 seeds the reference budgets, a
** second update runs nothing, and the seed statement run again inserts nothing
** and keeps an administrator's edit.
*******************************************************************************/
class WebPerfDefaultsChangelogTest
{
private static final String PREFIX = "web_performance_";
/*******************************************************************************
**
*******************************************************************************/
@Test
void seededBudgetsThroughRealLiquibase_idempotent_andKeepEdits() throws Exception
{
try(Connection connection = connect())
{
update();
assertThat(count(connection, "SELECT COUNT(*) FROM " + PREFIX + "budget")).isEqualTo(13);
assertThat(count(connection, "SELECT COUNT(*) FROM " + PREFIX + "budget WHERE seeded = TRUE AND enabled = TRUE")).isEqualTo(13);
for(String base : WebPerfSchema.TABLES)
{
assertThat(count(connection, "SELECT COUNT(*) FROM " + PREFIX + base)).as(base + " is queryable").isGreaterThanOrEqualTo(0);
}
int changesets = count(connection, "SELECT COUNT(*) FROM DATABASECHANGELOG");
////////////////////////////////////////////////////////////////
// a second update has nothing to run //
////////////////////////////////////////////////////////////////
update();
assertThat(count(connection, "SELECT COUNT(*) FROM DATABASECHANGELOG")).isEqualTo(changesets);
assertThat(count(connection, "SELECT COUNT(*) FROM " + PREFIX + "budget")).isEqualTo(13);
////////////////////////////////////////////////////////////////////////////
// an admin edits a seed; the seed statement (changeset 10) is run again: //
// nothing is inserted and the edit survives //
////////////////////////////////////////////////////////////////////////////
try(Statement statement = connection.createStatement())
{
statement.execute("UPDATE " + PREFIX + "budget SET threshold = 3000 WHERE name = 'LCP good (lab, mobile)'");
statement.execute(seedStatement());
}
assertThat(count(connection, "SELECT COUNT(*) FROM " + PREFIX + "budget")).isEqualTo(13);
assertThat(count(connection, "SELECT COUNT(*) FROM " + PREFIX + "budget WHERE name = 'LCP good (lab, mobile)' AND threshold = 3000")).isEqualTo(1);
}
}
/*******************************************************************************
** The INSERT of changeset 10, with the prefix filled in.
*******************************************************************************/
private static String seedStatement() throws Exception
{
String script;
try(java.io.InputStream in = WebPerfDefaultsChangelogTest.class.getClassLoader().getResourceAsStream(WebPerfSchema.CHANGELOG))
{
script = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
}
String seed = script.substring(script.indexOf("-- changeset qbit-web-performance:10"), script.indexOf("-- changeset qbit-web-performance:11"));
seed = seed.substring(seed.indexOf("INSERT INTO")).trim().replaceAll(";$", "");
return (seed.replace("${" + WebPerfSchema.CHANGELOG_PREFIX_PROPERTY + "}", PREFIX));
}
/*******************************************************************************
** A connection to the shared in-memory database.
*******************************************************************************/
private static Connection connect() throws Exception
{
return (DriverManager.getConnection("jdbc:h2:mem:wpdefaults;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", "sa", ""));
}
/*******************************************************************************
** One real Liquibase update on its own connection (Liquibase closes it).
*******************************************************************************/
private static void update() throws Exception
{
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connect()));
try(Liquibase liquibase = new Liquibase(WebPerfSchema.CHANGELOG, new ClassLoaderResourceAccessor(), database))
{
liquibase.setChangeLogParameter(WebPerfSchema.CHANGELOG_PREFIX_PROPERTY, PREFIX);
liquibase.update("");
}
}
/*******************************************************************************
**
*******************************************************************************/
private static int count(Connection connection, String sql) throws Exception
{
try(Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql))
{
resultSet.next();
return (resultSet.getInt(1));
}
}
}
@@ -0,0 +1,251 @@
/*
* 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.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import liquibase.Contexts;
import liquibase.LabelExpression;
import liquibase.Liquibase;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Referential integrity on PostgreSQL (the production database), with the
** changelog run by Liquibase as a host runs it:
**
** - every table possible-value field that names one of the QBit's tables,
** and every join, has a database foreign key (or an exemption with a
** reason), so a new reference cannot be added without its constraint;
** - rows whose parent is missing when the constraints are added are recorded
** in qqq_fk_quarantine and repaired (nulled, or deleted with a full copy),
** so the upgrade never fails on existing data;
** - the foreign key changesets are no-ops on a database that already has the
** constraints (a host that added them itself).
**
** Skipped when Docker is not available.
*******************************************************************************/
@Testcontainers(disabledWithoutDocker = true)
class WebPerfForeignKeyTest
{
private static final String PREFIX = "web_performance_";
/////////////////////////////////////////////////////////////////////////////
// references that are deliberately not foreign keys: "child.column" -> //
// reason. None today (content_table + content_id is polymorphic, and has //
// no possible-value source). //
/////////////////////////////////////////////////////////////////////////////
private static final Map<String, String> EXEMPT = Map.of();
/////////////////////////////////////////////////////////////////////////////
// the changesets before the foreign keys (1-11) //
/////////////////////////////////////////////////////////////////////////////
private static final int CHANGESETS_BEFORE_FOREIGN_KEYS = 11;
@Container
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine");
/*******************************************************************************
** Orphans are quarantined, every reference gets its constraint, and a
** re-run of the foreign key changesets changes nothing.
*******************************************************************************/
@Test
void testEveryReferenceHasAForeignKey() throws Exception
{
try(Connection connection = DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
Statement statement = connection.createStatement())
{
statement.execute("DROP SCHEMA public CASCADE");
statement.execute("CREATE SCHEMA public");
////////////////////////////////////////////////////////////////////////
// an existing install with rows whose parents are gone //
////////////////////////////////////////////////////////////////////////
migrate(connection, CHANGESETS_BEFORE_FOREIGN_KEYS);
statement.execute("INSERT INTO web_performance_target (id, url) VALUES (1, 'https://www.example.com/')");
statement.execute("INSERT INTO web_performance_lab_run (id, target_id) VALUES (1, 1), (2, 404)");
statement.execute("INSERT INTO web_performance_lab_finding (id, run_id, target_id) VALUES (1, 1, 1), (2, 404, 1)");
statement.execute("INSERT INTO web_performance_lab_report (id, run_id) VALUES (1, 404)");
statement.execute("INSERT INTO web_performance_regression (id, target_id, budget_id, first_run_id) VALUES (1, 1, 404, 404)");
migrate(connection, null);
////////////////////////////////////////////////////////////////////////
// every reference the meta-data declares has its constraint //
////////////////////////////////////////////////////////////////////////
Map<String, String> foreignKeys = foreignKeys(statement);
List<String> missing = new ArrayList<>();
for(String reference : references())
{
if(!foreignKeys.containsKey(reference) && !EXEMPT.containsKey(reference))
{
missing.add(reference);
}
}
assertThat(missing).as("references without a foreign key (add one to the changelog, or an exemption with a reason)").isEmpty();
assertThat(foreignKeys).containsEntry("web_performance_lab_finding.run_id", "web_performance_lab_run CASCADE")
.containsEntry("web_performance_lab_run.target_id", "web_performance_target RESTRICT")
.containsEntry("web_performance_regression.cause_change_event_id", "web_performance_change_event SET NULL");
////////////////////////////////////////////////////////////////////////
// the orphans were recorded, then nulled or deleted //
////////////////////////////////////////////////////////////////////////
assertThat(rows(statement, "SELECT table_name || '.' || column_name || ' ' || row_id || '->' || missing_parent_id || ' ' || action || ' ' || (row_data IS NOT NULL) FROM qqq_fk_quarantine"))
.containsExactlyInAnyOrder(
"web_performance_lab_run.target_id 2->404 NULLED false",
"web_performance_lab_finding.run_id 2->404 DELETED true",
"web_performance_lab_report.run_id 1->404 DELETED true",
"web_performance_regression.budget_id 1->404 NULLED false",
"web_performance_regression.first_run_id 1->404 NULLED false");
assertThat(rows(statement, "SELECT id || ' ' || COALESCE(CAST(target_id AS TEXT), '-') FROM web_performance_lab_run")).containsExactlyInAnyOrder("1 1", "2 -");
assertThat(rows(statement, "SELECT CAST(id AS TEXT) FROM web_performance_lab_finding")).containsExactly("1");
assertThat(rows(statement, "SELECT CAST(id AS TEXT) FROM web_performance_lab_report")).isEmpty();
////////////////////////////////////////////////////////////////////////
// a host that already has the constraints: the changesets are marked //
// ran and change nothing //
////////////////////////////////////////////////////////////////////////
statement.execute("DELETE FROM databasechangelog WHERE CAST(SUBSTRING(id FROM '[0-9]+$') AS INTEGER) > " + CHANGESETS_BEFORE_FOREIGN_KEYS);
migrate(connection, null);
assertThat(foreignKeys(statement)).isEqualTo(foreignKeys);
assertThat(rows(statement, "SELECT CAST(COUNT(*) AS TEXT) FROM qqq_fk_quarantine")).containsExactly("5");
}
}
/*******************************************************************************
** Run the changelog (all of it, or the first n changesets).
*******************************************************************************/
private static void migrate(Connection connection, Integer count) throws Exception
{
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
Liquibase liquibase = new Liquibase(WebPerfSchema.CHANGELOG, new ClassLoaderResourceAccessor(), database);
liquibase.setChangeLogParameter(WebPerfSchema.CHANGELOG_PREFIX_PROPERTY, PREFIX);
if(count == null)
{
liquibase.update(new Contexts(), new LabelExpression());
}
else
{
liquibase.update(count, new Contexts(), new LabelExpression());
}
}
/*******************************************************************************
** "child.column" for every table possible-value field and join of the QBit.
*******************************************************************************/
private static List<String> references() throws Exception
{
QInstance qInstance = WebPerfTestSupport.newInstance();
new WebPerfQBitProducer().withQBitConfig(WebPerfTestSupport.config().withTableNamePrefix(PREFIX)).produce(qInstance);
List<String> references = new ArrayList<>();
for(QTableMetaData table : qInstance.getTables().values())
{
for(QFieldMetaData field : table.getFields().values())
{
if(field.getPossibleValueSourceName() != null && qInstance.getTable(field.getPossibleValueSourceName()) != null)
{
references.add(table.getName() + "." + field.getBackendName());
}
}
}
for(QJoinMetaData join : qInstance.getJoins().values())
{
/////////////////////////////////////////////////////////////////////
// the referencing side is the one whose field is not its key //
/////////////////////////////////////////////////////////////////////
QTableMetaData left = qInstance.getTable(join.getLeftTable());
QTableMetaData right = qInstance.getTable(join.getRightTable());
join.getJoinOns().forEach(on -> references.add(on.getRightField().equals(right.getPrimaryKeyField())
? left.getName() + "." + left.getField(on.getLeftField()).getBackendName()
: right.getName() + "." + right.getField(on.getRightField()).getBackendName()));
}
assertThat(references).as("the QBit's references").hasSizeGreaterThanOrEqualTo(12);
return (references);
}
/*******************************************************************************
** "child.column" -> "parent RULE" for every foreign key in the database.
*******************************************************************************/
private static Map<String, String> foreignKeys(Statement statement) throws Exception
{
Map<String, String> foreignKeys = new HashMap<>();
try(ResultSet resultSet = statement.executeQuery("""
SELECT tc.table_name || '.' || kcu.column_name, ccu.table_name || ' ' || rc.delete_rule
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu ON kcu.constraint_name = tc.constraint_name
JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name
JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'"""))
{
while(resultSet.next())
{
foreignKeys.put(resultSet.getString(1), resultSet.getString(2));
}
}
return (foreignKeys);
}
/*******************************************************************************
** The first column of each row of a query, as text.
*******************************************************************************/
private static List<String> rows(Statement statement, String sql) throws Exception
{
List<String> rows = new ArrayList<>();
try(ResultSet resultSet = statement.executeQuery(sql))
{
while(resultSet.next())
{
rows.add(resultSet.getString(1));
}
}
return (rows);
}
}
@@ -0,0 +1,67 @@
/*
* 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.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleEntry;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Validate the shipped bundle against the QBit's own metadata, without a host.
*******************************************************************************/
class WebPerfHelpBundleTest
{
/*******************************************************************************
** All default features are present; registration needs no helpContent table.
*******************************************************************************/
@Test
void bundleCoversOwnedMetadata() throws Exception
{
QInstance qInstance = WebPerfTestSupport.newInstance();
new WebPerfQBitProducer().withQBitConfig(WebPerfTestSupport.config()).produce(qInstance);
assertThat(qInstance.getTable("helpContent")).isNull();
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement()
.satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-web-performance");
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank();
assertThat(bundle.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion"))
.isEqualTo(WebPerfQBitProducer.VERSION);
});
HelpBundleAssertions.forInstance(qInstance)
.bundles("help/qbit-web-performance.yaml")
.owning(target -> target.name().startsWith("web_performance_") || target.name().startsWith("webPerformance"))
.assertComplete();
// RC.7 validates supplied widget and lookup keys, but does not require their coverage.
var keys = HelpBundles.of(qInstance).getBundles().get(0).getEntries().stream().map(HelpBundleEntry::getKey).toList();
qInstance.getWidgets().keySet().stream().filter(name -> name.startsWith("webPerformance"))
.forEach(name -> assertThat(keys).contains("widget:" + name + ";slot:label"));
qInstance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("web_performance_"))
.forEach(name -> assertThat(keys).contains("possibleValueSource:" + name + ";slot:help"));
}
}
@@ -0,0 +1,655 @@
/*
* 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;
/*******************************************************************************
** The business home has no provider setup readout and no run-queue widget;
** those are the admin-placeable list.
*******************************************************************************/
@Test
void testHomeIsBusinessOnly()
{
List<String> home = qInstance.getApp(config.getAppName()).getWidgets();
assertThat(home).doesNotContain(config.qualify(WebPerfWidgets.SETUP), config.qualify(WebPerfWidgets.RECENT_RUNS));
assertThat(WebPerfWidgets.HOME).doesNotContain(WebPerfWidgets.SETUP, WebPerfWidgets.RECENT_RUNS);
assertThat(WebPerfWidgets.ADMIN).containsExactly(WebPerfWidgets.SETUP, WebPerfWidgets.RECENT_RUNS);
assertThat(home).contains(config.qualify(WebPerfWidgets.SITE_SCORES), config.qualify(WebPerfWidgets.TREND), config.qualify(WebPerfWidgets.SLOWEST_PAGES),
config.qualify(WebPerfWidgets.OPPORTUNITIES), config.qualify(WebPerfWidgets.FIELD_STATUS));
for(String name : WebPerfWidgets.ADMIN)
{
assertThat(qInstance.getWidget(config.qualify(name))).as(name + " is still defined for a host to place").isNotNull();
}
}
/*******************************************************************************
** Unconfigured: a business empty state naming no config key. Configured:
** nothing at all.
*******************************************************************************/
@Test
void testNotConfiguredEmptyState() throws Exception
{
assertThat(html(WebPerfWidgets.NOT_CONFIGURED, Map.of())).as("configured shows nothing").isEmpty();
config.withLabRunner(null).withCruxClient(null);
String card = html(WebPerfWidgets.NOT_CONFIGURED, Map.of());
assertThat(card).contains("Page speed testing is not set up").contains("Ask an admin to add a Google PageSpeed API key.");
assertThat(card).doesNotContain("googleApiKey").doesNotContain("qbit config").doesNotContain("environment variable");
}
/*******************************************************************************
** The admin widgets render for an admin, and are hidden (permission denied
** under enforcement) for a session without the admin permission.
*******************************************************************************/
@Test
void testAdminWidgetsAdminOnly() throws Exception
{
for(String name : WebPerfWidgets.ADMIN)
{
assertThat(html(name, Map.of())).as(name + " for an admin").isNotEmpty();
}
WebPerfTestSupport.login(qInstance, "web_performance.read", "web_performance.write", "web_performance.view.hasAccess");
for(String name : WebPerfWidgets.ADMIN)
{
assertThat(qInstance.getWidget(config.qualify(name)).getPermissionRules().getDenyBehavior()).isEqualTo(com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior.HIDDEN);
RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(qInstance.getWidget(config.qualify(name)));
input.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
org.junit.jupiter.api.Assertions.assertThrows(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class,
() -> new RenderWidgetAction().execute(input), name + " must not render without admin");
assertThat(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.hasWidgetPermission(input, config.qualify(name))).isFalse();
}
assertThat(html(WebPerfWidgets.SITE_SCORES, Map.of())).as("business widgets still render").isNotEmpty();
}
/*******************************************************************************
** 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");
}
/*******************************************************************************
** An odd daily cap rotates individual device strategies, not just pages.
** Skipped rows and repeated scheduling must not starve the remaining pairs.
*******************************************************************************/
@Test
void scheduledQuotaRotatesPagesAndStrategies() throws Exception
{
config.withMaxRunsPerDay(3);
Instant dayOne = Instant.parse("2026-10-19T12:00:00Z");
WebPerfService service = service();
service.applySync(service.planSync(sitemap), dayOne);
WebPerfService.QueuePlan first = service.enqueue(service.scheduledRequests(false, "audit", dayOne), dayOne);
assertEquals(3, first.queue().size());
assertEquals(3, first.overQuota().size());
assertEquals(0, service.enqueue(service.scheduledRequests(false, "audit", dayOne), dayOne).queue().size());
store().update("UPDATE {lab_run} SET status = 'SUCCEEDED' WHERE status = 'QUEUED'");
Instant dayTwo = dayOne.plusSeconds(86400);
WebPerfService.QueuePlan second = service.enqueue(service.scheduledRequests(false, "audit", dayTwo), dayTwo);
assertEquals(3, second.queue().size());
List<String> firstPairs = first.queue().stream().map(run -> run.targetId() + "|" + run.strategy()).toList();
assertThat(second.queue().stream().map(run -> run.targetId() + "|" + run.strategy()).toList())
.doesNotContainAnyElementsOf(firstPairs);
assertEquals(6, store().query("SELECT DISTINCT target_id, strategy FROM {lab_run} WHERE status <> 'SKIPPED_QUOTA'").size());
assertThat(service.scheduledRequests(true, "key pages", dayTwo)).hasSize(2)
.allSatisfy(run -> assertEquals(SITE + "/", run.url()));
}
/*******************************************************************************
** Manual and key-page runs consume the same allowance as the full audit.
** Persisted history rotates the remaining pairs after a service restart.
*******************************************************************************/
@Test
void scheduledQuotaSharesAllowanceAndSurvivesRestart() throws Exception
{
config.withMaxRunsPerDay(3);
Instant day = Instant.parse("2026-10-19T12:00:00Z");
WebPerfService service = service();
service.applySync(service.planSync(sitemap), day);
List<WebPerfService.RunRequest> manual = service.requestsFor(service.enabledTargets(false), "MANUAL", "selected page", day);
assertEquals(1, service.enqueue(List.of(manual.get(3)), day).queue().size());
assertEquals(2, service.enqueue(service.scheduledRequests(true, "key pages", day), day).queue().size());
assertEquals(0, service.enqueue(service.scheduledRequests(false, "audit", day), day).queue().size());
store().update("UPDATE {lab_run} SET status = 'SUCCEEDED' WHERE status = 'QUEUED'");
List<String> rotated = new ArrayList<>();
for(int nextDay = 1; nextDay <= 3; nextDay++)
{
Instant now = day.plusSeconds(86400L * nextDay);
WebPerfService restarted = new WebPerfService(new WebPerfRuntime(config), store());
assertEquals(2, restarted.enqueue(restarted.scheduledRequests(true, "key pages", now), now).queue().size());
WebPerfService.QueuePlan audit = restarted.enqueue(restarted.scheduledRequests(false, "audit", now), now);
assertEquals(1, audit.queue().size());
assertEquals(2, audit.merged().size());
WebPerfService.RunRequest queued = audit.queue().get(0);
assertThat(queued.url()).isNotEqualTo(SITE + "/");
rotated.add(queued.targetId() + "|" + queued.strategy());
store().update("UPDATE {lab_run} SET status = 'SUCCEEDED' WHERE status = 'QUEUED'");
}
assertThat(rotated).doesNotHaveDuplicates();
assertEquals(6, store().query("SELECT DISTINCT target_id, strategy FROM {lab_run} WHERE status <> 'SKIPPED_QUOTA'").size());
}
/*******************************************************************************
** The Setup card reports pages, budgets, the lab runner and field data from
** the runtime's own resolution, with the next step for each.
*******************************************************************************/
@Test
void testSetupCard() throws Exception
{
String card = html(WebPerfWidgets.SETUP, Map.of());
assertThat(card).contains("Needs setup").contains("Sync pages from sitemap").contains("Using defaults").contains("13 reference budgets");
assertThat(card).as("a configured runner and CrUX client count as set up").contains("A lab runner is configured").contains("Checked daily");
WebPerfService service = service();
service.applySync(service.planSync(List.of(SITE + "/")), Instant.now());
store().update("INSERT INTO {budget} (name, scope, source, metric, comparator, threshold, severity, enabled, seeded) VALUES ('mine', 'SITE', 'LAB', 'LCP', '<=', 2000, 'WARNING', TRUE, FALSE)");
card = html(WebPerfWidgets.SETUP, Map.of());
assertThat(card).contains("1 page").contains("14 budgets are enabled, 1 of them your own");
store().update("UPDATE {budget} SET enabled = FALSE");
assertThat(html(WebPerfWidgets.SETUP, Map.of())).contains("No budget is enabled");
config.withLabRunner(null).withCruxClient(null);
assertThat(html(WebPerfWidgets.SETUP, Map.of())).contains("No Google API key is configured, so lab tests cannot run").contains("Google field data is not checked");
}
/*******************************************************************************
** reportRetentionDays deletes old Lighthouse Reports when a run stores its
** own, and 0 (the default) keeps them forever.
*******************************************************************************/
@Test
void testReportRetention() throws Exception
{
for(int days : new int[] { 0, 30 })
{
store().update("DELETE FROM {lab_report}");
store().update("DELETE FROM {lab_finding}");
store().update("DELETE FROM {lab_run}");
store().update("DELETE FROM {budget}");
config.withReportRetentionDays(days);
google.respond(new FixtureServer.Response(200, FixtureServer.fixture("psi-kof22-home-mobile.json")));
long oldRunId = store().insert("lab_run", new java.util.LinkedHashMap<>(Map.of("status", "FAILED")));
store().insert("lab_report", new java.util.LinkedHashMap<>(Map.of("run_id", oldRunId, "bytes", 1,
"create_date", Instant.now().minus(java.time.Duration.ofDays(40)))));
WebPerfRuntime.of(qInstance, "web_performance_").allowReportPruneNow();
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()));
assertEquals(2, store().query("SELECT id FROM {lab_run} WHERE status = 'SUCCEEDED'").size(), "runs are untouched by pruning");
assertThat(store().query("SELECT id FROM {lab_finding}")).as("findings are untouched by pruning").hasSizeGreaterThan(5);
long old = store().query("SELECT id FROM {lab_report} WHERE run_id = ?", oldRunId).size();
long fresh = store().query("SELECT id FROM {lab_report} WHERE run_id <> ?", oldRunId).size();
assertEquals(2, fresh, "the new reports are kept");
assertEquals(days == 0 ? 1 : 0, old, "retention " + days + " days");
}
assertThat(new WebPerfRuntime(config).reportPruneDue()).as("a new process does not prune right away").isFalse();
}
/*******************************************************************************
** The worker runs a test, stores scores, findings, the report and the
** page's latest results.
*******************************************************************************/
@Test
void testWorkerSuccess() throws Exception
{
store().update("UPDATE {budget} SET enabled = FALSE");
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
{
store().update("UPDATE {budget} SET enabled = FALSE");
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;
@@ -70,6 +74,9 @@ class WebPerfQBitProducerTest
@Test
void testRegistersIntoAFreshInstance() throws Exception
{
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank();
assertThat(WebPerfQBitProducer.VERSION).isEqualTo(System.getProperty("qbit.artifactVersion"));
assertThat(new WebPerfQBitProducer().getQBitMetaData().getVersion()).isEqualTo(WebPerfQBitProducer.VERSION);
QInstance qInstance = WebPerfTestSupport.newInstance();
new WebPerfQBitProducer().withQBitConfig(WebPerfTestSupport.config()
.withUrlSource(new SitemapUrlSource("https://www.example.com/sitemap.xml"))
@@ -80,7 +87,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 +101,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 +190,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 +250,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));
@@ -0,0 +1,828 @@
/*
* 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.Serializable;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import com.kingsrook.qbits.webperf.lab.LabResultParser;
import com.kingsrook.qbits.webperf.processes.RegressionSteps;
import com.kingsrook.qbits.webperf.processes.WebPerfProcesses;
import com.kingsrook.qbits.webperf.service.WebPerfChangeService;
import com.kingsrook.qbits.webperf.service.WebPerfComparisonService;
import com.kingsrook.qbits.webperf.service.WebPerfEvaluationService;
import com.kingsrook.qbits.webperf.service.WebPerfService;
import com.kingsrook.qbits.webperf.spi.ChangeEventSource;
import com.kingsrook.qbits.webperf.spi.DeployProbe;
import com.kingsrook.qbits.webperf.spi.LabRunner;
import com.kingsrook.qbits.webperf.store.WebPerfStore;
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallback;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.context.QContext;
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.tables.query.QQueryFilter;
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.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import org.json.JSONObject;
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.assertThrows;
/*******************************************************************************
** P2 behavior on H2, with fake probes and runner and real QQQ record actions.
*******************************************************************************/
public class WebPerfRegressionTest
{
private static final Instant START = Instant.parse("2026-10-01T12:00:00Z");
private QInstance instance;
private WebPerfQBitConfig config;
private WebPerfStore store;
private WebPerfEvaluationService evaluator;
private long target;
private int sequence;
/*******************************************************************************
** Fresh tables; disable reference budgets to isolate statistical rules.
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
instance = WebPerfTestSupport.newInstance();
config = WebPerfTestSupport.config().withStrategies(List.of("MOBILE")).withMinSpacingMillis(0).withConcurrency(2);
new WebPerfQBitProducer().withQBitConfig(config).produce(instance);
WebPerfTestSupport.applyChangelog(instance, config.getTableNamePrefix());
WebPerfTestSupport.login(instance, "web_performance.read", "web_performance.write", "web_performance.manage.hasAccess",
"web_performance.view.hasAccess", "web_performance.admin.hasAccess", "web_performance.test.hasAccess");
store = new WebPerfRuntime(config).store(instance);
evaluator = new WebPerfEvaluationService(store);
store.update("UPDATE {budget} SET enabled = FALSE");
WebPerfService service = new WebPerfRuntime(config).service();
service.applySync(service.planSync(List.of("https://www.example.com/")), START);
target = WebPerfStore.asLong(store.query("SELECT id FROM {target}").get(0).get("id"));
store.update("UPDATE {target} SET tier = 'KEY', content_table = 'page', content_id = 7 WHERE id = ?", target);
RecordingSink.signals.clear();
RecordingSink.resolutions.clear();
AlertsInstanceMetaData.ofOrWithNew(instance).withSink(new QCodeReference(RecordingSink.class));
}
/*******************************************************************************
** Release this test's context.
*******************************************************************************/
@AfterEach
void tearDown()
{
QContext.clear();
}
/*******************************************************************************
** Suspect, two confirmations, one alert, then three recovery samples.
*******************************************************************************/
@Test
void confirmsAndRecoversWithoutDuplicates() throws Exception
{
baseline();
long change = change("FRONTEND_DEPLOY", "FRONTEND", "build-2", START.plusSeconds(6));
long first = measurement(70, "SCHEDULE", "13.1", Map.of("change_event_id", change));
evaluator.evaluateLab(first);
assertThat(regression().get("status")).isEqualTo("SUSPECTED");
assertThat(store.query("SELECT * FROM {lab_run} WHERE trigger_type = 'CONFIRM'")).hasSize(2);
assertThat(RecordingSink.signals).isEmpty();
assertThat(openCount()).isEqualTo(1);
completeConfirmations(first, 72, 71);
assertThat(regression().get("status")).isEqualTo("CONFIRMED");
assertThat(WebPerfStore.asDouble(regression().get("current_value"))).isEqualTo(71);
assertThat(regression().get("baseline_ref")).isEqualTo("5,4,3,2,1");
assertThat(WebPerfStore.asLong(regression().get("cause_change_event_id"))).isEqualTo(change);
evaluator.evaluateLab(first);
assertThat(store.query("SELECT * FROM {regression}")).hasSize(1);
assertThat(RecordingSink.signals).hasSize(1);
evaluate(96);
evaluate(95);
assertThat(regression().get("status")).isEqualTo("CONFIRMED");
evaluate(94);
assertThat(regression().get("status")).isEqualTo("RESOLVED");
assertThat(openCount()).isZero();
assertThat(RecordingSink.resolutions).hasSize(1);
assertThat(RecordingSink.signals.get(0).getDedupKey()).isEqualTo(RecordingSink.resolutions.get(0).getDedupKey());
}
/*******************************************************************************
** Lab noise stays visible as not confirmed, with no alert.
*******************************************************************************/
@Test
void rejectsNoise() throws Exception
{
baseline();
long first = evaluate(70);
completeConfirmations(first, 96, 95);
assertThat(regression().get("status")).isEqualTo("NOT_CONFIRMED");
assertThat(openCount()).isZero();
assertThat(RecordingSink.signals).isEmpty();
}
/*******************************************************************************
** Baselines require five eligible recent results on the same major version.
*******************************************************************************/
@Test
void boundsBaselineHistory() throws Exception
{
for(int i = 0; i < 4; i++)
{
measurement(96, "SCHEDULE", "13.0", Map.of());
}
measurement(96, "MANUAL", "13.0", Map.of());
measurement(96, "SCHEDULE", "12.9", Map.of());
long old = measurement(96, "SCHEDULE", "13.0", Map.of());
store.update("UPDATE {lab_run} SET completed_at = ? WHERE id = ?", START.minusSeconds(31 * 86400L), old);
evaluate(70);
assertThat(store.query("SELECT * FROM {regression}")).isEmpty();
measurement(96, "SITE_AUDIT", "13.1", Map.of());
evaluate(60);
assertThat(regression().get("status")).isEqualTo("SUSPECTED");
}
/*******************************************************************************
** A major-version change records a marker and retires incompatible baselines.
*******************************************************************************/
@Test
void resetsAtMajorVersionChange() throws Exception
{
baseline();
long first = evaluate(70);
completeConfirmations(first, 71, 72);
evaluator.evaluateLab(measurement(60, "SCHEDULE", "14.0", Map.of()));
assertThat(regression().get("status")).isEqualTo("RESOLVED");
assertThat(regression().get("resolution_note")).isEqualTo("Lighthouse major version changed; baseline reset");
assertThat(store.query("SELECT * FROM {change_event} WHERE kind = 'LIGHTHOUSE_VERSION'")).hasSize(1);
assertThat(store.query("SELECT * FROM {regression}")).hasSize(1);
}
/*******************************************************************************
** Relative and absolute thresholds; deterministic categories confirm now.
*******************************************************************************/
@Test
void appliesMetricThresholds() throws Exception
{
baseline();
evaluator.evaluateLab(measurement(95, "MANUAL", "13.0", Map.of("lcp_ms", 2499, "cls", 0.049, "tbt_ms", 199)));
assertThat(store.query("SELECT * FROM {regression}")).isEmpty();
evaluator.evaluateLab(measurement(95, "MANUAL", "13.0", Map.of("lcp_ms", 2500, "cls", 0.05, "tbt_ms", 200,
"score_accessibility", 95, "score_best_practices", 95, "score_seo", 95)));
assertThat(store.query("SELECT * FROM {regression} WHERE status = 'SUSPECTED'")).hasSize(3);
assertThat(store.query("SELECT * FROM {regression} WHERE status = 'CONFIRMED'")).hasSize(3);
assertThat(store.query("SELECT * FROM {lab_run} WHERE trigger_type = 'CONFIRM'")).hasSize(2);
assertThat(RecordingSink.signals).hasSize(3);
}
/*******************************************************************************
** A newly failing audit is actionable even if the rounded score is unchanged.
*******************************************************************************/
@Test
void newlyFailingAuditConfirms() throws Exception
{
baseline();
long run = measurement(96, "MANUAL", "13.0", Map.of());
store.insert(WebPerfSchema.LAB_FINDING, Map.of("run_id", run, "target_id", target, "strategy", "MOBILE",
"category", "SEO", "audit_id", "robots-txt", "kind", "FAILED_AUDIT", "score", 0));
evaluator.evaluateLab(run);
assertThat(regression().get("status")).isEqualTo("CONFIRMED");
assertThat(regression().get("failed_audits")).isEqualTo("robots-txt");
evaluate(96);
evaluate(96);
evaluate(96);
assertThat(regression().get("status")).isEqualTo("RESOLVED");
}
/*******************************************************************************
** Every scope and comparator, with source, strategy and severity matching.
*******************************************************************************/
@Test
void matchesBudgetScopesComparatorsAndSeverity() throws Exception
{
Map<String, Object> page = store.query("SELECT * FROM {target}").get(0);
for(String scope : List.of("SITE", "TIER", "TARGET", "CONTENT_TABLE", "PATH_PREFIX"))
{
String value = switch(scope)
{
case "SITE" -> "host:www.example.com";
case "TIER" -> "KEY";
case "TARGET" -> String.valueOf(target);
case "CONTENT_TABLE" -> "page";
default -> "/";
};
long id = budget(scope, value, "LAB", "MOBILE", "LCP", "<=", 2500, "WARNING");
assertThat(evaluator.breachedBudget(page, "LAB", "MOBILE", "LCP", 2500)).isNull();
assertThat(evaluator.breachedBudget(page, "LAB", "MOBILE", "LCP", 2501)).isNotNull();
assertThat(evaluator.breachedBudget(page, "LAB", "DESKTOP", "LCP", 2501)).isNull();
assertThat(evaluator.breachedBudget(page, "CRUX", "MOBILE", "LCP", 2501)).isNull();
store.update("UPDATE {budget} SET scope_value = 'unmatched' WHERE id = ?", id);
assertThat(evaluator.breachedBudget(page, "LAB", "MOBILE", "LCP", 2501)).isNull();
store.update("UPDATE {budget} SET enabled = FALSE WHERE id = ?", id);
}
for(String comparator : List.of("<", ">", ">=", "=", "!=", "<=", "==", "<>", "LE", "GE", "LT", "GT", "EQ", "NE"))
{
long id = budget("SITE", "", "LAB", null, "LCP", comparator, 10, "WARNING");
for(double value : List.of(9.0, 10.0, 11.0))
{
boolean acceptable = switch(comparator)
{
case "<", "LT" -> value < 10;
case ">", "GT" -> value > 10;
case ">=", "GE" -> value >= 10;
case "<=", "LE" -> value <= 10;
case "=", "==", "EQ" -> value == 10;
default -> value != 10;
};
assertThat(evaluator.breachedBudget(page, "LAB", "MOBILE", "LCP", value) == null).isEqualTo(acceptable);
}
store.update("UPDATE {budget} SET enabled = FALSE WHERE id = ?", id);
}
budget("TIER", "KEY", "LAB", null, "PERFORMANCE_SCORE", ">=", 90, "WARNING");
long critical = budget("TIER", "KEY", "LAB", null, "PERFORMANCE_SCORE", ">=", 50, "CRITICAL");
long first = evaluate(40);
assertThat(WebPerfStore.asLong(regression().get("budget_id"))).isEqualTo(critical);
completeConfirmations(first, 40, 41);
assertThat(regression().get("severity")).isEqualTo("CRITICAL");
}
/*******************************************************************************
** CrUX URL budgets use real values, never absent data or origin fallbacks.
*******************************************************************************/
@Test
void evaluatesCruxUrlBudgets() throws Exception
{
budget("SITE", "", "CRUX", "MOBILE", "LCP", "<=", 2500, "WARNING");
long first = field("URL", "OK", 3000, 1);
evaluator.evaluateField(first);
evaluator.evaluateField(first);
assertThat(regression().get("source")).isEqualTo("CRUX");
assertThat(regression().get("status")).isEqualTo("CONFIRMED");
evaluator.evaluateField(field("URL", "NO_DATA", null, 2));
evaluator.evaluateField(field("ORIGIN", "OK", 1000, 3));
assertThat(regression().get("status")).isEqualTo("CONFIRMED");
evaluator.evaluateField(field("URL", "OK", 2000, 4));
assertThat(regression().get("status")).isEqualTo("RESOLVED");
assertThat(openCount()).isZero();
assertThat(RecordingSink.signals).hasSize(1);
}
/*******************************************************************************
** Poll replay, settle delays and attribution through the real queue worker.
*******************************************************************************/
@Test
void pollsAndAttributesWorkerResults() throws Exception
{
MutableProbe probe = new MutableProbe();
config.withDeployProbe(probe).withLabRunner(new FakeRunner());
ChangeEventSource.Change cdn = new ChangeEventSource.Change("CDN_STAGE", "www.example.com", "OFF", "HEADERS_ONLY", START);
ChangeEventSource.Change content = new ChangeEventSource.Change("CONTENT", "page:7", "old", "new", START.plusSeconds(1));
config.withChangeEventSource(since -> List.of(cdn, content));
WebPerfChangeService changes = new WebPerfChangeService(store);
changes.poll(START.plusSeconds(2));
changes.poll(START.plusSeconds(3));
assertThat(store.query("SELECT * FROM {change_event}")).hasSize(3);
assertThat(store.query("SELECT * FROM {lab_run} WHERE trigger_type = 'CHANGE_COMPARE'")).hasSize(3);
assertThat(store.query("SELECT * FROM {lab_run} WHERE trigger_type = 'DEPLOY'").get(0).get("not_before")).isEqualTo(START.plusSeconds(302));
new WebPerfRuntime(config).service().runDue(Instant.now());
List<Map<String, Object>> done = store.query("SELECT * FROM {lab_run} WHERE status = 'SUCCEEDED'");
assertThat(done).hasSize(2);
assertThat(done).allSatisfy(row ->
{
assertThat(row.get("deployment_id")).isEqualTo("build-1");
assertThat(row.get("cdn_stage")).isEqualTo("HEADERS_ONLY");
assertThat(row.get("evaluated_at")).isNotNull();
});
probe.build = "build-2";
changes.poll(Instant.now());
assertThat(store.query("SELECT * FROM {change_event} WHERE kind = 'FRONTEND_DEPLOY'")).hasSize(2);
probe.build = null;
changes.poll(Instant.now());
assertThat(store.query("SELECT * FROM {change_event} WHERE kind = 'FRONTEND_DEPLOY'")).hasSize(2);
}
/*******************************************************************************
** Three samples on each side create reproducible, idempotent comparisons.
*******************************************************************************/
@Test
void recordsComparisonsWithCountsAndWindows() throws Exception
{
for(int i = 0; i < 3; i++)
{
measurement(90, "CHANGE_COMPARE", "13.0", Map.of("lcp_ms", 3000, "fcp_ms", 2000, "ttfb_ms", 900));
}
long event = change("CDN_STAGE", "www.example.com", "LIVE", START.plusSeconds(4));
sequence = 4;
for(int i = 0; i < 3; i++)
{
long id = measurement(96, "CHANGE_COMPARE", "13.1", Map.of("change_event_id", event, "lcp_ms", 2000, "fcp_ms", 1500, "ttfb_ms", 600));
evaluator.evaluateLab(id);
assertThat(store.query("SELECT * FROM {comparison}")).hasSize(i == 2 ? 4 : 0);
}
assertThat(new WebPerfComparisonService(store).compare(event)).isEqualTo(4);
assertThat(store.query("SELECT * FROM {comparison}")).hasSize(4);
Map<String, Object> lcp = store.query("SELECT * FROM {comparison} WHERE metric = 'LCP'").get(0);
assertThat(WebPerfStore.asDouble(lcp.get("delta"))).isEqualTo(-1000);
assertThat(WebPerfStore.asInt(lcp.get("before_n"))).isEqualTo(3);
assertThat(WebPerfStore.asInt(lcp.get("after_n"))).isEqualTo(3);
assertThat(lcp.get("before_window").toString()).contains(START.plusSeconds(1).toString());
assertThat(runAction(WebPerfProcesses.COMPARE_CHANGE, Map.of()).getException()).isEmpty();
}
/*******************************************************************************
** Real ETL actions require a note and manage permission, and retain history.
*******************************************************************************/
@Test
void actionsRequireNotesAndPermissions() throws Exception
{
budget("SITE", "", "LAB", null, "SEO_SCORE", ">=", 100, "WARNING");
evaluator.evaluateLab(measurement(96, "MANUAL", "13.0", Map.of("score_seo", 90)));
long id = WebPerfStore.asLong(regression().get("id"));
assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class, () -> runAction(WebPerfProcesses.DISMISS_REGRESSIONS, Map.of()));
assertThat(regression().get("status")).isEqualTo("CONFIRMED");
WebPerfTestSupport.login(instance, "web_performance.read", "web_performance.write");
assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class, () -> runAction(WebPerfProcesses.DISMISS_REGRESSIONS, Map.of(RegressionSteps.NOTE, "Expected change")));
assertThat(regression().get("status")).isEqualTo("CONFIRMED");
WebPerfTestSupport.login(instance, "web_performance.read", "web_performance.write", "web_performance.manage.hasAccess");
RunProcessOutput result = runAction(WebPerfProcesses.DISMISS_REGRESSIONS, Map.of(RegressionSteps.NOTE, "Expected change"));
assertThat(result.getException()).isEmpty();
assertThat(result.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY)).isNotNull();
assertThat(regression().get("status")).isEqualTo("DISMISSED");
assertThat(regression().get("resolution_note")).isEqualTo("Expected change");
assertThat(openCount()).isZero();
assertThat(evaluator.finishRegression(id, "RESOLVED", "Already closed", START)).isFalse();
evaluator.evaluateLab(measurement(96, "MANUAL", "13.0", Map.of("score_seo", 90)));
assertThat(runAction(WebPerfProcesses.RESOLVE_REGRESSIONS, Map.of(RegressionSteps.NOTE, "Fixed externally")).getException()).isEmpty();
assertThat(store.query("SELECT * FROM {regression} WHERE status = 'RESOLVED'")).hasSize(1);
}
/*******************************************************************************
** Target locking prevents concurrent duplicate incidents; failed writes roll back.
*******************************************************************************/
@Test
void serializesEvaluationAndRollsBack() throws Exception
{
baseline();
long id = measurement(70, "SCHEDULE", "13.0", Map.of());
try(var executor = Executors.newFixedThreadPool(2))
{
var first = executor.submit(() ->
{
evaluator.evaluateLab(id);
return (null);
});
var second = executor.submit(() ->
{
evaluator.evaluateLab(id);
return (null);
});
first.get();
second.get();
}
assertThat(store.query("SELECT * FROM {regression}")).hasSize(1);
assertThat(store.query("SELECT * FROM {lab_run} WHERE trigger_type = 'CONFIRM'")).hasSize(2);
assertThrows(java.sql.SQLException.class, () -> store.transaction(tx ->
{
tx.update("UPDATE {target} SET label = 'must roll back' WHERE id = ?", target);
tx.update("UPDATE no_such_table SET id = 1");
return (null);
}));
assertThat(store.query("SELECT label FROM {target}").get(0).get("label")).isNotEqualTo("must roll back");
}
/*******************************************************************************
** Core falls back to logging without an Alerts QBit; feature gating works.
*******************************************************************************/
@Test
void alertsAreOptional() throws Exception
{
AlertsInstanceMetaData.ofOrWithNew(instance).withSink(null);
long failures = QAlerts.getProducerFailureCount();
budget("SITE", "", "LAB", null, "SEO_SCORE", ">=", 100, "WARNING");
evaluator.evaluateLab(measurement(96, "MANUAL", "13.0", Map.of("score_seo", 90)));
assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures);
config.getFeatures().remove(WebPerfFeature.ALERTS);
evaluator.finishRegression(WebPerfStore.asLong(regression().get("id")), "RESOLVED", "Done", START);
assertThat(RecordingSink.signals).isEmpty();
}
/*******************************************************************************
** TTFB comes from a measured subpart and is null when not reported.
*******************************************************************************/
@Test
void parsesMeasuredTtfbOnly()
{
assertThat(LabResultParser.ttfb("{\"audits\":{}}")).isNull();
assertThat(LabResultParser.ttfb("{\"audits\":{\"lcp-phases-insight\":{\"details\":{\"items\":[{\"subpart\":\"timeToFirstByte\",\"duration\":321.4}]}}}}")).isEqualTo(321);
}
/*******************************************************************************
** Quota skips remain visible, and retry preserves each deliberate sample slot.
*******************************************************************************/
@Test
void retriesCappedConfirmationSamples() throws Exception
{
baseline();
config.withMaxRunsPerDay(1);
long first = evaluate(70);
assertThat(store.query("SELECT * FROM {lab_run} WHERE status = 'SKIPPED_QUOTA'")).hasSize(1);
config.withMaxRunsPerDay(500);
assertThat(runAction(WebPerfProcesses.RETRY_RUNS, Map.of()).getException()).isEmpty();
assertThat(store.query("SELECT * FROM {lab_run} WHERE status = 'QUEUED' AND trigger_type = 'CONFIRM'")).hasSize(2);
assertThat(runAction(WebPerfProcesses.RETRY_RUNS, Map.of()).getException()).isEmpty();
assertThat(store.query("SELECT * FROM {lab_run} WHERE status = 'QUEUED' AND trigger_type = 'CONFIRM'")).hasSize(2);
store.update("UPDATE {lab_run} SET status = 'SUCCEEDED', completed_at = ?, lighthouse_version = '13.0', score_performance = 70 WHERE status = 'QUEUED'",
START.plusSeconds(10));
evaluator.evaluatePending();
assertThat(regression().get("status")).isEqualTo("CONFIRMED");
assertThat(WebPerfStore.asLong(regression().get("first_run_id"))).isEqualTo(first);
}
/*******************************************************************************
** Existing seeded budgets are evaluated even before enough baseline exists.
*******************************************************************************/
@Test
void seededBudgetsWorkWithoutBaseline() throws Exception
{
store.update("UPDATE {budget} SET enabled = TRUE WHERE seeded = TRUE");
long first = evaluate(40);
assertThat(regression().get("status")).isEqualTo("SUSPECTED");
assertThat(regression().get("baseline_value")).isNull();
assertThat(regression().get("severity")).isEqualTo("CRITICAL");
completeConfirmations(first, 40, 40);
assertThat(regression().get("status")).isEqualTo("CONFIRMED");
}
/*******************************************************************************
** Build replays are idempotent even after a rollback to an earlier build.
*******************************************************************************/
@Test
void attributesRollbackAndQueuesBothCdnWindows() throws Exception
{
MutableProbe probe = new MutableProbe();
config.withDeployProbe(probe);
WebPerfChangeService changes = new WebPerfChangeService(store);
changes.poll(START);
probe.build = "build-2";
changes.poll(START.plusSeconds(1));
probe.build = "build-1";
changes.poll(START.plusSeconds(2));
long run = measurement(96, "MANUAL", "13.0", Map.of());
changes.attribute(run, START.plusSeconds(3));
assertThat(store.query("SELECT deployment_id FROM {lab_run} WHERE id = ?", run).get(0).get("deployment_id")).isEqualTo("build-1");
assertThat(store.query("SELECT * FROM {change_event}")).hasSize(2);
ChangeEventSource.Change cdn = new ChangeEventSource.Change("CDN_STAGE", "www.example.com", "HEADERS_ONLY", "LIVE", START.plusSeconds(4));
config.withChangeEventSource(since -> List.of(cdn));
changes.poll(START.plusSeconds(4));
changes.poll(START.plusSeconds(5));
assertThat(store.query("SELECT * FROM {lab_run} WHERE trigger_type = 'CHANGE_COMPARE'")).hasSize(6);
assertThat(store.query("SELECT * FROM {lab_run} WHERE trigger_type = 'CHANGE_COMPARE' AND not_before = ?", START.plusSeconds(3604))).hasSize(3);
assertThat(store.query("SELECT * FROM {lab_run} WHERE trigger_type = 'CHANGE_COMPARE' AND not_before = ?", START.plusSeconds(86404))).hasSize(3);
assertThat(runAction(WebPerfProcesses.PROBE_DEPLOYS, Map.of()).getException()).isEmpty();
}
/*******************************************************************************
** Five clean baseline measurements.
*******************************************************************************/
private void baseline() throws Exception
{
for(int i = 0; i < 5; i++)
{
measurement(96, "SCHEDULE", "13.0", Map.of());
}
}
/*******************************************************************************
** Store a deterministic successful measurement, with no network access.
*******************************************************************************/
private long measurement(int score, String trigger, String version, Map<String, Object> extra) throws Exception
{
Instant time = START.plusSeconds(++sequence);
Map<String, Object> row = new LinkedHashMap<>();
row.put("target_id", target);
row.put("url", "https://www.example.com/");
row.put("strategy", "MOBILE");
row.put("status", "SUCCEEDED");
row.put("trigger_type", trigger);
row.put("lighthouse_version", version);
row.put("started_at", time);
row.put("completed_at", time);
row.put("queued_at", time);
row.put("score_performance", score);
row.put("score_accessibility", 100);
row.put("score_best_practices", 100);
row.put("score_seo", 100);
row.put("lcp_ms", 2000);
row.put("fcp_ms", 1000);
row.put("tbt_ms", 0);
row.put("cls", 0);
row.putAll(extra);
return (store.insert(WebPerfSchema.LAB_RUN, row));
}
/*******************************************************************************
** Store and evaluate the next performance measurement.
*******************************************************************************/
private long evaluate(int score) throws Exception
{
long id = measurement(score, "SCHEDULE", "13.0", Map.of());
evaluator.evaluateLab(id);
return (id);
}
/*******************************************************************************
** Complete the independently queued confirmation samples.
*******************************************************************************/
private void completeConfirmations(long first, int score1, int score2) throws Exception
{
int index = 0;
for(Map<String, Object> row : store.query("SELECT id FROM {lab_run} WHERE trigger_ref IN (?, ?) ORDER BY id", "confirm:" + first + ":0", "confirm:" + first + ":1"))
{
store.update("UPDATE {lab_run} SET status = 'SUCCEEDED', completed_at = ?, lighthouse_version = '13.0', score_performance = ? WHERE id = ?",
START.plusSeconds(++sequence), index++ == 0 ? score1 : score2, row.get("id"));
evaluator.evaluateLab(WebPerfStore.asLong(row.get("id")));
}
}
/*******************************************************************************
** One editable budget.
*******************************************************************************/
private long budget(String scope, String scopeValue, String source, String strategy, String metric, String comparator, double threshold, String severity) throws Exception
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("name", "Test budget");
row.put("scope", scope);
row.put("scope_value", scopeValue);
row.put("source", source);
row.put("strategy", strategy);
row.put("metric", metric);
row.put("comparator", comparator);
row.put("threshold", threshold);
row.put("severity", severity);
row.put("enabled", true);
return (store.insert(WebPerfSchema.BUDGET, row));
}
/*******************************************************************************
** A source event without automatic queueing for fixtures.
*******************************************************************************/
private long change(String kind, String component, String value, Instant time) throws Exception
{
return (new WebPerfChangeService(store).record(new ChangeEventSource.Change(kind, component, "before", value, time), "WEBHOOK", "test", false));
}
/*******************************************************************************
** A real or unavailable CrUX collection period.
*******************************************************************************/
private long field(String scope, String status, Integer lcp, int day) throws Exception
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("source", "CRUX_API");
row.put("scope", scope);
row.put("scope_key", "https://www.example.com/");
row.put("form_factor", "PHONE");
row.put("status", status);
row.put("p75_lcp_ms", lcp);
row.put("period_end", LocalDate.of(2026, 10, day));
row.put("last_checked_at", START.plusSeconds(day));
return (store.insert(WebPerfSchema.FIELD_SNAPSHOT, row));
}
/*******************************************************************************
** The first incident, retained through status changes.
*******************************************************************************/
private Map<String, Object> regression() throws Exception
{
return (store.query("SELECT * FROM {regression} ORDER BY id").get(0));
}
/*******************************************************************************
** The count shown in Tested Pages.
*******************************************************************************/
private int openCount() throws Exception
{
return (WebPerfStore.asInt(store.query("SELECT open_regression_count FROM {target} WHERE id = ?", target).get(0).get("open_regression_count")));
}
/*******************************************************************************
** Run the actual ETL process, following the existing frontend-skip convention.
*******************************************************************************/
private RunProcessOutput runAction(String name, Map<String, Serializable> values) throws Exception
{
RunProcessInput input = new RunProcessInput();
input.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
input.setProcessName(config.qualify(name));
input.setValues(new HashMap<>(values));
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
input.setCallback(new QProcessCallback()
{
/*******************************************************************************
** Select this test's records.
*******************************************************************************/
@Override
public QQueryFilter getQueryFilter()
{
return (new QQueryFilter());
}
});
return (new RunProcessAction().execute(input));
}
/*******************************************************************************
** Core alert sink, without any Alerts QBit dependency.
*******************************************************************************/
public static class RecordingSink implements AlertSinkInterface
{
static final List<AlertSignal> signals = new ArrayList<>();
static final List<AlertResolution> resolutions = new ArrayList<>();
/*******************************************************************************
** Capture a raise.
*******************************************************************************/
@Override
public void raise(AlertSignal signal)
{
signals.add(signal);
}
/*******************************************************************************
** Capture a resolution.
*******************************************************************************/
@Override
public void resolve(AlertResolution resolution)
{
resolutions.add(resolution);
}
/*******************************************************************************
** This producer does not emit heartbeats.
*******************************************************************************/
@Override
public void heartbeat(String key, Instant at)
{
}
}
/*******************************************************************************
** A probe controlled by the test.
*******************************************************************************/
private static class MutableProbe implements DeployProbe
{
String build = "build-1";
/*******************************************************************************
** Frontend component.
*******************************************************************************/
@Override
public String component()
{
return ("FRONTEND");
}
/*******************************************************************************
** Build id without transport.
*******************************************************************************/
@Override
public String currentBuildId()
{
return (build);
}
}
/*******************************************************************************
** A minimal Lighthouse runner without transport.
*******************************************************************************/
private static class FakeRunner implements LabRunner
{
/*******************************************************************************
** Stored runner name.
*******************************************************************************/
@Override
public String name()
{
return ("TEST");
}
/*******************************************************************************
** Return a successful response.
*******************************************************************************/
@Override
public LabResponse run(LabRequest request)
{
JSONObject body = new JSONObject().put("lighthouseVersion", "13.0").put("finalUrl", request.url())
.put("categories", new JSONObject().put("performance", new JSONObject().put("score", 0.96)))
.put("audits", new JSONObject());
return (new LabResponse(200, body.toString(), Instant.now()));
}
}
}
@@ -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"));
}
}
@@ -0,0 +1,185 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import com.kingsrook.qbits.webperf.metadata.WebPerfHelp;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
** the qbit should be fully using them"). For every web_performance_* table:
** - runtime code (not just its metadata or the home widgets) reads or writes
** it, so no table exists only on paper;
** - it is either seeded by the shipped changelog or listed in STARTS_EMPTY
** with why it starts empty;
** - it has bundle help that says what it is for and how its rows get created.
** A new table fails this test until it is wired, seeded or explained.
*******************************************************************************/
class WebPerfTablesGuardrailTest
{
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "webperf");
/*******************************************************************************
** Tables the shipped changelog seeds (WebPerfDefaultsChangelogTest proves it).
*******************************************************************************/
static final Map<String, String> SEEDED = Map.of(
"budget", "changeset 10: Google's good thresholds and the Lighthouse category bands");
/*******************************************************************************
** Tables that legitimately start empty, and where their rows come from.
*******************************************************************************/
static final Map<String, String> STARTS_EMPTY = Map.of(
"target", "synced from the sitemap (Sync pages from sitemap) or added by hand; the page list is host content",
"lab_run", "runtime: every queued or finished Lighthouse test",
"lab_finding", "runtime: findings of each successful lab run",
"lab_report", "runtime: the full response of each successful lab run",
"field_snapshot", "runtime: Google field data checks",
"regression", "runtime: a budget is broken or a score drifts (empty is healthy)",
"change_event", "runtime: a deploy or content change is detected (needs a probe or source)",
"comparison", "runtime: Compare before and after on a change event");
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void tearDown()
{
QContext.clear();
}
/*******************************************************************************
** Every table is used by runtime code.
*******************************************************************************/
@Test
void everyTableIsUsedAtRuntime() throws Exception
{
List<String> runtimeSource;
try(Stream<Path> files = Files.walk(MAIN))
{
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("widgets")))
.map(WebPerfTablesGuardrailTest::read).toList();
}
for(String base : WebPerfSchema.TABLES)
{
Pattern use = Pattern.compile("\\{" + base + "\\}|WebPerfSchema\\." + base.toUpperCase(Locale.ROOT) + "\\b");
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
.as(base + " must be read or written by runtime code, not only declared").isTrue();
}
}
/*******************************************************************************
** Every table is seeded or explained.
*******************************************************************************/
@Test
void everyTableIsSeededOrExplained() throws Exception
{
String changelog = Files.readString(Path.of("src", "main", "resources", WebPerfSchema.CHANGELOG), StandardCharsets.UTF_8);
for(String base : WebPerfSchema.TABLES)
{
boolean seeded = SEEDED.containsKey(base);
assertThat(seeded || STARTS_EMPTY.containsKey(base)).as(base + " must be seeded or listed in STARTS_EMPTY").isTrue();
assertThat(seeded && STARTS_EMPTY.containsKey(base)).as(base + " cannot be both").isFalse();
assertThat(changelog.contains("INSERT INTO ${web_performance.tablePrefix}" + base + " ")).as(base + " seeded iff the changelog inserts it").isEqualTo(seeded);
}
assertThat(WebPerfSchema.TABLES).containsAll(SEEDED.keySet()).containsAll(STARTS_EMPTY.keySet());
}
/*******************************************************************************
** Every table and the app have bundle help that says how rows get created.
*******************************************************************************/
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
QInstance qInstance = WebPerfTestSupport.newInstance();
new WebPerfQBitProducer().withQBitConfig(WebPerfTestSupport.config()).produce(qInstance);
WebPerfQBitConfig config = WebPerfTestSupport.config();
assertThat(WebPerfHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
for(String base : WebPerfSchema.TABLES)
{
QTableMetaData table = qInstance.getTable(config.applyPrefix(base));
List<QHelpContent> help = table.getHelpContent() == null ? null : table.getHelpContent().get(WebPerfHelp.SLOT);
assertThat(help).as(base + " table help").isNotEmpty();
assertThat(help.get(0).getContent()).as(base + " help says how rows get created").contains("How rows get created");
}
assertThat(qInstance.getApp(config.getAppName()).getHelpContent()).containsKey(WebPerfHelp.SLOT);
assertThat(WebPerfHelp.entries()).hasSize(WebPerfSchema.TABLES.size() + 1);
}
/*******************************************************************************
** Help a host already wrote is kept; the bundle only fills empty slots.
*******************************************************************************/
@Test
void hostHelpIsNotOverridden() throws Exception
{
QInstance qInstance = WebPerfTestSupport.newInstance();
WebPerfQBitConfig config = WebPerfTestSupport.config();
new WebPerfQBitProducer().withQBitConfig(config).produce(qInstance);
QTableMetaData table = qInstance.getTable(config.applyPrefix(WebPerfSchema.BUDGET));
table.getHelpContent().remove(WebPerfHelp.SLOT);
table.withHelpContent(WebPerfHelp.SLOT, new QHelpContent("host words"));
WebPerfHelp.apply(qInstance, config);
assertThat(table.getHelpContent().get(WebPerfHelp.SLOT).get(0).getContent()).isEqualTo("host words");
}
/*******************************************************************************
**
*******************************************************************************/
private static String read(Path path)
{
try
{
return (Files.readString(path, StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -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
@@ -127,9 +127,18 @@ public final class WebPerfTestSupport
List<String> statements = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean skip = false;
for(String line : script.split("\n"))
{
if(line.trim().startsWith("--"))
//////////////////////////////////////////////////////////////////////
// a changeset limited to other databases (the dbms:postgresql //
// orphan cleanup) is skipped, as Liquibase skips it on H2 //
//////////////////////////////////////////////////////////////////////
if(line.startsWith("-- changeset "))
{
skip = line.contains(" dbms:") && !line.contains("h2");
}
if(skip || line.trim().startsWith("--"))
{
continue;
}
@@ -144,6 +153,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