Public: anyone can read this; the crew can change it.
src/metrics/openmetrics.ts writes the OpenMetrics text format from plain data, checked by a strict parser #3756
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3748-src-metrics-openmetrics-ts-writes-the-op → main 3 commits fork 3 threads · 3 unresolved
2 files
changed +342
−0
src/metrics/openmetrics.ts added +117
| @@ -0,0 +1,117 @@ | ||
| 1 | /** | |
| 2 | * OpenMetrics text exposition (spec 1.0) from plain data: counter, gauge and | |
| 3 | * histogram families in, one string ending in `# EOF` out. It runs no query | |
| 4 | * and reads nothing; whatever is wrong with the data throws. | |
| 5 | */ | |
| 6 | ||
| 7 | export type Labels = Record<string, string>; | |
| 8 | ||
| 9 | export type Sample = { labels?: Labels; value: number }; | |
| 10 | ||
| 11 | export type HistogramSample = { | |
| 12 | labels?: Labels; | |
| 13 | /** Finite upper bounds, strictly rising, with cumulative counts. `+Inf` is added from `count`. */ | |
| 14 | buckets: { le: number; count: number }[]; | |
| 15 | count: number; | |
| 16 | sum: number; | |
| 17 | }; | |
| 18 | ||
| 19 | export type Family = | |
| 20 | | { type: "counter" | "gauge"; name: string; help: string; samples: Sample[] } | |
| 21 | | { type: "histogram"; name: string; help: string; samples: HistogramSample[] }; | |
| 22 | ||
| 23 | const NAME = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/; | |
| 24 | const LABEL = /^[a-zA-Z_][a-zA-Z0-9_]*$/; | |
| 25 | ||
| 26 | function checkName(name: string): string { | |
| 27 | if (typeof name !== "string" || !NAME.test(name)) throw new Error(`invalid metric name: ${String(name)}`); | |
| 28 | return name; | |
| 29 | } | |
| 30 | ||
| 31 | export function escapeLabelValue(v: string): string { | |
| 32 | return v.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); | |
| 33 | } | |
| 34 | ||
| 35 | function escapeHelp(v: string): string { | |
| 36 | return v.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); | |
| 37 | } | |
| 38 | ||
| 39 | export function formatNumber(n: number): string { | |
| 40 | if (typeof n !== "number" || Number.isNaN(n)) return "NaN"; | |
| 41 | if (n === Infinity) return "+Inf"; | |
| 42 | if (n === -Infinity) return "-Inf"; | |
| 43 | return String(n); | |
| 44 | } | |
| 45 | ||
| 46 | function labelSet(labels: Labels | undefined, extra?: [string, string]): string { | |
| 47 | const pairs: [string, string][] = []; | |
| 48 | for (const [k, v] of Object.entries(labels ?? {})) { | |
| 49 | if (!LABEL.test(k) || k.startsWith("__")) throw new Error(`invalid label name: ${k}`); | |
| 50 | if (typeof v !== "string") throw new Error(`label ${k} must be a string`); | |
| 51 | pairs.push([k, v]); | |
| 52 | } | |
| 53 | pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); | |
| 54 | if (extra) pairs.push(extra); | |
| 55 | if (pairs.length === 0) return ""; | |
| 56 | return `{${pairs.map(([k, v]) => `${k}="${escapeLabelValue(v)}"`).join(",")}}`; | |
| 57 | } | |
| 58 | ||
| 59 | function checkCount(n: number, what: string): void { | |
| 60 | if (!Number.isFinite(n) || n < 0) throw new Error(`${what} must be a finite number >= 0`); | |
| 61 | } | |
| 62 | ||
| 63 | function histogramLines(base: string, s: HistogramSample, out: string[]): void { | |
| 64 | if (Object.hasOwn(s.labels ?? {}, "le")) throw new Error(`histogram ${base} may not carry a label named le`); | |
| 65 | checkCount(s.count, `${base} count`); | |
| 66 | if (!Number.isFinite(s.sum)) throw new Error(`${base} sum must be finite`); | |
| 67 | let prevLe = -Infinity; | |
| 68 | let prevCount = 0; | |
| 69 | for (const b of s.buckets) { | |
| 70 | if (!Number.isFinite(b.le) || b.le <= prevLe) throw new Error(`${base} bucket bounds must be finite and rising`); | |
| 71 | checkCount(b.count, `${base} bucket`); | |
| 72 | if (b.count < prevCount) throw new Error(`${base} buckets must be cumulative`); | |
| 73 | prevLe = b.le; | |
| 74 | prevCount = b.count; | |
| 75 | out.push(`${base}_bucket${labelSet(s.labels, ["le", formatNumber(b.le)])} ${b.count}`); | |
| 76 | } | |
| 77 | if (s.count < prevCount) throw new Error(`${base} count is below its last bucket`); | |
| 78 | out.push(`${base}_bucket${labelSet(s.labels, ["le", "+Inf"])} ${s.count}`); | |
| 79 | out.push(`${base}_count${labelSet(s.labels)} ${s.count}`); | |
| 80 | out.push(`${base}_sum${labelSet(s.labels)} ${formatNumber(s.sum)}`); | |
| 81 | } | |
| 82 | ||
| 83 | const SUFFIXES = { counter: ["_total"], gauge: [""], histogram: ["_bucket", "_count", "_sum"] } as const; | |
| 84 | ||
| 85 | export function openMetrics(families: Family[]): string { | |
| 86 | const out: string[] = []; | |
| 87 | const seen = new Set<string>(); | |
| 88 | const sampleNames = new Set<string>(); | |
| 89 | for (const f of families) { | |
| 90 | if (!Object.hasOwn(SUFFIXES, f.type)) throw new Error(`invalid metric type: ${String(f.type)}`); | |
outdated
Family names are compared only by base. A counter |
||
| 91 | checkName(f.name); | |
| 92 | // The family of a counter is named without its `_total`; its samples carry it. | |
outdated
f.type is never checked. Any value other than counter/histogram is written as |
||
| 93 | const base = f.type === "counter" ? f.name.replace(/_total$/, "") : f.name; | |
| 94 | if (!base) throw new Error(`invalid metric name: ${f.name}`); | |
| 95 | if (seen.has(base)) throw new Error(`duplicate family: ${base}`); | |
| 96 | seen.add(base); | |
| 97 | for (const suffix of SUFFIXES[f.type]) { | |
| 98 | if (sampleNames.has(base + suffix)) throw new Error(`sample name collision: ${base}${suffix}`); | |
| 99 | sampleNames.add(base + suffix); | |
| 100 | } | |
| 101 | out.push(`# TYPE ${base} ${f.type}`); | |
| 102 | if (f.help) out.push(`# HELP ${base} ${escapeHelp(f.help)}`); | |
| 103 | const keys = new Set<string>(); | |
| 104 | for (const s of f.samples as (Sample | HistogramSample)[]) { | |
| 105 | const key = labelSet(s.labels); | |
| 106 | if (keys.has(key)) throw new Error(`duplicate series in ${base}: ${base}${key}`); | |
| 107 | keys.add(key); | |
| 108 | if (f.type === "histogram") histogramLines(base, s as HistogramSample, out); | |
| 109 | else if (f.type === "counter") { | |
| 110 | checkCount((s as Sample).value, `${base} counter value`); | |
| 111 | out.push(`${base}_total${key} ${formatNumber((s as Sample).value)}`); | |
| 112 | } else out.push(`${base}${key} ${formatNumber((s as Sample).value)}`); | |
| 113 | } | |
| 114 | } | |
| 115 | out.push("# EOF"); | |
| 116 | return out.join("\n") + "\n"; | |
| 117 | } |
test/openmetrics.test.ts added +225
| @@ -0,0 +1,225 @@ | ||
| 1 | /** The OpenMetrics writer, checked by a strict parser written here. */ | |
| 2 | ||
| 3 | import { describe, expect, test } from "bun:test"; | |
| 4 | import { openMetrics, type Family, type HistogramSample } from "../src/metrics/openmetrics.ts"; | |
| 5 | ||
| 6 | type Parsed = { type: string; help?: string; samples: { name: string; labels: Record<string, string>; value: number }[] }; | |
| 7 | ||
| 8 | const SAMPLE = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{(.*)\})? (\S+)$/; | |
| 9 | ||
| 10 | function parseLabels(src: string): Record<string, string> { | |
| 11 | const out: Record<string, string> = {}; | |
| 12 | let i = 0; | |
| 13 | while (i < src.length) { | |
| 14 | const m = /^([a-zA-Z_][a-zA-Z0-9_]*)="/.exec(src.slice(i)); | |
| 15 | if (!m) throw new Error(`bad label at ${src.slice(i)}`); | |
| 16 | i += m[0].length; | |
| 17 | let v = ""; | |
| 18 | for (;;) { | |
| 19 | const c = src[i++]; | |
| 20 | if (c === undefined) throw new Error("unterminated label value"); | |
| 21 | if (c === '"') break; | |
| 22 | if (c === "\n") throw new Error("raw newline in label"); | |
| 23 | if (c === "\\") { | |
| 24 | const e = src[i++]; | |
| 25 | if (e === "n") v += "\n"; | |
| 26 | else if (e === "\\" || e === '"') v += e; | |
| 27 | else throw new Error("bad escape"); | |
| 28 | } else v += c; | |
| 29 | } | |
| 30 | out[m[1]!] = v; | |
| 31 | if (i < src.length) { | |
| 32 | if (src[i++] !== ",") throw new Error("labels must be comma separated"); | |
| 33 | } | |
| 34 | } | |
| 35 | return out; | |
| 36 | } | |
| 37 | ||
| 38 | function num(s: string): number { | |
| 39 | if (s === "+Inf") return Infinity; | |
| 40 | if (s === "-Inf") return -Infinity; | |
| 41 | if (s === "NaN") return NaN; | |
| 42 | if (!/^-?\d+(\.\d+)?(e[+-]?\d+)?$/i.test(s)) throw new Error(`bad number ${s}`); | |
| 43 | return Number(s); | |
| 44 | } | |
| 45 | ||
| 46 | function parse(text: string): Map<string, Parsed> { | |
| 47 | if (!text.endsWith("# EOF\n")) throw new Error("missing # EOF"); | |
| 48 | const lines = text.slice(0, -1).split("\n"); | |
| 49 | lines.pop(); | |
| 50 | const fams = new Map<string, Parsed>(); | |
| 51 | let cur: Parsed | undefined; | |
| 52 | let curName = ""; | |
| 53 | for (const line of lines) { | |
| 54 | let m = /^# TYPE (\S+) (counter|gauge|histogram)$/.exec(line); | |
| 55 | if (m) { | |
| 56 | if (fams.has(m[1]!)) throw new Error(`duplicate family ${m[1]}`); | |
| 57 | cur = { type: m[2]!, samples: [] }; | |
| 58 | curName = m[1]!; | |
| 59 | fams.set(curName, cur); | |
| 60 | continue; | |
| 61 | } | |
| 62 | m = /^# HELP (\S+) (.*)$/.exec(line); | |
| 63 | if (m) { | |
| 64 | if (!cur || m[1] !== curName) throw new Error("HELP outside its family"); | |
| 65 | cur.help = m[2]!; | |
| 66 | continue; | |
| 67 | } | |
| 68 | if (line.startsWith("#") || line === "") throw new Error(`unexpected line: ${line}`); | |
| 69 | m = SAMPLE.exec(line); | |
| 70 | if (!m || !cur) throw new Error(`bad sample: ${line}`); | |
| 71 | const name = m[1]!; | |
| 72 | const suffixes = cur.type === "counter" ? ["_total"] : cur.type === "histogram" ? ["_bucket", "_count", "_sum"] : [""]; | |
| 73 | if (!suffixes.some((s) => name === curName + s)) throw new Error(`sample ${name} not in family ${curName}`); | |
| 74 | cur.samples.push({ name, labels: parseLabels(m[3] ?? ""), value: num(m[4]!) }); | |
| 75 | } | |
| 76 | for (const [name, f] of fams) { | |
| 77 | if (f.type !== "histogram") continue; | |
| 78 | const groups = new Map<string, number[]>(); | |
| 79 | const ends = new Set<string>(); | |
| 80 | for (const s of f.samples.filter((x) => x.name === `${name}_bucket`)) { | |
| 81 | const { le, ...rest } = s.labels; | |
| 82 | if (le === undefined) throw new Error("bucket without le"); | |
| 83 | const key = JSON.stringify(rest); | |
| 84 | const prev = groups.get(key) ?? []; | |
| 85 | if (ends.has(key)) throw new Error("bucket after +Inf"); | |
| 86 | if (prev.length && s.value < prev[prev.length - 1]!) throw new Error("non-cumulative bucket"); | |
| 87 | prev.push(s.value); | |
| 88 | groups.set(key, prev); | |
| 89 | if (le === "+Inf") ends.add(key); | |
| 90 | } | |
| 91 | for (const key of groups.keys()) if (!ends.has(key)) throw new Error("histogram without +Inf bucket"); | |
| 92 | } | |
| 93 | return fams; | |
| 94 | } | |
| 95 | ||
| 96 | describe("parser (guards the guard)", () => { | |
| 97 | test("rejects the malformed", () => { | |
| 98 | expect(() => parse("# TYPE a gauge\na 1\n")).toThrow("EOF"); | |
| 99 | expect(() => parse("# TYPE a gauge\n# TYPE a gauge\n# EOF\n")).toThrow("duplicate"); | |
| 100 | expect(() => parse('# TYPE a gauge\na{x="q"r"} 1\n# EOF\n')).toThrow(); | |
| 101 | expect(() => parse('# TYPE a gauge\na{x="\\q"} 1\n# EOF\n')).toThrow("escape"); | |
| 102 | expect(() => | |
| 103 | parse('# TYPE h histogram\nh_bucket{le="1"} 3\nh_bucket{le="2"} 2\nh_bucket{le="+Inf"} 3\n# EOF\n'), | |
| 104 | ).toThrow("cumulative"); | |
| 105 | }); | |
| 106 | }); | |
| 107 | ||
| 108 | const hist: Family = { | |
| 109 | type: "histogram", | |
| 110 | name: "forge_request_seconds", | |
| 111 | help: "Request time.", | |
| 112 | samples: [ | |
| 113 | { | |
| 114 | labels: { route: "/" }, | |
| 115 | buckets: [ | |
| 116 | { le: 0.01, count: 2 }, | |
| 117 | { le: 0.1, count: 5 }, | |
| 118 | ], | |
| 119 | count: 6, | |
| 120 | sum: 0.75, | |
| 121 | }, | |
| 122 | ], | |
| 123 | }; | |
| 124 | ||
| 125 | const base: HistogramSample = { buckets: [{ le: 1, count: 2 }], count: 2, sum: 1 }; | |
| 126 | const hist1 = (name: string): Family => ({ type: "histogram", name, help: "", samples: [] }); | |
| 127 | ||
| 128 | describe("openMetrics", () => { | |
| 129 | test("writes counter, gauge and histogram families ending in # EOF", () => { | |
| 130 | const text = openMetrics([ | |
| 131 | { type: "counter", name: "forge_requests_total", help: "Requests.", samples: [{ labels: { code: "200" }, value: 7 }] }, | |
| 132 | { type: "gauge", name: "forge_apps", help: "Apps.", samples: [{ value: 3 }] }, | |
| 133 | hist, | |
| 134 | ]); | |
| 135 | expect(text.endsWith("# EOF\n")).toBe(true); | |
| 136 | const f = parse(text); | |
| 137 | expect(f.get("forge_requests")!.type).toBe("counter"); | |
| 138 | expect(f.get("forge_requests")!.samples[0]!.name).toBe("forge_requests_total"); | |
| 139 | expect(f.get("forge_apps")!.samples[0]!.value).toBe(3); | |
| 140 | expect(f.get("forge_apps")!.help).toBe("Apps."); | |
| 141 | const h = f.get("forge_request_seconds")!; | |
| 142 | expect(h.samples.map((s) => [s.name, s.labels.le, s.value])).toEqual([ | |
| 143 | ["forge_request_seconds_bucket", "0.01", 2], | |
| 144 | ["forge_request_seconds_bucket", "0.1", 5], | |
| 145 | ["forge_request_seconds_bucket", "+Inf", 6], | |
| 146 | ["forge_request_seconds_count", undefined, 6], | |
| 147 | ["forge_request_seconds_sum", undefined, 0.75], | |
| 148 | ]); | |
| 149 | }); | |
| 150 | ||
| 151 | test("empty input is just # EOF", () => { | |
| 152 | expect(openMetrics([])).toBe("# EOF\n"); | |
| 153 | }); | |
| 154 | ||
| 155 | test("counter names gain _total once", () => { | |
| 156 | const a = openMetrics([{ type: "counter", name: "x", help: "", samples: [{ value: 1 }] }]); | |
| 157 | const b = openMetrics([{ type: "counter", name: "x_total", help: "", samples: [{ value: 1 }] }]); | |
| 158 | expect(a).toBe(b); | |
| 159 | expect(a).toContain("x_total 1\n"); | |
| 160 | }); | |
| 161 | ||
| 162 | test("escapes label values and help", () => { | |
| 163 | const nasty = 'a"b\\c\nd'; | |
| 164 | const text = openMetrics([{ type: "gauge", name: "g", help: "l1\nl2", samples: [{ labels: { v: nasty }, value: 1 }] }]); | |
| 165 | expect(text).toContain('g{v="a\\"b\\\\c\\nd"} 1'); | |
| 166 | const g = parse(text).get("g")!; | |
| 167 | expect(g.samples[0]!.labels.v).toBe(nasty); | |
| 168 | expect(g.help).toBe("l1\\nl2"); | |
| 169 | }); | |
| 170 | ||
| 171 | test("names outside the grammar throw", () => { | |
| 172 | for (const name of ["", "1a", "a-b", "a b", "a\n"]) | |
| 173 | expect(() => openMetrics([{ type: "gauge", name, help: "", samples: [] }])).toThrow("invalid metric name"); | |
| 174 | expect(() => openMetrics([{ type: "gauge", name: "a:b_c", help: "", samples: [] }])).not.toThrow(); | |
| 175 | expect(() => openMetrics([{ type: "gauge", name: "g", help: "", samples: [{ labels: { "a-b": "x" }, value: 1 }] }])).toThrow("label"); | |
| 176 | expect(() => openMetrics([{ type: "gauge", name: "g", help: "", samples: [{ labels: { __x: "x" }, value: 1 }] }])).toThrow("label"); | |
| 177 | }); | |
| 178 | ||
| 179 | test("duplicate families and series throw", () => { | |
| 180 | const g: Family = { type: "gauge", name: "g", help: "", samples: [] }; | |
| 181 | expect(() => openMetrics([g, g])).toThrow("duplicate family"); | |
| 182 | expect(() => openMetrics([g, { type: "counter", name: "g_total", help: "", samples: [] }])).toThrow("duplicate family"); | |
| 183 | expect(() => openMetrics([{ type: "counter", name: "c", help: "", samples: [{ value: 1 }, { value: 2 }] }])).toThrow("duplicate series"); | |
| 184 | }); | |
| 185 | ||
| 186 | test("the same labels in another order are the same series", () => { | |
| 187 | const g = (labels: Record<string, string>[]): Family => ({ | |
| 188 | type: "gauge", | |
| 189 | name: "g", | |
| 190 | help: "", | |
| 191 | samples: labels.map((l) => ({ labels: l, value: 1 })), | |
| 192 | }); | |
| 193 | expect(() => openMetrics([g([{ a: "1", b: "2" }, { b: "2", a: "1" }])])).toThrow("duplicate series"); | |
| 194 | expect(openMetrics([g([{ b: "2", a: "1" }])])).toContain('g{a="1",b="2"} 1'); | |
| 195 | }); | |
| 196 | ||
| 197 | test("sample names may not collide across families", () => { | |
| 198 | const fam = (type: "counter" | "gauge", name: string): Family => ({ type, name, help: "", samples: [] }); | |
| 199 | expect(() => openMetrics([fam("counter", "g"), fam("gauge", "g_total")])).toThrow("collision"); | |
| 200 | expect(() => openMetrics([hist1("h"), fam("gauge", "h_count")])).toThrow("collision"); | |
| 201 | expect(() => openMetrics([hist1("h"), fam("gauge", "h_bucket")])).toThrow("collision"); | |
| 202 | expect(() => openMetrics([hist1("h"), fam("gauge", "h_sum")])).toThrow("collision"); | |
| 203 | expect(() => openMetrics([fam("gauge", "h_count"), hist1("h")])).toThrow("collision"); | |
| 204 | expect(() => openMetrics([fam("counter", "g"), fam("gauge", "g_other")])).not.toThrow(); | |
| 205 | }); | |
| 206 | ||
| 207 | test("an unknown type throws", () => { | |
| 208 | for (const type of ["summary", "", "toString", "__proto__"]) | |
| 209 | expect(() => openMetrics([{ type, name: "x", help: "", samples: [] } as unknown as Family])).toThrow("invalid metric type"); | |
| 210 | }); | |
| 211 | ||
| 212 | test("histograms must be cumulative, rising and without le label", () => { | |
| 213 | const bad = (s: Partial<HistogramSample>) => | |
| 214 | openMetrics([{ type: "histogram", name: "h", help: "", samples: [{ ...base, ...s }] }]); | |
| 215 | expect(() => bad({ buckets: [{ le: 1, count: 3 }, { le: 2, count: 2 }] })).toThrow("cumulative"); | |
| 216 | expect(() => bad({ buckets: [{ le: 2, count: 1 }, { le: 1, count: 2 }] })).toThrow("rising"); | |
| 217 | expect(() => bad({ count: 1 })).toThrow("below"); | |
| 218 | expect(() => bad({ labels: { le: "1" } })).toThrow("le"); | |
| 219 | }); | |
| 220 | ||
| 221 | test("counters reject negatives; special floats are spelled out", () => { | |
| 222 | expect(() => openMetrics([{ type: "counter", name: "c", help: "", samples: [{ value: -1 }] }])).toThrow(); | |
| 223 | expect(openMetrics([{ type: "gauge", name: "g", help: "", samples: [{ value: Infinity }] }])).toContain("g +Inf\n"); | |
| 224 | }); | |
| 225 | }); |
new above
offline
The label set is rendered in insertion order, and this string is the duplicate-series key in openMetrics. {a:1,b:2} and {b:2,a:1} are the same series but get different keys, so the duplicate passes and invalid exposition is written. Sort the pairs by name (also gives stable output) and test it.