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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/4013-forge-http-request-duration-seconds-only → main 4 commits fork 3 threads · 3 unresolved
5 files
changed +140
−32
docs/METRICS.md +10 −6
| @@ -2,19 +2,23 @@ | ||
| 2 | 2 | |
| 3 | 3 | `GET /metrics` answers Prometheus text (`text/plain; version=0.0.4`), one |
| 4 | 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 | |
| 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`). | |
| 7 | 8 | |
| 8 | 9 | | Family | Type | Labels | Meaning | |
| 9 | 10 | | --- | --- | --- | --- | |
| 10 | 11 | | `forge_egress_connections` | gauge | `repo`, `destination` | Open egress connections. | |
| 11 | 12 | | `forge_egress_bytes_in` | gauge | `repo`, `destination` | Bytes received from the destination over open connections. | |
| 12 | 13 | | `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. | | |
| 14 | 15 | |
| 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. | |
| 18 | 22 | |
| 19 | 23 | ## Prometheus |
| 20 | 24 |
src/core/route_timing.ts +42 −1
| @@ -8,6 +8,10 @@ | ||
| 8 | 8 | * nothing: the records go back and the timer retries. Rows older than |
| 9 | 9 | * `KEEP_DAYS` are deleted, checked once per hour at flush. |
| 10 | 10 | * |
| 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 | * | |
| 11 | 15 | * The key is the route pattern the router matched, never the raw path, so |
| 12 | 16 | * `/alice/x` and `/bob/y` land in one bucket and the table does not grow |
| 13 | 17 | * with what strangers type. Not the forge's health: that is `kernel/vitals`. |
| @@ -23,6 +27,8 @@ const HOUR_MS = 3_600_000; | ||
| 23 | 27 | const FLUSH_MS = 1000; |
| 24 | 28 | const FLUSH_KEYS = 500; |
| 25 | 29 | const SEP = "\u0000"; |
| 30 | /** Distinct patterns kept in the totals; the router bounds them far below this. */ | |
| 31 | export const MAX_TOTAL_PATTERNS = 1000; | |
| 26 | 32 | |
| 27 | 33 | export interface TimingRow { |
| 28 | 34 | readonly hour: string; |
| @@ -38,6 +44,39 @@ export interface RouteTiming { | ||
| 38 | 44 | stop(): void; |
| 39 | 45 | } |
| 40 | 46 | |
| 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); | |
| 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 | ||
| 41 | 80 | /** The bucket a duration falls in. */ |
| 42 | 81 | export function bucketOf(ms: number): number { |
| 43 | 82 | for (let i = 0; i < EDGES_MS.length; i++) if (ms <= EDGES_MS[i]!) return i; |
| @@ -119,8 +158,10 @@ export function createRouteTiming( | ||
| 119 | 158 | throw new RangeError("route timing refused: bad pattern"); |
| 120 | 159 | if (!Number.isFinite(ms) || ms < 0) |
| 121 | 160 | 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}`; | |
| 123 | 163 | const us = Math.round(ms * 1000); |
| 164 | addTotal(db, pattern, bucket, us); | |
| 124 | 165 | const had = pending.get(key); |
| 125 | 166 | if (had) { |
| 126 | 167 | had.n++; |
src/metrics/forge.ts +16 −25
| @@ -1,48 +1,39 @@ | ||
| 1 | 1 | /** |
| 2 | 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`. | |
| 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`. | |
| 6 | 9 | */ |
| 7 | 10 | 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"; | |
| 9 | 12 | import { openMetrics, type Family, type HistogramSample } from "./openmetrics.ts"; |
| 10 | 13 | |
| 11 | const HOUR_MS = 3_600_000; | |
| 12 | 14 | export const DURATION_FAMILY = "forge_http_request_duration_seconds"; |
| 13 | 15 | |
| 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 { | |
| 27 | 18 | 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))) { | |
| 29 | 20 | let running = 0; |
| 30 | 21 | const buckets = EDGES_MS.map((ms, i) => { |
| 31 | running += p.counts[i] ?? 0; | |
| 22 | running += t.counts[i] ?? 0; | |
| 32 | 23 | return { le: ms / 1000, count: running }; |
| 33 | 24 | }); |
| 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 }); | |
| 36 | 27 | } |
| 37 | 28 | return { |
| 38 | 29 | type: "histogram", |
| 39 | 30 | 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.", | |
| 41 | 32 | samples, |
| 42 | 33 | }; |
| 43 | 34 | } |
| 44 | 35 | |
| 45 | 36 | /** 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$/, ""); | |
| 48 | 39 | } |
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 |
||
| 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 () => { | ||
| 60 | 60 | expect(after).not.toContain("# EOF"); |
| 61 | 61 | }); |
| 62 | 62 | |
| 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 | ||
| 63 | 70 | test("every family the route serves is listed in docs/METRICS.md", async () => { |
| 64 | 71 | const text = await read(); |
| 65 | 72 | const doc = await readFile(join(import.meta.dir, "..", "docs", "METRICS.md"), "utf8"); |
new above
offline
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.