1/**
2 * Bridges Effect tracing into OpenTelemetry by installing an Effect `Tracer`
3 * that creates OpenTelemetry spans, records attributes, events, links, errors,
4 * and status, and keeps OpenTelemetry context active while traced effects run.
5 * Use this module when an application already has an OpenTelemetry
6 * `TracerProvider`, or when the Node and Web SDK layers should expose Effect
7 * spans to OTLP, console, or other OpenTelemetry-compatible exporters.
8 *
9 * The layer constructors wire Effect's tracer service to either the global
10 * OpenTelemetry tracer provider or an explicitly provided `OtelTracer`. This
11 * module does not create exporters or span processors by itself, so spans are
12 * exported only when the provider has been configured by the application or by
13 * the Node/Web SDK layers. Parentage is taken from Effect spans first and can
14 * also attach to the active OpenTelemetry context, while `makeExternalSpan` and
15 * `withSpanContext` are the entry points for continuing an incoming remote
16 * trace. Preserve `traceFlags` and `traceState` when building external spans;
17 * otherwise sampling defaults to sampled and trace state cannot be propagated.
18 *
19 * @stability unstable
20 * @since 4.0.0
21 */
22import * as Otel from "@opentelemetry/api"
23import * as OtelSemConv from "@opentelemetry/semantic-conventions"
24import * as Cause from "effect/Cause"
25import type * as Clock from "effect/Clock"
26import * as Context from "effect/Context"
27import * as Effect from "effect/Effect"
28import * as Exit from "effect/Exit"
29import { constTrue, dual } from "effect/Function"
30import * as Layer from "effect/Layer"
31import * as Option from "effect/Option"
32import * as Predicate from "effect/Predicate"
33import * as Tracer from "effect/Tracer"
34import { nanosToHrTime, recordToAttributes, unknownToAttributeValue } from "./internal/attributes.ts"
35import { Resource } from "./Resource.ts"
36
37// =============================================================================
38// Service Definitions
39// =============================================================================
40
41/**
42 * Context service containing the OpenTelemetry `Tracer` used to create spans for Effect tracing.
43 *
44 * @stability unstable
45 * @category services
46 * @since 4.0.0
47 */
48export class OtelTracer extends Context.Service<
49 OtelTracer,
50 Otel.Tracer
51>()("@effect/opentelemetry/Tracer") {}
52
53/**
54 * Context service containing the OpenTelemetry `TracerProvider` used to obtain tracers.
55 *
56 * @stability unstable
57 * @category services
58 * @since 4.0.0
59 */
60export class OtelTracerProvider extends Context.Service<
61 OtelTracerProvider,
62 Otel.TracerProvider
63>()("@effect/opentelemetry/Tracer/OtelTracerProvider") {}
64
65/**
66 * Context service containing OpenTelemetry trace flags used when constructing external span contexts.
67 *
68 * @stability unstable
69 * @category services
70 * @since 4.0.0
71 */
72export class OtelTraceFlags extends Context.Service<
73 OtelTraceFlags,
74 Otel.TraceFlags
75>()("@effect/opentelemetry/Tracer/OtelTraceFlags") {}
76
77/**
78 * Context service containing OpenTelemetry trace state used when constructing external span contexts.
79 *
80 * @stability unstable
81 * @category services
82 * @since 4.0.0
83 */
84export class OtelTraceState extends Context.Service<
85 OtelTraceState,
86 Otel.TraceState
87>()("@effect/opentelemetry/Tracer/OtelTraceState") {}
88
89// =============================================================================
90// Constructors
91// =============================================================================
92
93/**
94 * Creates an Effect `Tracer` implementation backed by the configured OpenTelemetry tracer.
95 *
96 * @stability unstable
97 * @category constructors
98 * @since 4.0.0
99 */
100export const make: Effect.Effect<Tracer.Tracer, never, OtelTracer> = Effect.map(
101 Effect.service(OtelTracer),
102 (tracer) =>
103 Tracer.make({
104 span(options) {
105 return new OtelSpan(
106 Otel.context,
107 Otel.trace,
108 tracer,
109 options
110 )
111 },
112 context(primitive, fiber) {
113 const currentSpan = fiber.cache.span
114
115 if (currentSpan === undefined) {
116 return primitive["~effect/Effect/evaluate"](fiber)
117 }
118
119 return Otel.context.with(
120 populateContext(Otel.context.active(), currentSpan),
121 () => primitive["~effect/Effect/evaluate"](fiber)
122 )
123 }
124 })
125)
126
127/**
128 * Creates an Effect external span from an OpenTelemetry span context, preserving trace flags and trace state when provided.
129 *
130 * @stability unstable
131 * @category constructors
132 * @since 4.0.0
133 */
134export const makeExternalSpan = (options: {
135 readonly traceId: string
136 readonly spanId: string
137 readonly traceFlags?: number | undefined
138 readonly traceState?: string | Otel.TraceState | undefined
139}): Tracer.ExternalSpan => {
140 let annotations = Context.empty()
141 if (options.traceFlags !== undefined) {
142 annotations = Context.add(annotations, OtelTraceFlags, options.traceFlags)
143 }
144
145 if (typeof options.traceState === "string") {
146 try {
147 annotations = Context.add(annotations, OtelTraceState, Otel.createTraceState(options.traceState))
148 } catch {
149 //
150 }
151 } else if (options.traceState) {
152 annotations = Context.add(annotations, OtelTraceState, options.traceState)
153 }
154
155 return {
156 _tag: "ExternalSpan",
157 traceId: options.traceId,
158 spanId: options.spanId,
159 sampled: Predicate.isNotUndefined(options.traceFlags) ? isSampled(options.traceFlags) : true,
160 annotations
161 }
162}
163
164// =============================================================================
165// Layers
166// =============================================================================
167
168/**
169 * Layer that provides the current global OpenTelemetry tracer provider.
170 *
171 * @stability unstable
172 * @category layers
173 * @since 4.0.0
174 */
175export const layerGlobalProvider: Layer.Layer<OtelTracerProvider> = Layer.sync(
176 OtelTracerProvider,
177 () => Otel.trace.getTracerProvider()
178)
179
180/**
181 * Layer that creates an OpenTelemetry tracer from the provided tracer provider and resource metadata.
182 *
183 * @stability unstable
184 * @category layers
185 * @since 4.0.0
186 */
187export const layerTracer: Layer.Layer<OtelTracer, never, OtelTracerProvider | Resource> = Layer.effect(
188 OtelTracer,
189 Effect.gen(function*() {
190 const resource = yield* Resource
191 const provider = yield* OtelTracerProvider
192 return provider.getTracer(
193 resource.attributes[OtelSemConv.ATTR_SERVICE_NAME] as string,
194 resource.attributes[OtelSemConv.ATTR_SERVICE_VERSION] as string
195 )
196 })
197)
198
199/**
200 * Layer that creates an OpenTelemetry tracer from the global tracer provider and the current resource.
201 *
202 * @stability unstable
203 * @category layers
204 * @since 4.0.0
205 */
206export const layerGlobalTracer: Layer.Layer<OtelTracer, never, Resource> = layerTracer.pipe(
207 Layer.provide(layerGlobalProvider)
208)
209
210/**
211 * Layer that installs an Effect tracer backed by the global OpenTelemetry tracer provider.
212 *
213 * @stability unstable
214 * @category layers
215 * @since 4.0.0
216 */
217export const layerGlobal: Layer.Layer<OtelTracer, never, Resource> = Layer.effect(Tracer.Tracer, make).pipe(
218 Layer.provideMerge(layerGlobalTracer)
219)
220
221/**
222 * Layer that installs the Effect tracer using an `OtelTracer` already provided in the environment.
223 *
224 * @stability unstable
225 * @category layers
226 * @since 4.0.0
227 */
228export const layerWithoutOtelTracer: Layer.Layer<never, never, OtelTracer> = Layer.effect(Tracer.Tracer, make)
229
230/**
231 * Layer that creates an OpenTelemetry tracer from a provider and resource, then installs it as the Effect tracer.
232 *
233 * **When to use**
234 *
235 * Use when you already provide an `OtelTracerProvider` and a `Resource`, and
236 * want Effect spans backed by a tracer derived from them.
237 *
238 * @see {@link layerTracer} for creating only the OpenTelemetry tracer service
239 * @see {@link layerGlobal} for installing the Effect tracer from the global provider
240 * @see {@link layerWithoutOtelTracer} for installing an already-provided `OtelTracer`
241 *
242 * @stability unstable
243 * @category layers
244 * @since 4.0.0
245 */
246export const layer: Layer.Layer<OtelTracer, never, OtelTracerProvider | Resource> = layerWithoutOtelTracer.pipe(
247 Layer.provideMerge(layerTracer)
248)
249
250// =============================================================================
251// Utilities / Combinators
252// =============================================================================
253
254const bigint1e6 = BigInt(1_000_000)
255const bigint1e9 = BigInt(1_000_000_000)
256
257/**
258 * Gets the current OpenTelemetry span.
259 *
260 * **Details**
261 *
262 * This accessor works with both the official OpenTelemetry API, such as
263 * `Tracer.layer` and `NodeSdk.layer`, and the lightweight OTLP module, such as
264 * `OtlpTracer.layer`. When using OTLP, the returned span is a wrapper that
265 * conforms to the OpenTelemetry `Span` interface.
266 *
267 * @stability unstable
268 * @category accessors
269 * @since 4.0.0
270 */
271export const currentOtelSpan: Effect.Effect<Otel.Span, Cause.NoSuchElementError> = Effect.clockWith((clock) =>
272 Effect.map(Effect.currentSpan, (span) =>
273 OtelSpanTypeId in span
274 ? (span as OtelSpan).span
275 : makeOtelSpan(span, clock))
276)
277
278const makeOtelSpan = (span: Tracer.Span, clock: Clock.Clock): Otel.Span => {
279 const spanContext: Otel.SpanContext = {
280 traceId: span.traceId,
281 spanId: span.spanId,
282 traceFlags: span.sampled ? Otel.TraceFlags.SAMPLED : Otel.TraceFlags.NONE,
283 isRemote: false
284 }
285
286 let exit = Exit.void
287
288 const self: Otel.Span = {
289 spanContext: () => spanContext,
290 setAttribute(key, value) {
291 span.attribute(key, value)
292 return self
293 },
294 setAttributes(attributes) {
295 for (const [key, value] of Object.entries(attributes)) {
296 span.attribute(key, value)
297 }
298 return self
299 },
300 addEvent(name) {
301 let attributes: Otel.Attributes | undefined = undefined
302 let startTime: Otel.TimeInput | undefined = undefined
303 if (arguments.length === 3) {
304 attributes = arguments[1]
305 startTime = arguments[2]
306 } else {
307 startTime = arguments[1]
308 }
309 span.event(name, convertOtelTimeInput(startTime, clock), attributes)
310 return self
311 },
312 addLink(link) {
313 span.addLinks([{
314 span: makeExternalSpan(link.context),
315 attributes: link.attributes ?? {}
316 }])
317 return self
318 },
319 addLinks(links) {
320 span.addLinks(links.map((link) => ({
321 span: makeExternalSpan(link.context),
322 attributes: link.attributes ?? {}
323 })))
324 return self
325 },
326 setStatus(status) {
327 exit = status.code === Otel.SpanStatusCode.ERROR
328 ? Exit.die(status.message ?? "Unknown error")
329 : Exit.void
330 return self
331 },
332 updateName: () => self,
333 end(endTime) {
334 const time = convertOtelTimeInput(endTime, clock)
335 span.end(time, exit)
336 return self
337 },
338 isRecording: constTrue,
339 recordException(exception, timeInput) {
340 const time = convertOtelTimeInput(timeInput, clock)
341 const cause = Cause.fail(exception)
342 const error = Cause.prettyErrors(cause, {
343 includeCauseInStack: true
344 })[0]
345 span.event(error.message, time, {
346 "exception.type": error.name,
347 "exception.message": error.message,
348 "exception.stacktrace": error.stack ?? ""
349 })
350 }
351 }
352 return self
353}
354
355const convertOtelTimeInput = (input: Otel.TimeInput | undefined, clock: Clock.Clock): bigint => {
356 if (input === undefined) {
357 return clock.currentTimeNanosUnsafe()
358 } else if (typeof input === "number") {
359 return BigInt(Math.round(input * 1_000_000))
360 } else if (input instanceof Date) {
361 return BigInt(input.getTime()) * bigint1e6
362 }
363 const [seconds, nanos] = input
364 return BigInt(seconds) * bigint1e9 + BigInt(nanos)
365}
366
367/**
368 * Sets an effect's parent span from the given OpenTelemetry `SpanContext`.
369 *
370 * **When to use**
371 *
372 * Use when you need an effect to continue a trace from a parent span context
373 * produced by OpenTelemetry instrumentation outside Effect.
374 *
375 * @stability unstable
376 * @category propagation
377 * @since 4.0.0
378 */
379export const withSpanContext: {
380 (
381 spanContext: Otel.SpanContext
382 ): <A, E, R>(
383 self: Effect.Effect<A, E, R>
384 ) => Effect.Effect<A, E, Exclude<R, Tracer.ParentSpan>>
385 <A, E, R>(
386 self: Effect.Effect<A, E, R>,
387 spanContext: Otel.SpanContext
388 ): Effect.Effect<A, E, Exclude<R, Tracer.ParentSpan>>
389} = dual(2, <A, E, R>(
390 self: Effect.Effect<A, E, R>,
391 spanContext: Otel.SpanContext
392) => Effect.withParentSpan(self, makeExternalSpan(spanContext)))
393
394// =============================================================================
395// Internals
396// =============================================================================
397
398const OtelSpanTypeId = "~@effect/opentelemetry/OtelTracer/OtelSpan"
399
400const kindMap = {
401 "internal": Otel.SpanKind.INTERNAL,
402 "client": Otel.SpanKind.CLIENT,
403 "server": Otel.SpanKind.SERVER,
404 "producer": Otel.SpanKind.PRODUCER,
405 "consumer": Otel.SpanKind.CONSUMER
406}
407
408/** @internal */
409export class OtelSpan implements Tracer.Span {
410 readonly [OtelSpanTypeId]: typeof OtelSpanTypeId
411 readonly _tag = "Span"
412
413 readonly name: string
414 readonly kind: Tracer.SpanKind
415 readonly annotations: Context.Context<never>
416 readonly links: Array<Tracer.SpanLink>
417 readonly span: Otel.Span
418 readonly spanId: string
419 readonly traceId: string
420 readonly attributes = new Map<string, unknown>()
421 readonly sampled: boolean
422 readonly parent: Option.Option<Tracer.AnySpan>
423 status: Tracer.SpanStatus
424
425 constructor(
426 contextApi: Otel.ContextAPI,
427 traceApi: Otel.TraceAPI,
428 tracer: Otel.Tracer,
429 options: Parameters<Tracer.Tracer["span"]>[0]
430 ) {
431 this[OtelSpanTypeId] = OtelSpanTypeId
432 this.name = options.name
433 this.annotations = options.annotations
434 this.links = options.links
435 this.kind = options.kind
436 const active = contextApi.active()
437 this.parent = options.root !== true
438 ? Option.orElse(options.parent, () => getOtelParent(traceApi, active, options.annotations))
439 : options.parent
440 this.span = tracer.startSpan(
441 options.name,
442 {
443 startTime: nanosToHrTime(options.startTime),
444 links: options.links.length > 0
445 ? options.links.map((link) => ({
446 context: makeSpanContext(link.span),
447 attributes: recordToAttributes(link.attributes)
448 }))
449 : undefined as any,
450 kind: kindMap[this.kind]
451 },
452 Option.isSome(this.parent) ?
453 populateContext(active, this.parent.value, options.annotations) :
454 Otel.trace.deleteSpan(active)
455 )
456 const spanContext = this.span.spanContext()
457 this.spanId = spanContext.spanId
458 this.traceId = spanContext.traceId
459 this.status = {
460 _tag: "Started",
461 startTime: options.startTime
462 }
463 this.sampled = isSampled(spanContext.traceFlags)
464 }
465
466 attribute(key: string, value: unknown) {
467 this.span.setAttribute(key, unknownToAttributeValue(value))
468 this.attributes.set(key, value)
469 }
470
471 addLinks(links: ReadonlyArray<Tracer.SpanLink>): void {
472 // oxlint-disable-next-line no-restricted-syntax
473 this.links.push(...links)
474 this.span.addLinks(links.map((link) => ({
475 context: makeSpanContext(link.span),
476 attributes: recordToAttributes(link.attributes)
477 })))
478 }
479
480 end(endTime: bigint, exit: Exit.Exit<unknown, unknown>) {
481 const hrTime = nanosToHrTime(endTime)
482 this.status = {
483 _tag: "Ended",
484 endTime,
485 exit,
486 startTime: this.status.startTime
487 }
488
489 if (exit._tag === "Success") {
490 this.span.setStatus({ code: Otel.SpanStatusCode.OK })
491 } else {
492 if (Cause.hasInterruptsOnly(exit.cause)) {
493 this.span.setStatus({
494 code: Otel.SpanStatusCode.OK,
495 message: Cause.pretty(exit.cause)
496 })
497 this.span.setAttribute("span.label", "⚠︎ Interrupted")
498 this.span.setAttribute("status.interrupted", true)
499 } else {
500 const errors = Cause.prettyErrors(exit.cause, {
501 includeCauseInStack: true
502 })
503 if (errors.length > 0) {
504 for (const error of errors) {
505 this.span.recordException(error, hrTime)
506 }
507 this.span.setStatus({
508 code: Otel.SpanStatusCode.ERROR,
509 message: errors[0].message
510 })
511 } else {
512 // empty cause means no error
513 this.span.setStatus({ code: Otel.SpanStatusCode.OK })
514 }
515 }
516 }
517 this.span.end(hrTime)
518 }
519
520 event(name: string, startTime: bigint, attributes?: Record<string, unknown>) {
521 this.span.addEvent(
522 name,
523 attributes ? recordToAttributes(attributes) : undefined,
524 nanosToHrTime(startTime)
525 )
526 }
527}
528
529const isSampled = (traceFlags: Otel.TraceFlags): boolean =>
530 (traceFlags & Otel.TraceFlags.SAMPLED) === Otel.TraceFlags.SAMPLED
531
532class OtelParentSpanContext extends Context.Service<
533 OtelParentSpanContext,
534 Otel.SpanContext
535>()("@effect/opentelemetry/Tracer/OtelParentSpanContext") {}
536
537const getOtelParent = (
538 tracer: Otel.TraceAPI,
539 context: Otel.Context,
540 annotations: Context.Context<never>
541): Option.Option<Tracer.AnySpan> => {
542 const otelParent = tracer.getSpanContext(context)
543 if (!otelParent) return Option.none()
544 return Option.some(Tracer.externalSpan({
545 spanId: otelParent.spanId,
546 traceId: otelParent.traceId,
547 sampled: isSampled(otelParent.traceFlags),
548 annotations: Context.add(annotations, OtelParentSpanContext, otelParent)
549 }))
550}
551
552const makeSpanContext = (
553 span: Tracer.AnySpan,
554 annotations?: Context.Context<never>
555): Otel.SpanContext => {
556 const otelParent = Context.getOrUndefined(span.annotations, OtelParentSpanContext)
557 if (otelParent !== undefined) {
558 if (annotations === undefined) return otelParent
559 const traceFlags = extractTraceService(span, annotations, OtelTraceFlags)
560 const traceState = extractTraceService(span, annotations, OtelTraceState)
561 return {
562 ...otelParent,
563 traceFlags: traceFlags ?? otelParent.traceFlags,
564 traceState: traceState ?? otelParent.traceState!
565 }
566 }
567 const traceFlags = makeTraceFlags(span, annotations)
568 const traceState = makeTraceState(span, annotations)!
569 return ({
570 spanId: span.spanId,
571 traceId: span.traceId,
572 isRemote: span._tag === "ExternalSpan",
573 traceFlags,
574 traceState
575 })
576}
577
578const makeTraceFlags = (
579 span: Tracer.AnySpan,
580 annotations: Context.Context<never> | undefined
581): Otel.TraceFlags => {
582 let traceFlags: Otel.TraceFlags | undefined
583 if (Predicate.isNotUndefined(annotations)) {
584 traceFlags = extractTraceService(span, annotations, OtelTraceFlags)
585 if (Predicate.isUndefined(traceFlags)) {
586 traceFlags = Context.getOrUndefined(span.annotations, OtelTraceFlags)
587 }
588 }
589 return traceFlags ?? (span.sampled ? Otel.TraceFlags.SAMPLED : Otel.TraceFlags.NONE)
590}
591
592const makeTraceState = (
593 span: Tracer.AnySpan,
594 annotations: Context.Context<never> | undefined
595): Otel.TraceState | undefined => {
596 let traceState: Otel.TraceState | undefined
597 if (Predicate.isNotUndefined(annotations)) {
598 traceState = extractTraceService(span, annotations, OtelTraceState)
599 if (Predicate.isUndefined(traceState)) {
600 traceState = Context.getOrUndefined(span.annotations, OtelTraceState)
601 }
602 }
603 return traceState
604}
605
606const extractTraceService = <I, S>(
607 parent: Tracer.AnySpan,
608 annotations: Context.Context<never>,
609 service: Context.Service<I, S>
610) => {
611 const instance = Context.getOrUndefined(annotations, service)
612 if (Predicate.isNotUndefined(instance)) {
613 return instance
614 }
615 return Context.getOrUndefined(parent.annotations, service)
616}
617
618const populateContext = (
619 context: Otel.Context,
620 span: Tracer.AnySpan,
621 annotations?: Context.Context<never> | undefined
622): Otel.Context =>
623 span instanceof OtelSpan ?
624 Otel.trace.setSpan(context, span.span) :
625 Otel.trace.setSpanContext(context, makeSpanContext(span, annotations))