1/**
2 * Exports Effect tracing spans over OTLP/HTTP.
3 *
4 * This module creates a `Tracer.Tracer` backed by the shared OTLP batch
5 * exporter, so spans created by Effect tracing APIs can be sent to an
6 * OpenTelemetry Collector, vendor endpoint, or local collector. Exported spans
7 * include identifiers, parent links, attributes, events, timing, kind, and
8 * status information. Use the constructor directly or install it through the
9 * provided layer.
10 *
11 * @stability unstable
12 * @since 4.0.0
13 */
14import * as Cause from "../Cause.ts"
15import * as Config from "../Config.ts"
16import type * as Context from "../Context.ts"
17import * as Duration from "../Duration.ts"
18import * as Effect from "../Effect.ts"
19import * as Hex from "../encoding/Hex.ts"
20import type * as Exit from "../Exit.ts"
21import { flow } from "../Function.ts"
22import type * as Headers from "../http/Headers.ts"
23import type * as HttpClient from "../http/HttpClient.ts"
24import * as Layer from "../Layer.ts"
25import * as Option from "../Option.ts"
26import type * as Scope from "../Scope.ts"
27import * as Tracer from "../Tracer.ts"
28import type { ExtractTag } from "../Types.ts"
29import * as OtlpEnv from "./internal/otlpEnv.ts"
30import * as Exporter from "./OtlpExporter.ts"
31import type { KeyValue, Resource } from "./OtlpResource.ts"
32import { entriesToAttributes } from "./OtlpResource.ts"
33import * as OtlpResource from "./OtlpResource.ts"
34import { OtlpSerialization } from "./OtlpSerialization.ts"
35
36/**
37 * Creates a `Tracer` that exports ended sampled spans to an OTLP traces endpoint.
38 *
39 * **Details**
40 *
41 * Spans are batched using the configured interval and batch size, serialized
42 * with `OtlpSerialization`, and flushed when the surrounding `Scope` closes.
43 *
44 * @stability unstable
45 * @category constructors
46 * @since 4.0.0
47 */
48export const make: (
49 options: {
50 readonly url: string
51 readonly resource?: {
52 readonly serviceName?: string | undefined
53 readonly serviceVersion?: string | undefined
54 readonly attributes?: Record<string, unknown>
55 } | undefined
56 readonly headers?: Headers.Input | undefined
57 readonly exportInterval?: Duration.Input | undefined
58 readonly maxBatchSize?: number | undefined
59 readonly context?: (<X>(primitive: Tracer.EffectPrimitive<X>, span: Tracer.AnySpan) => X) | undefined
60 readonly shutdownTimeout?: Duration.Input | undefined
61 }
62) => Effect.Effect<
63 Tracer.Tracer,
64 never,
65 Exporter.Flusher | OtlpSerialization | HttpClient.HttpClient | Scope.Scope
66> = Effect.fnUntraced(function*(options) {
67 const otelResource = yield* OtlpResource.fromConfig(options.resource)
68 const serialization = yield* OtlpSerialization
69 const scope: Scope = {
70 name: OtlpResource.serviceNameUnsafe(otelResource)
71 }
72
73 const exporter = yield* Exporter.make({
74 label: "OtlpTracer",
75 url: options.url,
76 headers: options.headers,
77 exportInterval: options.exportInterval ?? Duration.seconds(5),
78 maxBatchSize: options.maxBatchSize ?? 1000,
79 body(spans) {
80 const data: TraceData = {
81 resourceSpans: [{
82 resource: otelResource,
83 scopeSpans: [{
84 scope,
85 spans
86 }]
87 }]
88 }
89 return [serialization.traces(data), Effect.void]
90 },
91 shutdownTimeout: options.shutdownTimeout ?? Duration.seconds(3)
92 })
93
94 function exportFn(span: SpanImpl) {
95 if (!span.sampled) return
96 exporter.push(makeOtlpSpan(span))
97 }
98
99 return Tracer.make({
100 span(options) {
101 return new SpanImpl(options, exportFn)
102 },
103 context: options.context ?
104 function(primitive, fiber) {
105 if (fiber.cache.span === undefined) {
106 return primitive["~effect/Effect/evaluate"](fiber)
107 }
108 return options.context!(primitive, fiber.cache.span)
109 } :
110 undefined
111 })
112})
113
114/**
115 * Provides `Tracer.Tracer` using the OTLP tracer created by `make`.
116 *
117 * @stability unstable
118 * @category layers
119 * @since 4.0.0
120 */
121export const layer: (options: {
122 readonly url: string
123 readonly resource?: {
124 readonly serviceName?: string | undefined
125 readonly serviceVersion?: string | undefined
126 readonly attributes?: Record<string, unknown>
127 } | undefined
128 readonly headers?: Headers.Input | undefined
129 readonly exportInterval?: Duration.Input | undefined
130 readonly maxBatchSize?: number | undefined
131 readonly context?: (<X>(primitive: Tracer.EffectPrimitive<X>, span: Tracer.AnySpan) => X) | undefined
132 readonly shutdownTimeout?: Duration.Input | undefined
133}) => Layer.Layer<Exporter.Flusher, never, OtlpSerialization | HttpClient.HttpClient> = flow(
134 make,
135 Layer.effect(Tracer.Tracer),
136 Layer.provideMerge(Exporter.layerFlusher)
137)
138
139/**
140 * Creates an OTLP traces layer from OpenTelemetry configuration.
141 *
142 * @stability unstable
143 * @category layers
144 * @since 4.0.0
145 */
146export const layerFromConfig = (options?: {
147 readonly resource?: {
148 readonly serviceName?: string | undefined
149 readonly serviceVersion?: string | undefined
150 readonly attributes?: Record<string, unknown>
151 } | undefined
152 readonly headers?: Headers.Input | undefined
153 readonly context?: (<X>(primitive: Tracer.EffectPrimitive<X>, span: Tracer.AnySpan) => X) | undefined
154}): Layer.Layer<Exporter.Flusher, never, HttpClient.HttpClient | OtlpSerialization> =>
155 Effect.gen(function*() {
156 const { disabled, endpoint, exporters } = yield* Config.all({
157 disabled: Config.Boolean("OTEL_SDK_DISABLED").pipe(Config.withDefault(false)),
158 endpoint: OtlpEnv.endpoint("TRACES"),
159 exporters: OtlpEnv.exporters("TRACES")
160 })
161
162 if (disabled || !endpoint || !exporters.includes("otlp")) {
163 return Exporter.layerFlusher
164 }
165
166 const { baseTimeout, tracesTimeout, exportTimeout, scheduleDelay, maxBatchSize } = yield* Config.all({
167 baseTimeout: Config.option(Config.Int("OTEL_EXPORTER_OTLP_TIMEOUT")),
168 tracesTimeout: Config.option(Config.Int("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT")),
169 exportTimeout: Config.option(Config.Int("OTEL_BSP_EXPORT_TIMEOUT")),
170 scheduleDelay: Config.option(
171 Config.Int("OTEL_BSP_SCHEDULE_DELAY").pipe(
172 Config.map(Duration.millis)
173 )
174 ),
175 maxBatchSize: Config.option(Config.Int("OTEL_BSP_MAX_EXPORT_BATCH_SIZE"))
176 })
177
178 const shutdownTimeout = Option.firstSomeOf([tracesTimeout, baseTimeout, exportTimeout]).pipe(
179 Option.map((_) => Duration.millis(_))
180 )
181
182 return layer({
183 url: endpoint.toString(),
184 resource: options?.resource,
185 headers: options?.headers ?? (yield* OtlpEnv.headers("TRACES")),
186 exportInterval: Option.getOrUndefined(scheduleDelay),
187 maxBatchSize: Option.getOrUndefined(maxBatchSize),
188 context: options?.context,
189 shutdownTimeout: Option.getOrUndefined(shutdownTimeout)
190 })
191 }).pipe(Effect.orDie, Layer.unwrap)
192
193// internal
194
195class SpanImpl implements Tracer.Span {
196 readonly _tag = "Span"
197 readonly name: string
198 readonly parent: Option.Option<Tracer.AnySpan>
199 readonly annotations: Context.Context<never>
200 readonly links: Array<Tracer.SpanLink>
201 readonly kind: Tracer.SpanKind
202 readonly sampled: boolean
203 readonly export: (span: SpanImpl) => void
204 status: Tracer.SpanStatus
205 _traceId: string | undefined = undefined
206 _spanId: string | undefined = undefined
207 _attributes: Map<string, unknown> | undefined = undefined
208 _events: Array<[name: string, startTime: bigint, attributes: Record<string, unknown> | undefined]> | undefined =
209 undefined
210
211 constructor(
212 options: {
213 readonly name: string
214 readonly parent: Option.Option<Tracer.AnySpan>
215 readonly annotations: Context.Context<never>
216 readonly links: Array<Tracer.SpanLink>
217 readonly startTime: bigint
218 readonly kind: Tracer.SpanKind
219 readonly sampled: boolean
220 },
221 exportFn: (span: SpanImpl) => void
222 ) {
223 this.name = options.name
224 this.parent = options.parent
225 this.annotations = options.annotations
226 this.links = options.links
227 this.kind = options.kind
228 this.sampled = options.sampled
229 this.export = exportFn
230 this.status = {
231 _tag: "Started",
232 startTime: options.startTime
233 }
234 }
235
236 get traceId(): string {
237 return this._traceId ??= Option.isSome(this.parent) ? this.parent.value.traceId : Hex.random(32)
238 }
239
240 get spanId(): string {
241 return this._spanId ??= Hex.random(16)
242 }
243
244 get attributes(): Map<string, unknown> {
245 return this._attributes ??= new Map()
246 }
247
248 get events(): Array<[name: string, startTime: bigint, attributes: Record<string, unknown> | undefined]> {
249 return this._events ??= []
250 }
251
252 end(endTime: bigint, exit: Exit.Exit<unknown, unknown>): void {
253 this.status = {
254 _tag: "Ended",
255 startTime: this.status.startTime,
256 endTime,
257 exit
258 }
259 this.export(this)
260 }
261
262 attribute(key: string, value: unknown): void {
263 this.attributes.set(key, value)
264 }
265
266 event(name: string, startTime: bigint, attributes?: Record<string, unknown>): void {
267 this.events.push([name, startTime, attributes])
268 }
269
270 addLinks(links: ReadonlyArray<Tracer.SpanLink>): void {
271 // oxlint-disable-next-line no-restricted-syntax
272 this.links.push(...links)
273 }
274}
275
276const makeOtlpSpan = (self: SpanImpl): OtlpSpan => {
277 const status = self.status as ExtractTag<Tracer.SpanStatus, "Ended">
278 const attributes = self._attributes === undefined ? [] : entriesToAttributes(self._attributes)
279 const events: Array<Event> = []
280 if (self._events !== undefined) {
281 for (let i = 0; i < self._events.length; i++) {
282 const [name, startTime, attributes] = self._events[i]
283 events.push({
284 name,
285 timeUnixNano: String(startTime),
286 attributes: attributes
287 ? entriesToAttributes(Object.entries(attributes))
288 : [],
289 droppedAttributesCount: 0
290 })
291 }
292 }
293 let otelStatus: Status
294
295 if (status.exit._tag === "Success") {
296 otelStatus = { code: StatusCode.Ok }
297 } else if (Cause.hasInterruptsOnly(status.exit.cause)) {
298 otelStatus = {
299 code: StatusCode.Ok,
300 message: "Interrupted"
301 }
302 attributes.push({
303 key: "span.label",
304 value: { stringValue: "⚠︎ Interrupted" }
305 }, {
306 key: "status.interrupted",
307 value: { boolValue: true }
308 })
309 } else {
310 const errors = Cause.prettyErrors(status.exit.cause, {
311 includeCauseInStack: true
312 })
313 otelStatus = {
314 code: StatusCode.Error
315 }
316 if (errors.length > 0) {
317 otelStatus.message = errors[0].message
318 for (const error of errors) {
319 events.push({
320 name: "exception",
321 timeUnixNano: String(status.endTime),
322 droppedAttributesCount: 0,
323 attributes: [
324 {
325 "key": "exception.type",
326 "value": {
327 "stringValue": error.name
328 }
329 },
330 {
331 "key": "exception.message",
332 "value": {
333 "stringValue": error.message
334 }
335 },
336 {
337 "key": "exception.stacktrace",
338 "value": {
339 "stringValue": error.stack ?? "No stack trace available"
340 }
341 }
342 ]
343 })
344 }
345 }
346 }
347
348 const links: Array<Link> = []
349 for (let i = 0; i < self.links.length; i++) {
350 const link = self.links[i]
351 links.push({
352 traceId: link.span.traceId,
353 spanId: link.span.spanId,
354 attributes: entriesToAttributes(Object.entries(link.attributes)),
355 droppedAttributesCount: 0
356 })
357 }
358
359 return {
360 traceId: self.traceId,
361 spanId: self.spanId,
362 parentSpanId: Option.isSome(self.parent) ? self.parent.value.spanId : undefined,
363 name: self.name,
364 kind: SpanKind[self.kind],
365 startTimeUnixNano: String(status.startTime),
366 endTimeUnixNano: String(status.endTime),
367 attributes,
368 droppedAttributesCount: 0,
369 events,
370 droppedEventsCount: 0,
371 status: otelStatus,
372 links,
373 droppedLinksCount: 0
374 }
375}
376
377/**
378 * Root OTLP traces payload containing spans grouped by resource.
379 *
380 * @stability unstable
381 * @category models
382 * @since 4.0.0
383 */
384export interface TraceData {
385 readonly resourceSpans: Array<ResourceSpan>
386}
387
388/**
389 * Group of OTLP scope spans associated with a single resource.
390 *
391 * @stability unstable
392 * @category models
393 * @since 4.0.0
394 */
395export interface ResourceSpan {
396 readonly resource: Resource
397 readonly scopeSpans: Array<ScopeSpan>
398 readonly schemaUrl?: string | undefined
399}
400
401/**
402 * Group of OTLP spans emitted by a single instrumentation scope.
403 *
404 * @stability unstable
405 * @category models
406 * @since 4.0.0
407 */
408export interface ScopeSpan {
409 readonly scope: Scope
410 readonly spans: Array<OtlpSpan>
411 readonly schemaUrl?: string | undefined
412}
413
414interface Scope {
415 readonly name: string
416}
417
418interface OtlpSpan {
419 readonly traceId: string
420 readonly spanId: string
421 readonly parentSpanId: string | undefined
422 readonly name: string
423 readonly kind: number
424 readonly startTimeUnixNano: string
425 readonly endTimeUnixNano: string
426 readonly attributes: Array<KeyValue>
427 readonly droppedAttributesCount: number
428 readonly events: Array<Event>
429 readonly droppedEventsCount: number
430 readonly status: Status
431 readonly links: Array<Link>
432 readonly droppedLinksCount: number
433}
434
435interface Event {
436 readonly attributes: Array<KeyValue>
437 readonly name: string
438 readonly timeUnixNano: string
439 readonly droppedAttributesCount: number
440}
441
442interface Link {
443 readonly attributes: Array<KeyValue>
444 readonly spanId: string
445 readonly traceId: string
446 readonly droppedAttributesCount: number
447}
448
449interface Status {
450 readonly code: StatusCode
451 message?: string
452}
453
454const StatusCode = {
455 Unset: 0,
456 Ok: 1,
457 Error: 2
458} as const
459
460type StatusCode = typeof StatusCode[keyof typeof StatusCode]
461
462const SpanKind = {
463 unspecified: 0,
464 internal: 1,
465 server: 2,
466 client: 3,
467 producer: 4,
468 consumer: 5
469} as const