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

An app's settings page draws p75 load per day for 30 days, with gaps for silent days #3248

Merged opened this

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

Merged

scout/forge:scout/3239-an-app-s-settings-page-draws-p75-load-pe → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/3239-an-app-s-settings-page-draws-p75-load-pe was merged into main as 4ae0875.

Review

Sign in to review this change.
6 files changed +143 −5
README.md +1 −1
@@ -255,7 +255,7 @@ Every page links one static, immutable script (`src/web/beacon.ts`, under 2 KB,
255255
256256 ### Every app's visitors
257257
258 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.
258 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`. The page also draws p75 load for each of the last 30 days as a sparkline (server-rendered, no script); a day with no reports is a gap in the line, not a zero, and an app with none in 30 days says so in words. The same 30 values, oldest first with `null` for a silent day, are `vitals.loadDays` in the API. In the terminal all five are on the second line of the app's **Deploys** screen over ssh, and a third line there shows the 30 days as block characters with `·` for a silent day, then the range.
259259
260260 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.
261261
src/apps/vitals.ts +41
@@ -48,6 +48,8 @@ export interface AppVitals {
4848 readonly inpP75: number | undefined;
4949 /** Layout shift is a unitless score, not a duration. */
5050 readonly clsP75: number | undefined;
51 /** p75 load for each of the last 30 days, oldest first; null for a day with no reports. */
52 readonly loadDays: (number | null)[];
5153 }
5254
5355 const DAY = 24 * 60 * 60 * 1000;
@@ -84,9 +86,30 @@ export function appVitals(db: Database, appId: string, at: number = Date.now()):
8486 lcpP75: p75(db, appId, "lcp", since),
8587 inpP75: p75(db, appId, "inp", since),
8688 clsP75: p75(db, appId, "cls", since),
89 loadDays: loadP75Days(db, appId, at),
8790 };
8891 }
8992
93 /**
94 * Each day's p75 page load (nearest rank), oldest first, today last; a day
outdated

This is called only from the web page. Return it through appVisitors (admin-gated) so the API and the terminal's Deploys screen can show it too.

95 * with no load reports is `null`. One query on the (app_id, day) index.
96 */
97 export function loadP75Days(db: Database, appId: string, at: number = Date.now(), days = 30): (number | null)[] {
98 const first = dayOf(at - (days - 1) * DAY);
99 const rows = db
100 .query<{ day: string; v: number }, [string, string]>(
101 `SELECT day, load AS v FROM (
102 SELECT day, load,
103 ROW_NUMBER() OVER (PARTITION BY day ORDER BY load) AS rn,
104 COUNT(*) OVER (PARTITION BY day) AS n
105 FROM app_vital WHERE app_id = ? AND day >= ? AND load IS NOT NULL
106 ) WHERE rn = (3 * n + 3) / 4`,
107 )
108 .all(appId, first);
109 const byDay = new Map(rows.map((r) => [r.day, r.v]));
110 return Array.from({ length: days }, (_, i) => byDay.get(dayOf(at - (days - 1 - i) * DAY)) ?? null);
111 }
112
90113 /** A duration as a person reads it: "420 ms", "1.3 s", or a dash with no data. */
91114 export const speedText = (v: number | undefined): string =>
92115 v === undefined ? "–" : v < 1000 ? `${Math.round(v)} ms` : `${(v / 1000).toFixed(1)} s`;
@@ -97,3 +120,21 @@ export const shiftText = (v: number | undefined): string => (v === undefined ? "
97120 /** The one line the terminal shows. */
98121 export const visitorsLine = (v: AppVitals): string =>
99122 `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)}`;
123
124 /** Thirty days of load as blocks, oldest first, `·` for a silent day: "▁▃█·▂". Empty with no data. */
125 export function loadTrend(days: readonly (number | null)[]): string {
126 const known = days.filter((v): v is number => v !== null);
127 if (known.length === 0) return "";
128 const top = Math.max(...known);
129 const blocks = "▁▂▃▄▅▆▇█";
130 return days
131 .map((v) => (v === null ? "·" : blocks[Math.min(7, Math.floor((v / Math.max(1, top)) * 8))]))
132 .join("");
133 }
134
135 /** The terminal's line under the figures: the trend and its range, or nothing with no data. */
136 export function trendLine(days: readonly (number | null)[]): string {
137 const known = days.filter((v): v is number => v !== null);
138 if (known.length === 0) return "";
139 return `p75 load, 30 days ${loadTrend(days)} ${speedText(Math.min(...known))}–${speedText(Math.max(...known))}`;
140 }
src/tui/screens/deploys.ts +6 −2
@@ -8,7 +8,7 @@
88 */
99
1010 import { appVisitors } from "../../actions/apps.ts";
11 import { visitorsLine } from "../../apps/vitals.ts";
11 import { trendLine, visitorsLine } from "../../apps/vitals.ts";
1212 import { listRepoRuns } from "../../actions/runs.ts";
1313 import { carriedByRuns, carriesText } from "../../core/carried.ts";
1414 import type { Repo } from "../../core/repos.ts";
@@ -40,6 +40,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4040 let carries = new Map<string, number[]>();
4141 let loaded = false;
4242 let visitors = "";
43 let trend = "";
4344
4445 const read = (): void => {
4546 const runs = readDeploys(f, repo);
@@ -50,6 +51,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5051 );
5152 const seen = appVisitors(f.app, f.user, repo);
5253 visitors = seen === undefined ? "" : visitorsLine(seen);
54 trend = seen === undefined ? "" : trendLine(seen.loadDays);
5355 loaded = true;
5456 ctx.invalidate();
5557 };
@@ -66,7 +68,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
6668 load: async () => read(),
6769 render(area, s) {
6870 const c = ctx.theme.c;
69 const [head, body] = split(area, "v", [len(visitors === "" ? 1 : 2), fill(1)]);
71 const [head, body] = split(area, "v", [len(visitors === "" ? 1 : trend === "" ? 2 : 3), fill(1)]);
7072 s.text(
7173 head!.x,
7274 head!.y,
@@ -76,6 +78,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
7678 );
7779 if (visitors !== "")
7880 s.text(head!.x, head!.y + 1, truncate(visitors, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
81 if (trend !== "")
82 s.text(head!.x, head!.y + 2, truncate(trend, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
7983 if (list.runs.length === 0) {
8084 emptyState(s, body!, c, {
8185 title: loaded ? "No deploys" : "Reading…",
src/web/pages/settings.ts +12 −2
@@ -80,6 +80,7 @@ import {
8080 repoPalette,
8181 settingsLayout,
8282 } from "../chrome.ts";
83 import { daySpark } from "../studio/kpi_row.ts";
8384 import { settingsLead } from "../studio/crew.ts";
8485 import { pageTitle } from "../studio/nouns.ts";
8586 import { appHref } from "./apps.ts";
@@ -630,7 +631,15 @@ const SCOPE_WORDS: Record<AppEnvScope, string> = {
630631 };
631632
632633 /** Production's visitors and the speed they saw, from the app's own beacon. */
633 function appVisitorsView(v: AppVitals): Html {
634 export function appVisitorsView(v: AppVitals): Html {
635 const known = v.loadDays.filter((n): n is number => n !== null);
636 const latest = v.loadDays.findLast((n) => n !== null) ?? null;
outdated

The label carries no values. Build it from ms over the last non-null day and the min and max, so a screen reader hears the trend and not just that a chart exists.

637 const spark = daySpark(
638 v.loadDays,
639 known.length === 0
640 ? "p75 load each day over the last 30 days"
641 : `p75 load each day over the last 30 days, latest ${ms(latest ?? undefined)}, range ${ms(Math.min(...known))} to ${ms(Math.max(...known))}`,
642 );
634643 return card(
635644 html`<p class="muted small mb">
636645 Counted by the beacon the template ships, through the forge: no
@@ -645,7 +654,8 @@ function appVisitorsView(v: AppVitals): Html {
645654 <div><dt>p75 LCP</dt><dd>${ms(v.lcpP75)}</dd></div>
646655 <div><dt>p75 INP</dt><dd>${ms(v.inpP75)}</dd></div>
647656 <div><dt>p75 CLS</dt><dd>${shiftText(v.clsP75)}</dd></div>
648 </dl>`,
657 </dl>
658 ${spark ?? html`<p class="muted small mt">No load reports in the last 30 days</p>`}`,
649659 { head: html`${icon("rocket")}<h2>Visitors and speed</h2>` },
650660 );
651661 }
test/app-vitals-days.test.ts added +81
@@ -0,0 +1,81 @@
1 /**
2 * An app's p75 load per day (#3239): oldest first, `null` for a silent day,
3 * drawn with a gap on the settings page and in words when there is nothing.
4 */
5 import { expect, test } from "bun:test";
6 import { loadP75Days, loadTrend, trendLine, appVitals } from "../src/apps/vitals.ts";
7 import { migrations } from "../src/core/schema.ts";
8 import { openDb } from "../src/kernel/db.ts";
9 import { appVisitorsView } from "../src/web/pages/settings.ts";
10 import type { AppVitals } from "../src/apps/vitals.ts";
11
12 const NOW = Date.parse("2026-10-20T12:00:00Z");
13 const DAY = 86_400_000;
14 const none: AppVitals = { today: 0, week: 0, ttfbP75: undefined, loadP75: undefined, lcpP75: undefined, inpP75: undefined, clsP75: undefined, loadDays: [] };
15
16 function seeded() {
17 const db = openDb(":memory:", migrations);
18 db.run("INSERT INTO user (id, username, email, password_hash) VALUES ('u1','u','u@x','x')");
19 db.run("INSERT INTO repo (id, owner_id, name) VALUES ('r1','u1','one')");
20 db.run("INSERT INTO app (id, repo_id, slug, access) VALUES ('a1','r1','one','public')");
21 const put = db.prepare("INSERT INTO app_vital (app_id, day, at, visitor, load) VALUES (?, ?, 0, ?, ?)");
22 // Days 1, 2 and 4 of a 4 day window; day 3 is silent.
23 const day = (back: number) => new Date(NOW - back * DAY).toISOString().slice(0, 10);
24 [100, 200, 300, 400].forEach((l, i) => put.run("a1", day(3), `v${i}`, l));
25 put.run("a1", day(2), "v0", 50);
26 put.run("a1", day(0), "v0", 80);
27 put.run("a1", day(0), "v1", 120);
28 put.run("other", day(1), "v0", 999);
29 return db;
30 }
31
32 test("loadP75Days is oldest first with null for the silent day", () => {
33 const db = seeded();
34 expect(loadP75Days(db, "a1", NOW, 4)).toEqual([300, 50, null, 120]);
35 const thirty = loadP75Days(db, "a1", NOW);
36 expect(thirty).toHaveLength(30);
37 expect(thirty.slice(26)).toEqual([300, 50, null, 120]);
38 expect(thirty.slice(0, 26).every((v) => v === null)).toBe(true);
39 });
40
41 test("the settings card breaks the line at the silent day", () => {
42 const db = seeded();
43 const out = String(appVisitorsView({ ...none, loadDays: loadP75Days(db, "a1", NOW, 4) }));
44 expect(out).toContain("p75 load each day over the last 30 days");
45 expect(out.match(/<polyline|<circle/g)).toHaveLength(2);
46 expect(out).not.toContain("No load reports");
47 });
48
49 test("no reports in 30 days says so in words, with no svg", () => {
50 const out = String(appVisitorsView({ ...none, loadDays: loadP75Days(seeded(), "nobody", NOW) }));
51 expect(out).toContain("No load reports in the last 30 days");
52 expect(out).not.toContain("kpi-spark");
53 });
54
55 test("the day query uses the app/day index", () => {
56 const db = seeded();
57 const plan = db
58 .query("EXPLAIN QUERY PLAN SELECT day, load FROM app_vital WHERE app_id = ? AND day >= ? AND load IS NOT NULL")
59 .all("a1", "2026-09-01")
60 .map((r) => JSON.stringify(r))
61 .join(" ");
62 expect(plan).toContain("app_vital_app_day");
63 });
64
65 test("the series rides on appVitals, so the API and terminal carry it", () => {
66 const v = appVitals(seeded(), "a1", NOW);
67 expect(v.loadDays).toHaveLength(30);
68 expect(v.loadDays.slice(26)).toEqual([300, 50, null, 120]);
69 });
70
71 test("the label names the latest day and the range", () => {
72 const out = String(appVisitorsView({ ...none, loadDays: [800, null, 2100, 1200, null] }));
73 expect(out).toContain("latest 1.2 s, range 800 ms to 2.1 s");
74 });
75
76 test("the terminal trend is blocks with a dot for a silent day", () => {
77 expect(loadTrend([100, null, 800])).toBe("▂·█");
78 expect(loadTrend([null, null])).toBe("");
79 expect(trendLine([800, null, 2100])).toContain("800 ms–2.1 s");
80 expect(trendLine([null])).toBe("");
81 });
test/apps-measure.test.ts +2
@@ -149,6 +149,7 @@ describe("a report through the proxy", () => {
149149 if (!bob.ok) throw new Error(bob.error);
150150 expect(appVisitors(app, bob.value, repo)).toBeUndefined();
151151 expect(appVisitors(app, owner, repo)).toMatchObject({ today: 1 });
152 expect(appVisitors(app, owner, repo)?.loadDays).toHaveLength(30);
152153 const get = (token?: string) =>
153154 fetch(`${servers.url}/api/v1/repos/alice/notes/app`, {
154155 headers: token ? { authorization: `Bearer ${token}` } : {},
@@ -160,6 +161,7 @@ describe("a report through the proxy", () => {
160161 expect(await asBob.json()).not.toHaveProperty("vitals");
161162 const asAlice = await get(createToken(app.db, owner.id, "r", "ro").token);
162163 expect(await asAlice.json()).toMatchObject({ vitals: { today: 1 } });
164 expect(((await (await get(createToken(app.db, owner.id, "r2", "ro").token)).json()) as { vitals: { loadDays: unknown[] } }).vitals.loadDays).toHaveLength(30);
163165 });
164166
165167 test("a sealed pull's preview refuses an anonymous report and stores nothing", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs