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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3200-an-app-s-visitors-reports-keep-ttfb-lcp → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/3200-an-app-s-visitors-reports-keep-ttfb-lcp was merged into main as bb24729.

Review

Sign in to review this change.
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,
245245
246246 ### Every app's visitors
247247
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.
249249
250250 ## Live in the terminal
251251
src/apps/vitals.ts +16 −6
@@ -1,8 +1,8 @@
11 /**
22 * What an app's visitors report about it, kept against that app. The
33 * 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.
66 */
77
88 import type { Database } from "bun:sqlite";
@@ -30,8 +30,8 @@ export function recordAppVital(
3030 if ((held?.n ?? 0) >= dayMax) return false;
3131 const visitor = sha256(`${saltFor(db, at)}\0${appId}\0${client}\0${agent}`).slice(0, 32);
3232 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],
3535 );
3636 return true;
3737 }
@@ -42,13 +42,17 @@ export interface AppVitals {
4242 /** The sum of each of the last 7 days' distinct visitors (a visitor cannot be followed across days). */
4343 readonly week: number;
4444 /** 75th percentile page load, ms, over 7 days; undefined with no data. */
45 readonly ttfbP75: number | undefined;
4546 readonly loadP75: number | undefined;
47 readonly lcpP75: number | undefined;
4648 readonly inpP75: number | undefined;
49 /** Layout shift is a unitless score, not a duration. */
50 readonly clsP75: number | undefined;
4751 }
4852
4953 const DAY = 24 * 60 * 60 * 1000;
5054
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 {
5256 const n =
5357 db
5458 .query<{ n: number }, [string, string]>(
@@ -75,8 +79,11 @@ export function appVitals(db: Database, appId: string, at: number = Date.now()):
7579 return {
7680 today: count(dayOf(at)),
7781 week: count(since),
82 ttfbP75: p75(db, appId, "ttfb", since),
7883 loadP75: p75(db, appId, "load", since),
84 lcpP75: p75(db, appId, "lcp", since),
7985 inpP75: p75(db, appId, "inp", since),
86 clsP75: p75(db, appId, "cls", since),
8087 };
8188 }
8289
@@ -84,6 +91,9 @@ export function appVitals(db: Database, appId: string, at: number = Date.now()):
8491 export const speedText = (v: number | undefined): string =>
8592 v === undefined ? "–" : v < 1000 ? `${Math.round(v)} ms` : `${(v / 1000).toFixed(1)} s`;
8693
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
8797 /** The one line the terminal shows. */
8898 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)}`;
outdated

visitorsLine still reports only load and INP. Add ttfb, lcp and cls (shiftText) so the terminal matches the settings page, and test it.

src/core/migration-pins.json +2 −1
@@ -120,5 +120,6 @@
120120 "20261019120000-drill-phases": "574071a8576f982fe8483af90b472a03793ffa03e3b391c786d5c5144e3bb82e",
121121 "20261019130000-run-model-prompt": "ce2e94f439649fc042c0a951f1af4a740b91f674dc94521e865ec517f68994f5",
122122 "20261019140000-route-timing": "0fd7cfa4e0a50c4b2b36162601f9ddc3318e7fa445559732364b2b52a06de011",
123 "20261019150000-page-vitals": "63fe647d95733a5194a99ae955181f2456d5c2e8c9c38d37bbf1bad395b72bdf"
123 "20261019150000-page-vitals": "63fe647d95733a5194a99ae955181f2456d5c2e8c9c38d37bbf1bad395b72bdf",
124 "20261020120000-app-vitals-five": "6c75f4137e5592bf728ffe05d42d33bc158c9dabab3c666c58bc7b6dceb57576"
124125 }
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 {
5151 updateRepoSettings,
5252 } from "../../actions/repos.ts";
5353 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";
5555 import type { PreviewData } from "../../apps/data.ts";
5656 import {
5757 getAppByRepo,
@@ -634,8 +634,11 @@ function appVisitorsView(v: AppVitals): Html {
634634 <dl class="stats">
635635 <div><dt>Visitors today</dt><dd>${v.today}</dd></div>
636636 <div><dt>Visitors, 7 days</dt><dd>${v.week}</dd></div>
637 <div><dt>p75 TTFB</dt><dd>${ms(v.ttfbP75)}</dd></div>
637638 <div><dt>p75 load</dt><dd>${ms(v.loadP75)}</dd></div>
639 <div><dt>p75 LCP</dt><dd>${ms(v.lcpP75)}</dd></div>
638640 <div><dt>p75 INP</dt><dd>${ms(v.inpP75)}</dd></div>
641 <div><dt>p75 CLS</dt><dd>${shiftText(v.clsP75)}</dd></div>
639642 </dl>`,
640643 { head: html`${icon("rocket")}<h2>Visitors and speed</h2>` },
641644 );
test/apps-measure.test.ts +26
@@ -226,6 +226,32 @@ describe("what is kept", () => {
226226 expect(v.week).toBe(4);
227227 });
228228
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
229255 test("the prune and the per-app reads use an index", () => {
230256 const plan = (sql: string) =>
231257 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
131131 if (!made.ok) throw new Error(made.error);
132132 const record = made.value;
133133 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");
136136 const c = await signUp(forge, {
137137 username: "carol",
138138 email: "[email protected]",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs