From f2627890bebaeec1d2feb7220e247c57f863ca7c Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 11:59:46 +0200 Subject: [PATCH] feat(core-testing): RecordingMetrics test double --- .../core-testing/src/instrumentation/index.ts | 1 + .../instrumentation/recording-metrics.test.ts | 89 +++++++++++++++++++ .../src/instrumentation/recording-metrics.ts | 66 ++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 packages/core-testing/src/instrumentation/recording-metrics.test.ts create mode 100644 packages/core-testing/src/instrumentation/recording-metrics.ts diff --git a/packages/core-testing/src/instrumentation/index.ts b/packages/core-testing/src/instrumentation/index.ts index 60ee39f..c9e8673 100644 --- a/packages/core-testing/src/instrumentation/index.ts +++ b/packages/core-testing/src/instrumentation/index.ts @@ -1,5 +1,6 @@ export { RecordingTracer, type RecordedSpan } from "./recording-tracer"; export { RecordingLogger, type RecordedCapture } from "./recording-logger"; +export { RecordingMetrics, type RecordedMetric } from "./recording-metrics"; export { RecordingJobQueue } from "./recording-job-queue"; export { RecordingEventBus } from "./recording-event-bus"; export { RecordingRealtimeBroadcaster } from "./recording-realtime-broadcaster"; diff --git a/packages/core-testing/src/instrumentation/recording-metrics.test.ts b/packages/core-testing/src/instrumentation/recording-metrics.test.ts new file mode 100644 index 0000000..6be6ec6 --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-metrics.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { RecordingMetrics } from "@/instrumentation/recording-metrics"; + +describe("RecordingMetrics", () => { + it("records counter calls with kind, name, value, and attributes", () => { + const recording = new RecordingMetrics(); + recording.counter("http.requests", 1, { method: "GET", route: "/api/me" }); + + expect(recording.metrics).toHaveLength(1); + const [m] = recording.metrics; + expect(m!.kind).toBe("counter"); + expect(m!.name).toBe("http.requests"); + expect(m!.value).toBe(1); + expect(m!.attributes).toEqual({ method: "GET", route: "/api/me" }); + }); + + it("counter() defaults value to 1 when omitted", () => { + const recording = new RecordingMetrics(); + recording.counter("events.signups"); + + expect(recording.metrics[0]!.value).toBe(1); + }); + + it("records histogram calls", () => { + const recording = new RecordingMetrics(); + recording.histogram("http.duration", 123, { route: "/api/list" }); + + expect(recording.metrics).toHaveLength(1); + const [m] = recording.metrics; + expect(m!.kind).toBe("histogram"); + expect(m!.name).toBe("http.duration"); + expect(m!.value).toBe(123); + expect(m!.attributes).toEqual({ route: "/api/list" }); + }); + + it("records gauge calls", () => { + const recording = new RecordingMetrics(); + recording.gauge("queue.depth", 42, { queue: "emails" }); + + expect(recording.metrics).toHaveLength(1); + const [m] = recording.metrics; + expect(m!.kind).toBe("gauge"); + expect(m!.name).toBe("queue.depth"); + expect(m!.value).toBe(42); + expect(m!.attributes).toEqual({ queue: "emails" }); + }); + + it("accumulates multiple calls across all kinds", () => { + const recording = new RecordingMetrics(); + recording.counter("a"); + recording.histogram("b", 10); + recording.gauge("c", 5); + + expect(recording.metrics).toHaveLength(3); + expect(recording.metrics.map((m) => m.kind)).toEqual([ + "counter", + "histogram", + "gauge", + ]); + }); + + it("reset() clears all recorded metrics", () => { + const recording = new RecordingMetrics(); + recording.counter("x"); + recording.histogram("y", 1); + expect(recording.metrics).toHaveLength(2); + + recording.reset(); + expect(recording.metrics).toHaveLength(0); + }); + + it("find() returns the first matching metric", () => { + const recording = new RecordingMetrics(); + recording.counter("a"); + recording.histogram("b", 10); + + const found = recording.find((m) => m.kind === "histogram"); + expect(found).toBeDefined(); + expect(found!.name).toBe("b"); + }); + + it("find() returns undefined when no metric matches", () => { + const recording = new RecordingMetrics(); + recording.counter("a"); + + const found = recording.find((m) => m.kind === "gauge"); + expect(found).toBeUndefined(); + }); +}); diff --git a/packages/core-testing/src/instrumentation/recording-metrics.ts b/packages/core-testing/src/instrumentation/recording-metrics.ts new file mode 100644 index 0000000..2d40f78 --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-metrics.ts @@ -0,0 +1,66 @@ +// Local type alias matching the contract in @repo/core-shared/instrumentation. +// Kept inline to avoid a build-graph cycle between core-testing and core-shared. +type MetricAttributeValue = string | number | boolean; + +interface IMetrics { + counter( + name: string, + value?: number, + attributes?: Record, + ): void; + histogram( + name: string, + value: number, + attributes?: Record, + ): void; + gauge( + name: string, + value: number, + attributes?: Record, + ): void; +} + +export type RecordedMetric = { + kind: "counter" | "histogram" | "gauge"; + name: string; + value: number; + attributes: Record; +}; + +export class RecordingMetrics implements IMetrics { + metrics: RecordedMetric[] = []; + + counter( + name: string, + value = 1, + attributes: Record = {}, + ): void { + this.metrics.push({ kind: "counter", name, value, attributes }); + } + + histogram( + name: string, + value: number, + attributes: Record = {}, + ): void { + this.metrics.push({ kind: "histogram", name, value, attributes }); + } + + gauge( + name: string, + value: number, + attributes: Record = {}, + ): void { + this.metrics.push({ kind: "gauge", name, value, attributes }); + } + + reset(): void { + this.metrics = []; + } + + find( + predicate: (m: RecordedMetric) => boolean, + ): RecordedMetric | undefined { + return this.metrics.find(predicate); + } +}