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

forge_http_request_duration_seconds only grows, so rate() and histogram_quantile() read it correctly #4034

Merged opened this

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

Merged

torque/forge:torque/4013-forge-http-request-duration-seconds-only → main 4 commits fork 3 threads · 3 unresolved

  • Merged. torque/4013-forge-http-request-duration-seconds-only was merged into main as 9633874.

Review

Sign in to review this change.
5 files changed +140 −32
docs/METRICS.md +10 −6
@@ -2,19 +2,23 @@
22
33 `GET /metrics` answers Prometheus text (`text/plain; version=0.0.4`), one
44 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
6 an admin's `ro` token. It is never cached (`cache-control: no-store`).
5 for a site admin alone: 404 without credentials and for anyone else who is
6 not an admin, 200 for an admin's `ro` token. It is never cached
7 (`cache-control: no-store`).
78
89 | Family | Type | Labels | Meaning |
910 | --- | --- | --- | --- |
1011 | `forge_egress_connections` | gauge | `repo`, `destination` | Open egress connections. |
1112 | `forge_egress_bytes_in` | gauge | `repo`, `destination` | Bytes received from the destination over open connections. |
1213 | `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 | `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. |
1415
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.
16 The duration histogram counts every request the process has answered since
17 it started; its `_bucket`, `_count` and `_sum` only grow, so `rate()` and
18 `histogram_quantile()` read it as Prometheus expects. A restart starts it at
19 zero again, which Prometheus reads as a counter reset. A request counts the
20 moment it is answered. The hourly `route_timing` table, which keeps 30 days,
21 is the place to look further back.
1822
1923 ## Prometheus
2024
src/core/route_timing.ts +42 −1
@@ -8,6 +8,10 @@
88 * nothing: the records go back and the timer retries. Rows older than
99 * `KEEP_DAYS` are deleted, checked once per hour at flush.
1010 *
11 * `record` also adds to totals kept in memory per database for the life of
12 * the process (`readTotals`). They never drop, so `GET /metrics` can serve
13 * them as a Prometheus histogram (#4013); a restart starts them at zero.
14 *
1115 * The key is the route pattern the router matched, never the raw path, so
1216 * `/alice/x` and `/bob/y` land in one bucket and the table does not grow
1317 * with what strangers type. Not the forge's health: that is `kernel/vitals`.
@@ -23,6 +27,8 @@ const HOUR_MS = 3_600_000;
2327 const FLUSH_MS = 1000;
2428 const FLUSH_KEYS = 500;
2529 const SEP = "\u0000";
30 /** Distinct patterns kept in the totals; the router bounds them far below this. */
31 export const MAX_TOTAL_PATTERNS = 1000;
2632
2733 export interface TimingRow {
2834 readonly hour: string;
@@ -38,6 +44,39 @@ export interface RouteTiming {
3844 stop(): void;
3945 }
4046
47 /** One pattern's totals since the process started: a count per bucket and the summed duration. */
48 export interface RouteTotal {
49 /** `EDGES_MS.length + 1` counts, not cumulative; the last is everything slower. */
50 readonly counts: readonly number[];
51 readonly sumUs: number;
52 }
53
54 const totals = new WeakMap<Database, Map<string, { counts: number[]; sumUs: number }>>();
55
56 function addTotal(db: Database, pattern: string, bucket: number, us: number): void {
57 let byPattern = totals.get(db);
58 if (byPattern === undefined) {
59 byPattern = new Map();
60 totals.set(db, byPattern);

Past the cap a new pattern is silently left out of the histogram. Fold those into one fixed overflow pattern so the loss shows up in the series.

61 }
62 let t = byPattern.get(pattern);
63 if (t === undefined) {
64 if (byPattern.size >= MAX_TOTAL_PATTERNS) return;
65 t = { counts: new Array<number>(EDGES_MS.length + 1).fill(0), sumUs: 0 };
66 byPattern.set(pattern, t);
67 }
68 t.counts[bucket] = (t.counts[bucket] ?? 0) + 1;
69 t.sumUs += us;
70 }
71
72 /** Every pattern recorded against `db` in this process, with its totals; a copy. */
73 export function readTotals(db: Database): Map<string, RouteTotal> {
74 const out = new Map<string, RouteTotal>();
75 for (const [pattern, t] of totals.get(db) ?? [])
76 out.set(pattern, { counts: [...t.counts], sumUs: t.sumUs });
77 return out;
78 }
79
4180 /** The bucket a duration falls in. */
4281 export function bucketOf(ms: number): number {
4382 for (let i = 0; i < EDGES_MS.length; i++) if (ms <= EDGES_MS[i]!) return i;
@@ -119,8 +158,10 @@ export function createRouteTiming(
119158 throw new RangeError("route timing refused: bad pattern");
120159 if (!Number.isFinite(ms) || ms < 0)
121160 throw new RangeError("route timing refused: bad duration");
122 const key = `${Math.floor(now() / HOUR_MS)}${SEP}${pattern}${SEP}${bucketOf(ms)}`;
161 const bucket = bucketOf(ms);
162 const key = `${Math.floor(now() / HOUR_MS)}${SEP}${pattern}${SEP}${bucket}`;
123163 const us = Math.round(ms * 1000);
164 addTotal(db, pattern, bucket, us);
124165 const had = pending.get(key);
125166 if (had) {
126167 had.n++;
src/metrics/forge.ts +16 −25
@@ -1,48 +1,39 @@
11 /**
22 * 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`.
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`.
69 */
710 import type { Database } from "bun:sqlite";
8 import { EDGES_MS, readTiming } from "../core/route_timing.ts";
11 import { EDGES_MS, readTotals } from "../core/route_timing.ts";
912 import { openMetrics, type Family, type HistogramSample } from "./openmetrics.ts";
1013
11 const HOUR_MS = 3_600_000;
1214 export const DURATION_FAMILY = "forge_http_request_duration_seconds";
1315
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 }>();
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 }
16 /** The duration histogram since the process started: cumulative buckets, in seconds. */
17 export function durationFamily(db: Database): Family {
2718 const samples: HistogramSample[] = [];
28 for (const [pattern, p] of [...per].sort(([a], [b]) => (a < b ? -1 : 1))) {
19 for (const [pattern, t] of [...readTotals(db)].sort(([a], [b]) => (a < b ? -1 : 1))) {
2920 let running = 0;
3021 const buckets = EDGES_MS.map((ms, i) => {
31 running += p.counts[i] ?? 0;
22 running += t.counts[i] ?? 0;
3223 return { le: ms / 1000, count: running };
3324 });
34 const count = running + (p.counts[EDGES_MS.length] ?? 0);
35 samples.push({ labels: { pattern }, buckets, count, sum: p.sumUs / 1e6 });
25 const count = running + (t.counts[EDGES_MS.length] ?? 0);
26 samples.push({ labels: { pattern }, buckets, count, sum: t.sumUs / 1e6 });
3627 }
3728 return {
3829 type: "histogram",
3930 name: DURATION_FAMILY,
40 help: "Seconds the forge took to answer a request, by route pattern, over the last hour.",
31 help: "Seconds the forge took to answer a request, by route pattern, since the process started.",
4132 samples,
4233 };
4334 }
4435
4536 /** 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$/, "");
37 export function forgeMetrics(db: Database): string {
38 return openMetrics([durationFamily(db)]).replace(/# EOF\n$/, "");
4839 }
test/metrics-forge.test.ts added +65
@@ -0,0 +1,65 @@
1 /** forge_http_request_duration_seconds only grows: no value drops across an hour boundary (#4013). */
2
3 import { expect, test } from "bun:test";
4 import { Database } from "bun:sqlite";
5 import { createRouteTiming, MAX_TOTAL_PATTERNS, readTotals } from "../src/core/route_timing.ts";
6 import { migrate } from "../src/kernel/migrate.ts";
7 import { migrationFiles } from "../src/core/schema.ts";
8 import { forgeMetrics } from "../src/metrics/forge.ts";
9
10 /** Every sample line of the family, `name{labels}` → value. */
11 const samples = (text: string): Map<string, number> =>
12 new Map(
13 [...text.matchAll(/^(forge_http_request_duration_seconds_\w+\{[^}]*\}) (\S+)$/gm)].map(
14 (m) => [m[1]!, Number(m[2])],
15 ),
16 );
17
18 test("no bucket, count or sum drops as the hours pass", () => {
19 const db = new Database(":memory:");
20 migrate(db, migrationFiles);
21 const hour = 3_600_000;
22 let clock = Date.UTC(2020, 0, 6, 10, 59);
23 const timing = createRouteTiming(db, () => clock);
24 const scrapes: Map<string, number>[] = [];
25 // 10:59, 11:30, 12:30 and 13:30: the 10:00 hour leaves any one- or two-hour window.
26 for (const [i, step] of [0, 31 * 60_000, hour, hour].entries()) {
27 clock += step;
28 if (i < 2) timing.record("/a/:id", 3); // 0.003 s, in the le="0.005" bucket
29 if (i === 0) timing.record("/b", 700); // 0.7 s, in the le="1" bucket
30 timing.flush();
31 scrapes.push(samples(forgeMetrics(db)));
32 }
33 for (let i = 1; i < scrapes.length; i++)
34 for (const [key, before] of scrapes[i - 1]!) expect(scrapes[i]!.get(key) ?? -1).toBeGreaterThanOrEqual(before);
35 const last = scrapes.at(-1)!;
36 expect(last.get('forge_http_request_duration_seconds_count{pattern="/a/:id"}')).toBe(2);
37 expect(last.get('forge_http_request_duration_seconds_bucket{pattern="/a/:id",le="0.002"}')).toBe(0);
38 expect(last.get('forge_http_request_duration_seconds_bucket{pattern="/a/:id",le="0.005"}')).toBe(2);
39 expect(last.get('forge_http_request_duration_seconds_sum{pattern="/a/:id"}')).toBe(0.006);
40 expect(last.get('forge_http_request_duration_seconds_bucket{pattern="/b",le="0.5"}')).toBe(0);
41 expect(last.get('forge_http_request_duration_seconds_bucket{pattern="/b",le="1"}')).toBe(1);
42 expect(last.get('forge_http_request_duration_seconds_bucket{pattern="/b",le="+Inf"}')).toBe(1);
43 expect(last.get('forge_http_request_duration_seconds_sum{pattern="/b"}')).toBe(0.7);
44 timing.stop();
45 db.close();
46 });
47
48 test("the HELP line says since the process started, not the last hour", () => {
49 const db = new Database(":memory:");
50 expect(forgeMetrics(db)).toContain(
51 "# HELP forge_http_request_duration_seconds Seconds the forge took to answer a request, by route pattern, since the process started.",
52 );
53 db.close();
54 });
55

This test opens two databases and never closes them, and never calls ta.stop(). Close both, and stop the timer, as the first test does.

56 test("totals are kept per database and stop growing new patterns at the cap", () => {
57 const a = new Database(":memory:");
58 const b = new Database(":memory:");
59 const ta = createRouteTiming(a);

Nit: this test never calls ta.stop() or closes a/b. a is never migrated, so the pending 1006 records retry a failing flush every second for the rest of the run. The timer is unref'd, so this is harmless, but ta.stop(); a.close(); b.close(); at the end would match the other two tests.

60 for (let i = 0; i < MAX_TOTAL_PATTERNS + 5; i++) ta.record(`/p${i}`, 1);
61 expect(readTotals(a).size).toBe(MAX_TOTAL_PATTERNS);
62 expect(readTotals(b).size).toBe(0);
63 ta.record("/p0", 1);
64 expect(readTotals(a).get("/p0")!.counts[0]).toBe(2);
65 });
test/metrics-route.test.ts +7
@@ -60,6 +60,13 @@ test("a recorded request moves its bucket, count and sum", async () => {
6060 expect(after).not.toContain("# EOF");
6161 });
6262
63 test("a request without credentials is answered 404, as docs/METRICS.md says", async () => {
64 const res = await fetch(`${servers.url}/metrics`);
65 expect(res.status).toBe(404);
66 const doc = await readFile(join(import.meta.dir, "..", "docs", "METRICS.md"), "utf8");
67 expect(doc).toContain("404 without credentials");
68 });
69
6370 test("every family the route serves is listed in docs/METRICS.md", async () => {
6471 const text = await read();
6572 const doc = await readFile(join(import.meta.dir, "..", "docs", "METRICS.md"), "utf8");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs