Skip to main content

Two measurement contexts, deliberately never merged. Surfaced at /admin/perf, explained at /showcases/observability.

Context Source Answers Enforced by
Field real-user telemetry (analytics.events, event_type = 'timing') how fast is it for real visitors, right now nothing — reported only
Lab a production build, committed as src/lib/server/perf/snapshot.json what did this commit do to the bundle snapshot.gate.test.ts

Field data cannot catch a bundle regression: weight does not appear in RUM until it has already shipped. Lab data cannot tell you the site is slow for users in Australia. Scoring one against the other's budget is the mistake budgets.json splits kind: "lab" from kind: "field" to prevent — a warm local preview and a real user across the internet are not the same measurement, so ttfb_ms (600 ms) and field_ttfb_ms (1800 ms) are separate budgets.

Telemetry lanes

The dev server writes into the production analytics database. This is fixed at the source (telemetry.ts and journey-beacon.ts are dev-gated), but the read side filters anyway, for two reasons: rows already written stay in the retention window, and a filter that reports what it excluded is the only thing that notices if the source-side gate regresses.

The skew is not symmetric noise. Dev samples come from localhost — no network, no TLS, no cold start — so they pull every latency percentile down. Measured over 30 days: 72% of samples were dev rows, and TTFB p75 read 1051 ms against an honest 1431 ms. Contamination made the site look faster, so nothing about the number invited suspicion.

The discriminator (db/analytics/lanes.ts): web-vitals/attribution records the CSS selector it blamed, and Svelte's scope-class format differs between builds.

dev   nav.flex-1.overflow-y-auto.p-2.s-Xv7_7mcdkQaC.scrollbar-nav
prod  nav.flex-1.overflow-y-auto.p-2.scrollbar-nav.svelte-1e55qdy

No extra column, no backfill. DEV_SCOPE_PATTERN is written to be valid in both POSIX (Postgres ~) and JavaScript regex, because the SQL filter and the TS classifier must not diverge. It is anchored so it cannot match svelte-: the character after s must be -, and in svelte- it is v. A pattern matching both would filter out every real sample and show an empty production lane on a healthy deployment.

Targets carrying no scope class at all are unknown — excluded, never assumed prod. Within a session, dev wins over prod: one dev marker proves a developer's browser was involved, while a prod marker only proves one component came from a prod build.

Targets vs ratchets

budgets.json holds both, and they do different jobs.

  • Targets (budgets) — where we want to be. Scored on /admin/perf, never build-failing. Several are red today; saying so is the point.
  • Ratchets (ceilings) — the measured value at the moment it was accepted. Asserted by the gate. Moved down to bank an improvement; up only as an explicit, reviewed decision once feature work has used the ~3% headroom — never to absorb a dependency.

The heaviest route is ~609 KB gzipped against a 250 KB target. A gate wired to the target would fail on the commit that introduced it and be muted the same afternoon; ratchets stop the number growing while the target stays visible as the thing still to fix. Lowering a ceiling is how an improvement gets banked.

Bundle weight suits a gate unusually well: invisible in review (a one-line import can add 100 KB), deterministic (same source, same number), and only catchable before shipping.

Regenerating the lab snapshot

Must be a production build. A dev-mode build compiles both halves differently and inflates client JS ~9%; the snapshot records its NODE_ENV and the gate refuses to score anything that does not say production.

podman exec -e NODE_ENV=production -e GIT_SHA=$(git rev-parse --short HEAD) v10r bun run perf:snapshot

Note compose.yaml sets NODE_ENV=development, so the -e override is required — without it every local build number is inflated. Vercel sets production itself, so deploys are unaffected.

Must name its source. The container has no git binary, so the host passes GIT_SHA; the script refuses to write without it rather than record null (the committed snapshot carried null for two weeks — a number nobody could reproduce or bisect). vr sets it on every container run, suffixed -dirty when the tree differs from HEAD, and snapshot.gate.test.ts rejects a committed snapshot without one.

Reading the idle-gap panel

TTFB grouped by how long the platform sat idle before a visit, counting crawler hits (~50× human traffic here — they are what actually keeps a container warm).

It is a proxy, not an instrument. It cannot see which container served a request, so a short gap does not prove a warm start: under a crawler burst, requests fan out across many fresh containers, producing short gaps and slow responses together. Two ways to get this wrong, both of which produced a confidently wrong answer before the panel existed:

  • Anchoring on the timing event and using lag() over all events measures the gap to the sibling event of the same page load, milliseconds earlier — every sample lands in <1m and the chart shows one bar.
  • Excluding crawler hits from the stream massively overstates idle time, because crawlers are what fill the gaps.

The panel refuses to be read as a trend below MIN_SAMPLES_FOR_P75 per bucket. A curve drawn from n=2 is how an audit reaches a conclusion it has to retract.

Manual probes

scripts/perf/ — measure a prod build or the live deployment; dev-server numbers are meaningless.

Script Measures
snapshot.ts writes the committed lab snapshot (bundle + prerendered HTML)
db-explain.ts corpus size, HNSW plan, GUCs, hot-query EXPLAIN ANALYZE
ttfb.sh first-byte across representative routes
bundle.sh client JS chunks + prerendered HTML weight
cold-start.sh container vite-ready + first TTFB
← Back to Stack

Think this pattern could be better? Tell us how.

Leave feedback