1
12import { afterAll, describe, expect, it } from "vitest";
13import { context, trace, SpanStatusCode } from "@opentelemetry/api";
14import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
15import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
16import { initOtel, isOtelEnabled, getOtelState, shutdownOtel, startSpan, startAsyncSpan } from "../otel.js";
17import { captureTraceparent, resumeTraceparent, registerTraceparentPropagator } from "../traceparent.js";
18
19describe("initOtel — disabled by default", () => {
20 it("no endpoint → disabled state, no globals registered", async () => {
21 await shutdownOtel();
22 const saved = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
23 delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
24 try {
25 const state = await initOtel({ serviceName: "factory-api" });
26 expect(state.enabled).toBe(false);
27 expect(state.serviceName).toBe("factory-api");
28 expect(isOtelEnabled()).toBe(false);
29 expect(getOtelState()?.enabled).toBe(false);
30 } finally {
31 if (saved === undefined) delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
32 else process.env.OTEL_EXPORTER_OTLP_ENDPOINT = saved;
33 }
34 });
35
36 it("is idempotent (second call returns the first state)", async () => {
37 await shutdownOtel();
38 const saved = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
39 delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
40 try {
41 const a = await initOtel({ serviceName: "factory-api", endpoint: "" });
42 const b = await initOtel({ serviceName: "factory-worker" });
43 expect(b).toBe(a);
44 expect(b.serviceName).toBe("factory-api");
45 } finally {
46 if (saved === undefined) delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
47 else process.env.OTEL_EXPORTER_OTLP_ENDPOINT = saved;
48 }
49 });
50
51 it("startSpan degrades to a no-op run when disabled", async () => {
52 await shutdownOtel();
53 const saved = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
54 delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
55 try {
56 let ran = false;
57 startSpan("test.op", { email: "a@b.co", workspaceId: "w1" }, () => {
58 ran = true;
59 });
60 expect(ran).toBe(true);
61 } finally {
62 if (saved === undefined) delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
63 else process.env.OTEL_EXPORTER_OTLP_ENDPOINT = saved;
64 }
65 });
66});
67
68describe("initOtel — enabled path (in-process SDK)", () => {
69 it("boots the SDK, registers the propagator, scrubs exported span attrs, and round-trips a traceparent", async () => {
70 await shutdownOtel();
71 registerTraceparentPropagator();
72 const cm = new AsyncLocalStorageContextManager();
73 cm.enable();
74 context.setGlobalContextManager(cm);
75
76
77
78
79
80
81 const exporter = new InMemorySpanExporter();
82 const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] });
83 trace.setGlobalTracerProvider(provider);
84
85 let tp: string | undefined;
86 startSpan("api.request", { email: "pii@example.com", token: "secret", workspaceId: "ws_1", route: "/x" }, () => {
87 tp = captureTraceparent();
88 });
89
90
91 expect(tp).toBeDefined();
92 expect(tp).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
93
94
95
96 let workerTraceId: string | undefined;
97 resumeTraceparent(tp, () => {
98 startSpan("worker.job", { queue: "webhooks" }, () => {
99 const sc = trace.getSpanContext(context.active());
100 workerTraceId = sc?.traceId;
101 });
102 });
103 expect(workerTraceId).toBe(tp!.split("-")[1]);
104
105
106 const spans = exporter.getFinishedSpans();
107 const apiSpan = spans.find((s) => s.name === "api.request");
108 expect(apiSpan).toBeDefined();
109 expect(apiSpan!.attributes["email"]).toBeUndefined();
110 expect(apiSpan!.attributes["token"]).toBeUndefined();
111 expect(apiSpan!.attributes["workspaceId"]).toBe("ws_1");
112
113 await provider.shutdown();
114 });
115
116 it("startAsyncSpan ends the span on reject and marks ERROR", async () => {
117 trace.disable();
118 const exporter = new InMemorySpanExporter();
119 const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] });
120 trace.setGlobalTracerProvider(provider);
121 await expect(
122 startAsyncSpan("op", {}, async () => {
123 throw new Error("boom");
124 })
125 ).rejects.toThrow("boom");
126 const [span] = exporter.getFinishedSpans();
127 expect(span).toBeDefined();
128 expect(span!.status.code).toBe(SpanStatusCode.ERROR);
129 await provider.shutdown();
130 });
131
132 afterAll(async () => {
133 await shutdownOtel();
134 });
135});
136