OpenTelemetry
One span per step, one root span per run's first turn. Bridge the engine's lifecycle events to OpenTelemetry and see workflows in Jaeger, Grafana or Datadog.
@dudousxd/nestjs-durable-otel 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.
pnpm add @dudousxd/nestjs-durable-otel @opentelemetry/apiimport { attachDurableOtel } from '@dudousxd/nestjs-durable-otel';
// after the module boots, with the engine in hand:
const detach = attachDurableOtel(engine); // uses the global tracer providerThe root span carries durable.run_id and durable.workflow; each step span carries
durable.run_id, durable.step.seq and durable.step.kind; a failed run marks its span with an
error status. Step spans are timed from the engine's durationMs, so latencies are accurate.
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;
provider?: TracerProvider;
}Pass tracer to supply an already-created Tracer directly, or provider to have
attachDurableOtel pull one from a specific TracerProvider (both 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 Telescope watcher use — three views of one event log. OTel is for debugging in production (latency, correlation, alerts); the dashboard is for operating the workflow.
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 exactly once per run, so a workflow that suspends (a sleep, a signal wait, a remote dispatch
awaiting a decision) and later resumes does not get a fresh root span either: there simply
isn't one to attach the resumed steps to. In practice this means steps that execute 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.
The Telescope watcher does not hit this boundary: it derives a
run's traceId from the run id rather than holding a root span, so a run that suspends and resumes
stays one trace on Telescope's Traces page. Every entry it records also carries a run:<runId> tag,
so filtering the Entries view by that tag reconstructs the full lifecycle either way. The
dashboard's run timeline is unaffected: it's built from the state
store's checkpoints, not from spans.
Distributed tracing across workers
A remote step runs in another process — often another language (the Python SDK).
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 RemoteTask, 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 and languages.
Core stays OTel-free (it never imports @opentelemetry/api), so you wire the provider in. Use
otelTraceparent from @dudousxd/nestjs-durable-otel, which reads the current active span via the
globally-registered W3C propagator:
import { WorkflowEngine } from '@dudousxd/nestjs-durable-core';
import { otelTraceparent } from '@dudousxd/nestjs-durable-otel';
const engine = new WorkflowEngine({
store,
transport,
traceparent: () => otelTraceparent(),
});With the NestJS module, pass it as the traceparent option:
import { otelTraceparent } from '@dudousxd/nestjs-durable-otel';
DurableModule.forRoot({
store,
transport,
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 as before). The
stamped value travels in the documented RemoteTask.traceparent field, so any language SDK that
honours W3C trace context picks it up automatically.
Control plane
The embedded dashboard — a React SPA served by NestJS that lists runs and renders each one as a graph across local and remote steps, with retry and cancel.
Metrics
Dependency-free run/step counters and duration percentiles — feed a /metrics route or a Prometheus scrape endpoint, with nothing but the engine's own lifecycle events.