Agora
Observability

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/api

Attaching 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:

start/durable.ts
import engine from '@adonis-agora/durable/services/main'
import { attachDurableOtel } from '@adonis-agora/durable/otel'

const detach = attachDurableOtel(engine) // uses the global tracer provider

It creates one root span per run and one child span per step (local / remote / sleep / signal). Each step span carries durable.run_id, durable.workflow, durable.step.seq, and durable.step.kind; a failed run marks its span with an error status. Pass { tracer } or { provider } in the options to use a specific tracer instead of the global one. With no OTel SDK registered, it falls back to no-op spans — zero overhead.

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/telescope installed and configured with otel.enabled: true, you likely don't need to wire attachDurableOtel at all. Telescope's generic OTel bridge already turns durable's own agora:durable:* diagnostics events (emitted via diagnostics-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).

Lightweight metrics

If you want dependency-free counters and latency percentiles without a full OTel pipeline, attachDurableMetrics(engine) collects them from the same events:

import { attachDurableMetrics } from '@adonis-agora/durable/otel'

const metrics = attachDurableMetrics(engine)
// ...later, expose a snapshot on a /metrics route:
const snap = metrics.snapshot()
// { runs: { started, completed, failed, suspended },
//   steps: { completed, failed },
//   stepDurationMs: { count, p50, p95, max }, runDurationMs: { … } }

The handle has snapshot(), reset(), and unsubscribe().

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. The worker 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.

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:

config/durable.ts
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-otel is installed, the @adonis-agora/durable provider auto-wires this traceparent provider for you (read structurally from a global slot, no hard dependency) — so distributed tracing works with zero config in that setup. An explicit traceparent in config/durable.ts overrides 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.

On this page