Files
skills/docs/api-stack/api/api-saga/src/instruments/instruments.md
2026-05-29 08:20:56 +01:00

3.6 KiB

instruments

(no submodules)

Purpose

The saga orchestrator emits an OTel surface matching design doc §9.1: three spans (saga.instance, saga.step, saga.compensate), two counters (saga.instances_total, saga.compensations_total), one histogram (saga.step_duration_ms), and one observable gauge (saga.in_flight). This module centralises construction so every caller binds against the same names with the same attribute schema.

Tests construct the bag against an in-memory MeterProvider and BasicTracerProvider and assert exact emissions — the assertion catches name drift, attribute drift, or missing instruments before they leak into the operator's dashboards.

Features

  • One createSagaInstruments(meter, tracer) factory — all instruments built lazily inside the call so tests can spin up a fresh registry per case.
  • Names + units lifted verbatim from design §9.1.
  • Span factories — startInstanceSpan, startStepSpan, startCompensateSpan — wrap the tracer call so attribute names (saga.name, saga.tenant, step.name, attempt) stay consistent across every call site.
  • bindInFlight(observer) — the orchestrator passes a closure that returns the live count from the persistence layer; the OTel callback path forwards the value on every collector poll.

Usage: Basic

import { metrics, trace } from "@opentelemetry/api";
import { createSagaInstruments } from "@suaveplan/api-saga/instruments/instruments";

const instruments = createSagaInstruments(
    metrics.getMeter("api-saga", "0.1.0"),
    trace.getTracer("api-saga", "0.1.0")
);

const span = instruments.startInstanceSpan({
    sagaName: "checkout",
    tenantId: "acme",
});
try {
    // ... run saga
    instruments.instancesTotal.add(1, {
        name: "checkout",
        outcome: "completed",
    });
} finally {
    span.end();
}

Usage: Advanced

Bind the in-flight observer to the persistence layer so the gauge reads the live running-count from the database on every collector poll. The callback is invoked once per observation window so it should be O(1) wrt. the persistence query — count, not list:

import { createSagaInstruments } from "@suaveplan/api-saga";

const instruments = createSagaInstruments(meter, tracer);

instruments.bindInFlight(() => {
    return persistence.listInstances({ state: "running" }).length
        + persistence.listInstances({ state: "compensating" }).length;
});

instruments.stepDurationMs.record(elapsedMs, {
    name: "checkout",
    step: "charge",
    outcome: "completed",
});

API Reference

  • createSagaInstruments(meter, tracer): SagaInstruments — builder.
  • SagaInstruments:
    • instancesTotal: Counter<{ name, outcome }>,
    • stepDurationMs: Histogram<{ name, step, outcome }>,
    • compensationsTotal: Counter<{ name, step }>,
    • inFlight: ObservableGauge,
    • startInstanceSpan(args), startStepSpan(args), startCompensateSpan(args) — open named spans with the design's attribute schema,
    • bindInFlight(observer: () => number): void.
  • SagaOutcome, SagaStepOutcome — literal-union labels written onto counters/histogram for safe partitioning.

Implementation Notes

The instruments are typed loosely (Counter<{ ... }> is type-narrowed at the surface level, but OTel's runtime API erases the generic); the type pins help downstream call sites stay honest about which attributes belong to which counter. The factory function intentionally does not cache the bag — the orchestrator owns the lifecycle so a host restart yields a fresh registry along with a fresh DB connection, fresh participant registry, etc.