1import * as NodeSdk from "@effect/opentelemetry/NodeSdk"
2import * as OtelTracer from "@effect/opentelemetry/OtelTracer"
3import { assert, describe, it } from "@effect/vitest"
4import * as OtelApi from "@opentelemetry/api"
5import { AsyncHooksContextManager } from "@opentelemetry/context-async-hooks"
6import { InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
7import * as Cause from "effect/Cause"
8import * as EffectContext from "effect/Context"
9import * as Effect from "effect/Effect"
10import * as Option from "effect/Option"
11import * as EffectTracer from "effect/Tracer"
12
13const TracingLayer = NodeSdk.layer(Effect.sync(() => ({
14 resource: {
15 serviceName: "test"
16 },
17 spanProcessor: [new SimpleSpanProcessor(new InMemorySpanExporter())]
18})))
19
20// needed to test context propagation
21const contextManager = new AsyncHooksContextManager()
22OtelApi.context.setGlobalContextManager(contextManager)
23
24describe("Tracer", () => {
25 describe("provided", () => {
26 it.effect("withSpan", () =>
27 Effect.gen(function*() {
28 const span = yield* Effect.currentSpan
29 assert.instanceOf(span, OtelTracer.OtelSpan)
30 }).pipe(
31 Effect.withSpan("ok"),
32 Effect.provide(TracingLayer)
33 ))
34
35 it.effect("withSpan links", () =>
36 Effect.gen(function*() {
37 const linkedSpan = yield* Effect.makeSpanScoped("B")
38 const span = yield* Effect.currentSpan.pipe(
39 Effect.withSpan("A"),
40 Effect.linkSpans(linkedSpan)
41 )
42 assert.instanceOf(span, OtelTracer.OtelSpan)
43 assert.lengthOf(span.links, 1)
44 }).pipe(
45 Effect.provide(TracingLayer)
46 ))
47
48 it.effect("nested withSpan sets correct parent chain", () =>
49 Effect.gen(function*() {
50 const child = yield* Effect.currentSpan.pipe(
51 Effect.withSpan("child"),
52 Effect.withSpan("parent")
53 )
54 assert.instanceOf(child, OtelTracer.OtelSpan)
55 assert.strictEqual(child.name, "child")
56 assert.isDefined(child.parent)
57 assert.strictEqual((child.parent.valueOrUndefined! as OtelTracer.OtelSpan).name, "parent")
58 }).pipe(
59 Effect.provide(TracingLayer)
60 ))
61
62 it.effect("supervisor sets context", () =>
63 Effect.sync(() => {
64 const context = OtelApi.context.active()
65 assert.isDefined(OtelApi.trace.getSpan(context))
66 }).pipe(
67 Effect.withSpan("ok"),
68 Effect.provide(TracingLayer)
69 ))
70
71 it.effect("supervisor sets context generator", () =>
72 Effect.gen(function*() {
73 yield* Effect.yieldNow
74 const context = OtelApi.context.active()
75 assert.isDefined(OtelApi.trace.getSpan(context))
76 }).pipe(
77 Effect.withSpan("ok"),
78 Effect.provide(TracingLayer)
79 ))
80
81 it.effect("currentOtelSpan", () =>
82 Effect.gen(function*() {
83 const span = yield* Effect.currentSpan
84 const otelSpan = yield* OtelTracer.currentOtelSpan
85 assert.strictEqual((span as OtelTracer.OtelSpan).span, otelSpan)
86 }).pipe(
87 Effect.withSpan("ok"),
88 Effect.provide(TracingLayer)
89 ))
90
91 it.effect.each([OtelApi.SpanStatusCode.UNSET, OtelApi.SpanStatusCode.OK])(
92 "honors non-error wrapper status %s",
93 (status) =>
94 Effect.gen(function*() {
95 const span = yield* Effect.currentSpan
96 const wrapper = yield* OtelTracer.currentOtelSpan
97 wrapper.setStatus({ code: status })
98 wrapper.end()
99 assert.strictEqual(span.status._tag, "Ended")
100 if (span.status._tag === "Ended") {
101 assert.strictEqual(span.status.exit._tag, "Success")
102 }
103 }).pipe(Effect.withSpan("repro"))
104 )
105
106 it.effect("honors an error wrapper status", () =>
107 Effect.gen(function*() {
108 const span = yield* Effect.currentSpan
109 const wrapper = yield* OtelTracer.currentOtelSpan
110 wrapper.setStatus({ code: OtelApi.SpanStatusCode.ERROR })
111 wrapper.end()
112 assert.strictEqual(span.status._tag, "Ended")
113 if (span.status._tag === "Ended") {
114 assert.strictEqual(span.status.exit._tag, "Failure")
115 }
116 }).pipe(Effect.withSpan("repro")))
117
118 it.effect("preserves the sampling decision of generic external spans", () =>
119 Effect.gen(function*() {
120 const span = yield* Effect.currentSpan
121 assert.instanceOf(span, OtelTracer.OtelSpan)
122 assert.strictEqual(span.span.spanContext().traceFlags, OtelApi.TraceFlags.NONE)
123 }).pipe(
124 Effect.withSpan("child"),
125 Effect.withParentSpan(EffectTracer.externalSpan({
126 traceId: "1".repeat(32),
127 spanId: "2".repeat(16),
128 sampled: false
129 })),
130 Effect.provide(TracingLayer)
131 ))
132
133 it("preserves trace state and locality on an active OpenTelemetry parent", () => {
134 const parent: OtelApi.SpanContext = {
135 traceId: "1".repeat(32),
136 spanId: "2".repeat(16),
137 traceFlags: OtelApi.TraceFlags.SAMPLED,
138 traceState: OtelApi.createTraceState("vendor=value"),
139 isRemote: false
140 }
141 const active = OtelApi.trace.setSpanContext(OtelApi.ROOT_CONTEXT, parent)
142 let receivedParent: OtelApi.SpanContext | undefined
143 const tracer = {
144 startSpan(_name: string, _options: unknown, context: OtelApi.Context) {
145 receivedParent = OtelApi.trace.getSpanContext(context)
146 return {
147 spanContext: () => ({
148 traceId: "3".repeat(32),
149 spanId: "4".repeat(16),
150 traceFlags: OtelApi.TraceFlags.SAMPLED
151 })
152 } as OtelApi.Span
153 }
154 } as OtelApi.Tracer
155
156 const child = new OtelTracer.OtelSpan(
157 { active: () => active } as OtelApi.ContextAPI,
158 OtelApi.trace,
159 tracer,
160 {
161 name: "child",
162 parent: Option.none(),
163 annotations: EffectContext.empty(),
164 links: [],
165 startTime: 0n,
166 kind: "internal",
167 root: false,
168 sampled: true
169 }
170 )
171
172 assert.instanceOf(child, OtelTracer.OtelSpan)
173 assert.deepStrictEqual(
174 [receivedParent?.traceState?.serialize(), receivedParent?.isRemote],
175 ["vendor=value", false]
176 )
177 })
178
179 it.effect("records every pretty error", () =>
180 Effect.gen(function*() {
181 const exporter = new InMemorySpanExporter()
182 const spanProcessor = new SimpleSpanProcessor(exporter)
183 const firstFailure = Cause.fail(new Error("first"))
184 const secondFailure = Cause.fail(new Error("second"))
185 const cause = Cause.combine(firstFailure, secondFailure)
186
187 yield* Effect.failCause(cause).pipe(
188 Effect.withSpan("error-span"),
189 Effect.andThen(Effect.never), // keep the exporter alive
190 Effect.provide(NodeSdk.layer(() => ({
191 resource: {
192 serviceName: "test"
193 },
194 spanProcessor: [spanProcessor]
195 }))),
196 Effect.forkChild({ startImmediately: true })
197 )
198
199 const spanData = exporter.getFinishedSpans()[0]
200 if (spanData === undefined) {
201 return yield* Effect.die("Missing span data")
202 }
203 const exceptionEvents = spanData.events.filter((event) => event.name === "exception")
204 assert.lengthOf(exceptionEvents, 2)
205 assert.strictEqual(spanData.status.message, "first")
206 }))
207
208 it.effect("renders nested error causes in the stacktrace", () =>
209 Effect.gen(function*() {
210 const exporter = new InMemorySpanExporter()
211 const spanProcessor = new SimpleSpanProcessor(exporter)
212 const error = new Error("outer failure", { cause: new Error("inner cause") })
213
214 yield* Effect.die(error).pipe(
215 Effect.withSpan("error-span"),
216 Effect.andThen(Effect.never), // keep the exporter alive
217 Effect.provide(NodeSdk.layer(() => ({
218 resource: {
219 serviceName: "test"
220 },
221 spanProcessor: [spanProcessor]
222 }))),
223 Effect.forkChild({ startImmediately: true })
224 )
225
226 const spanData = exporter.getFinishedSpans()[0]
227 if (spanData === undefined) {
228 return yield* Effect.die("Missing span data")
229 }
230 const exceptionEvent = spanData.events.find((event) => event.name === "exception")
231 assert(exceptionEvent !== undefined)
232 const stacktrace = exceptionEvent.attributes?.["exception.stacktrace"]
233 assert.isString(stacktrace)
234 assert.include(stacktrace as string, "[cause]: Error: inner cause")
235 }))
236
237 it.effect("withSpanContext", () =>
238 Effect.gen(function*() {
239 const effect = Effect.gen(function*() {
240 const span = yield* Effect.currentParentSpan
241 assert(span._tag === "Span")
242 if (span.parent._tag === "None") {
243 return yield* Effect.die("No parent span")
244 }
245 return span.parent.value
246 }).pipe(Effect.withSpan("child"))
247
248 const services = yield* Effect.context<never>()
249
250 yield* Effect.promise(async () => {
251 await OtelApi.trace.getTracer("test").startActiveSpan("otel-span", {
252 root: true,
253 attributes: { "root": "yes" }
254 }, async (span) => {
255 try {
256 const parent = await effect.pipe(
257 OtelTracer.withSpanContext(span.spanContext()),
258 Effect.provideContext(services),
259 Effect.runPromise
260 )
261 const { spanId, traceId } = span.spanContext()
262 assert.containsSubset(parent, {
263 spanId,
264 traceId
265 })
266 } finally {
267 span.end()
268 }
269 })
270 })
271 }).pipe(
272 Effect.provide(TracingLayer)
273 ))
274 })
275
276 describe("not provided", () => {
277 it.effect("withSpan", () =>
278 Effect.gen(function*() {
279 const span = yield* Effect.currentSpan
280 assert.notInstanceOf(span, OtelTracer.OtelSpan)
281 }).pipe(
282 Effect.withSpan("ok")
283 ))
284 })
285})