/** * Unit: OTel init contract — disabled-by-default no-op + idempotency + * the enabled path (SDK boots, the global providers get registered, the * scrubber runs on exported spans). * * The ENABLED test uses a real (in-process) TracerProvider via the public * SDK API: we can't assert against a live collector in this environment * (staging-only — recorded), but we CAN assert the SDK actually initialized * and that spans flow through the scrubber (the spec's span-scrubber * assertion at the export boundary). */ import { afterAll, describe, expect, it } from "vitest"; import { context, trace, SpanStatusCode } from "@opentelemetry/api"; import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"; import { initOtel, isOtelEnabled, getOtelState, shutdownOtel, startSpan, startAsyncSpan } from "../otel.js"; import { captureTraceparent, resumeTraceparent, registerTraceparentPropagator } from "../traceparent.js"; describe("initOtel — disabled by default", () => { it("no endpoint → disabled state, no globals registered", async () => { await shutdownOtel(); const saved = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; try { const state = await initOtel({ serviceName: "factory-api" }); expect(state.enabled).toBe(false); expect(state.serviceName).toBe("factory-api"); expect(isOtelEnabled()).toBe(false); expect(getOtelState()?.enabled).toBe(false); } finally { if (saved === undefined) delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; else process.env.OTEL_EXPORTER_OTLP_ENDPOINT = saved; } }); it("is idempotent (second call returns the first state)", async () => { await shutdownOtel(); const saved = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; try { const a = await initOtel({ serviceName: "factory-api", endpoint: "" }); const b = await initOtel({ serviceName: "factory-worker" }); expect(b).toBe(a); expect(b.serviceName).toBe("factory-api"); // first init wins } finally { if (saved === undefined) delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; else process.env.OTEL_EXPORTER_OTLP_ENDPOINT = saved; } }); it("startSpan degrades to a no-op run when disabled", async () => { await shutdownOtel(); const saved = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; try { let ran = false; startSpan("test.op", { email: "a@b.co", workspaceId: "w1" }, () => { ran = true; }); expect(ran).toBe(true); } finally { if (saved === undefined) delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; else process.env.OTEL_EXPORTER_OTLP_ENDPOINT = saved; } }); }); describe("initOtel — enabled path (in-process SDK)", () => { it("boots the SDK, registers the propagator, scrubs exported span attrs, and round-trips a traceparent", async () => { await shutdownOtel(); registerTraceparentPropagator(); const cm = new AsyncLocalStorageContextManager(); cm.enable(); context.setGlobalContextManager(cm); // A real in-process provider (the initOtel enabled path needs a live // OTLP endpoint to boot — staging-only; here we exercise the SAME // primitives initOtel uses, plus the scrubber at the export boundary). // 2.x: span processors attach via the spanProcessors constructor option // (addSpanProcessor() is gone). const exporter = new InMemorySpanExporter(); const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); trace.setGlobalTracerProvider(provider); let tp: string | undefined; startSpan("api.request", { email: "pii@example.com", token: "secret", workspaceId: "ws_1", route: "/x" }, () => { tp = captureTraceparent(); }); // capture: the traceparent carried the active span's ids expect(tp).toBeDefined(); expect(tp).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/); // resume in a "worker": a span started under the resumed context keeps // the SAME trace id let workerTraceId: string | undefined; resumeTraceparent(tp, () => { startSpan("worker.job", { queue: "webhooks" }, () => { const sc = trace.getSpanContext(context.active()); workerTraceId = sc?.traceId; }); }); expect(workerTraceId).toBe(tp!.split("-")[1]); // the scrubber ran at export: no PII left on the exported span const spans = exporter.getFinishedSpans(); const apiSpan = spans.find((s) => s.name === "api.request"); expect(apiSpan).toBeDefined(); expect(apiSpan!.attributes["email"]).toBeUndefined(); expect(apiSpan!.attributes["token"]).toBeUndefined(); expect(apiSpan!.attributes["workspaceId"]).toBe("ws_1"); await provider.shutdown(); }); it("startAsyncSpan ends the span on reject and marks ERROR", async () => { trace.disable(); // the previous test's (shut down) provider still holds the global const exporter = new InMemorySpanExporter(); const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); trace.setGlobalTracerProvider(provider); await expect( startAsyncSpan("op", {}, async () => { throw new Error("boom"); }) ).rejects.toThrow("boom"); const [span] = exporter.getFinishedSpans(); expect(span).toBeDefined(); expect(span!.status.code).toBe(SpanStatusCode.ERROR); await provider.shutdown(); }); afterAll(async () => { await shutdownOtel(); }); });