Aviary
Observability

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-otel
import { 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 115

Counters 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.

On this page