OpenTelemetry
One trace per run, one span per step. Bridge the engine's lifecycle events to OpenTelemetry with attachDurableOtel and see workflows in Jaeger, Grafana, or Datadog — plus distributed tracing across worker processes.
The @adonis-agora/durable/otel subpath turns workflow runs into OpenTelemetry traces — a root span per run, a child span per step — so they show up in the tracing stack you already run. It ships in the main @adonis-agora/durable package; install @opentelemetry/api as the only extra peer.
npm i @opentelemetry/apiAttaching it
attachDurableOtel(engine, options?) subscribes to the engine's lifecycle events and bridges them onto OpenTelemetry. Call it once at boot with the engine in hand; it returns a detach function:
import engine from '@adonis-agora/durable/services/main'
import { attachDurableOtel } from '@adonis-agora/durable/otel'
const detach = attachDurableOtel(engine) // uses the global tracer providerIt creates one root span per run and one child span per step (local / remote / sleep / signal). The root span is named workflow <name> and carries durable.run_id and durable.workflow; each step span is named step <name> and carries durable.run_id, durable.step.seq, and durable.step.kind. A failed step or run marks its span with an error status (and the error message, when there is one). Step spans are emitted when the step settles (step.completed / step.failed) and their start is reconstructed from the event's durationMs, so the span covers the step's real window and latencies are accurate. With no OTel SDK registered, it falls back to no-op spans — zero overhead. Spans come only from the events the instance itself produced: with a control plane, another pod's events re-delivered here are skipped (isRemoteEvent), so a run is traced once, by the pod that ran it, not once per pod. Before 0.51 every pod with the bridge attached opened a duplicate trace of each run it heard about.
attachDurableOtel takes an options object if you don't want the global tracer provider:
export interface DurableOtelOptions {
/** Tracer to use. Defaults to one from the global provider (or `provider` if given). */
tracer?: Tracer
/** Tracer provider to take the tracer from. Defaults to the global OTel provider. */
provider?: TracerProvider
}Pass tracer to supply an already-created Tracer, or provider to take one (named @adonis-agora/durable) from a specific TracerProvider — both types come from @opentelemetry/api — instead of the process-wide global:
const detach = attachDurableOtel(engine, { provider: myTracerProvider })It subscribes to the same engine lifecycle events the dashboard and the Telescope view use — three views of one event log. OTel is for debugging in production (latency, correlation, alerts); the dashboard is for operating the workflow.
Already running Telescope? If your app has
@adonis-agora/telescopeinstalled and configured withotel.enabled: true, you likely don't need to wireattachDurableOtelat all. Telescope's generic OTel bridge already turns durable's ownagora:durable:*diagnostics events (emitted viadiagnostics-bridge.ts— the same events the Telescope view uses) into OTLP spans and logs, alongside every other Agora library's events, in one place — zero durable-specific code required. Prefer that route when both libs are present. Reach for durable's own bridge on this page only when Telescope isn't part of the stack, or when you specifically want durable's root-span-per-run / child-span-per-step shape (Telescope's generic bridge parents spans under the resolved trace id only, not under a sibling run span).
A suspended-then-resumed run is NOT one continuous root span
The root span opens on run.started (the run's first pending → running transition) and is ended and forgotten on run.suspended — it does not reopen when the run resumes. run.started fires once per run (a retried run goes back to pending and fires it again), so a workflow that suspends (a sleep, a signal wait, a remote dispatch awaiting a result) and later resumes does not get a fresh root span either: there is no root to attach the resumed steps to. Steps that settle after a resume show up as spans with no parent — detached from the run's original trace — rather than nested under one long-lived root.
A run that parks blocked (capability.unavailable / protocol.incompatible) ends its root span the same way, with an error status, since the resume from blocked emits no new run.started either. The dashboard's run timeline is unaffected: it is built from the state store's checkpoints, not from spans.
onError handoffs
A failure handed to an onError handler adds a durable.run.routed event to the run's root span, with the attributes durable.on_error.handler, durable.error.code, and durable.error.message. A failure that could not be routed adds durable.run.unrouted with durable.on_error.reason and durable.error.code. Both are emitted while the root span is still open — the handoff before the run suspends on its handler, the unrouted failure before the run fails — so they land on the run's trace, subject to the suspend boundary above.
Lightweight metrics
If you want dependency-free counters and latency percentiles without a full OTel pipeline, this subpath also exports attachDurableMetrics(engine), which collects them from the same events and needs no OTel SDK. See Metrics for it and the Prometheus-shaped collectMetrics.
Distributed tracing across workers
A remote step runs in another process. Left alone, that worker would start a fresh, detached trace, so the work it does wouldn't show up under the run's root span. To keep one trace across the boundary, the engine stamps a W3C traceparent on each dispatched task, taken from an optional traceparent provider. A worker that honours W3C trace context reads it and continues the trace instead of starting a new one — the step's span lands as a child of the workflow's span, even across processes and languages (the Python SDK, for instance).
Agora's own TypeScript step runner (durable:worker, @adonis-agora/durable/worker, and the worker side of the BullMQ, queue and db transports) carries the traceparent on the task but does not open a span from it: it restores the originating request context (including its traceId) instead, and a step handler ((input, log) => …) is not handed the task. The step still appears in the trace as the step <name> span attachDurableOtel emits on the engine side, with its real duration.
Core stays OTel-free, so you wire the provider in. Use otelTraceparent from @adonis-agora/durable/otel, which reads the current active span via the globally-registered W3C propagator:
import { defineConfig } from '@adonis-agora/durable'
import { otelTraceparent } from '@adonis-agora/durable/otel'
export default defineConfig({
// … plus your transport/store (see Getting Started)
traceparent: () => otelTraceparent(),
})The provider is just () => string | undefined, so you can supply your own context reader instead of the OTel one. Omit it and no traceparent is sent (the worker starts its own trace). The stamped value travels in the RemoteTask.traceparent field, so any worker that honours W3C trace context picks it up automatically.
When
@adonis-agora/diagnostics-otelis installed, the@adonis-agora/durableprovider auto-wires thistraceparentprovider for you (read structurally from a global slot, no hard dependency) — so distributed tracing works with zero config in that setup. An explicittraceparentinconfig/durable.tsoverrides that auto-wiring, which is how you bridge a tracer durable does not know about.
Context propagation is automatic
Alongside the trace, the full Agora request context (userRef / tenant / traceId) rides each remote task automatically. When @adonis-agora/context is installed, the @adonis-agora/durable provider snapshots the live context onto the dispatched RemoteTask (read structurally from the global accessor slot, no hard dependency); before a worker runs the step handler, durable restores that snapshot into the worker's context. So a dispatched ctx.step(ref, input) on a worker sees the originating request's userRef / tenant / traceId with zero manual serialize/deserialize — best-effort, and a no-op when @adonis-agora/context is not installed.
Treat the propagated context as correlation metadata, not an authorization boundary: on an engine-driven path that runs outside the originating request (a retry or a crash recovery), the snapshot may be empty or stale.
Events & interceptors
The two programmatic hooks into a running engine — engine.subscribe for the lifecycle event stream (every EngineEvent type and its payload, and how events from other instances arrive), and engine.use for onion middleware around a local step. Plus currentStep() for step logs, collectMetrics for a Prometheus endpoint and attachDurableDiagnostics for the diagnostics bus.
Metrics
Dependency-free run/step counters and duration percentiles. collectMetrics serves a Prometheus scrape, attachDurableMetrics a JSON snapshot with p50/p95/max, both built from the engine's own lifecycle events.