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

Every app made from the template measures its visitors and page speed from its first deploy #2792

Merged opened this

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

Merged

scout/forge:scout/2714-every-app-made-from-the-template-measure → main 8 commits fork 12 threads · 12 unresolved

  • Merged. scout/2714-every-app-made-from-the-template-measure was merged into main as e143db6.

Review

Sign in to review this change.
14 files changed +533 −7
README.md +4
@@ -232,6 +232,10 @@ form that mints one and shows its secret once; others see no section.
232232
233233 Every page links one static, immutable script (`src/web/beacon.ts`, under 2 KB, no dependency) that sends, once per page view with `sendBeacon`, the route's path, TTFB, load time, LCP, INP (the worst interaction) and CLS to `POST /_measure`. The route is same-origin only, validates every field, caps the body at 512 bytes and the rate per client, and answers 204. Reports land in `page_vital` (bun:sqlite) keyed by route *pattern*; no IP and no user agent string is stored. A unique visitor is a hash under a salt that rotates daily (yesterday's salt is deleted), rows are kept 90 days. Do-Not-Track and the `forge_measure=off` cookie send nothing, and the server stores nothing for them either. The CSP is unchanged: the script is nonce'd like the shell's.
234234
235 ### Every app's visitors
236
outdated

"The terminal has no screen for them: it reads the same JSON through the API." Both doors means src/tui. Add the numbers to the app's terminal screen (deploys.ts is the natural place), then change this sentence to say how to reach them over ssh.

237 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.
238
235239 ## Live in the terminal
236240
237241 `ssh` into the forge and you get the whole thing — repositories, code, issues,
src/actions/apps.ts +12 −1
@@ -12,6 +12,7 @@ import { mayView } from "../core/sealed.ts";
1212 import { requireVerified } from "./verify.ts";
1313 import { requireAppRoom } from "./app_limit.ts";
1414 import type { App } from "../app.ts";
15 import { appVitals, type AppVitals } from "../apps/vitals.ts";
1516 import { appsRuntime, envsFor, type EnvStatus } from "../apps/runtime.ts";
1617 import { grantUrl, originFor } from "../apps/proxy.ts";
1718 import { scaffoldFiles } from "../apps/scaffold.ts";
@@ -47,7 +48,7 @@ import { listAllAgents } from "./crew.ts";
4748 import { plan, planIssueBody, planningAvailable } from "../crew/plan.ts";
4849 import type { Issue } from "../core/issues.ts";
4950 import { getPull } from "../core/pulls.ts";
50 import { canEnterApp, canWrite, getRepo, type Repo } from "../core/repos.ts";
51 import { canAdmin, canEnterApp, canWrite, getRepo, type Repo } from "../core/repos.ts";
5152 import type { Run } from "../core/runs.ts";
5253 import { getUserByName, type User } from "../core/users.ts";
5354 import { resolveOid } from "../git/merge.ts";
@@ -480,6 +481,16 @@ export function appView(
480481 });
481482 }
482483
484 /**
485 * An app's visitors and speed, for those who administer its repository: the
486 * same gate as the settings card, so every door shows them to the same people.
487 */
488 export function appVisitors(app: App, actor: User | undefined, repo: Repo): AppVitals | undefined {
489 if (!canAdmin(app.db, repo, actor)) return undefined;
490 const record = getAppByRepo(app.db, repo.id);
491 return record === undefined ? undefined : appVitals(app.db, record.id);
492 }
493
483494 export type EnvError = "not_found" | "no_runtime" | "no_such_env";
484495
485496 /** The URL to open an environment at — via a grant for a private app. */
src/apps/measure.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * `POST /__forge/measure` on an app's own host: the door the template's
3 * beacon posts to. The forge answers it, so the app holds no secret and
4 * the report is filed against the app whose host it arrived at, never one
5 * named in the body. Same-origin only, opted-out visitors stored nothing,
6 * rate-limited per client and per app, a body of a few hundred bytes at
7 * most, every field validated (`web/measure.ts`). Always an empty 204 or a
8 * short refusal.
9 */
10
11 import type { App } from "../app.ts";
12 import { RateLimiter } from "../core/ratelimit.ts";
13 import { readBounded } from "../http/limits.ts";
14 import { clientAddress, clientBucket } from "../http/peer.ts";
15 import { optedOut, parseReport, REPORT_MAX } from "../web/measure.ts";
16 import type { AppTarget } from "./proxy.ts";
17 import { recordAppVital } from "./vitals.ts";
18
19 const PER_CLIENT = 30;
20 const PER_APP = 600;
21
22 const limiter = (perMinute: number) =>
23 new RateLimiter({ perMinute, adminPerMinute: perMinute, enabled: true });
24
25 const limits = new WeakMap<App, { client: RateLimiter; apps: Map<string, RateLimiter> }>();
26
27 const refuse = (status: number, headers?: Record<string, string>): Response =>
28 new Response(null, { status, headers: { "cache-control": "no-store", ...headers } });
29
30 /** The page's own origin: the request's Origin names the host it was sent to. */
31 function sameOrigin(request: Request): boolean {
32 const origin = request.headers.get("origin");
33 const host = request.headers.get("host");
34 if (origin === null || host === null) return false;
35 try {
36 return new URL(origin).host === host;
37 } catch {
38 return false;
39 }
40 }
41
42 export async function appMeasure(
43 app: App,
44 request: Request,
45 target: AppTarget,
46 ): Promise<Response> {
47 if (request.method !== "POST") return refuse(405, { allow: "POST" });
48 if (!sameOrigin(request)) return refuse(403);
49 if (optedOut(request)) return refuse(204);
50
51 let pair = limits.get(app);
52 if (pair === undefined) {
53 pair = { client: limiter(PER_CLIENT), apps: new Map() };
54 limits.set(app, pair);
55 }
56 let forApp = pair.apps.get(target.app.id);
57 if (forApp === undefined) {
58 forApp = limiter(PER_APP);
59 pair.apps.set(target.app.id, forApp);
60 }
61 for (const verdict of [
62 pair.client.take(`${target.app.id}:${clientBucket(request)}`, "ro"),
63 forApp.take("app", "ro"),
64 ]) {
65 if (!verdict.allowed)
66 return refuse(429, { "retry-after": String(Math.max(1, verdict.retryAfter)) });
67 }
68
69 // Previews are drafts and the card counts production only: store nothing.
70 if (target.env !== "production") return refuse(204);
71
72 const bytes = await readBounded(request, REPORT_MAX);
73 if (bytes === "too_large") return refuse(413);
74 const report = parseReport(new TextDecoder().decode(bytes));
75 if (report === "malformed") return refuse(400);
76 recordAppVital(
77 app.db,
78 target.app.id,
79 report,
80 clientAddress(request),
81 request.headers.get("user-agent") ?? "",
82 );
83 return refuse(204);
84 }
src/apps/proxy.ts +11
@@ -35,6 +35,7 @@ import { loadKey } from "../kernel/secret.ts";
3535 import { previewEnv, type EnvName } from "./data.ts";
3636 import type { AppsRuntime, EnvStatus } from "./runtime.ts";
3737 import { crewNow, tellLink } from "./crew.ts";
38 import { appMeasure } from "./measure.ts";
3839 import { rewritable, withToolbar, type PillWhere } from "./toolbar.ts";
3940
4041 export interface AppTarget {
@@ -588,6 +589,16 @@ export async function proxyApp(
588589 });
589590 }
590591 }
592 // The beacon's report: filed against this app, never waking it. A private
593 // app (or a sealed pull's preview) admits only the people it admits, so
594 // it keeps its visitors to itself.
595 if (url.pathname === "/__forge/measure" && !target.nested) {
596 if (target.app.access === "private" || sealedPull(runtime, target) !== undefined) {
597 const admitted = admit(runtime, request, target);
598 if (!admitted.ok) return admitted.error;
599 }
600 return appMeasure(runtime.app, request, target);
601 }
591602 // An environment asleep wakes for the request that wants it, and the
592603 // request waits the second that takes rather than seeing a closed door.
593604 if (runtime.envStatus(target.app, target.env)?.state === "asleep")
src/apps/scaffold.ts +10
@@ -14,6 +14,7 @@
1414
1515 import { readFileSync } from "node:fs";
1616 import type { FileWrite } from "../git/write.ts";
17 import { beaconJs } from "../web/beacon.ts";
1718
1819 /**
1920 * The evaluator an app decides with is the forge's own, carried as text:
@@ -25,6 +26,12 @@ const CAN_TS = readFileSync(
2526 "utf8",
2627 );
2728
29 /**
30 * The forge's own page-view beacon, posting to the door the forge proxy
31 * answers on the app's host: the app holds no secret and keeps no report.
32 */
33 const BEACON_JS = beaconJs.replace("/_measure", "/__forge/measure") + "\n";
34
2835 export interface ScaffoldInput {
2936 /** The repository name; also the title on the page. */
3037 readonly name: string;
@@ -69,6 +76,7 @@ export function scaffoldFiles(input: ScaffoldInput): FileWrite[] {
6976 { path: "scripts/contract-emit.ts", content: CONTRACT_EMIT_TS },
7077 { path: "static/app.css", content: APP_CSS },
7178 { path: "static/live.js", content: LIVE_JS },
79 { path: "static/beacon.js", content: BEACON_JS },
7280 { path: "src/who.test.ts", content: WHO_TEST },
7381 { path: "src/signin.test.ts", content: SIGNIN_TEST },
7482 { path: "src/db.test.ts", content: DB_TEST },
@@ -167,6 +175,7 @@ decide nothing themselves. \`AGENTS.md\` says how to add to it.
167175 | \`src/tty.ts\` | the terminal, over the same actions |
168176 | \`src/env.ts\` | what the forge told this process |
169177 | \`static/app.css\`, \`static/live.js\` | the look in the tokens' words, and the list that stays current |
178 | \`static/beacon.js\` | reports each page's load and INP to the forge, which counts visitors: no secret, no address kept |
170179
outdated

Two table rows for static/beacon.js, with different wording and misaligned columns. Delete one; every app made from the template would ship this duplicate in its README.

171180 ## What every app stands on
172181
@@ -1702,6 +1711,7 @@ export function layout(body: string, who: Who, path = "/"): string {
17021711 <meta name="twitter:description" content="\${escape(description)}">
17031712 <link rel="stylesheet" href="\${tokensHref}">
17041713 <link rel="stylesheet" href="\${asset("app.css")}">
1714 <script defer src="\${asset("beacon.js")}"></script>
17051715 <script type="module" src="\${asset("live.js")}"></script>
outdated

A classic script without defer blocks the first paint. Use <script defer src=...>, or make it type=module like live.js, so measuring never delays what it measures.

17061716 <body>
17071717 <div class="page">
src/apps/vitals.ts added +89
@@ -0,0 +1,89 @@
1 /**
2 * What an app's visitors report about it, kept against that app. The
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.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { sha256 } from "../kernel/hash.ts";
10 import { dayOf, DAY_MAX, saltFor, type Report } from "../web/measure.ts";
11
12 const KEEP_MS = 90 * 24 * 60 * 60 * 1000;
13
14 export function recordAppVital(
15 db: Database,
16 appId: string,
17 report: Report,
18 client: string,
19 agent: string,
20 at: number = Date.now(),
21 dayMax: number = DAY_MAX,
22 ): boolean {
23 const day = dayOf(at);
24 db.run("DELETE FROM app_vital WHERE at < ?", [at - KEEP_MS]);
25 const held = db
26 .query<{ n: number }, [string, string]>(
outdated

This DELETE runs on every report and has no index on at. Index it, or prune on a schedule.

27 "SELECT COUNT(*) AS n FROM app_vital WHERE app_id = ? AND day = ?",
28 )
29 .get(appId, day);
30 if ((held?.n ?? 0) >= dayMax) return false;
31 const visitor = sha256(`${saltFor(db, at)}\0${appId}\0${client}\0${agent}`).slice(0, 32);
32 db.run(
outdated

High: nothing ever deletes from app_vital. recordPageVital prunes page_vital WHERE at < at - KEEP_MS on every insert. Do the same here, and remove an app's rows when the app is deleted (deleteApp in src/core/apps.ts only deletes the app row). Add a test that a 91-day-old row is gone after the next record.

33 "INSERT INTO app_vital (app_id, day, at, visitor, load, inp) VALUES (?, ?, ?, ?, ?, ?)",
34 [appId, day, at, visitor, report.load, report.inp],
35 );
36 return true;
37 }
38
39 export interface AppVitals {
40 /** Distinct visitors today. */
41 readonly today: number;
42 /** The sum of each of the last 7 days' distinct visitors (a visitor cannot be followed across days). */
43 readonly week: number;
44 /** 75th percentile page load, ms, over 7 days; undefined with no data. */
45 readonly loadP75: number | undefined;
46 readonly inpP75: number | undefined;
47 }
48
49 const DAY = 24 * 60 * 60 * 1000;
50
51 function p75(db: Database, appId: string, column: "load" | "inp", since: string): number | undefined {
52 const n =
53 db
54 .query<{ n: number }, [string, string]>(
55 `SELECT COUNT(${column}) AS n FROM app_vital WHERE app_id = ? AND day >= ?`,
56 )
57 .get(appId, since)?.n ?? 0;
58 if (n === 0) return undefined;
59 return db
60 .query<{ v: number }, [string, string, number]>(
61 `SELECT ${column} AS v FROM app_vital WHERE app_id = ? AND day >= ? AND ${column} IS NOT NULL ORDER BY ${column} LIMIT 1 OFFSET ?`,
62 )
63 .get(appId, since, Math.ceil(0.75 * n) - 1)?.v;
64 }
65
66 /** Production's visitors and speed (a preview's report is never stored). */
67 export function appVitals(db: Database, appId: string, at: number = Date.now()): AppVitals {
68 const since = dayOf(at - 6 * DAY);
69 const count = (from: string): number =>
outdated

Previews are stored but never read, and the card counts production only. Either skip storing non-production rows or show them; as it stands they are dead data. Also add a test that a pull-N row is excluded from appVitals.

70 db
71 .query<{ n: number }, [string, string]>(
72 "SELECT COUNT(*) AS n FROM (SELECT DISTINCT day, visitor FROM app_vital WHERE app_id = ? AND day >= ?)",
73 )
74 .get(appId, from)?.n ?? 0;
75 return {
76 today: count(dayOf(at)),
77 week: count(since),
78 loadP75: p75(db, appId, "load", since),
79 inpP75: p75(db, appId, "inp", since),
80 };
81 }
82
83 /** A duration as a person reads it: "420 ms", "1.3 s", or a dash with no data. */
84 export const speedText = (v: number | undefined): string =>
85 v === undefined ? "–" : v < 1000 ? `${Math.round(v)} ms` : `${(v / 1000).toFixed(1)} s`;
86
87 /** The one line the terminal shows. */
88 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)}`;
src/core/apps.ts +1
@@ -219,5 +219,6 @@ export function updateApp(
219219 }
220220
221221 export function deleteApp(db: Database, appId: string): boolean {
222 db.run("DELETE FROM app_vital WHERE app_id = ?1", [appId]);
222223 return db.query("DELETE FROM app WHERE id = ?1").run(appId).changes > 0;
223224 }
src/core/migrations/20261019160000-app-vitals.ts added +19
@@ -0,0 +1,19 @@
1 // Every app measures its own visitors and page speed from its first deploy
2 // (#2714). The same report the forge's own pages send (`page_vital`), kept
3 // apart and filed under the app it came through, so the forge's numbers
4 // and an app's never mix. `visitor` is hashed under the day's rotating salt
5 // and the app's id; no address is kept. A row is dropped after 90 days (as `page_vital`'s are)
outdated

This header says what is kept but not for how long. page_vital says 90 days. Say it here and in the README once the prune exists.

6 // and with its app. Production only: a preview's report is answered and dropped.
7 export default `
8 CREATE TABLE app_vital (
9 id INTEGER PRIMARY KEY,
10 app_id TEXT NOT NULL,
11 day TEXT NOT NULL,
12 at INTEGER NOT NULL,
13 visitor TEXT NOT NULL,
14 load REAL,
15 inp REAL
16 );
17 CREATE INDEX app_vital_app_day ON app_vital (app_id, day);
18 CREATE INDEX app_vital_at ON app_vital (at);
outdated

Add CREATE INDEX app_vital_at ON app_vital (at); — the per-insert prune in recordAppVital scans the whole table without it.

19 `;
src/http/api.ts +6 −3
@@ -246,6 +246,7 @@ import { isSafeRef, isSafeTreePath } from "../git/guard.ts";
246246 import { listBranches } from "../git/branches.ts";
247247 import {
248248 appView,
249 appVisitors,
249250 deleteAppEnvVar,
250251 disableApp,
251252 enableApp,
@@ -4936,9 +4937,11 @@ export function apiRoutes(router: Router<Handler>): void {
49364937 if (!repo) return notFound();
49374938 const view = appView(ctx.app, ctx.auth.user, repo);
49384939 if (!view.ok) return notFound();
4939 return json(
4940 appJson(ctx, view.value.app, repo, appsRuntime(ctx.app)),
4941 );
4940 const vitals = appVisitors(ctx.app, ctx.auth.user, repo);
4941 return json({
4942 ...appJson(ctx, view.value.app, repo, appsRuntime(ctx.app)),
4943 ...(vitals === undefined ? {} : { vitals }),
4944 });
49424945 });
49434946
49444947 router.add("POST", "/api/v1/repos/:owner/:name/app", async (ctx) => {
src/tui/screens/deploys.ts +8 −1
@@ -7,6 +7,8 @@
77 * joined to `deploy_pull` in one grouped read for `carries #3 #5`.
88 */
99
10 import { appVisitors } from "../../actions/apps.ts";
11 import { visitorsLine } from "../../apps/vitals.ts";
1012 import { listRepoRuns } from "../../actions/runs.ts";
1113 import { carriedByRuns, carriesText } from "../../core/carried.ts";
1214 import type { Repo } from "../../core/repos.ts";
@@ -37,6 +39,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
3739 const list = new RunList(f);
3840 let carries = new Map<string, number[]>();
3941 let loaded = false;
42 let visitors = "";
4043
4144 const read = (): void => {
4245 const runs = readDeploys(f, repo);
@@ -45,6 +48,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4548 f.db,
4649 runs.map((r) => r.id),
4750 );
51 const seen = appVisitors(f.app, f.user, repo);
52 visitors = seen === undefined ? "" : visitorsLine(seen);
4853 loaded = true;
4954 ctx.invalidate();
5055 };
@@ -61,7 +66,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
6166 load: async () => read(),
6267 render(area, s) {
6368 const c = ctx.theme.c;
64 const [head, body] = split(area, "v", [len(1), fill(1)]);
69 const [head, body] = split(area, "v", [len(visitors === "" ? 1 : 2), fill(1)]);
6570 s.text(
6671 head!.x,
6772 head!.y,
@@ -69,6 +74,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
6974 style(c.mutedFg, c.bg),
7075 head!.x + head!.w,
7176 );
77 if (visitors !== "")
78 s.text(head!.x, head!.y + 1, truncate(visitors, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
7279 if (list.runs.length === 0) {
7380 emptyState(s, body!, c, {
7481 title: loaded ? "No deploys" : "Reading…",
src/web/measure.ts +2 −2
@@ -87,10 +87,10 @@ export function optedOut(request: Request): boolean {
8787 return cookie.split(";").some((pair) => pair.trim() === "forge_measure=off");
8888 }
8989
90 const dayOf = (at: number): string => new Date(at).toISOString().slice(0, 10);
90 export const dayOf = (at: number): string => new Date(at).toISOString().slice(0, 10);
9191
9292 /** Today's salt, made on first use; every other day's is deleted. */
93 function saltFor(db: Database, at: number): string {
93 export function saltFor(db: Database, at: number): string {
9494 const day = dayOf(at);
9595 const found = db
9696 .query<{ salt: string }, [string]>("SELECT salt FROM vital_salt WHERE day = ?")
src/web/pages/settings.ts +20
@@ -51,6 +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";
5455 import type { PreviewData } from "../../apps/data.ts";
5556 import {
5657 getAppByRepo,
@@ -571,6 +572,7 @@ function appSettingsView(
571572 const domains = listAppDomains(ctx.app, ctx.auth.user, repo);
572573 return html`${form}
573574 ${when(listed?.ok === true, () => html`<div class="mt">${appMembersView(base, values.access, named)}</div>`)}
575 <div class="mt">${appVisitorsView(appVitals(ctx.app.db, record.id))}</div>

The card is web-only. Show the same four numbers in the terminal's app view too, or state in the README why this is read-only on the page. The README also says nothing about where visitors and speed are seen.

574576 <div class="mt">
575577 ${appEnvView(base, listAppEnvNames(ctx.app.db, record.id))}
576578 </div>
@@ -609,6 +611,24 @@ const SCOPE_WORDS: Record<AppEnvScope, string> = {
609611 preview: "previews",
610612 };
611613
614 /** Production's visitors and the speed they saw, from the app's own beacon. */
615 function appVisitorsView(v: AppVitals): Html {
616 return card(
617 html`<p class="muted small mb">
618 Counted by the beacon the template ships, through the forge: no
619 address is kept, and a visitor is the same only within a day, so the
620 7 day figure adds the days up.
621 </p>
622 <dl class="stats">
623 <div><dt>Visitors today</dt><dd>${v.today}</dd></div>
624 <div><dt>Visitors, 7 days</dt><dd>${v.week}</dd></div>
625 <div><dt>p75 load</dt><dd>${ms(v.loadP75)}</dd></div>
626 <div><dt>p75 INP</dt><dd>${ms(v.inpP75)}</dd></div>
627 </dl>`,
628 { head: html`${icon("rocket")}<h2>Visitors and speed</h2>` },
629 );
630 }
631
612632 function appEnvView(base: string, names: AppEnvName[]): Html {
613633 const rows = names.map((v) =>
614634 row({
test/apps-measure.test.ts added +247
@@ -0,0 +1,247 @@
1 /**
2 * Every app measures its visitors and page speed from its first deploy
3 * (#2714): the template ships the forge's beacon, the forge answers
4 * `/__forge/measure` on the app's host, and a report is stored against that
5 * app and no other.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { newApp } from "../src/actions/apps.ts";
14 import { AppsRuntime } from "../src/apps/runtime.ts";
15 import { scaffoldFiles } from "../src/apps/scaffold.ts";
16 import { appVisitors } from "../src/actions/apps.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { getRepo } from "../src/core/repos.ts";
19 import { appVitals, recordAppVital } from "../src/apps/vitals.ts";
20 import { deleteApp, getAppByRepo } from "../src/core/apps.ts";
21 import { createUser } from "../src/core/users.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24
25 quietLogs();
26
27 const DOMAIN = "apps.test";
28
29 test("the template links the beacon and the beacon posts to the forge's door", () => {
30 const files = new Map(scaffoldFiles({ name: "n", description: "d" }).map((f) => [f.path, f.content]));
31 expect(files.get("static/beacon.js")).toContain('"/__forge/measure"');
32 expect(files.get("static/beacon.js")).not.toContain('"/_measure"');
33 expect(files.get("src/page.ts")).toContain('asset("beacon.js")');
34 });
35
36 describe("a report through the proxy", () => {
37 let dataDir: string;
38 let app: App;
39 let servers: Servers;
40 let runtime: AppsRuntime;
41 const ids: Record<string, string> = {};
42
43 beforeAll(async () => {
44 dataDir = await mkdtemp(join(tmpdir(), "forge-app-measure-"));
45 app = createApp({ dataDir, port: 0, host: "forge.test" });
46 servers = await serve(app);
47 runtime = new AppsRuntime(app, {
48 domain: DOMAIN,
49 scheme: "http",
50 publicPort: servers.port,
51 forgeUrl: servers.url,
52 concurrency: 1,
53 });
54 await runtime.start();
55 const a = await createUser(app.db, { origin: "seed", trusted: true,
56 username: "alice",
57 email: "[email protected]",
58 password: "a-good-passphrase",
59 adminIfFirst: true,
60 });
61 if (!a.ok) throw new Error(a.error);
62 for (const name of ["notes", "diary", "busy", "wide", "secret"]) {
63 const made = await newApp(app, a.value, {
64 name,
65 description: "A place for words",
66 visibility: name === "secret" ? "private" : "public",
67 });
68 if (!made.ok) throw new Error(String(made.error));
69 ids[name] = getAppByRepo(app.db, made.value.repo.id)!.id;
70 }
71 }, 120_000);
72
73 afterAll(async () => {
74 await runtime.stop();
75 servers.stop();
76 app.close();
77 await rm(dataDir, { recursive: true, force: true });
78 });
79
80 const report = (slug: string, body: unknown, headers: Record<string, string> = {}) =>
81 fetch(`${servers.url}/__forge/measure`, {
82 method: "POST",
83 body: typeof body === "string" ? body : JSON.stringify(body),
84 headers: {
85 host: `${slug}.${DOMAIN}`,
86 origin: `http://${slug}.${DOMAIN}`,
87 "user-agent": "test",
88 ...headers,
89 },
90 });
91 const good = { path: "/", ttfb: 40, load: 800, lcp: 600, inp: 90, cls: 0 };
92
93 test("is stored against that app and no other, and the app's page counts it", async () => {
94 expect((await report("notes", good)).status).toBe(204);
95 const rows = app.db
96 .query<{ app_id: string }, []>("SELECT app_id FROM app_vital")
97 .all();
98 expect(rows.map((r) => r.app_id)).toEqual([ids["notes"]!]);
99 expect(appVitals(app.db, ids["notes"]!)).toMatchObject({
100 today: 1,
101 week: 1,
102 loadP75: 800,
103 inpP75: 90,
104 });
105 expect(appVitals(app.db, ids["diary"]!)).toMatchObject({ today: 0, week: 0 });
106 // The body cannot name another app.
107 expect((await report("notes", { ...good, app: ids["diary"] })).status).toBe(400);
108 });
109
110 test("keeps no address and refuses a foreign origin, a bad body and an opted-out visitor", async () => {
111 expect((await report("notes", good, { origin: "http://evil.test" })).status).toBe(403);
112 expect((await report("notes", "nope")).status).toBe(400);
113 expect((await report("notes", { ...good, load: -1 })).status).toBe(400);
114 const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n;
115 expect((await report("notes", good, { dnt: "1" })).status).toBe(204);
116 expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n).toBe(before);
117 const columns = app.db.query<{ name: string }, []>("PRAGMA table_info(app_vital)").all();
outdated

Missing tests. (a) A private app or sealed preview refuses an anonymous report. (b) The 31st report from one client returns 429. (c) recordAppVital stops at dayMax. (d) An oversized body returns 413. (e) The p75 of several rows is right, and a preview row is not counted. Each is behaviour this change adds and none is covered.

118 expect(columns.map((c) => c.name).join()).not.toMatch(/ip|addr|agent/);
119 });
outdated

{ redirect: "manual" } ends up in the headers object, so it sends a header called redirect and fetch still follows the redirect. The as never cast hides the mistake. Pass redirect: "manual" as a fetch option and assert the actual refusal status, not just not.toBe(204). Also add a test for the per-app 600/min limit: removing forApp.take would currently pass every test.

120
121 test("a private app refuses an anonymous report and stores nothing", async () => {
122 const res = await report("secret", good);
123 expect(res.status).toBe(404);
124 expect(
125 app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM app_vital WHERE app_id = ?").get(ids["secret"]!)!.n,
126 ).toBe(0);
127 });
128
129 test("the 31st report from one client is a 429", async () => {
130 const statuses: number[] = [];
131 for (let i = 0; i < 31; i++) statuses.push((await report("busy", good)).status);
132 expect(statuses.slice(0, 30).every((s) => s === 204)).toBe(true);
133 expect(statuses[30]).toBe(429);
134 });
135
136 test("the 601st report to one app in a minute is a 429, whoever sends it", async () => {
137 let last = 0;
138 for (let i = 0; i < 601; i++)
139 last = (await report("wide", good, { "x-forwarded-for": `10.${i >> 8}.${i & 255}.1` })).status;
140 expect(last).toBe(429);
141 }, 60_000);
142
143 test("the numbers are the admin's alone, on the API as on the page", async () => {
144 const owner = (await import("../src/core/users.ts")).getUserByName(app.db, "alice")!;
145 const repo = getRepo(app.db, "alice", "notes")!;
146 expect(appVisitors(app, undefined, repo)).toBeUndefined();
147 const bob = await createUser(app.db, { origin: "seed", trusted: true,
148 username: "bob", email: "[email protected]", password: "a-good-passphrase" });
149 if (!bob.ok) throw new Error(bob.error);
150 expect(appVisitors(app, bob.value, repo)).toBeUndefined();
151 expect(appVisitors(app, owner, repo)).toMatchObject({ today: 1 });
152 const get = (token?: string) =>
153 fetch(`${servers.url}/api/v1/repos/alice/notes/app`, {
154 headers: token ? { authorization: `Bearer ${token}` } : {},
155 });
156 const anon = await get();
157 expect(anon.status).toBe(200);
158 expect(await anon.json()).not.toHaveProperty("vitals");
159 const asBob = await get(createToken(app.db, bob.value.id, "r", "ro").token);
160 expect(await asBob.json()).not.toHaveProperty("vitals");
161 const asAlice = await get(createToken(app.db, owner.id, "r", "ro").token);
162 expect(await asAlice.json()).toMatchObject({ vitals: { today: 1 } });
163 });
164
165 test("a sealed pull's preview refuses an anonymous report and stores nothing", async () => {
166 const repo = getRepo(app.db, "alice", "diary")!;
167 const alice = (await import("../src/core/users.ts")).getUserByName(app.db, "alice")!;
168 app.db
169 .query(
170 `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, sealed)
171 VALUES ('sealed1', ?1, 99, 'pull', 't', '', ?2, 'open', 1)`,
172 )
173 .run(repo.id, alice.id);
174 app.db
175 .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES ('sealed1', 'main', 'x', 'a', 'b')")
176 .run();
177 const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n;
178 expect((await report("diary--99", good)).status).toBe(404);
179 expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n).toBe(before);
180 });
181
182 test("a body over the cap is a 413", async () => {
183 const res = await report("diary", JSON.stringify({ ...good, path: "/" + "a".repeat(600) }));
184 expect(res.status).toBe(413);
185 });
186
187 test("a preview's report is accepted but not stored", async () => {
188 const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n;
189 expect((await report("diary--7", good)).status).toBe(204);
190 expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n).toBe(before);
191 });
192 });
193
194 describe("what is kept", () => {
195 const rows = (db: App["db"]) => db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM app_vital").get()!.n;
196 let dataDir: string;
197 let app: App;
198
199 beforeAll(async () => {
200 dataDir = await mkdtemp(join(tmpdir(), "forge-app-vitals-"));
201 app = createApp({ dataDir, port: 0, host: "forge.test" });
202 });
203 afterAll(async () => {
204 app.close();
205 await rm(dataDir, { recursive: true, force: true });
206 });
207
208 const rep = (load: number) => ({ path: "/", ttfb: null, load, lcp: null, inp: null, cls: null }) as never;
209
210 test("a day stops at dayMax rows per app", () => {
211 const at = Date.UTC(2026, 0, 5, 12);
212 expect(recordAppVital(app.db, "a1", rep(1), "c", "u", at, 2)).toBe(true);
213 expect(recordAppVital(app.db, "a1", rep(1), "d", "u", at, 2)).toBe(true);
214 expect(recordAppVital(app.db, "a1", rep(1), "e", "u", at, 2)).toBe(false);
215 expect(recordAppVital(app.db, "a2", rep(1), "e", "u", at, 2)).toBe(true);
216 expect(rows(app.db)).toBe(3);
217 });
218
219 test("p75 is right over several rows", () => {
220 const at = Date.UTC(2026, 0, 6, 12);
221 for (const [i, load] of [100, 200, 300, 400].entries())
222 recordAppVital(app.db, "p", rep(load), `c${i}`, "u", at);
223 const v = appVitals(app.db, "p", at);
224 expect(v.loadP75).toBe(300);
225 expect(v.today).toBe(4);
226 expect(v.week).toBe(4);
227 });
228
229 test("the prune and the per-app reads use an index", () => {
230 const plan = (sql: string) =>
231 app.db.query<{ detail: string }, []>(`EXPLAIN QUERY PLAN ${sql}`).all().map((r) => r.detail).join("\n");
232 expect(plan("DELETE FROM app_vital WHERE at < 1")).toContain("app_vital_at");
233 expect(plan("SELECT COUNT(*) FROM app_vital WHERE app_id = 'a' AND day = 'd'")).toContain("app_vital_app_day");
234 });
235
236 test("a 91-day-old row is gone after the next record, and deleting the app removes its rows", () => {
237 const now = Date.UTC(2026, 5, 1, 12);
238 recordAppVital(app.db, "old", rep(1), "c", "u", now - 91 * 86_400_000);
239 recordAppVital(app.db, "old", rep(2), "d", "u", now - 89 * 86_400_000);
240 recordAppVital(app.db, "old", rep(3), "e", "u", now);
241 const n = (id: string) =>
242 app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM app_vital WHERE app_id = ?").get(id)!.n;
243 expect(n("old")).toBe(2);
244 deleteApp(app.db, "old");
245 expect(n("old")).toBe(0);
246 });
247 });
test/tui-deploys.test.ts +20
@@ -7,6 +7,8 @@ import { join } from "node:path";
77 import { createRepo } from "../src/actions/repos.ts";
88 import { signUp } from "../src/actions/users.ts";
99 import { createApp, type App as Forge } from "../src/app.ts";
10 import { createApp as createAppRecord } from "../src/core/apps.ts";
11 import { recordAppVital } from "../src/apps/vitals.ts";
1012 import { createRun } from "../src/core/runs.ts";
1113 import type { Repo } from "../src/core/repos.ts";
1214 import type { User } from "../src/core/users.ts";
@@ -121,3 +123,21 @@ test("a non-reader sees nothing of a private repo's deploys", async () => {
121123 }
122124 expect(await screenText(alice, r.value)).toContain("#77");
123125 });
126
127 test("an admin sees the app's visitors and speed above the deploys, and a reader does not", async () => {
128 const r = await createRepo(forge, alice, { name: "shop", visibility: "public" });
129 if (!r.ok) throw new Error("repo failed");
130 const made = createAppRecord(forge.db, { repoId: r.value.id, slug: "shop", access: "public" });
131 if (!made.ok) throw new Error(made.error);
132 const record = made.value;
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");
136 const c = await signUp(forge, {
137 username: "carol",
138 email: "[email protected]",
139 password: "a-long-passphrase",
140 }, { slot: fixtureSlot(forge) });
141 if (!c.ok) throw new Error("signup failed");
142 expect(await screenText(c.value, r.value)).not.toContain("visitors");
143 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs