Performance SLOs and Monitoring¶
This is the source of truth for build, synthetic, and real-user performance budgets. Performance measurements are advisory and do not block merges or deployments.
Active reports and monitoring¶
Candidate bundle budgets¶
scripts/ci/bundle-budget.ts measures Brotli payload sizes from the built Vite
output and reports drift without failing the build.
It runs for production images in the shared vite-builder stage in
Dockerfile, keeping the measurement next to the exact candidate output without
adding another frontend build to .github/workflows/deploy.yml.
Thresholds live in scripts/ci/perf-budgets.json. All bands are advisory.
Live-production synthetic preflight¶
.github/workflows/synthetic-perf.yml probes production every six hours and on
manual dispatch. It is independent of PR and deployment workflows so noisy
third-party-network failures or a slow live site cannot block a fix.
Each configured route gets three Lighthouse runs. The report requires every route and every metric to have all three samples. Missing or out-of-band data is reported in the Actions summary; available artifacts are retained for 14 days.
These are mobile numbers. lhci collect is run with no preset, and
Lighthouse’s default is emulated mobile — a Moto-G-class viewport with 4× CPU
throttling and slow-4G network throttling. The bands below are therefore already
the phone case, not an average of all traffic, and they should not be read as
representative of a desktop visitor.
There is deliberately no desktop counterpart lane, and the reason is worth
stating so it is not added as an obvious oversight: the mobile ÷ desktop ratio
between two Lighthouse presets is largely a property of the presets — the 4×
CPU slowdown and the throttled link are configuration, not measurement — so it
would produce an authoritative-looking number that says almost nothing about
this site. The device comparison that does mean something comes from real users,
via --by-device on the RUM reporter above.
Synthetic bands are in scripts/ci/synthetic-perf-bands.json:
Route class |
LCP |
TBT |
CLS |
TTFB |
Minimum score |
|---|---|---|---|---|---|
core |
2500 ms |
300 ms |
0.10 |
800 ms |
80 |
content |
3000 ms |
400 ms |
0.15 |
1000 ms |
75 |
interactive |
3500 ms |
500 ms |
0.20 |
1200 ms |
70 |
realtime |
4000 ms |
600 ms |
0.20 |
1500 ms |
65 |
Lighthouse cannot produce a representative INP without real interaction, so the lab report uses Total Blocking Time as its responsiveness proxy. INP is measured from real-user data.
The candidate continues to be protected by build correctness and the VPS blue/green health checks. A post-deploy synthetic rollback loop remains an operator integration until the webhook can report completion and accept an authenticated rollback action.
Real-user monitoring¶
lib/rum.ts sends LCP, INP, CLS, TTFB, and FCP samples to /api/rum. The API
validates the metric, derives its route class, reduces the pathname to its first
segment so handles/IDs/slugs are not logged, and emits:
[rum:metric]for every accepted sample, so a log backend can calculate percentiles rather than seeing only slow navigations[rum:poor]for Web Vitals’ native poor rating[rum:slo-breach]when an individual navigation exceeds its route-class band
The shared route-class thresholds are in lib/rum-slo-bands.json. Route
classification and threshold lookup are in lib/rum-slo.ts.
The device dimension — read this before trusting a pooled percentile¶
Every sample also carries a bucketed device context: formFactor
(mobile/tablet/desktop), vw, dpr, mem, cores, net, saveData.
See lib/rum.ts §Device context for why each field is bucketed the way it is —
the short version is that these are the fields that change a decision, and
nothing finer, because /api/rum is anonymous by design.
A pooled p75 cannot see this site’s dominant performance fact. The load cost
here is overwhelmingly main-thread JavaScript and GPU compositing, and a phone
pays 4–6× for both. Mixed into one population with desktop traffic, a 3× mobile
regression reads as mild drift and passes its band. That is not hypothetical:
six consecutive audits measured only unthrottled desktop Chromium
(docs/loading-audit-2026-08-11/01-measurements.md §6) and none of them could
have seen it. Split the report before concluding a route is healthy:
node scripts/ci/rum-slo-report.mjs --by-device --min-samples=100 web.log
--by-device adds a Device column and prints the mobile ÷ desktop p75 ratio per
metric. Samples from a client cached before the dimension shipped are bucketed
as unknown rather than dropped, so the population never silently shrinks when
the beacon changes. Note that the split divides each row’s samples three ways —
lower --min-samples accordingly or widen the window.
Aggregate a captured log window locally:
node scripts/ci/rum-slo-report.mjs --min-samples=100 web.log
Use it as a machine gate:
node scripts/ci/rum-slo-report.mjs \
--strict \
--min-samples=100 \
--p95-multiplier=1.25 \
web.log
The strict report fails when a present route/metric group has fewer than the
minimum samples, p75 exceeds its SLO, or p95 exceeds 125% of the SLO. The script
also accepts structured log lines on stdin and supports --json.
Production alerting still needs the container log stream forwarded to a central
backend. Alert on aggregate windows, not a single [rum:slo-breach] event.
CDN and host drift¶
Cloudflare cache rules¶
Apply the committed cache rules once with a scoped token:
CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_ID=... \
bash deploy/apply-cloudflare-cache-rules.sh
Verify exact semantic drift without writing:
CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_ID=... VERIFY_ONLY=1 \
bash deploy/apply-cloudflare-cache-rules.sh
The scheduled synthetic workflow runs this verification when both repository secrets are configured. If they are absent, the job emits an explicit notice instead of pretending drift was checked.
VPS tuning¶
Review the committed values against host RAM and connection demand, then apply:
sudo bash deploy/apply-perf-tuning.sh
Verify the installed files and live PostgreSQL/Apache settings without writing:
VERIFY_ONLY=1 sudo bash deploy/apply-perf-tuning.sh
Capture equal traffic windows before and after applying:
Save the synthetic workflow summary and Lighthouse artifacts.
Export at least 100
[rum:metric]samples per important route/metric group.Run
rum-slo-report.mjson both windows and retain its JSON output.Record error rate, web-container restarts, PostgreSQL saturation, and Apache busy workers for the same timestamps.
Do not claim a host-tuning win without those before/after artifacts.
Rollback policy¶
The blue/green hotswap already rolls back automatically when the new web container is unhealthy or Apache does not serve through the new port.
For performance regressions after a healthy swap, roll back when either is true for two consecutive comparable windows with at least 100 samples per affected group:
p75 exceeds the route-class budget
p95 exceeds 125% of the route-class budget
A synthetic breach of 25% or more on the newly deployed version is also a rollback signal after one confirmation run.
Rollback command and verification:
Run
./deploy.sh production <previous-sha>on the VPS.Re-run the synthetic workflow.
Recompute the equivalent RUM window.
Keep the rollback until both synthetic and RUM bands recover.
The percentile evaluator is repository code; unattended performance rollback is not active because this repo has no authenticated central-metrics query or rollback webhook. Wiring those two external pieces is required before CI can safely automate the command above.
Executable rollout checklist¶
Report candidate bundle size inside the shared image graph.
Add advisory scheduled synthetic probes outside PR traffic.
Emit complete structured RUM samples and percentile reports.
Add exact Cloudflare drift detection.
Add read-only VPS tuning verification.
Configure
CLOUDFLARE_API_TOKENandCLOUDFLARE_ZONE_IDin production.Apply the Cloudflare rules and save the successful verification output.
Review, apply, and verify VPS tuning on the production host.
Forward
[rum:metric]logs to a durable metrics backend.Capture before/after p75 and p95 windows by route class.
Add aggregate alert rules in the metrics backend.
Add authenticated deploy-completion and rollback controls before enabling unattended performance rollback.