Public: anyone can read this; the crew can change it.

Boards can chart each app's p75 load and INP per day from an app_vital source #3214

Merged opened this

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

Merged

scout/forge:scout/3201-boards-can-chart-each-app-s-p75-load-and → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/3201-boards-can-chart-each-app-s-p75-load-and was merged into main as def2db4.

Review

Sign in to review this change.
3 files changed +91 −0
README.md +2
@@ -247,6 +247,8 @@ Every page links one static, immutable script (`src/web/beacon.ts`, under 2 KB,
247247
248248 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.
249249
250 The `app_vital` board source (`src/boards/sources.ts`) charts those reports: one row per app per day with `app` (the app's id), `day`, `load_p75`, `inp_p75` (ms) and `reports` (rows kept that day), the percentiles nearest-rank per app per day as `reach` does. `max` or `avg` over several days summarises daily percentiles, not the percentile of the whole range. Its rows belong to the app's repository, so a query sees only the repos in its scope. Today boards query with `repoIds: "all"` (`src/web/pages/boards.ts`) and only a site admin reaches them; no non-admin scope exists, so nothing yet applies the settings page's `canAdmin` gate per repository, and whoever builds one must. Days older than the 90-day trim are gone, not zero. The slug and the visitor hash are not columns. No dependency was added. Left out: a starting board for it; pick `app_vital` in the `/boards` source picker.
251
250252 ## Live in the terminal
251253
252254 `ssh` into the forge and you get the whole thing — repositories, code, issues,
src/boards/sources.ts +27
@@ -252,6 +252,33 @@ export const SOURCES: Readonly<Record<string, Source>> = {
252252 value: "the count, or the percentile (nearest rank) of that route that day",
253253 },
254254 },
255 // What each app's visitors report (`apps/vitals.ts`): one row per app per
256 // day, filed under the app's repo, so a scope of the repos someone
257 // administers (as the app's settings card decides) is the only gate. The
258 // visitor hash and the slug are not listed.
259 app_vital: {
260 table: "app_vital_day",
261 owner: "repo_id",
262 select:
263 "SELECT a.repo_id AS repo_id, v.app_id AS app, v.day || 'T00:00:00.000Z' AS day, " +
264 "MAX(CASE WHEN v.load IS NOT NULL AND v.rl = (v.nl * 75 + 99) / 100 THEN v.load END) AS load_p75, " +
265 "MAX(CASE WHEN v.inp IS NOT NULL AND v.ri = (v.ni * 75 + 99) / 100 THEN v.inp END) AS inp_p75, " +
266 "COUNT(*) AS reports FROM (SELECT app_id, day, load, inp, " +
267 "ROW_NUMBER() OVER (PARTITION BY app_id, day ORDER BY load IS NULL, load) AS rl, " +
268 "COUNT(load) OVER (PARTITION BY app_id, day) AS nl, " +
269 "ROW_NUMBER() OVER (PARTITION BY app_id, day ORDER BY inp IS NULL, inp) AS ri, " +
270 "COUNT(inp) OVER (PARTITION BY app_id, day) AS ni FROM app_vital) v " +
271 "JOIN app a ON a.id = v.app_id GROUP BY v.app_id, v.day",
272 time: "day",

The inner SELECT ranks all of app_vital before the scope and from/to filters see it. Push the day bound down, or cap the window scan, so a one-app scope does not pay for the whole table.

273 columns: { repo_id: "text", app: "text", day: "time", load_p75: "number", inp_p75: "number", reports: "number" },
274 docs: {
275 app: "the app's id",
276 day: "the day (UTC)",
277 load_p75: "page load, ms, nearest-rank 75th percentile of that day's reports; null with none",
278 inp_p75: "interaction to next paint, ms, same rank; null with none",
279 reports: "reports kept that day (production only)",
280 },
281 },
255282 // Forges made by the one-click path (#2914): one row per spawn event
256283 // (`actions/spawn.ts`, `spawn/live.ts`). The forge's name and the failure's
257284 // words are not listed; the step is one of a fixed few.
test/boards-app-vital.test.ts added +62
@@ -0,0 +1,62 @@
1 /**
2 * The `app_vital` source: each app's p75 load and INP per day, nearest rank,
3 * visible only through the repos a scope names.
4 */
5 import { beforeAll, expect, test } from "bun:test";
6 import type { Database } from "bun:sqlite";
7 import { runQuery, type Scope } from "../src/boards/query.ts";
8 import { migrations } from "../src/core/schema.ts";
9 import { openDb } from "../src/kernel/db.ts";
10
11 let db: Database;
12 const q = (scope: Scope, extra: Record<string, unknown> = {}) =>
13 runQuery(db, { source: "app_vital", aggregate: "max", column: "load_p75", groupBy: "day", series: "app", ...extra }, scope);
14
15 beforeAll(() => {
16 db = openDb(":memory:", migrations);
17 db.run("INSERT INTO user (id, username, email, password_hash) VALUES ('u1','u','u@x','x'), ('u2','w','w@x','x')");
18 db.run("INSERT INTO repo (id, owner_id, name) VALUES ('r1','u1','one'), ('r2','u2','two')");
19 db.run("INSERT INTO app (id, repo_id, slug, access) VALUES ('a1','r1','one','public'), ('a2','r2','two','public')");
20 const put = db.prepare("INSERT INTO app_vital (app_id, day, at, visitor, load, inp) VALUES (?, ?, 0, ?, ?, ?)");
21 // a1, day 1: loads 100..400 -> rank ceil(.75*4)=3 -> 300; inp 10,20 -> rank 2 -> 20.
22 [100, 200, 300, 400].forEach((l, i) => put.run("a1", "2026-10-01", `v${i}`, l, i < 2 ? (i + 1) * 10 : null));
23 // a1, day 2: one report, no inp.
24 put.run("a1", "2026-10-02", "v0", 50, null);
25 // a2, day 1: five loads -> rank ceil(3.75)=4 -> 40; inp 5.
26 [50, 10, 20, 30, 40].forEach((l, i) => put.run("a2", "2026-10-01", `w${i}`, l, i === 0 ? 5 : null));
27 });
28
29 test("each app's p75 per day is the nearest rank", () => {
30 const rows = runQuery(db, { source: "app_vital", aggregate: "max", column: "load_p75", groupBy: "day", series: "app" }, { repoIds: "all" });
31 expect(rows).toEqual({
32 ok: true,
33 rows: [
34 { group: "2026-10-01T00:00:00.000Z", value: 300, series: "a1" },
35 { group: "2026-10-01T00:00:00.000Z", value: 40, series: "a2" },
36 { group: "2026-10-02T00:00:00.000Z", value: 50, series: "a1" },
37 ],
38 });
39 const inp = q({ repoIds: "all" }, { column: "inp_p75" });
40 expect(inp).toEqual({
41 ok: true,
42 rows: [
43 { group: "2026-10-01T00:00:00.000Z", value: 20, series: "a1" },
44 { group: "2026-10-01T00:00:00.000Z", value: 5, series: "a2" },
45 { group: "2026-10-02T00:00:00.000Z", value: null, series: "a1" },
46 ],
47 });
48 const n = q({ repoIds: "all" }, { aggregate: "sum", column: "reports" });
49 expect(n.ok && n.rows.map((r) => r.value)).toEqual([4, 5, 1]);
50 });
51
52 test("a scope sees only its own repos' apps, and a stranger none", () => {
53 const mine = q({ repoIds: ["r2"] });
54 expect(mine).toEqual({ ok: true, rows: [{ group: "2026-10-01T00:00:00.000Z", value: 40, series: "a2" }] });
55 expect(q({ repoIds: [] })).toEqual({ ok: true, rows: [] });
56 expect(q({ repoIds: ["r9"] })).toEqual({ ok: true, rows: [] });
57 });
58
59 test("from and to bound the days", () => {
60 const r = q({ repoIds: "all" }, { from: "2026-10-02T00:00:00Z" });
61 expect(r.ok && r.rows.length).toBe(1);
62 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs