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

Merged opened this

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

Merged

scout/forge:scout/3748-src-metrics-openmetrics-ts-writes-the-op → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/3748-src-metrics-openmetrics-ts-writes-the-op was merged into main as 827e8ff.

Review

Sign in to review this change.
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 "";
outdated

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.

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 g (sample g_total) next to a gauge g_total, or a histogram h next to a gauge h_count, emit colliding sample names yet pass. Track emitted sample names too and throw on collision; add tests.

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 # TYPE x <whatever> with gauge samples. Throw on an unknown type at the boundary and test it.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs