diff --git a/packages/core-testing/src/instrumentation/recording-tracer.test.ts b/packages/core-testing/src/instrumentation/recording-tracer.test.ts new file mode 100644 index 0000000..b571288 --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-tracer.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { RecordingTracer } from "@/instrumentation/recording-tracer"; + +describe("RecordingTracer", () => { + it("records every startSpan call with name, op, attributes, status, durationMs", async () => { + const tracer = new RecordingTracer(); + await tracer.startSpan( + { name: "blog.getArticles", op: "use-case", attributes: { limit: 10 } }, + async (span) => { + span.setAttribute("count", 3); + span.setStatus("ok"); + return undefined; + }, + ); + expect(tracer.spans).toHaveLength(1); + const s = tracer.spans[0]!; + expect(s.name).toBe("blog.getArticles"); + expect(s.op).toBe("use-case"); + expect(s.attributes).toMatchObject({ limit: 10, count: 3 }); + expect(s.status).toBe("ok"); + expect(typeof s.durationMs).toBe("number"); + expect(s.durationMs).toBeGreaterThanOrEqual(0); + }); + + it("records error status when fn throws", async () => { + const tracer = new RecordingTracer(); + await expect( + tracer.startSpan({ name: "x" }, async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(tracer.spans).toHaveLength(1); + expect(tracer.spans[0]!.status).toBe("error"); + expect(tracer.spans[0]!.statusMessage).toBe("boom"); + }); + + it("records error status when set explicitly via span.setStatus", async () => { + const tracer = new RecordingTracer(); + await tracer.startSpan({ name: "x" }, async (span) => { + span.setStatus("error", "validation failed"); + return undefined; + }); + expect(tracer.spans[0]!.status).toBe("error"); + expect(tracer.spans[0]!.statusMessage).toBe("validation failed"); + }); + + it("reset() clears recorded spans", async () => { + const tracer = new RecordingTracer(); + await tracer.startSpan({ name: "x" }, async () => undefined); + expect(tracer.spans).toHaveLength(1); + tracer.reset(); + expect(tracer.spans).toHaveLength(0); + }); + + it("findSpan returns first matching span by name", async () => { + const tracer = new RecordingTracer(); + await tracer.startSpan({ name: "a" }, async () => undefined); + await tracer.startSpan({ name: "b" }, async () => undefined); + expect(tracer.findSpan("b")).toBeDefined(); + expect(tracer.findSpan("missing")).toBeUndefined(); + }); + + it("nested spans are recorded in order (children appear after parent end)", async () => { + const tracer = new RecordingTracer(); + await tracer.startSpan({ name: "parent" }, async () => { + await tracer.startSpan({ name: "child" }, async () => undefined); + }); + expect(tracer.spans.map((s) => s.name)).toEqual(["child", "parent"]); + }); +}); diff --git a/packages/core-testing/src/instrumentation/recording-tracer.ts b/packages/core-testing/src/instrumentation/recording-tracer.ts new file mode 100644 index 0000000..31a1c36 --- /dev/null +++ b/packages/core-testing/src/instrumentation/recording-tracer.ts @@ -0,0 +1,59 @@ +import type { + ITracer, + ISpan, + SpanOpts, + AttributeValue, +} from "@repo/core-shared/instrumentation"; + +export type RecordedSpan = { + name: string; + op?: string; + attributes: Record; + status: "ok" | "error"; + statusMessage?: string; + durationMs: number; +}; + +export class RecordingTracer implements ITracer { + spans: RecordedSpan[] = []; + + async startSpan(opts: SpanOpts, fn: (span: ISpan) => Promise): Promise { + const start = performance.now(); + const recorded: RecordedSpan = { + name: opts.name, + op: opts.op, + attributes: { ...(opts.attributes ?? {}) }, + status: "ok", + durationMs: 0, + }; + const span: ISpan = { + setAttribute(key, value) { + recorded.attributes[key] = value; + }, + setStatus(status, message) { + recorded.status = status; + recorded.statusMessage = message; + }, + }; + try { + const result = await fn(span); + recorded.durationMs = performance.now() - start; + this.spans.push(recorded); + return result; + } catch (err) { + recorded.status = "error"; + recorded.statusMessage = err instanceof Error ? err.message : String(err); + recorded.durationMs = performance.now() - start; + this.spans.push(recorded); + throw err; + } + } + + reset(): void { + this.spans = []; + } + + findSpan(name: string): RecordedSpan | undefined { + return this.spans.find((s) => s.name === name); + } +}