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

/metrics adds the app series: apps up or asleep, and each cell's CPU and memory #4052

Merged opened this

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

Merged

scout/forge:scout/3752-metrics-adds-the-app-series-apps-up-or-a → main 5 commits fork 6 threads · 6 unresolved

  • Merged. scout/3752-metrics-adds-the-app-series-apps-up-or-a was merged into main as 354efd4.

Review

Sign in to review this change.
6 files changed +256 −12
docs/METRICS.md +8
@@ -12,6 +12,14 @@ not an admin, 200 for an admin's `ro` token. It is never cached
1212 | `forge_egress_bytes_in` | gauge | `repo`, `destination` | Bytes received from the destination over open connections. |
1313 | `forge_egress_bytes_out` | gauge | `repo`, `destination` | Bytes sent to the destination over open connections. |
1414 | `forge_http_request_duration_seconds` | histogram | `pattern` (plus `le`) | Seconds taken to answer a request, by the route pattern matched (never the raw path), since the process started. Buckets are `route_timing`'s edges, 1 ms to 2.5 s, in seconds. |
15 | `forge_app_up` | gauge | `app`, `env` | 1 when the app environment is serving, 0 when it is asleep, stopped, deploying or failed. |
16 | `forge_cell_cpu_seconds` (samples `forge_cell_cpu_seconds_total`) | counter | `app`, `env` | CPU seconds (user plus system) used by the processes of a running app cell. |
17 | `forge_cell_memory_bytes` | gauge | `app`, `env` | Resident memory of the processes of a running app cell. |
18
19 `app` is the app's slug, or its id for a private app: a private app's name is
20 never in the body. `env` is `production` or `pull-<n>`. The cell families are
21 what the cell watch last read (every few seconds); a cell that is not running
22 has no series (a cell keeps the slug it started under, so after a rename its figures return when it restarts), and the forge starts nothing and reads no process to answer.
1523
1624 The duration histogram counts every request the process has answered since
1725 it started; its `_bucket`, `_count` and `_sum` only grow, so `rate()` and
src/http/api.ts +2 −1
@@ -435,6 +435,7 @@ import { manifestFor } from "../actions/here.ts";
435435 import { egressSnapshot } from "../apps/egress.ts";
436436 import { egressMetrics } from "../apps/egress_metrics.ts";
437437 import { forgeMetrics } from "../metrics/forge.ts";
438 import { appSources } from "../metrics/app_sources.ts";
438439 import {
439440 acceptReport,
440441 dismissReport,
@@ -4981,7 +4982,7 @@ export function apiRoutes(router: Router<Handler>): void {
49814982 router.add("GET", "/metrics", (ctx) => {
49824983 const denied = accountAdmin(ctx, "ro");
49834984 if (denied) return denied;
4984 return new Response(egressMetrics(egressSnapshot()) + forgeMetrics(ctx.app.db), {
4985 return new Response(egressMetrics(egressSnapshot()) + forgeMetrics(ctx.app.db, appSources(ctx.app)), {
49854986 headers: {
49864987 "content-type": "text/plain; version=0.0.4; charset=utf-8",
49874988 "cache-control": "no-store",
src/metrics/app_sources.ts added +24
@@ -0,0 +1,24 @@
1 /**
2 * What the app families of `GET /metrics` read from a running forge: every
3 * app's environments as the runtime already holds them, and the cells the
4 * watch has already read. It starts nothing and reads no process.
5 */
6 import type { App } from "../app.ts";
7 import { cellCounters } from "../apps/contain.ts";
8 import { appsRuntime } from "../apps/runtime.ts";
9 import { listApps } from "../core/apps.ts";
10 import type { AppSources } from "./forge.ts";
11
12 export function appSources(app: App): AppSources {
13 const runtime = appsRuntime(app);
14 if (runtime === undefined) return { apps: [], cells: [] };
15 return {
16 apps: listApps(app.db).map((a) => ({
17 id: a.id,
18 slug: a.slug,
19 private: a.access === "private",

This line is the only thing that keeps a private app's slug out of /metrics, and no test runs it. Every test passes private: true directly to appFamilies. Add a test that builds appSources from a db with a private app and checks that the slug is absent from the body.

20 envs: runtime.status(a).map((e) => ({ env: e.env, state: e.state })),
21 })),
22 cells: cellCounters(),
23 };
24 }
src/metrics/forge.ts +73 −11
@@ -1,15 +1,17 @@
11 /**
2 * The forge's own families for `GET /metrics` (#3975): request duration as a
3 * histogram per route pattern. It is read from the totals `route_timing`
4 * keeps in memory since the process started, not from the hourly table, so
5 * every value only grows (#4013): Prometheus reads a drop as a counter reset,
6 * and a sliding window would make `rate()` and `histogram_quantile()` jump
7 * every hour. A restart resets them, which Prometheus expects. No query runs;
8 * the text comes from `openmetrics.ts`.
2 * The forge's own families for `GET /metrics` (#3975, #3752): request duration
3 * as a histogram per route pattern, and the apps, up or not, with each cell's
4 * CPU and memory as the watch last read them. The duration is read from the
5 * totals `route_timing` keeps in memory since the process started, not from
6 * the hourly table, so every value only grows (#4013): Prometheus reads a drop
7 * as a counter reset, and a restart resets them, which it expects. Nothing is
8 * started or shelled out to read a figure. The text comes from `openmetrics.ts`.
99 */
1010 import type { Database } from "bun:sqlite";
11 import type { CellCounter } from "../apps/contain.ts";
12 import type { EnvState } from "../apps/runtime.ts";
1113 import { EDGES_MS, readTotals } from "../core/route_timing.ts";
12 import { openMetrics, type Family, type HistogramSample } from "./openmetrics.ts";
14 import { openMetrics, type Family, type HistogramSample, type Sample } from "./openmetrics.ts";
1315
1416 export const DURATION_FAMILY = "forge_http_request_duration_seconds";
1517
@@ -33,7 +35,67 @@ export function durationFamily(db: Database): Family {
3335 };
3436 }
3537
36 /** The forge's families as Prometheus text (no `# EOF`: the body is `text/plain; version=0.0.4`). */
37 export function forgeMetrics(db: Database): string {
38 return openMetrics([durationFamily(db)]).replace(/# EOF\n$/, "");
38 export const UP_FAMILY = "forge_app_up";
39 export const CELL_CPU_FAMILY = "forge_cell_cpu_seconds_total";
40 export const CELL_MEMORY_FAMILY = "forge_cell_memory_bytes";
41
42 /** One app as the runtime holds it: who it is, whether it is private, and each environment's state. */
43 export interface AppReading {
44 readonly id: string;
45 readonly slug: string;
46 readonly private: boolean;
47 readonly envs: readonly { readonly env: string; readonly state: EnvState }[];
48 }
49
50 /** What the app families read: the runtime's recorded states and the watch's counters. */
51 export interface AppSources {
52 readonly apps: readonly AppReading[];
53 readonly cells: readonly CellCounter[];
54 }
55
56 /** A private app is named by its id alone, never its slug. */
57 const nameOf = (a: AppReading): string => (a.private ? a.id : a.slug);
58
59 /** `forge_app_up` per app and environment, and each running cell's CPU and memory. */
60 export function appFamilies({ apps, cells }: AppSources): Family[] {
61 const sorted = [...apps].sort((a, b) => (nameOf(a) < nameOf(b) ? -1 : 1));
62 const up: Sample[] = [];
63 const cpu: Sample[] = [];
64 const mem: Sample[] = [];
65 // The watch labels a cell `app:<slug>/<env>`, and during a deploy the old
66 // and new cells share a label: their figures add up to the environment's.
67 const byCell = new Map<string, { cpuUsec: number; memoryBytes: number }>();
68 for (const c of cells) {
69 if (!c.label.startsWith("app:")) continue;
70 const key = c.label.slice(4);
71 const held = byCell.get(key) ?? { cpuUsec: 0, memoryBytes: 0 };
72 held.cpuUsec += c.cpuUsec;
73 held.memoryBytes += c.memoryBytes;
74 byCell.set(key, held);
75 }
76 for (const a of sorted) {
77 const app = nameOf(a);
78 for (const e of a.envs)
79 up.push({ labels: { app, env: e.env }, value: e.state === "running" ? 1 : 0 });
80 for (const e of a.envs) {
81 const c = byCell.get(`${a.slug}/${e.env}`);

Low, non-blocking. The lookup goes through a.slug, but the runtime fixes a cell's label at start (runtime.ts:1416, app:<slug>/<env>). So a cell that outlives a rename is credited to whichever app now holds the old slug. If that app is public, a private app's CPU and memory go out under the public app's name. Only admins can read this endpoint, so nothing reaches a non-admin, but the figures are attributed to the wrong app. Fix in a follow-up: put the app id in the cell label (app:<id>/<env>) and key byCell on a.id.

82 if (c === undefined) continue;
83 const labels = { app, env: e.env };
outdated

Matching on the slug the cell started under means a cell from a renamed app is credited to whichever app holds that slug now, which can be a different and public app. At the least, this must not create a duplicate series (see the comment below). Ideally, carry the app id in the cell's label.

84 cpu.push({ labels, value: c.cpuUsec / 1e6 });
85 mem.push({ labels, value: c.memoryBytes });
outdated

High: during a deploy, the old cell and the new cell are both watched under app:<slug>/<env>. That gives two samples with the same {app, env}, openMetrics throws duplicate series, and GET /metrics returns 500 for the whole scrape. Aggregate the cells per {app, env} (or keep the newest key) before you push samples, and add a test with two counters that share a label.

86 }
87 }
88 return [
89 { type: "gauge", name: UP_FAMILY, help: "1 when the app environment is serving, 0 when it is asleep, stopped, deploying or failed.", samples: up },
90 { type: "counter", name: CELL_CPU_FAMILY, help: "CPU seconds, user plus system, used by the processes of an app cell since it started.", samples: cpu },
91 { type: "gauge", name: CELL_MEMORY_FAMILY, help: "Resident memory in bytes of the processes of an app cell.", samples: mem },
92 ];
outdated

Non-blocking: this joins on the slug the cell's label captured when it started, not on the app id. After a rename, a still-running cell's figures attach to whichever app now holds the old slug. That can be a private app's figures under a public name, though only admins can scrape this. The durable fix is an id in the watch label in runtime.ts. Please file it as an issue rather than growing this pull.

93 }
94
95 /** The forge's families as Prometheus text (no `# EOF`: the body is `text/plain; version=0.0.4`). */
96 export function forgeMetrics(
97 db: Database,
98 sources: AppSources = { apps: [], cells: [] },
99 ): string {
100 return openMetrics([durationFamily(db), ...appFamilies(sources)]).replace(/# EOF\n$/, "");
39101 }
test/metrics-apps-route.test.ts added +97
@@ -0,0 +1,97 @@
1 /** `GET /metrics` from a real forge: a seeded running app is up, a sleeping one is 0, a private app is named by id only (#3752). */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { AppsRuntime } from "../src/apps/runtime.ts";
10 import { createApp as createAppRecord, type AppRecord } from "../src/core/apps.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { appSources } from "../src/metrics/app_sources.ts";
15 import { forgeMetrics } from "../src/metrics/forge.ts";
16
17 let dir: string;
18 let app: App;
19 let servers: Servers;
20 let ro: string;
21 let open: AppRecord;
22 let secret: AppRecord;
23 let runtime: AppsRuntime;
24
25 const record = (r: { ok: boolean; value?: AppRecord; error?: string }): AppRecord => {
26 if (!r.ok) throw new Error(String(r.error));
27 return r.value!;
28 };
29
30 beforeAll(async () => {
31 dir = await mkdtemp(join(tmpdir(), "metrics-apps-"));
32 app = createApp({ dataDir: dir, port: 0 });
33 servers = await serve(app);
34 const made = await createUser(app.db, {
35 origin: "seed",
36 trusted: true,
37 username: "alice",
38 email: "[email protected]",
39 password: "a-good-passphrase",
40 adminIfFirst: true,
41 });
42 if (!made.ok) throw new Error(made.error);
43 ro = createToken(app.db, made.value.id, "t", "ro").token;
44 const repos = [];
45 for (const name of ["notes", "plans"]) {
46 const r = await createRepo(app, made.value, { name });
47 if (!r.ok) throw new Error(String(r.error));
48 repos.push(r.value);
49 }
50 open = record(createAppRecord(app.db, { repoId: repos[0]!.id, slug: "notes", access: "public" }));
51 secret = record(createAppRecord(app.db, { repoId: repos[1]!.id, slug: "secret-plans", access: "private" }));
52 // An unstarted runtime holding what a started one would: no cell is spawned.
53 runtime = new AppsRuntime(app, { domain: "apps.test", scheme: "http", publicPort: servers.port, forgeUrl: servers.url });
54 const envs = (runtime as unknown as { envs: Map<string, unknown> }).envs;
55 const live = {
56 commitOid: "abc",
57 handle: { port: 1 },
58 runId: "r1",
59 since: "2026-01-01T00:00:00Z",
60 servingAt: "2026-01-01T00:00:00Z",
61 createdAt: "2026-01-01T00:00:00Z",
62 manifest: {},
63 deployStartedAt: null,
64 };
65 const base = { openStreams: 0, openRequests: 0, chain: Promise.resolve(), restarts: [] };
66 envs.set("a", { ...base, app: open, env: "production", live });
67 envs.set("b", { ...base, app: secret, env: "production", live });
68 envs.set("c", { ...base, app: open, env: "pull-7", asleep: { commitOid: "abc", runId: "r2", since: "2026-01-01T00:00:00Z" } });
69 }, 60_000);
70
71 afterAll(async () => {
72 servers.stop();
73 app.close();
74 await rm(dir, { recursive: true, force: true });
75 });
76
77 test("appSources marks the private app private", () => {
78 const bySlug = new Map(appSources(app).apps.map((a) => [a.slug, a]));
79 expect(bySlug.get("notes")?.private).toBe(false);
80 expect(bySlug.get("secret-plans")?.private).toBe(true);
81 });
82
83 test("the body names a private app by id and never by slug", () => {
84 const body = forgeMetrics(app.db, appSources(app));
85 expect(body).toContain(`forge_app_up{app="${secret.id}",env="production"} 1`);
86 expect(body).not.toContain("secret-plans");
87 });
88
89 test("GET /metrics with an admin ro token carries forge_app_up for the seeded apps", async () => {
90 const res = await fetch(`${servers.url}/metrics`, { headers: { authorization: `Bearer ${ro}` } });
91 const body = await res.text();
92 expect(res.status).toBe(200);
93 expect(body).toContain('forge_app_up{app="notes",env="production"} 1');
94 expect(body).toContain('forge_app_up{app="notes",env="pull-7"} 0');
95 expect(body).toContain(`forge_app_up{app="${secret.id}",env="production"} 1`);
96 expect(body).not.toContain("secret-plans");
97 });
test/metrics-cells.test.ts added +52
@@ -0,0 +1,52 @@
1 /** `/metrics` serves the app series: `forge_app_up` moves when an app sleeps, a cell's figures appear once recorded, a private app is named by id only (#3752). */
2
3 import { expect, test } from "bun:test";
4 import { Database } from "bun:sqlite";
5 import type { CellCounter } from "../src/apps/contain.ts";
6 import { appFamilies, forgeMetrics, type AppReading } from "../src/metrics/forge.ts";
7 import { openMetrics } from "../src/metrics/openmetrics.ts";
8
9 const app = (state: "running" | "asleep"): AppReading[] => [
10 { id: "app1", slug: "notes", private: false, envs: [{ env: "production", state }] },
11 { id: "app2", slug: "secret-plans", private: true, envs: [{ env: "pull-3", state: "running" }] },
12 ];
13 const cell = (label: string): CellCounter => ({ key: 1, label, cpuUsec: 2_500_000, memoryBytes: 4096 });
14 const text = (apps: AppReading[], cells: CellCounter[]) => openMetrics(appFamilies({ apps, cells }));
15
16 test("forge_app_up is 1 while running and 0 once the app sleeps", () => {
17 expect(text(app("running"), [])).toContain('forge_app_up{app="notes",env="production"} 1');
18 expect(text(app("asleep"), [])).toContain('forge_app_up{app="notes",env="production"} 0');
19 });
20
21 test("a cell's figures appear when the watch has read it, and not before", () => {
22 expect(text(app("running"), [])).not.toContain("forge_cell_memory_bytes{");
23 const out = text(app("running"), [cell("app:notes/production")]);
24 expect(out).toContain('forge_cell_cpu_seconds_total{app="notes",env="production"} 2.5');
25 expect(out).toContain('forge_cell_memory_bytes{app="notes",env="production"} 4096');
26 });
27
28 test("a private app appears under its id only", () => {
29 const out = text(app("running"), [cell("app:secret-plans/pull-3"), cell("check:x")]);
30 expect(out).toContain('forge_app_up{app="app2",env="pull-3"} 1');
31 expect(out).toContain('forge_cell_memory_bytes{app="app2",env="pull-3"} 4096');
32 expect(out).not.toContain("secret-plans");
33 expect(out).not.toContain("check:x");
34 });
35
36 test("forgeMetrics carries the families as Prometheus text without a trailing EOF", () => {
37 const db = new Database(":memory:");
38 db.run("CREATE TABLE route_timing (hour TEXT, pattern TEXT, bucket INTEGER, n INTEGER, sum_us INTEGER)");
39 const body = forgeMetrics(db, { apps: app("running"), cells: [] });
40 expect(body).toContain("# TYPE forge_app_up gauge");
41 expect(body).toContain("# TYPE forge_cell_cpu_seconds counter");
42 expect(body).not.toContain("# EOF");
43 });
44
45 test("two cells under one label (a deploy's old and new) are one series, summed, and the scrape does not throw", () => {
46 const db = new Database(":memory:");
47 db.run("CREATE TABLE route_timing (hour TEXT, pattern TEXT, bucket INTEGER, n INTEGER, sum_us INTEGER)");
48 const cells = [cell("app:notes/production"), { ...cell("app:notes/production"), key: 2 }];
49 const body = forgeMetrics(db, { apps: app("running"), cells });
50 expect(body).toContain('forge_cell_memory_bytes{app="notes",env="production"} 8192');
51 expect(body).toContain('forge_cell_cpu_seconds_total{app="notes",env="production"} 5');
52 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs