1
12import { describe, expect, it } from "vitest";
13import { METRIC_NAMES, metrics, m, registeredInstruments, setQueueDepth, setWsConnections } from "../metrics.js";
14
15const SPEC_METRICS: { name: string; kind: "counter" | "histogram" | "gauge" }[] = [
16 { name: "http_request_duration_seconds", kind: "histogram" },
17 { name: "http_request_count", kind: "counter" },
18 { name: "db_query_duration_seconds", kind: "histogram" },
19 { name: "redis_command_duration_seconds", kind: "histogram" },
20 { name: "queue_depth", kind: "gauge" },
21 { name: "queue_job_duration_seconds", kind: "histogram" },
22 { name: "ws_active_connections", kind: "gauge" },
23 { name: "ws_messages_total", kind: "counter" },
24 { name: "search_query_duration_seconds", kind: "histogram" },
25 { name: "ai_tokens_total", kind: "counter" },
26 { name: "ai_request_duration_seconds", kind: "histogram" },
27 { name: "storage_ops_total", kind: "counter" },
28 { name: "storage_op_duration_seconds", kind: "histogram" },
29 { name: "error_total", kind: "counter" }
30];
31
32describe("metrics conformance", () => {
33 it("registers EXACTLY the spec's metric names (no more, no fewer)", () => {
34 const specNames = SPEC_METRICS.map((x) => x.name);
35 expect([...METRIC_NAMES].sort()).toEqual([...specNames].sort());
36 });
37
38 it("every registered instrument matches its spec kind", () => {
39 const reg = registeredInstruments();
40 for (const { name, kind } of SPEC_METRICS) {
41 const entry = reg[name];
42 expect(entry, `missing registration for ${name}`).toBeDefined();
43 expect(entry!.kind).toBe(kind);
44 expect(entry!.instrument).toBeTypeOf("object");
45 }
46 });
47
48 it("the instruments are singletons (stable identity across accessors)", () => {
49
50 expect(metrics.httpRequestDuration).toBe(metrics.httpRequestDuration);
51 expect(registeredInstruments()["http_request_count"]!.instrument).toBe(metrics.httpRequestCount);
52 expect(registeredInstruments()["queue_depth"]!.instrument).toBe(metrics.queueDepthGauge);
53 });
54
55 it("instruments created under the no-op global RE-BIND when a real provider is set (the critical no-op-binding guarantee)", async () => {
56
57
58
59 const { metrics: otelMetrics } = await import("@opentelemetry/api");
60 const { MeterProvider, InMemoryMetricExporter, PeriodicExportingMetricReader, AggregationTemporality } = await import(
61 "@opentelemetry/sdk-metrics"
62 );
63
64
65 otelMetrics.disable();
66
67 const before = metrics.errorTotal;
68 m.error.record({ service: "factory-api", code: "X" });
69
70 const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
71 const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
72 const mp = new MeterProvider({ readers: [reader] });
73 otelMetrics.setGlobalMeterProvider(mp);
74
75
76 expect(metrics.errorTotal).not.toBe(before);
77 m.error.record({ service: "factory-api", code: "X" });
78 m.http.record({ route: "/api/v1/pages/:id", method: "GET", status: "200" }, 0.012);
79 const { resourceMetrics } = await reader.collect();
80 const all = resourceMetrics.scopeMetrics.flatMap((s) => s.metrics);
81 const err = all.find((x) => x.descriptor.name === "error_total");
82 const http = all.find((x) => x.descriptor.name === "http_request_count");
83 expect(err, "error_total must be exported after rebind").toBeDefined();
84 expect(err!.dataPoints.length).toBe(1);
85 expect(err!.dataPoints[0]!.value).toBe(1);
86 expect(http, "http_request_count must be exported after rebind").toBeDefined();
87
88
89 setQueueDepth("webhooks", 7);
90 const { resourceMetrics: rm2 } = await reader.collect();
91 const qd = rm2.scopeMetrics.flatMap((s) => s.metrics).find((x) => x.descriptor.name === "queue_depth");
92 expect(qd, "queue_depth gauge must re-bind after the provider swap").toBeDefined();
93 expect(qd!.dataPoints.length).toBe(1);
94 expect(qd!.dataPoints[0]!.value).toBe(7);
95 await mp.shutdown();
96
97 otelMetrics.disable();
98 });
99
100 it("the typed recorders are no-op-safe without a collector (dev contract)", () => {
101
102 expect(() => {
103 m.http.record({ route: "/api/v1/pages/:id", method: "GET", status: "200" }, 0.012);
104 m.db.record({ model: "block", operation: "findMany" }, 0.003);
105 m.redis.record({ command: "GET" }, 0.001);
106 m.queue.setDepth("webhooks", 42);
107 m.queue.recordJob({ queue: "webhooks" }, 0.2);
108 m.ws.setConnections(1234);
109 m.ws.message();
110 m.search.record({ type: "bm25" }, 0.05);
111 m.ai.tokens({ direction: "in", model: "gpt-4o-mini" }, 100);
112 m.ai.request({ model: "gpt-4o-mini" }, 1.5);
113 m.storage.op({ op: "put" }, 0.08);
114 m.error.record({ service: "factory-api", code: "INTERNAL_ERROR" });
115 }).not.toThrow();
116 });
117});
118