1import { assert, describe, it } from "@effect/vitest"
2import { assertNone, deepStrictEqual, strictEqual } from "@effect/vitest/utils"
3import { Cause, Context, Duration, Effect, Exit, Fiber, Layer, Scheduler, Tracer } from "effect"
4import { HttpClient, HttpClientResponse } from "effect/http"
5import { OtlpSerialization, OtlpTracer } from "effect/observability"
6import { TestClock } from "effect/testing"
7import type { Span } from "effect/Tracer"
8
9const getParent = (span: Tracer.Span): Tracer.AnySpan => {
10 if (span.parent._tag === "None") {
11 throw new Error("Expected parent span")
12 }
13 return span.parent.value
14}
15
16const otlpTracerLayer = OtlpTracer.layer({
17 url: "http://localhost:4318/v1/traces",
18 resource: {
19 serviceName: "test-service"
20 }
21}).pipe(
22 Layer.provide(OtlpSerialization.layerJson),
23 Layer.provide(Layer.succeed(
24 HttpClient.HttpClient,
25 HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response())))
26 ))
27)
28
29const makeRootAndChildSpans = Effect.gen(function*() {
30 const root = yield* Effect.withSpan(Effect.currentSpan, "root")
31 const child = yield* Effect.withSpan(Effect.currentSpan, "child", { parent: root })
32 return [root, child] as const
33})
34
35const assertSpanIdentifiers = (root: Tracer.Span, child: Tracer.Span) => {
36 assert.match(root.traceId, /^[0-9a-f]{32}$/)
37 assert.match(root.spanId, /^[0-9a-f]{16}$/)
38 strictEqual(child.traceId, root.traceId)
39 assert.match(child.spanId, /^[0-9a-f]{16}$/)
40}
41
42describe("Tracer", () => {
43 describe("span identifiers", () => {
44 it.effect("generates native span identifiers", () =>
45 Effect.gen(function*() {
46 const [root, child] = yield* makeRootAndChildSpans
47 assertSpanIdentifiers(root, child)
48 }))
49
50 it.effect("generates OTLP span identifiers", () =>
51 Effect.gen(function*() {
52 const [root, child] = yield* makeRootAndChildSpans
53 assert.notInstanceOf(root, Tracer.NativeSpan)
54 assert.notInstanceOf(child, Tracer.NativeSpan)
55 assertSpanIdentifiers(root, child)
56 }).pipe(Effect.provide(otlpTracerLayer)))
57 })
58
59 describe("Effect.withSpan", () => {
60 it.effect("should capture the stack trace", () =>
61 Effect.gen(function*() {
62 const error = new Error("boom")
63 const errorSite = error.stack?.split("\n")[1]?.trim()
64 assert.isDefined(errorSite)
65 const cause = yield* Effect.die(error).pipe(
66 Effect.withSpan("C", {
67 annotations: Tracer.DisablePropagation.context(true)
68 }),
69 Effect.sandbox,
70 Effect.flip
71 )
72
73 assert.include(Cause.pretty(cause), errorSite)
74 }))
75
76 it.effect("should set the parent span", () =>
77 Effect.gen(function*() {
78 const span = yield* Effect.currentSpan.pipe(
79 Effect.withSpan("A"),
80 Effect.withSpan("B")
81 )
82
83 strictEqual(span.name, "A")
84 strictEqual((getParent(span) as Span).name, "B")
85 }))
86
87 it.effect("should override the parent span when root is set to true", () =>
88 Effect.gen(function*() {
89 const span = yield* Effect.currentSpan.pipe(
90 Effect.withSpan("A", { root: true }),
91 Effect.withSpan("B")
92 )
93
94 strictEqual(span.name, "A")
95 assertNone(span.parent)
96 }))
97
98 it.effect("should set an external parent span", () =>
99 Effect.gen(function*() {
100 const span = yield* Effect.currentSpan.pipe(
101 Effect.withSpan("A", {
102 parent: {
103 _tag: "ExternalSpan",
104 spanId: "000",
105 traceId: "111",
106 sampled: true,
107 annotations: Context.empty()
108 }
109 })
110 )
111
112 strictEqual(span.name, "A")
113 strictEqual(getParent(span).spanId, "000")
114 }))
115
116 it.effect("should still apply minimum trace level with sampled parent spans", () =>
117 Effect.gen(function*() {
118 const span = yield* Effect.currentSpan.pipe(
119 Effect.withSpan("A", {
120 parent: Tracer.externalSpan({
121 spanId: "000",
122 traceId: "111",
123 sampled: true
124 }),
125 level: "Info"
126 }),
127 Effect.provideService(Tracer.MinimumTraceLevel, "Error")
128 )
129
130 strictEqual(span.sampled, false)
131 }))
132
133 it.effect("should not set the parent span when none exists", () =>
134 Effect.gen(function*() {
135 const span = yield* Effect.withSpan(Effect.currentSpan, "A")
136
137 strictEqual(span.name, "A")
138 assertNone(span.parent)
139 strictEqual(span.attributes.get("code.stacktrace"), undefined)
140 }))
141
142 it.effect("should preserve nested span context with OtlpTracer", () =>
143 Effect.gen(function*() {
144 const innerEffect = Effect.succeed(42).pipe(
145 Effect.withSpan("child-span", {
146 attributes: {
147 "test": "child"
148 }
149 })
150 )
151
152 const result = yield* innerEffect.pipe(
153 Effect.withSpan("parent-span", {
154 attributes: {
155 "test": "parent"
156 }
157 })
158 )
159
160 strictEqual(result, 42)
161 }).pipe(
162 Effect.provide(otlpTracerLayer)
163 ))
164
165 it.effect("should set the correct start and end time", () =>
166 Effect.gen(function*() {
167 const spanFiber = yield* Effect.currentSpan.pipe(
168 Effect.delay("1 second"),
169 Effect.withSpan("A"),
170 Effect.forkChild
171 )
172
173 yield* TestClock.adjust("2 seconds")
174
175 const span = yield* Fiber.join(spanFiber)
176
177 strictEqual(span.name, "A")
178 strictEqual(span.status.startTime, 0n)
179 strictEqual((span.status as any)["endTime"], 1000000000n)
180 strictEqual(span.status._tag, "Ended")
181 }))
182
183 it.effect("should capture logs as span events", () =>
184 Effect.gen(function*() {
185 yield* TestClock.adjust(Duration.millis(0.01))
186
187 const [span, fiberId] = yield* Effect.log("event").pipe(
188 Effect.andThen(Effect.all([Effect.currentSpan, Effect.fiberId])),
189 Effect.withSpan("A")
190 )
191
192 strictEqual(span.name, "A")
193 assertNone(span.parent)
194 deepStrictEqual((span as Tracer.NativeSpan).events, [
195 ["event", 10_000n, {
196 "effect.fiberId": fiberId,
197 "effect.logLevel": "INFO"
198 }]
199 ])
200 }))
201
202 it.effect("should attach attributes and kind to the current span", () =>
203 Effect.gen(function*() {
204 const result = yield* Effect.succeed(42).pipe(
205 Effect.withSpan("test-span", {
206 attributes: {
207 "code.filepath": "test.ts",
208 "code.lineno": 1
209 }
210 })
211 )
212
213 strictEqual(result, 42)
214 }))
215 })
216
217 describe("Effect.annotateSpans", () => {
218 it.effect("should allow adding span attributes as key/value pairs", () =>
219 Effect.gen(function*() {
220 const span = yield* Effect.currentSpan.pipe(
221 Effect.withSpan("A"),
222 Effect.annotateSpans("key", "value")
223 )
224
225 strictEqual(span.name, "A")
226 assertNone(span.parent)
227 strictEqual(span.attributes.get("key"), "value")
228 }))
229
230 it.effect("should allow adding span attributes as a record", () =>
231 Effect.gen(function*() {
232 const span = yield* Effect.currentSpan.pipe(
233 Effect.withSpan("A"),
234 Effect.annotateSpans({
235 key: "value",
236 key2: "value2"
237 })
238 )
239
240 strictEqual(span.attributes.get("key"), "value")
241 strictEqual(span.attributes.get("key2"), "value2")
242 }))
243 })
244
245 describe("Effect.useSpan", () => {
246 it.effect("ends the span when the callback throws", () =>
247 Effect.gen(function*() {
248 const defect = new Error("boom")
249 const spans: Array<Tracer.Span> = []
250 const tracer = Tracer.make({
251 span: (options) => {
252 const span = new Tracer.NativeSpan(options)
253 spans.push(span)
254 return span
255 }
256 })
257
258 const exit = yield* Effect.useSpan("A", (): Effect.Effect<never> => {
259 throw defect
260 }).pipe(Effect.withTracer(tracer), Effect.exit)
261
262 deepStrictEqual(exit, Exit.die(defect))
263 strictEqual(spans.length, 1)
264 const span = spans[0]!
265 strictEqual(span.status._tag, "Ended")
266 if (span.status._tag === "Ended") {
267 deepStrictEqual(span.status.exit, Exit.die(defect))
268 }
269 }))
270 })
271
272 describe("interruption as a traced region starts", () => {
273 const interruptAtEveryStep = (
274 make: () => Effect.Effect<unknown>,
275 check: (spans: ReadonlyArray<Tracer.NativeSpan>, label: string) => void
276 ) =>
277 Effect.gen(function*() {
278 let started = 0
279 for (let ops = 3; ops <= 6; ops++) {
280 for (let prefix = 0; prefix < 4; prefix++) {
281 for (let yields = 0; yields < 6; yields++) {
282 const spans: Array<Tracer.NativeSpan> = []
283 const tracer = Tracer.make({
284 span(options) {
285 const span = new Tracer.NativeSpan(options)
286 spans.push(span)
287 return span
288 }
289 })
290 let effect = make()
291 for (let i = 0; i < prefix; i++) effect = Effect.andThen(Effect.void, effect)
292 yield* Effect.gen(function*() {
293 const fiber = yield* Effect.forkChild(effect)
294 for (let i = 0; i < yields; i++) yield* Effect.yieldNow
295 yield* Fiber.interrupt(fiber)
296 }).pipe(Effect.withTracer(tracer), Effect.provideService(Scheduler.MaxOpsBeforeYield, ops))
297 started += spans.length
298 check(spans, `ops ${ops}, prefix ${prefix}, yields ${yields}`)
299 }
300 }
301 }
302 assert.isAbove(started, 0, "the child never started a span")
303 })
304
305 it.effect("ends interrupted spans and restores the parent for outer finalizers", () => {
306 let seen: string | undefined
307 let checked = 0
308 return interruptAtEveryStep(() => {
309 seen = undefined
310 const finalizer = Effect.flatMap(Effect.orDie(Effect.currentParentSpan), (span) =>
311 Effect.sync(() => {
312 seen = span._tag === "Span" ? span.name : span.spanId
313 }))
314 return Effect.withSpan(Effect.ensuring(Effect.withSpan(Effect.never, "inner"), finalizer), "outer")
315 }, (spans, label) => {
316 for (const span of spans) {
317 strictEqual(span.status._tag, "Ended", span.name + ": " + label)
318 }
319 if (seen !== undefined) {
320 checked++
321 strictEqual(seen, "outer", label)
322 }
323 }).pipe(Effect.tap(() => Effect.sync(() => assert.isAbove(checked, 0, "the outer finalizer never ran"))))
324 })
325 })
326
327 describe("Effect.useSpanScoped", () => {
328 it.effect("should control span lifetimes with a scope", () =>
329 Effect.gen(function*() {
330 const span = yield* Effect.scoped(Effect.makeSpanScoped("A"))
331 strictEqual(span.status._tag, "Ended")
332 strictEqual(span.attributes.get("code.stacktrace"), undefined)
333 }))
334 })
335
336 describe("Effect.annotateCurrentSpan", () => {
337 it.effect("should allow adding attributes to the current span", () =>
338 Effect.gen(function*() {
339 yield* Effect.annotateCurrentSpan("key", "value")
340 const span = yield* Effect.currentSpan
341 strictEqual(span.attributes.get("key"), "value")
342 }).pipe(Effect.withSpan("A")))
343 })
344
345 describe("Effect.withParentSpan", () => {
346 it("runs continuations after the region under the restored span", () => {
347 let active: string | undefined
348 const tracer = Tracer.make({
349 span: (options) => new Tracer.NativeSpan(options),
350 context(primitive, fiber) {
351 const previous = active
352 active = fiber.cache.span?.spanId
353 try {
354 return primitive["~effect/Effect/evaluate"](fiber)
355 } finally {
356 active = previous
357 }
358 }
359 })
360 const seen: Array<string | undefined> = []
361 const record = Effect.map(() => {
362 seen.push(active)
363 })
364 Effect.runSync(
365 Effect.sync(() => undefined).pipe(
366 record,
367 Effect.withParentSpan(Tracer.externalSpan({ spanId: "child", traceId: "trace" })),
368 record,
369 Effect.withParentSpan(Tracer.externalSpan({ spanId: "parent", traceId: "trace" })),
370 Effect.withTracer(tracer)
371 )
372 )
373 deepStrictEqual(seen, ["child", "parent"])
374 })
375
376 it.effect("should allow setting the parent span for the current span", () =>
377 Effect.gen(function*() {
378 const span = yield* Effect.currentSpan
379 strictEqual(getParent(span).spanId, "456")
380 }).pipe(
381 Effect.withSpan("A"),
382 Effect.withParentSpan({
383 _tag: "ExternalSpan",
384 traceId: "123",
385 spanId: "456",
386 sampled: true,
387 annotations: Context.empty()
388 })
389 ))
390 })
391
392 describe("Effect.withTracerEnabled", () => {
393 it.effect("should allow enabling/disabling the tracer", () =>
394 Effect.gen(function*() {
395 const span = yield* Effect.currentSpan.pipe(
396 Effect.withSpan("A"),
397 Effect.withTracerEnabled(false)
398 )
399 const spanB = yield* Effect.currentSpan.pipe(
400 Effect.withSpan("B"),
401 Effect.withTracerEnabled(true)
402 )
403
404 strictEqual(span.name, "A")
405 strictEqual(span.spanId, "noop")
406 strictEqual(spanB.name, "B")
407 }))
408 })
409
410 describe("Effect.withTracerTiming", () => {
411 it.effect("should include timing information in spans when true", () =>
412 Effect.gen(function*() {
413 yield* TestClock.adjust("1 millis")
414
415 const span = yield* Effect.currentSpan.pipe(
416 Effect.withSpan("A"),
417 Effect.withTracerTiming(true) // default
418 )
419
420 strictEqual(span.status.startTime, 1_000_000n)
421 }))
422
423 it.effect("should not include timing information in spans when false", () =>
424 Effect.gen(function*() {
425 yield* TestClock.adjust("1 millis")
426
427 const span = yield* Effect.currentSpan.pipe(
428 Effect.withSpan("A"),
429 Effect.withTracerTiming(false)
430 )
431
432 strictEqual(span.status.startTime, 0n)
433 }))
434
435 it.effect("should set start and end times to zero when timing is disabled", () =>
436 Effect.gen(function*() {
437 yield* TestClock.adjust("1 millis")
438
439 const useSpan = yield* Effect.useSpan("useSpan", (span) => Effect.succeed(span))
440 const withSpan = yield* Effect.currentSpan.pipe(Effect.withSpan("withSpan"))
441
442 deepStrictEqual(
443 [useSpan.status, withSpan.status].map((status) => {
444 strictEqual(status._tag, "Ended")
445 return status._tag === "Ended" ? [status.startTime, status.endTime] : undefined
446 }),
447 [[0n, 0n], [0n, 0n]],
448 "disabled span timing"
449 )
450 }).pipe(Effect.withTracerTiming(false)))
451 })
452
453 describe("Effect.linkSpans", () => {
454 it.effect("should link two spans together", () =>
455 Effect.gen(function*() {
456 const childA = yield* Effect.makeSpan("childA")
457 const childB = yield* Effect.makeSpan("childB")
458 const currentSpan = yield* Effect.currentSpan.pipe(
459 Effect.withSpan("A", {
460 links: [{ span: childB, attributes: {} }]
461 }),
462 Effect.linkSpans(childA)
463 )
464 deepStrictEqual(
465 currentSpan.links.map((_) => _.span),
466 [childA, childB]
467 )
468 }))
469 })
470
471 describe("Layer.parentSpan", () => {
472 it.effect("should set the parent trace span for the layer constructor", () =>
473 Effect.gen(function*() {
474 const span = yield* Effect.makeSpan("child")
475 const parent = getParent(span) as Tracer.Span
476 strictEqual(parent.name, "parent")
477 strictEqual(span.attributes.get("code.stacktrace"), undefined)
478 strictEqual(parent.attributes.get("code.stacktrace"), undefined)
479 }).pipe(Effect.provide(Layer.unwrap(
480 Effect.map(
481 Effect.makeSpanScoped("parent"),
482 (span) => Layer.parentSpan(span)
483 )
484 ))))
485 })
486
487 describe("Layer.span", () => {
488 it.effect("should create a new parent trace span for the layer constructor", () =>
489 Effect.gen(function*() {
490 const span = yield* Effect.makeSpan("child")
491 const parent = getParent(span) as Tracer.Span
492 strictEqual(parent.name, "parent")
493 strictEqual(parent.attributes.get("code.stacktrace"), undefined)
494 }).pipe(Effect.provide(Layer.span("parent"))))
495
496 it.effect("should call onEnd when the span is ending", () =>
497 Effect.gen(function*() {
498 let onEndCalled = false
499 const span = yield* Effect.currentSpan.pipe(
500 Effect.provide(Layer.span("span", {
501 onEnd: (span, _exit) =>
502 Effect.sync(() => {
503 strictEqual(span.name, "span")
504 onEndCalled = true
505 })
506 }))
507 )
508 strictEqual(span.name, "span")
509 strictEqual(onEndCalled, true)
510 }))
511 })
512
513 describe("Layer.withSpan", () => {
514 it.effect("sets the trace span for the layer constructor", () =>
515 Effect.gen(function*() {
516 let onEndCalled = false
517 const layer = Layer.effectDiscard(Effect.gen(function*() {
518 const span = yield* Effect.currentSpan
519 strictEqual(span.name, "span")
520 strictEqual(span.attributes.get("code.stacktrace"), undefined)
521 })).pipe(Layer.withSpan("span", {
522 onEnd: (span, _exit) =>
523 Effect.sync(() => {
524 strictEqual(span.name, "span")
525 onEndCalled = true
526 })
527 }))
528
529 const span = yield* Effect.currentSpan.pipe(
530 Effect.provide(layer),
531 Effect.option
532 )
533
534 assertNone(span)
535 strictEqual(onEndCalled, true)
536 }))
537 })
538
539 describe("Tracer.DisablePropagation", () => {
540 it.effect("should allow disabling span propagation via noop spans", () =>
541 Effect.gen(function*() {
542 const span = yield* Effect.currentSpan.pipe(
543 Effect.withSpan("A", {
544 annotations: Tracer.DisablePropagation.context(true)
545 })
546 )
547 const spanB = yield* Effect.currentSpan.pipe(
548 Effect.withSpan("B")
549 )
550
551 strictEqual(span.name, "A")
552 strictEqual(span.spanId, "noop")
553 strictEqual(spanB.name, "B")
554 }))
555
556 it.effect("should prevent a span from being used as a parent span", () =>
557 Effect.gen(function*() {
558 const span = yield* Effect.currentSpan.pipe(
559 Effect.withSpan("child"),
560 Effect.withSpan("disabled", {
561 annotations: Tracer.DisablePropagation.context(true)
562 }),
563 Effect.withSpan("parent")
564 )
565 strictEqual(span.name, "child")
566 const parent = getParent(span)
567 strictEqual(parent._tag, "Span")
568 strictEqual((parent as Span).name, "parent")
569 }))
570 })
571})