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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3239-an-app-s-settings-page-draws-p75-load-pe → main 2 commits fork 2 threads · 2 unresolved
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, | ||
| 255 | 255 | |
| 256 | 256 | ### Every app's visitors |
| 257 | 257 | |
| 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. | |
| 259 | 259 | |
| 260 | 260 | 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. |
| 261 | 261 |
src/apps/vitals.ts +41
| @@ -48,6 +48,8 @@ export interface AppVitals { | ||
| 48 | 48 | readonly inpP75: number | undefined; |
| 49 | 49 | /** Layout shift is a unitless score, not a duration. */ |
| 50 | 50 | 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)[]; | |
| 51 | 53 | } |
| 52 | 54 | |
| 53 | 55 | const DAY = 24 * 60 * 60 * 1000; |
| @@ -84,9 +86,30 @@ export function appVitals(db: Database, appId: string, at: number = Date.now()): | ||
| 84 | 86 | lcpP75: p75(db, appId, "lcp", since), |
| 85 | 87 | inpP75: p75(db, appId, "inp", since), |
| 86 | 88 | clsP75: p75(db, appId, "cls", since), |
| 89 | loadDays: loadP75Days(db, appId, at), | |
| 87 | 90 | }; |
| 88 | 91 | } |
| 89 | 92 | |
| 93 | /** | |
| 94 | * Each day's p75 page load (nearest rank), oldest first, today last; a day | |
| 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 | ||
| 90 | 113 | /** A duration as a person reads it: "420 ms", "1.3 s", or a dash with no data. */ |
| 91 | 114 | export const speedText = (v: number | undefined): string => |
| 92 | 115 | 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 ? " | ||
| 97 | 120 | /** The one line the terminal shows. */ |
| 98 | 121 | export const visitorsLine = (v: AppVitals): string => |
| 99 | 122 | `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 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { appVisitors } from "../../actions/apps.ts"; |
| 11 | import { visitorsLine } from "../../apps/vitals.ts"; | |
| 11 | import { trendLine, visitorsLine } from "../../apps/vitals.ts"; | |
| 12 | 12 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 13 | 13 | import { carriedByRuns, carriesText } from "../../core/carried.ts"; |
| 14 | 14 | import type { Repo } from "../../core/repos.ts"; |
| @@ -40,6 +40,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 40 | 40 | let carries = new Map<string, number[]>(); |
| 41 | 41 | let loaded = false; |
| 42 | 42 | let visitors = ""; |
| 43 | let trend = ""; | |
| 43 | 44 | |
| 44 | 45 | const read = (): void => { |
| 45 | 46 | const runs = readDeploys(f, repo); |
| @@ -50,6 +51,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 50 | 51 | ); |
| 51 | 52 | const seen = appVisitors(f.app, f.user, repo); |
| 52 | 53 | visitors = seen === undefined ? "" : visitorsLine(seen); |
| 54 | trend = seen === undefined ? "" : trendLine(seen.loadDays); | |
| 53 | 55 | loaded = true; |
| 54 | 56 | ctx.invalidate(); |
| 55 | 57 | }; |
| @@ -66,7 +68,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 66 | 68 | load: async () => read(), |
| 67 | 69 | render(area, s) { |
| 68 | 70 | 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)]); | |
| 70 | 72 | s.text( |
| 71 | 73 | head!.x, |
| 72 | 74 | head!.y, |
| @@ -76,6 +78,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 76 | 78 | ); |
| 77 | 79 | if (visitors !== "") |
| 78 | 80 | 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); | |
| 79 | 83 | if (list.runs.length === 0) { |
| 80 | 84 | emptyState(s, body!, c, { |
| 81 | 85 | title: loaded ? "No deploys" : "Reading…", |
src/web/pages/settings.ts +12 −2
| @@ -80,6 +80,7 @@ import { | ||
| 80 | 80 | repoPalette, |
| 81 | 81 | settingsLayout, |
| 82 | 82 | } from "../chrome.ts"; |
| 83 | import { daySpark } from "../studio/kpi_row.ts"; | |
| 83 | 84 | import { settingsLead } from "../studio/crew.ts"; |
| 84 | 85 | import { pageTitle } from "../studio/nouns.ts"; |
| 85 | 86 | import { appHref } from "./apps.ts"; |
| @@ -630,7 +631,15 @@ const SCOPE_WORDS: Record<AppEnvScope, string> = { | ||
| 630 | 631 | }; |
| 631 | 632 | |
| 632 | 633 | /** 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 |
||
| 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 | ); | |
| 634 | 643 | return card( |
| 635 | 644 | html`<p class="muted small mb"> |
| 636 | 645 | Counted by the beacon the template ships, through the forge: no |
| @@ -645,7 +654,8 @@ function appVisitorsView(v: AppVitals): Html { | ||
| 645 | 654 | <div><dt>p75 LCP</dt><dd>${ms(v.lcpP75)}</dd></div> |
| 646 | 655 | <div><dt>p75 INP</dt><dd>${ms(v.inpP75)}</dd></div> |
| 647 | 656 | <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>`}`, | |
| 649 | 659 | { head: html`${icon("rocket")}<h2>Visitors and speed</h2>` }, |
| 650 | 660 | ); |
| 651 | 661 | } |
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", () => { | ||
| 149 | 149 | if (!bob.ok) throw new Error(bob.error); |
| 150 | 150 | expect(appVisitors(app, bob.value, repo)).toBeUndefined(); |
| 151 | 151 | expect(appVisitors(app, owner, repo)).toMatchObject({ today: 1 }); |
| 152 | expect(appVisitors(app, owner, repo)?.loadDays).toHaveLength(30); | |
| 152 | 153 | const get = (token?: string) => |
| 153 | 154 | fetch(`${servers.url}/api/v1/repos/alice/notes/app`, { |
| 154 | 155 | headers: token ? { authorization: `Bearer ${token}` } : {}, |
| @@ -160,6 +161,7 @@ describe("a report through the proxy", () => { | ||
| 160 | 161 | expect(await asBob.json()).not.toHaveProperty("vitals"); |
| 161 | 162 | const asAlice = await get(createToken(app.db, owner.id, "r", "ro").token); |
| 162 | 163 | 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); | |
| 163 | 165 | }); |
| 164 | 166 | |
| 165 | 167 | test("a sealed pull's preview refuses an anonymous report and stores nothing", async () => { |
new above
offline
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.