/** * Unit: the central db/redis metric recorder (TASK-0135). * * `DbRedisMetricsProcessor` turns the AUTO-INSTRUMENTED spans * (@prisma/instrumentation operation spans + ioredis command spans) into the * spec histograms `db_query_duration_seconds` / `redis_command_duration_seconds` * at `onEnd`. This test drives `onEnd` with the exact span shapes those * instrumentations emit (verified against @prisma/instrumentation 5.22.0 + * @opentelemetry/instrumentation-ioredis 0.70.0) and asserts the metrics land * in a real MeterProvider with the right labels + duration. */ import { afterAll, describe, expect, it } from "vitest"; import { metrics as otelMetrics } from "@opentelemetry/api"; import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"; import { MeterProvider, InMemoryMetricExporter, PeriodicExportingMetricReader, AggregationTemporality } from "@opentelemetry/sdk-metrics"; import { DbRedisMetricsProcessor } from "../otel.js"; let mp: MeterProvider | undefined; let reader: PeriodicExportingMetricReader | undefined; function makeSpan(name: string, attributes: Record, durationSec: number): ReadableSpan { // Minimal ReadableSpan: onEnd only reads name/attributes/duration. return { name, attributes, duration: [Math.floor(durationSec), Math.round((durationSec % 1) * 1e9)] } as unknown as ReadableSpan; } describe("DbRedisMetricsProcessor (central db/redis recorder)", () => { it("records db_query_duration_seconds from a prisma operation span", async () => { otelMetrics.disable(); const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 }); mp = new MeterProvider({ readers: [reader] }); otelMetrics.setGlobalMeterProvider(mp); const proc = new DbRedisMetricsProcessor(); // @prisma/instrumentation emits name "prisma:client:operation" with // attributes { method: , model: , name: "Model.Action" }. proc.onEnd( makeSpan("prisma:client:operation", { method: "findMany", model: "Page", name: "Page.findMany" }, 0.023) ); const { resourceMetrics } = await reader.collect(); const all = resourceMetrics.scopeMetrics.flatMap((s) => s.metrics); const db = all.find((x) => x.descriptor.name === "db_query_duration_seconds"); expect(db, "db_query_duration_seconds present").toBeDefined(); const dp = db!.dataPoints[0]!; expect(dp.attributes.model).toBe("Page"); expect(dp.attributes.operation).toBe("findMany"); expect((dp.value as { sum: number }).sum).toBeCloseTo(0.023, 5); }); it("records redis_command_duration_seconds from an ioredis command span", async () => { otelMetrics.disable(); const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 }); mp = new MeterProvider({ readers: [reader] }); otelMetrics.setGlobalMeterProvider(mp); const proc = new DbRedisMetricsProcessor(); // ioredis emits span.name = cmd.name and attributes db.system.name="redis", // db.operation.name=, db.query.text=, server.address/port. proc.onEnd( makeSpan( "GET", { "db.operation.name": "GET", "db.system.name": "redis", "db.query.text": "GET somekey", "server.address": "localhost", "server.port": 6379 }, 0.0012 ) ); const { resourceMetrics } = await reader.collect(); const all = resourceMetrics.scopeMetrics.flatMap((s) => s.metrics); const redis = all.find((x) => x.descriptor.name === "redis_command_duration_seconds"); expect(redis, "redis_command_duration_seconds present").toBeDefined(); const dp = redis!.dataPoints[0]!; expect(dp.attributes.command).toBe("GET"); expect((dp.value as { sum: number }).sum).toBeCloseTo(0.0012, 5); }); it("ignores spans that are neither prisma-operation nor redis", async () => { otelMetrics.disable(); const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 }); mp = new MeterProvider({ readers: [reader] }); otelMetrics.setGlobalMeterProvider(mp); const proc = new DbRedisMetricsProcessor(); proc.onEnd(makeSpan("api.request", { route: "/x", method: "GET" }, 0.5)); proc.onEnd(makeSpan("http.get", { "url.path": "/x" }, 0.5)); const { resourceMetrics } = await reader.collect(); const all = resourceMetrics.scopeMetrics.flatMap((s) => s.metrics); expect(all.find((x) => x.descriptor.name === "db_query_duration_seconds")).toBeUndefined(); expect(all.find((x) => x.descriptor.name === "redis_command_duration_seconds")).toBeUndefined(); }); afterAll(async () => { await mp?.shutdown(); otelMetrics.disable(); }); });