1
12import { afterAll, describe, expect, it } from "vitest";
13import { metrics as otelMetrics } from "@opentelemetry/api";
14import type { ReadableSpan } from "@opentelemetry/sdk-trace-base";
15import { MeterProvider, InMemoryMetricExporter, PeriodicExportingMetricReader, AggregationTemporality } from "@opentelemetry/sdk-metrics";
16import { DbRedisMetricsProcessor } from "../otel.js";
17
18let mp: MeterProvider | undefined;
19let reader: PeriodicExportingMetricReader | undefined;
20
21function makeSpan(name: string, attributes: Record<string, unknown>, durationSec: number): ReadableSpan {
22
23 return {
24 name,
25 attributes,
26 duration: [Math.floor(durationSec), Math.round((durationSec % 1) * 1e9)]
27 } as unknown as ReadableSpan;
28}
29
30describe("DbRedisMetricsProcessor (central db/redis recorder)", () => {
31 it("records db_query_duration_seconds from a prisma operation span", async () => {
32 otelMetrics.disable();
33 const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
34 reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
35 mp = new MeterProvider({ readers: [reader] });
36 otelMetrics.setGlobalMeterProvider(mp);
37
38 const proc = new DbRedisMetricsProcessor();
39
40
41 proc.onEnd(
42 makeSpan("prisma:client:operation", { method: "findMany", model: "Page", name: "Page.findMany" }, 0.023)
43 );
44
45 const { resourceMetrics } = await reader.collect();
46 const all = resourceMetrics.scopeMetrics.flatMap((s) => s.metrics);
47 const db = all.find((x) => x.descriptor.name === "db_query_duration_seconds");
48 expect(db, "db_query_duration_seconds present").toBeDefined();
49 const dp = db!.dataPoints[0]!;
50 expect(dp.attributes.model).toBe("Page");
51 expect(dp.attributes.operation).toBe("findMany");
52 expect((dp.value as { sum: number }).sum).toBeCloseTo(0.023, 5);
53 });
54
55 it("records redis_command_duration_seconds from an ioredis command span", async () => {
56 otelMetrics.disable();
57 const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
58 reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
59 mp = new MeterProvider({ readers: [reader] });
60 otelMetrics.setGlobalMeterProvider(mp);
61
62 const proc = new DbRedisMetricsProcessor();
63
64
65 proc.onEnd(
66 makeSpan(
67 "GET",
68 {
69 "db.operation.name": "GET",
70 "db.system.name": "redis",
71 "db.query.text": "GET somekey",
72 "server.address": "localhost",
73 "server.port": 6379
74 },
75 0.0012
76 )
77 );
78
79 const { resourceMetrics } = await reader.collect();
80 const all = resourceMetrics.scopeMetrics.flatMap((s) => s.metrics);
81 const redis = all.find((x) => x.descriptor.name === "redis_command_duration_seconds");
82 expect(redis, "redis_command_duration_seconds present").toBeDefined();
83 const dp = redis!.dataPoints[0]!;
84 expect(dp.attributes.command).toBe("GET");
85 expect((dp.value as { sum: number }).sum).toBeCloseTo(0.0012, 5);
86 });
87
88 it("ignores spans that are neither prisma-operation nor redis", async () => {
89 otelMetrics.disable();
90 const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
91 reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
92 mp = new MeterProvider({ readers: [reader] });
93 otelMetrics.setGlobalMeterProvider(mp);
94
95 const proc = new DbRedisMetricsProcessor();
96 proc.onEnd(makeSpan("api.request", { route: "/x", method: "GET" }, 0.5));
97 proc.onEnd(makeSpan("http.get", { "url.path": "/x" }, 0.5));
98
99 const { resourceMetrics } = await reader.collect();
100 const all = resourceMetrics.scopeMetrics.flatMap((s) => s.metrics);
101 expect(all.find((x) => x.descriptor.name === "db_query_duration_seconds")).toBeUndefined();
102 expect(all.find((x) => x.descriptor.name === "redis_command_duration_seconds")).toBeUndefined();
103 });
104
105 afterAll(async () => {
106 await mp?.shutdown();
107 otelMetrics.disable();
108 });
109});
110