From 0ffda8078f022a39ff54efc957be61d4a3be18a6 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 6 May 2026 23:41:54 +0200 Subject: [PATCH] feat(core-shared): add withSpan higher-order helper --- .../src/instrumentation/with-span.test.ts | 62 +++++++++++++++++++ .../src/instrumentation/with-span.ts | 12 ++++ 2 files changed, 74 insertions(+) create mode 100644 packages/core-shared/src/instrumentation/with-span.test.ts create mode 100644 packages/core-shared/src/instrumentation/with-span.ts diff --git a/packages/core-shared/src/instrumentation/with-span.test.ts b/packages/core-shared/src/instrumentation/with-span.test.ts new file mode 100644 index 0000000..bb87a03 --- /dev/null +++ b/packages/core-shared/src/instrumentation/with-span.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from "vitest"; +import { withSpan } from "@/instrumentation/with-span"; +import type { ITracer, ISpan, SpanOpts } from "@/instrumentation/tracer.interface"; + +function makeRecordingTracer() { + const calls: SpanOpts[] = []; + const tracer: ITracer = { + startSpan: vi.fn(async (opts, fn) => { + calls.push(opts); + const span: ISpan = { setAttribute: () => {}, setStatus: () => {} }; + return fn(span); + }), + }; + return { tracer, calls }; +} + +describe("withSpan", () => { + it("wraps fn with a span using static opts", async () => { + const { tracer, calls } = makeRecordingTracer(); + const fn = async (a: number, b: number) => a + b; + const wrapped = withSpan(tracer, { name: "test.add", op: "use-case" }, fn); + const result = await wrapped(2, 3); + expect(result).toBe(5); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ name: "test.add", op: "use-case" }); + }); + + it("wraps fn with span opts derived from args (function form)", async () => { + const { tracer, calls } = makeRecordingTracer(); + const fn = async (id: string) => `result-${id}`; + const wrapped = withSpan( + tracer, + ([id]) => ({ name: "test.byId", op: "repository", attributes: { id } }), + fn, + ); + const result = await wrapped("abc"); + expect(result).toBe("result-abc"); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + name: "test.byId", + op: "repository", + attributes: { id: "abc" }, + }); + }); + + it("propagates errors thrown by fn", async () => { + const { tracer } = makeRecordingTracer(); + const wrapped = withSpan(tracer, { name: "test.err" }, async () => { + throw new Error("boom"); + }); + await expect(wrapped()).rejects.toThrow("boom"); + }); + + it("preserves identity across multiple invocations (closure stable)", async () => { + const { tracer, calls } = makeRecordingTracer(); + const wrapped = withSpan(tracer, { name: "test.same" }, async (n: number) => n); + await wrapped(1); + await wrapped(2); + expect(calls).toHaveLength(2); + expect(calls.every((c) => c.name === "test.same")).toBe(true); + }); +}); diff --git a/packages/core-shared/src/instrumentation/with-span.ts b/packages/core-shared/src/instrumentation/with-span.ts new file mode 100644 index 0000000..5867660 --- /dev/null +++ b/packages/core-shared/src/instrumentation/with-span.ts @@ -0,0 +1,12 @@ +import type { ITracer, SpanOpts } from "./tracer.interface"; + +export function withSpan( + tracer: ITracer, + opts: SpanOpts | ((args: Args) => SpanOpts), + fn: (...args: Args) => Promise, +): (...args: Args) => Promise { + return (...args) => { + const resolved = typeof opts === "function" ? opts(args) : opts; + return tracer.startSpan(resolved, () => fn(...args)); + }; +}