| 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 | */ |
| 14 | import * as Cause from "../Cause.ts" |
| 15 | import * as Config from "../Config.ts" |
| 16 | import type * as Context from "../Context.ts" |
| 17 | import * as Duration from "../Duration.ts" |
| 18 | import * as Effect from "../Effect.ts" |
| 19 | import * as Hex from "../encoding/Hex.ts" |
| 20 | import type * as Exit from "../Exit.ts" |
| 21 | import { flow } from "../Function.ts" |
| 22 | import type * as Headers from "../http/Headers.ts" |
| 23 | import type * as HttpClient from "../http/HttpClient.ts" |
| 24 | import * as Layer from "../Layer.ts" |
| 25 | import * as Option from "../Option.ts" |
| 26 | import type * as Scope from "../Scope.ts" |
| 27 | import * as Tracer from "../Tracer.ts" |
| 28 | import type { ExtractTag } from "../Types.ts" |
| 29 | import * as OtlpEnv from "./internal/otlpEnv.ts" |
| 30 | import * as Exporter from "./OtlpExporter.ts" |
| 31 | import type { KeyValue, Resource } from "./OtlpResource.ts" |
| 32 | import { entriesToAttributes } from "./OtlpResource.ts" |
| 33 | import * as OtlpResource from "./OtlpResource.ts" |
| 34 | import { 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 | */ |
| 48 | export 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 | */ |
| 121 | export 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 | */ |
| 146 | export 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 | |
| 195 | class 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 | |
| 276 | const 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 | */ |
| 384 | export 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 | */ |
| 395 | export 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 | */ |
| 408 | export interface ScopeSpan { |
| 409 | readonly scope: Scope |
| 410 | readonly spans: Array<OtlpSpan> |
| 411 | readonly schemaUrl?: string | undefined |
| 412 | } |
| 413 | |
| 414 | interface Scope { |
| 415 | readonly name: string |
| 416 | } |
| 417 | |
| 418 | interface 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 | |
| 435 | interface Event { |
| 436 | readonly attributes: Array<KeyValue> |
| 437 | readonly name: string |
| 438 | readonly timeUnixNano: string |
| 439 | readonly droppedAttributesCount: number |
| 440 | } |
| 441 | |
| 442 | interface Link { |
| 443 | readonly attributes: Array<KeyValue> |
| 444 | readonly spanId: string |
| 445 | readonly traceId: string |
| 446 | readonly droppedAttributesCount: number |
| 447 | } |
| 448 | |
| 449 | interface Status { |
| 450 | readonly code: StatusCode |
| 451 | message?: string |
| 452 | } |
| 453 | |
| 454 | const StatusCode = { |
| 455 | Unset: 0, |
| 456 | Ok: 1, |
| 457 | Error: 2 |
| 458 | } as const |
| 459 | |
| 460 | type StatusCode = typeof StatusCode[keyof typeof StatusCode] |
| 461 | |
| 462 | const SpanKind = { |
| 463 | unspecified: 0, |
| 464 | internal: 1, |
| 465 | server: 2, |
| 466 | client: 3, |
| 467 | producer: 4, |
| 468 | consumer: 5 |
| 469 | } as const |