Public: anyone can read this; the crew can change it.
An app's visitors' reports keep TTFB, LCP and CLS beside load and INP #3211
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3200-an-app-s-visitors-reports-keep-ttfb-lcp → main 2 commits fork 1 thread · 1 unresolved
7 files
changed +59
−11
README.md +1 −1
| @@ -245,7 +245,7 @@ Every page links one static, immutable script (`src/web/beacon.ts`, under 2 KB, | ||
| 245 | 245 | |
| 246 | 246 | ### Every app's visitors |
| 247 | 247 | |
| 248 | An app made from the template ships the same beacon as `static/beacon.js`, posting to `POST /__forge/measure` on the app's own host. The forge answers that door itself (`src/apps/measure.ts`), before the app is woken: same-origin only, 30 reports a minute per client and 600 per app (429), a body over the cap is a 413, and a private app or a sealed pull's preview refuses anyone its gate refuses. The app is the host's, never the body's. Production reports are kept in `app_vital` (`src/apps/vitals.ts`, load and INP, a visitor hashed under the day's salt, no address, kept 90 days and removed with the app, at most 20,000 rows per app a day); previews store nothing. Only Do-Not-Track opts a visitor out on an app's host: the `forge_measure=off` cookie is set on the forge's host and never reaches an app's. Read the numbers (visitors today and over 7 days, p75 load and INP) on the app's settings page under **Visitors and speed**, or as `vitals` in an admin's `GET /api/v1/repos/:owner/:name/app`. Both doors show them only to people who administer the repository (`appVisitors` in `src/actions/apps.ts`), so a reader or an anonymous caller sees no `vitals`. In the terminal they are the second line of the app's **Deploys** screen over ssh. | |
| 248 | An app made from the template ships the same beacon as `static/beacon.js`, posting to `POST /__forge/measure` on the app's own host. The forge answers that door itself (`src/apps/measure.ts`), before the app is woken: same-origin only, 30 reports a minute per client and 600 per app (429), a body over the cap is a 413, and a private app or a sealed pull's preview refuses anyone its gate refuses. The app is the host's, never the body's. Production reports are kept in `app_vital` (`src/apps/vitals.ts`, TTFB, load, LCP, INP and CLS, a visitor hashed under the day's salt, no address, kept 90 days and removed with the app, at most 20,000 rows per app a day); previews store nothing. Only Do-Not-Track opts a visitor out on an app's host: the `forge_measure=off` cookie is set on the forge's host and never reaches an app's. Read the numbers (visitors today and over 7 days, p75 TTFB, load, LCP, INP and CLS) on the app's settings page under **Visitors and speed**, or as `vitals` in an admin's `GET /api/v1/repos/:owner/:name/app`. Both doors show them only to people who administer the repository (`appVisitors` in `src/actions/apps.ts`), so a reader or an anonymous caller sees no `vitals`. In the terminal all five are on the second line of the app's **Deploys** screen over ssh. | |
| 249 | 249 | |
| 250 | 250 | ## Live in the terminal |
| 251 | 251 |
src/apps/vitals.ts +16 −6
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * What an app's visitors report about it, kept against that app. The |
| 3 | 3 | * report is the forge's own (`web/measure.ts`): same validation, same daily |
| 4 | * salt, no address stored. Only the two numbers the app's page shows are | |
| 5 | * kept: load and INP, for 90 days. A day keeps at most DAY_MAX rows per app. | |
| 4 | * salt, no address stored. The five numbers (TTFB, load, LCP, INP, CLS) are | |
| 5 | * kept for 90 days. A day keeps at most DAY_MAX rows per app. | |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| @@ -30,8 +30,8 @@ export function recordAppVital( | ||
| 30 | 30 | if ((held?.n ?? 0) >= dayMax) return false; |
| 31 | 31 | const visitor = sha256(`${saltFor(db, at)}\0${appId}\0${client}\0${agent}`).slice(0, 32); |
| 32 | 32 | db.run( |
| 33 | "INSERT INTO app_vital (app_id, day, at, visitor, load, inp) VALUES (?, ?, ?, ?, ?, ?)", | |
| 34 | [appId, day, at, visitor, report.load, report.inp], | |
| 33 | "INSERT INTO app_vital (app_id, day, at, visitor, ttfb, load, lcp, inp, cls) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 34 | [appId, day, at, visitor, report.ttfb, report.load, report.lcp, report.inp, report.cls], | |
| 35 | 35 | ); |
| 36 | 36 | return true; |
| 37 | 37 | } |
| @@ -42,13 +42,17 @@ export interface AppVitals { | ||
| 42 | 42 | /** The sum of each of the last 7 days' distinct visitors (a visitor cannot be followed across days). */ |
| 43 | 43 | readonly week: number; |
| 44 | 44 | /** 75th percentile page load, ms, over 7 days; undefined with no data. */ |
| 45 | readonly ttfbP75: number | undefined; | |
| 45 | 46 | readonly loadP75: number | undefined; |
| 47 | readonly lcpP75: number | undefined; | |
| 46 | 48 | readonly inpP75: number | undefined; |
| 49 | /** Layout shift is a unitless score, not a duration. */ | |
| 50 | readonly clsP75: number | undefined; | |
| 47 | 51 | } |
| 48 | 52 | |
| 49 | 53 | const DAY = 24 * 60 * 60 * 1000; |
| 50 | 54 | |
| 51 | function p75(db: Database, appId: string, column: "load" | "inp", since: string): number | undefined { | |
| 55 | function p75(db: Database, appId: string, column: "ttfb" | "load" | "lcp" | "inp" | "cls", since: string): number | undefined { | |
| 52 | 56 | const n = |
| 53 | 57 | db |
| 54 | 58 | .query<{ n: number }, [string, string]>( |
| @@ -75,8 +79,11 @@ export function appVitals(db: Database, appId: string, at: number = Date.now()): | ||
| 75 | 79 | return { |
| 76 | 80 | today: count(dayOf(at)), |
| 77 | 81 | week: count(since), |
| 82 | ttfbP75: p75(db, appId, "ttfb", since), | |
| 78 | 83 | loadP75: p75(db, appId, "load", since), |
| 84 | lcpP75: p75(db, appId, "lcp", since), | |
| 79 | 85 | inpP75: p75(db, appId, "inp", since), |
| 86 | clsP75: p75(db, appId, "cls", since), | |
| 80 | 87 | }; |
| 81 | 88 | } |
| 82 | 89 | |
| @@ -84,6 +91,9 @@ export function appVitals(db: Database, appId: string, at: number = Date.now()): | ||
| 84 | 91 | export const speedText = (v: number | undefined): string => |
| 85 | 92 | v === undefined ? "–" : v < 1000 ? `${Math.round(v)} ms` : `${(v / 1000).toFixed(1)} s`; |
| 86 | 93 | |
| 94 | /** A layout shift score: "0.05", or a dash with no data. */ | |
| 95 | export const shiftText = (v: number | undefined): string => (v === undefined ? "–" : v.toFixed(2)); | |
| 96 | ||
| 87 | 97 | /** The one line the terminal shows. */ |
| 88 | 98 | export const visitorsLine = (v: AppVitals): string => |
| 89 | `visitors ${v.today} today, ${v.week} in 7 days · p75 load ${speedText(v.loadP75)} · p75 INP ${speedText(v.inpP75)}`; | |
| 99 | `visitors ${v.today} today, ${v.week} in 7 days · p75 TTFB ${speedText(v.ttfbP75)} · load ${speedText(v.loadP75)} · LCP ${speedText(v.lcpP75)} · INP ${speedText(v.inpP75)} · CLS ${shiftText(v.clsP75)}`; | |
src/core/migration-pins.json +2 −1
| @@ -120,5 +120,6 @@ | ||
| 120 | 120 | "20261019120000-drill-phases": "574071a8576f982fe8483af90b472a03793ffa03e3b391c786d5c5144e3bb82e", |
| 121 | 121 | "20261019130000-run-model-prompt": "ce2e94f439649fc042c0a951f1af4a740b91f674dc94521e865ec517f68994f5", |
| 122 | 122 | "20261019140000-route-timing": "0fd7cfa4e0a50c4b2b36162601f9ddc3318e7fa445559732364b2b52a06de011", |
| 123 | "20261019150000-page-vitals": "63fe647d95733a5194a99ae955181f2456d5c2e8c9c38d37bbf1bad395b72bdf" | |
| 123 | "20261019150000-page-vitals": "63fe647d95733a5194a99ae955181f2456d5c2e8c9c38d37bbf1bad395b72bdf", | |
| 124 | "20261020120000-app-vitals-five": "6c75f4137e5592bf728ffe05d42d33bc158c9dabab3c666c58bc7b6dceb57576" | |
| 124 | 125 | } |
src/core/migrations/20261020120000-app-vitals-five.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | // An app keeps the same five numbers the forge's own pages do (#3200): | |
| 2 | // ttfb, lcp and cls join load and inp. Additive, and nullable: a row from | |
| 3 | // before reads null for the three, and no p75 counts it. | |
| 4 | export default ` | |
| 5 | ALTER TABLE app_vital ADD COLUMN ttfb REAL; | |
| 6 | ALTER TABLE app_vital ADD COLUMN lcp REAL; | |
| 7 | ALTER TABLE app_vital ADD COLUMN cls REAL; | |
| 8 | `; |
src/web/pages/settings.ts +4 −1
| @@ -51,7 +51,7 @@ import { | ||
| 51 | 51 | updateRepoSettings, |
| 52 | 52 | } from "../../actions/repos.ts"; |
| 53 | 53 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 54 | import { appVitals, speedText as ms, type AppVitals } from "../../apps/vitals.ts"; | |
| 54 | import { appVitals, shiftText, speedText as ms, type AppVitals } from "../../apps/vitals.ts"; | |
| 55 | 55 | import type { PreviewData } from "../../apps/data.ts"; |
| 56 | 56 | import { |
| 57 | 57 | getAppByRepo, |
| @@ -634,8 +634,11 @@ function appVisitorsView(v: AppVitals): Html { | ||
| 634 | 634 | <dl class="stats"> |
| 635 | 635 | <div><dt>Visitors today</dt><dd>${v.today}</dd></div> |
| 636 | 636 | <div><dt>Visitors, 7 days</dt><dd>${v.week}</dd></div> |
| 637 | <div><dt>p75 TTFB</dt><dd>${ms(v.ttfbP75)}</dd></div> | |
| 637 | 638 | <div><dt>p75 load</dt><dd>${ms(v.loadP75)}</dd></div> |
| 639 | <div><dt>p75 LCP</dt><dd>${ms(v.lcpP75)}</dd></div> | |
| 638 | 640 | <div><dt>p75 INP</dt><dd>${ms(v.inpP75)}</dd></div> |
| 641 | <div><dt>p75 CLS</dt><dd>${shiftText(v.clsP75)}</dd></div> | |
| 639 | 642 | </dl>`, |
| 640 | 643 | { head: html`${icon("rocket")}<h2>Visitors and speed</h2>` }, |
| 641 | 644 | ); |
test/apps-measure.test.ts +26
| @@ -226,6 +226,32 @@ describe("what is kept", () => { | ||
| 226 | 226 | expect(v.week).toBe(4); |
| 227 | 227 | }); |
| 228 | 228 | |
| 229 | test("every one of the five p75s reads back, and a missing one is undefined", () => { | |
| 230 | const at = Date.UTC(2026, 0, 7, 12); | |
| 231 | recordAppVital(app.db, "old5", { path: "/", ttfb: null, load: 5, lcp: null, inp: null, cls: 0 }, "z", "u", at); | |
| 232 | for (const [i, n] of [1, 2, 3, 4].entries()) | |
| 233 | recordAppVital( | |
| 234 | app.db, | |
| 235 | "five", | |
| 236 | { path: "/", ttfb: n * 10, load: n * 100, lcp: n * 1000, inp: n * 20, cls: n / 10 }, | |
| 237 | `c${i}`, | |
| 238 | "u", | |
| 239 | at, | |
| 240 | ); | |
| 241 | expect(appVitals(app.db, "five", at)).toMatchObject({ | |
| 242 | ttfbP75: 30, | |
| 243 | loadP75: 300, | |
| 244 | lcpP75: 3000, | |
| 245 | inpP75: 60, | |
| 246 | clsP75: 0.3, | |
| 247 | }); | |
| 248 | const old = appVitals(app.db, "old5", at); | |
| 249 | expect(old.loadP75).toBe(5); | |
| 250 | expect(old.ttfbP75).toBeUndefined(); | |
| 251 | expect(old.lcpP75).toBeUndefined(); | |
| 252 | expect(old.clsP75).toBe(0); | |
| 253 | }); | |
| 254 | ||
| 229 | 255 | test("the prune and the per-app reads use an index", () => { |
| 230 | 256 | const plan = (sql: string) => |
| 231 | 257 | app.db.query<{ detail: string }, []>(`EXPLAIN QUERY PLAN ${sql}`).all().map((r) => r.detail).join("\n"); |
test/tui-deploys.test.ts +2 −2
| @@ -131,8 +131,8 @@ test("an admin sees the app's visitors and speed above the deploys, and a reader | ||
| 131 | 131 | if (!made.ok) throw new Error(made.error); |
| 132 | 132 | const record = made.value; |
| 133 | 133 | const at = Date.now(); |
| 134 | recordAppVital(forge.db, record.id, { path: "/", ttfb: null, load: 420, lcp: null, inp: 80, cls: 0 }, "c", "u", at); | |
| 135 | expect(await screenText(alice, r.value)).toContain("visitors 1 today, 1 in 7 days · p75 load 420 ms · p75 INP 80 ms"); | |
| 134 | recordAppVital(forge.db, record.id, { path: "/", ttfb: 90, load: 420, lcp: 1300, inp: 80, cls: 0.05 }, "c", "u", at); | |
| 135 | expect(await screenText(alice, r.value)).toContain("visitors 1 today, 1 in 7 days · p75 TTFB 90 ms · load 420 ms · LCP 1.3 s · INP 80 ms · CLS 0.05"); | |
| 136 | 136 | const c = await signUp(forge, { |
| 137 | 137 | username: "carol", |
| 138 | 138 | email: "[email protected]", |
new above
offline
visitorsLine still reports only load and INP. Add ttfb, lcp and cls (shiftText) so the terminal matches the settings page, and test it.