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.
Both the OTel package and core ship a metrics collector — no OpenTelemetry SDK, no Prometheus client library required. Each subscribes to the same engine lifecycle events the dashboard, OTel bridge and Telescope watcher use, and turns them into counters you can read on demand.
attachDurableMetrics — snapshot-style counters
@dudousxd/nestjs-durable-otel (the same package as the OTel bridge, no OTel SDK needed for this
part) exports attachDurableMetrics:
pnpm add @dudousxd/nestjs-durable-otelimport { attachDurableMetrics } from '@dudousxd/nestjs-durable-otel';
const metrics = attachDurableMetrics(engine);It returns a handle with three methods:
export interface DurableMetrics {
/** A point-in-time copy of the counters and duration percentiles. */
snapshot(): DurableMetricsSnapshot;
/** Zero everything (e.g. between scrape windows). */
reset(): void;
/** Stop collecting. */
unsubscribe(): void;
}snapshot() returns run/step counts by outcome plus p50/p95/max duration stats for both runs and
steps:
export interface DurableMetricsSnapshot {
runs: { started: number; completed: number; failed: number; suspended: number };
steps: { completed: number; failed: number };
/** Per-step wall-clock (from the `durationMs` on step events). */
stepDurationMs: { count: number; p50: number; p95: number; max: number };
/** Per-run wall-clock, measured `run.started` → terminal. */
runDurationMs: { count: number; p50: number; p95: number; max: number };
}Feed it from a plain HTTP route — snapshot() returns JSON, so it drops straight into a NestJS
controller:
@Controller()
class MetricsController {
constructor(private readonly metrics: DurableMetrics) {}
@Get('metrics')
get() {
return this.metrics.snapshot();
}
}Call reset() after a scrape if you want each window's counters independent rather than
cumulative since boot. Call unsubscribe() on shutdown to stop listening.
collectMetrics — Prometheus text exposition
@dudousxd/nestjs-durable-core ships a second collector, collectMetrics, shaped for a real
Prometheus scrape rather than a JSON snapshot:
import { collectMetrics } from '@dudousxd/nestjs-durable-core';
const metrics = collectMetrics(engine);Its handle:
export interface MetricsCollector {
snapshot(): MetricsSnapshot;
/** Prometheus text exposition of the counters — serve this from a `/metrics` endpoint. */
prometheus(): string;
/** Unsubscribe from the engine. */
stop(): void;
}MetricsSnapshot adds a per-workflow breakdown and a step-started counter that
DurableMetricsSnapshot doesn't track:
export interface MetricsSnapshot {
runs: { started: number; completed: number; failed: number; suspended: number };
steps: { started: number; completed: number; failed: number };
/** Per-workflow run counters, keyed by workflow name. */
byWorkflow: Record<string, { started: number; completed: number; failed: number }>;
/** Sum + count of recorded step durations (ms), for an average. */
stepDuration: { sumMs: number; count: number };
}prometheus() renders it as # TYPE + sample lines — wire it straight into a scrape endpoint:
@Controller()
class MetricsController {
constructor(private readonly metrics: MetricsCollector) {}
@Get('metrics')
@Header('Content-Type', 'text/plain; version=0.0.4')
get() {
return this.metrics.prometheus();
}
}A scrape returns counters like:
# TYPE durable_runs_total counter
durable_runs_total{event="started"} 42
durable_runs_total{event="completed"} 38
durable_runs_total{event="failed"} 2
durable_runs_total{event="suspended"} 6
# TYPE durable_steps_total counter
durable_steps_total{event="started"} 120
durable_steps_total{event="completed"} 115
durable_steps_total{event="failed"} 3
# TYPE durable_runs_by_workflow_total counter
durable_runs_by_workflow_total{workflow="order-fulfillment",event="completed"} 38
# TYPE durable_step_duration_ms_sum counter
durable_step_duration_ms_sum 184230
# TYPE durable_step_duration_count counter
durable_step_duration_count 115Counters are per process — a multi-pod deployment needs one scrape target per instance (or an aggregating layer), the same as any Prometheus client library.
Which one to use
Both subscribe to the same event stream and are safe to run side by side — collectMetrics and
attachDurableMetrics don't conflict, since each just adds its own listener via engine.subscribe.
Reach for collectMetrics (core) when you already scrape Prometheus text; reach for
attachDurableMetrics (otel package) when you want p50/p95/max duration percentiles out of the box,
or a JSON snapshot for a custom dashboard rather than a text exposition format.