Agora

OpenTelemetry

A zero-config bridge that turns trace() spans into real OTel spans, records emits as span events, and publishes the W3C traceparent.

The core package ships an OpenTelemetry bridge that reconstructs real OTel spans from trace() activity and records POINT emits as events on the active span. When an OTel SDK is installed it turns on automatically — no extra wiring — and when it isn't, it's a no-op you can safely leave on.

Optional peer

@opentelemetry/api is an optional peer dependency of @adonis-agora/diagnostics. The bridge module (and its OTel import) is loaded dynamically by the provider only when OTel is resolvable, so the emit/trace hot path stays OTel-free by default.

Auto-start

If @opentelemetry/api can be resolved — because your app has an OTel SDK, e.g. @adonisjs/otel or a manual NodeSDK setup — the bridge starts with the app and stops with it. Every trace() span is already an OTel span; there is nothing to install or call. With no SDK present the bridge is skipped entirely, silently, and OTel is never even loaded.

Opting out

Set otel: false in your diagnostics config and the bridge never starts, SDK or not:

config/diagnostics.ts
import { defineConfig } from '@adonis-agora/diagnostics'

export default defineConfig({
  otel: false,
})

Do that when you want emit/trace to stay OTel-free on a hot path even though an SDK is installed. See the config reference for the other keys.

What the bridge does

It subscribes to every agora:<lib>:<event> base channel and its five span sub-channels (current and future, via the registry), and reconstructs one OTel span per agora spanId:

  • start opens a span named agora.<lib>.<event>, parented to context.active() at publish time, with attributes agora.lib, agora.event, agora.trace_id (when present), and flattened scalar payload fields as agora.payload.<key>.
  • end (sync) / asyncEnd close the span OK, recording agora.result (when scalar) and agora.duration_ms.
  • error records the exception and closes the span with status ERROR.

POINT events (emit) are added to the active span as span events named agora.<lib>.<event>, carrying agora.lib, agora.event and the flattened payload, timestamped with the envelope's ts. With no active span there is nothing to attach them to, so they are dropped. (Disable this with recordPointEvents: false.)

Events a lib-specific consumer has claimed are skipped by default — the claiming lib already records its own typed observation, so bridging them again would duplicate every such event. Override with recordClaimed: true.

Zero-overhead without an SDK

Every @opentelemetry/api call resolves to a no-op span/tracer when no SDK is registered, so the bridge still subscribes but produces nothing. The tracer is resolved lazily on each start, so a late-registered SDK wins.

Bridge options

If you start the bridge manually (see below), start(opts) / new DiagnosticsOtelBridge(opts) accept (BridgeOptions):

OptionTypeDefaultDescription
tracerNamestring@adonis-agora/diagnosticsName passed to trace.getTracer(...).
recordPointEventsbooleantrueRecord POINT emits as events on the active span.
maxOpenSpansnumber10000Cap on open (un-ended) spans; the oldest is force-ended when exceeded.
recordClaimedbooleanfalseBridge events whose lib:event is claimed by a lib-specific consumer. Default skips them so they aren't recorded twice; true bridges everything.

The open-span cap is a safety valve: if a trace() span never observes an end/error (e.g. a process killed mid-operation), the oldest span is force-ended with an ERROR status rather than leaking memory.

Manual control

You rarely need this — the provider handles it — but the bridge is exported from the @adonis-agora/diagnostics/otel subpath:

import { start, stop, DiagnosticsOtelBridge } from '@adonis-agora/diagnostics/otel'

// Idempotent module-level singleton:
start({ tracerName: 'my-app', recordPointEvents: true })
// …
stop()

// Or manage an instance yourself:
const bridge = new DiagnosticsOtelBridge({ maxOpenSpans: 1000 })
bridge.start()
console.log(bridge.openSpanCount)
bridge.stop()

start() is idempotent — a second call with no stop() in between returns the existing singleton.

The traceparent slot

When the bridge starts it also publishes the current active span as a W3C traceparent string on a cross-copy-stable global slot, Symbol.for('@agora/otel:traceparent'). This lets a durable/remote engine continue the trace on a worker with zero config — it reads the slot structurally and no-ops when the slot is absent:

import { otelTraceparent } from '@adonis-agora/diagnostics/otel'

// Wire it into a durable engine so remote steps continue the trace:
engine.configure({ traceparent: () => otelTraceparent() })

otelTraceparent() uses the globally-registered OTel propagator (propagation.inject), so a standard W3C setup needs no extra config; with no SDK registered it returns undefined and never throws. @adonis-agora/diagnostics/otel also exports publishTraceparentSlot() / clearTraceparentSlot() and the TRACEPARENT_SLOT symbol for manual control.

Relationship to trace correlation

The bridge produces OTel spans; the traceId on each envelope (auto-filled from @adonis-agora/context) is a separate, ecosystem-level correlation id surfaced as the agora.trace_id attribute. The two are complementary: OTel gives you the trace tree, traceId ties an event back to the originating request even on a path with no active OTel span (e.g. a background worker).

On this page