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

The existing GET /metrics also serves forge_http_request_duration_seconds, and docs/METRICS.md lists every family #3985

Merged opened this

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

Merged

scout/forge:scout/3975-the-existing-get-metrics-also-serves-for → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/3975-the-existing-get-metrics-also-serves-for was merged into main as 20a8e9e.

Review

Sign in to review this change.
4 files changed +153 −1
docs/METRICS.md added +33
@@ -0,0 +1,33 @@
1 # Metrics
2
3 `GET /metrics` answers Prometheus text (`text/plain; version=0.0.4`), one
4 format for the whole body. It names repositories and route patterns, so it is
5 for a site admin alone: 401 without credentials, 404 for anyone else, 200 for

Low: this line is inaccurate. Without credentials the route answers 404, not 401: test/egress-metrics-route.test.ts:92 asserts it, and the preview gives 404 to an anonymous curl. Say "404 for anyone but a site admin" so a person debugging a scrape is not sent looking for a 401.

6 an admin's `ro` token. It is never cached (`cache-control: no-store`).
7
8 | Family | Type | Labels | Meaning |
9 | --- | --- | --- | --- |
10 | `forge_egress_connections` | gauge | `repo`, `destination` | Open egress connections. |
11 | `forge_egress_bytes_in` | gauge | `repo`, `destination` | Bytes received from the destination over open connections. |
12 | `forge_egress_bytes_out` | gauge | `repo`, `destination` | Bytes sent to the destination over open connections. |
13 | `forge_http_request_duration_seconds` | histogram | `pattern` (plus `le`) | Seconds taken to answer a request, by the route pattern matched (never the raw path), over the last hour. Buckets are `route_timing`'s edges, 1 ms to 2.5 s, in seconds. |
14
15 The duration histogram is read from the hourly `route_timing` table, so it
16 covers the current hour and the one before; requests are written within about
17 a second.
18
19 ## Prometheus
20
21 Make an admin `ro` token in your account settings, then:
22
23 ```yaml
24 scrape_configs:
25 - job_name: forge
26 scheme: https
27 metrics_path: /metrics
28 authorization:
29 type: Bearer
30 credentials_file: /etc/prometheus/forge-token
31 static_configs:
32 - targets: ["forge.example.com"]
33 ```
src/http/api.ts +2 −1
@@ -433,6 +433,7 @@ import { clientAddress, clientBucket } from "./peer.ts";
433433 import { manifestFor } from "../actions/here.ts";
434434 import { egressSnapshot } from "../apps/egress.ts";
435435 import { egressMetrics } from "../apps/egress_metrics.ts";
436 import { forgeMetrics } from "../metrics/forge.ts";
436437 import {
437438 acceptReport,
438439 dismissReport,
@@ -4873,7 +4874,7 @@ export function apiRoutes(router: Router<Handler>): void {
48734874 router.add("GET", "/metrics", (ctx) => {
48744875 const denied = accountAdmin(ctx, "ro");
48754876 if (denied) return denied;
4876 return new Response(egressMetrics(egressSnapshot()), {
4877 return new Response(egressMetrics(egressSnapshot()) + forgeMetrics(ctx.app.db), {
48774878 headers: {
48784879 "content-type": "text/plain; version=0.0.4; charset=utf-8",
48794880 "cache-control": "no-store",
src/metrics/forge.ts added +48
@@ -0,0 +1,48 @@
1 /**
2 * The forge's own families for `GET /metrics` (#3975): request duration as a
3 * histogram per route pattern, read from `route_timing` over the last hour.
4 * One indexed range query on the primary key; the text comes from
5 * `openmetrics.ts`.
6 */
7 import type { Database } from "bun:sqlite";
8 import { EDGES_MS, readTiming } from "../core/route_timing.ts";
9 import { openMetrics, type Family, type HistogramSample } from "./openmetrics.ts";
10
11 const HOUR_MS = 3_600_000;
12 export const DURATION_FAMILY = "forge_http_request_duration_seconds";
13
14 /** The duration histogram: hour buckets from an hour ago on, cumulative, in seconds. */
15 export function durationFamily(db: Database, now: number = Date.now()): Family {
16 const since = new Date(now - HOUR_MS).toISOString().slice(0, 13);
17 const per = new Map<string, { counts: number[]; sumUs: number }>();

Medium, follow-up: the issue asked for this window, but a Prometheus histogram's _bucket, _count and _sum are expected to only grow. Here they drop at the top of every hour when the older hour leaves the window. rate() reads each drop as a counter reset and over-counts, so histogram_quantile(rate(...)) gets a spike every hour. The window is also 1–2 hours, not "the last hour" as HELP says. Monotonic counts, for example in-process totals since boot fed by the same record(), would fix both. Filed as a follow-up issue rather than blocking this pull.

18 for (const row of readTiming(db, since)) {
19 let p = per.get(row.pattern);
20 if (p === undefined) {
21 p = { counts: new Array<number>(EDGES_MS.length + 1).fill(0), sumUs: 0 };
22 per.set(row.pattern, p);
23 }
24 p.counts[row.bucket] = (p.counts[row.bucket] ?? 0) + row.n;
25 p.sumUs += row.sum_us;
26 }
27 const samples: HistogramSample[] = [];
28 for (const [pattern, p] of [...per].sort(([a], [b]) => (a < b ? -1 : 1))) {
29 let running = 0;
30 const buckets = EDGES_MS.map((ms, i) => {
31 running += p.counts[i] ?? 0;
32 return { le: ms / 1000, count: running };
33 });
34 const count = running + (p.counts[EDGES_MS.length] ?? 0);
35 samples.push({ labels: { pattern }, buckets, count, sum: p.sumUs / 1e6 });
36 }
37 return {
38 type: "histogram",
39 name: DURATION_FAMILY,
40 help: "Seconds the forge took to answer a request, by route pattern, over the last hour.",
41 samples,
42 };
43 }
44
45 /** The forge's families as Prometheus text (no `# EOF`: the body is `text/plain; version=0.0.4`). */
46 export function forgeMetrics(db: Database, now: number = Date.now()): string {
47 return openMetrics([durationFamily(db, now)]).replace(/# EOF\n$/, "");
48 }
test/metrics-route.test.ts added +70
@@ -0,0 +1,70 @@
1 /** `GET /metrics` serves the duration histogram, and docs/METRICS.md lists every family it serves (#3975). */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, readFile, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createRouteTiming } from "../src/core/route_timing.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12
13 let dir: string;
14 let app: App;
15 let servers: Servers;
16 let ro: string;
17
18 beforeAll(async () => {
19 dir = await mkdtemp(join(tmpdir(), "metrics-route-"));
20 app = createApp({ dataDir: dir, port: 0 });
21 servers = await serve(app);
22 const made = await createUser(app.db, {
23 origin: "seed",
24 trusted: true,
25 username: "alice",
26 email: "[email protected]",
27 password: "a-good-passphrase",
28 adminIfFirst: true,
29 });
30 if (!made.ok) throw new Error(made.error);
31 ro = createToken(app.db, made.value.id, "t", "ro").token;
32 }, 60_000);
33
34 afterAll(async () => {
35 servers.stop();
36 app.close();
37 await rm(dir, { recursive: true, force: true });
38 });
39
40 const read = async () =>
41 (await fetch(`${servers.url}/metrics`, { headers: { authorization: `Bearer ${ro}` } })).text();
42
43 test("a recorded request moves its bucket, count and sum", async () => {
44 const pattern = "/zz/metrics-test/:id";
45 const bucket = (text: string, le: string) =>
46 Number(
47 new RegExp(`forge_http_request_duration_seconds_bucket\\{pattern="${pattern.replace(/[/:]/g, (c) => `\\${c}`)}",le="${le.replace(".", "\\.").replace("+", "\\+")}"\\} (\\d+)`).exec(text)?.[1] ?? 0,
48 );
49 const before = await read();
50 expect(bucket(before, "0.005")).toBe(0);
51 const timing = createRouteTiming(app.db);
52 timing.record(pattern, 3);
53 timing.flush();
54 const after = await read();
55 expect(bucket(after, "0.002")).toBe(0);
56 expect(bucket(after, "0.005")).toBe(1);
57 expect(bucket(after, "+Inf")).toBe(1);
58 expect(after).toContain(`forge_http_request_duration_seconds_count{pattern="${pattern}"} 1`);
59 expect(after).toContain(`forge_http_request_duration_seconds_sum{pattern="${pattern}"} 0.003`);
60 expect(after).not.toContain("# EOF");
61 });
62
63 test("every family the route serves is listed in docs/METRICS.md", async () => {
64 const text = await read();
65 const doc = await readFile(join(import.meta.dir, "..", "docs", "METRICS.md"), "utf8");
66 const families = [...text.matchAll(/^# TYPE (\S+) /gm)].map((m) => m[1]!);
67 expect(families).toContain("forge_egress_connections");
68 expect(families).toContain("forge_http_request_duration_seconds");
69 for (const f of families) expect(doc).toContain(`\`${f}\``);
70 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs