import { trace } from "@opentelemetry/api"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { addAttributes, buildSpanContextObject, getTraceContext, recordException, withSpan, } from "./span-utils.js"; describe("span-utils", () => { const tracer = trace.getTracer("test-tracer"); describe("withSpan", () => { it("calls fn and returns its result", { timeout: 5000 }, async () => { const result = await withSpan( tracer, "test.span", async (_span) => { return 42; } ); expect(result).toBe(42); }); it("passes the active span to fn", { timeout: 5000 }, async () => { let receivedSpan: unknown = null; await withSpan(tracer, "test.span", async (span) => { receivedSpan = span; }); expect(receivedSpan).not.toBeNull(); }); it("re-throws errors thrown by fn", { timeout: 5000 }, async () => { const error = new Error("test failure"); await expect( withSpan(tracer, "test.span", async () => { throw error; }) ).rejects.toThrow("test failure"); }); it("records exception on the span when fn throws", { timeout: 5000, }, async () => { const recordedExceptions: unknown[] = []; const fakeSpan = { recordException: (err: unknown) => { recordedExceptions.push(err); }, setStatus: () => {}, end: () => {}, setAttribute: () => {}, }; const patchedTracer = { startActiveSpan( _name: string, _opts: unknown, fn: (span: typeof fakeSpan) => T ): T { return fn(fakeSpan); }, } as unknown as typeof tracer; const error = new TypeError("boom"); await expect( withSpan(patchedTracer, "err.span", async () => { throw error; }) ).rejects.toThrow("boom"); expect(recordedExceptions).toHaveLength(1); expect(recordedExceptions[0]).toBe(error); }); it("sets initial attributes from options", { timeout: 5000, }, async () => { const setAttrs: Record = {}; const fakeSpan = { recordException: () => {}, setStatus: () => {}, end: () => {}, setAttribute: (k: string, v: unknown) => { setAttrs[k] = v; }, }; const patchedTracer = { startActiveSpan( _name: string, opts: { attributes?: Record }, fn: (span: typeof fakeSpan) => T ): T { for (const [k, v] of Object.entries( opts.attributes ?? {} )) { fakeSpan.setAttribute(k, v); } return fn(fakeSpan); }, } as unknown as typeof tracer; await withSpan(patchedTracer, "attr.span", async () => "ok", { attributes: { "test.key": "test-value" }, }); expect(setAttrs["test.key"]).toBe("test-value"); }); }); describe("recordException", () => { it("sets span status to ERROR and records the exception for Error instances", { timeout: 5000, }, () => { const events: unknown[] = []; let statusCode: unknown = null; const span = { recordException: (err: unknown) => { events.push(err); }, setStatus: (s: { code: unknown }) => { statusCode = s.code; }, } as Parameters[0]; const err = new RangeError("out of range"); recordException(span, err); expect(events).toHaveLength(1); expect(events[0]).toBe(err); expect(statusCode).toBe(2); // SpanStatusCode.ERROR === 2 }); it("does not throw for non-Error values", { timeout: 5000 }, () => { const span = { recordException: () => {}, setStatus: () => {}, } as Parameters[0]; expect(() => recordException(span, "string error")).not.toThrow(); expect(() => recordException(span, 42)).not.toThrow(); expect(() => recordException(span, null)).not.toThrow(); expect(() => recordException(span, undefined)).not.toThrow(); expect(() => recordException(span, { code: 500 })).not.toThrow(); }); it("wraps non-Error values in an Error before recording", { timeout: 5000, }, () => { const events: unknown[] = []; const span = { recordException: (err: unknown) => { events.push(err); }, setStatus: () => {}, } as Parameters[0]; recordException(span, "plain string"); expect(events[0]).toBeInstanceOf(Error); expect((events[0] as Error).message).toBe("plain string"); }); }); describe("addAttributes", () => { it("sets all attributes on the span", { timeout: 5000 }, () => { const attrs: Record = {}; const span = { setAttribute: (k: string, v: unknown) => { attrs[k] = v; }, } as Parameters[0]; addAttributes(span, { "http.method": "GET", "http.status_code": 200, "http.url": "https://example.com", }); expect(attrs["http.method"]).toBe("GET"); expect(attrs["http.status_code"]).toBe(200); expect(attrs["http.url"]).toBe("https://example.com"); }); it("skips undefined values", { timeout: 5000 }, () => { const keys: string[] = []; const span = { setAttribute: (k: string) => { keys.push(k); }, } as Parameters[0]; addAttributes(span, { "defined.key": "value", "undefined.key": undefined, }); expect(keys).toContain("defined.key"); expect(keys).not.toContain("undefined.key"); }); it("handles empty attributes map without throwing", { timeout: 5000, }, () => { const span = { setAttribute: () => {}, } as Parameters[0]; expect(() => addAttributes(span, {})).not.toThrow(); }); }); describe("getTraceContext", () => { it("returns empty object when no span is active (no-op tracer)", { timeout: 5000, }, () => { const ctx = getTraceContext(); expect(ctx).toEqual({}); }); it("returns an object (never throws)", { timeout: 5000 }, () => { expect(() => getTraceContext()).not.toThrow(); }); }); describe("buildSpanContextObject", () => { it("returns empty object for undefined span context", { timeout: 5000, }, () => { expect(buildSpanContextObject(undefined)).toEqual({}); }); it("populates traceId when present", { timeout: 5000 }, () => { const result = buildSpanContextObject({ traceId: "4bf92f3577b34da6a3ce929d0e0e4736", spanId: "00f067aa0ba902b7", traceFlags: 1, }); expect(result.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736"); }); it("populates spanId when present", { timeout: 5000 }, () => { const result = buildSpanContextObject({ traceId: "4bf92f3577b34da6a3ce929d0e0e4736", spanId: "00f067aa0ba902b7", traceFlags: 1, }); expect(result.spanId).toBe("00f067aa0ba902b7"); }); it("populates traceFlags when present", { timeout: 5000 }, () => { const result = buildSpanContextObject({ traceId: "4bf92f3577b34da6a3ce929d0e0e4736", spanId: "00f067aa0ba902b7", traceFlags: 1, }); expect(result.traceFlags).toBe(1); }); it("omits traceId when undefined in span context", { timeout: 5000, }, () => { const result = buildSpanContextObject({ traceId: undefined as unknown as string, spanId: "00f067aa0ba902b7", traceFlags: 1, }); expect("traceId" in result).toBe(false); expect(result.spanId).toBe("00f067aa0ba902b7"); expect(result.traceFlags).toBe(1); }); it("omits spanId when undefined in span context", { timeout: 5000, }, () => { const result = buildSpanContextObject({ traceId: "4bf92f3577b34da6a3ce929d0e0e4736", spanId: undefined as unknown as string, traceFlags: 1, }); expect(result.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736"); expect("spanId" in result).toBe(false); expect(result.traceFlags).toBe(1); }); it("omits traceFlags when undefined in span context", { timeout: 5000, }, () => { const result = buildSpanContextObject({ traceId: "4bf92f3577b34da6a3ce929d0e0e4736", spanId: "00f067aa0ba902b7", traceFlags: undefined as unknown as number, }); expect(result.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736"); expect(result.spanId).toBe("00f067aa0ba902b7"); expect("traceFlags" in result).toBe(false); }); it("getTraceContext accepts a span for testability", { timeout: 5000, }, async () => { const tracer = trace.getTracer("test"); await withSpan(tracer, "ctx-test", async (span) => { // The no-op span context has traceId/spanId/traceFlags set to // their zero values; verify getTraceContext accepts it const ctx = getTraceContext(span); expect(typeof ctx).toBe("object"); }); }); }); });