Observability integration

OpenTelemetry spans, Prometheus metrics, structlog / loguru context and Sentry breadcrumbs for every machine -- one line with instrument_all(), telemetry-hygienic by default.

This library contains failures by design: a raising action is logged and the transition still completes, a raising guard is False, an unhandled event is dropped. A long-lived machine should not die because one side effect had a bad day — which also means those failures are invisible unless you look. The [observability] extra is how you look: every plugin is a thin mapping over the existing hooks, attached to one interpreter with .use() or to every interpreter in the process with a single instrument_all() call at startup.

Install

pip install "xstate-statemachine[observability]"
pip install structlog loguru sentry-sdk   # optional -- soft imports, never pinned

[observability] pins exactly two packages: opentelemetry-api>=1.20 and prometheus-client>=0.17. You still configure the OpenTelemetry SDK (exporter, sampler) yourself — the library only talks to the API, as the OTel project recommends for libraries. structlog, loguru and sentry-sdk are detected when their plugin is constructed; a missing one raises MissingExtraError naming the package (pip install structlog). Tested versions are in the compatibility table.

For a complete, runnable app – PrometheusPlugin and OpenTelemetryPlugin over two charts in choreography with in-memory exporters, tests that no order id becomes a label, and a test suite – see the eda_fulfilment example.

Quick start

from prometheus_client import CollectorRegistry, generate_latest

from xstate_statemachine import SyncInterpreter, create_machine
from xstate_statemachine.contrib.observability import (
    PrometheusPlugin,
    instrument_all,
    uninstrument_all,
)

machine = create_machine(
    {
        "id": "door",
        "initial": "closed",
        "states": {
            "closed": {
                "on": {
                    "OPEN": "open",
                },
            },
            "open": {
                "on": {
                    "CLOSE": "closed",
                },
            },
        },
    }
)

registry = CollectorRegistry()
attached = instrument_all(
    otel=True, prometheus=PrometheusPlugin(registry=registry)
)

door = SyncInterpreter(machine).start()  # built AFTER the call -> instrumented
door.send("OPEN")
door.send("KNOCK")  # undeclared -> label "unknown"
door.stop()

text = generate_latest(registry).decode()
assert (
    'xstatemachine_transitions_total{event="OPEN",from_state="door.closed"'
    in text
)
assert 'disposition="unhandled"' in text and "KNOCK" not in text
uninstrument_all(attached)  # tests: leave the process as found

A Grafana-ready starting point:

# transitions per second, by machine and event
sum by (machine, event) (rate(xstatemachine_transitions_total[5m]))
# share of events nobody handled (misrouted traffic, stale clients)
sum by (machine) (rate(xstatemachine_events_received_total{disposition="unhandled"}[5m]))
  / sum by (machine) (rate(xstatemachine_events_received_total[5m]))
# p95 event-processing latency
histogram_quantile(0.95, sum by (le, machine) (rate(xstatemachine_transition_duration_seconds_bucket[5m])))
# action failures that were contained -- alert on any
sum by (machine, action) (increase(xstatemachine_action_errors_total[15m])) > 0

OTel collector note: the plugins emit through the global TracerProvider. Point the SDK’s OTLP exporter at your collector (OTEL_EXPORTER_OTLP_ENDPOINT), and let the collector do tail sampling — a busy machine emits one span per event.

Reference

instrument_all(interp_or_app=None, *, otel=False, prometheus=False, structlog=False, loguru=False, sentry=False, discovered=False, allow=None)

Each flag is False, True (a default-configured plugin) or a ready plugin instance. The target decides where they go:

interp_or_app Effect
None (default) plugins.register_global() — every interpreter constructed afterwards: both engines, spawned/invoked children, from_snapshot restores. Existing interpreters are not touched.
an interpreter .use() each plugin. Already running? on_interpreter_start is replayed so its gauges/registrations see it. A plugin instance already attached to it (e.g. globally) is skipped – use() itself dedupes by identity since 0.11.0, so nothing can double a metric.
an object with a plugins list (e.g. the [starlette] StatechartRegistry) appended; the registry attaches them to every interpreter it builds.

discovered=True also attaches entry-point plugins through plugins.attach_discovered() (#296); allow= narrows them by name. Returns the plugins attached. Anything else raises TypeError.

uninstrument_all(attached)

Unregisters a global instrument_all() (new interpreters only).

OpenTelemetryPlugin(tracer=None, *, span_per="event", record_context=False, redact=())

One span named statechart.transition per processed event, opened in on_event_received and closed in on_event_processed — the hook that knows the outcome:

Attribute Value
statechart.machine_id the chart id (not the instance)
statechart.event.type allow-listed event type (see Threat model)
statechart.from / statechart.to sorted active leaf ids before / after
statechart.changed, .denied, .deferred from the Receipt
statechart.actions action types executed, in order
statechart.context only with record_context=True, redact()-ed JSON

Guard evaluations are span events (guard_evaluated with guard.name, guard.result). Action / guard errors, service errors and runaway-chain trips are recorded with record_exception and set the span status to ERROR. Each invoked service gets a child span statechart.service (service.src, duration). A traceparent in the event payload (or payload["headers"]) becomes a span link to the remote context; an invalid one is ignored. span_per="transition" adds a statechart.microstep child span per transition taken.

📝 There is no official OpenTelemetry semantic convention for state machines; the statechart.* namespace is ours.

agent_span_exporter(tracer=None)

An on_span callable for the [agents] AgentTracePlugin: each trace record becomes a gen_ai.<operation> span with its gen_ai.* fields. AgentTracePlugin(on_span="otel") wires it for you.

PrometheusPlugin(registry=None, *, labels=("machine",), max_label_values=100, clock=None)

Metric Type Labels
xstatemachine_transitions_total counter machine, from_state, to_state, event
xstatemachine_transition_duration_seconds histogram machine — received → settled
xstatemachine_events_received_total counter machine, event, disposition (handled, unhandled, denied, deferred, dropped, duplicate)
xstatemachine_guard_evaluations_total counter machine, guard, result
xstatemachine_guard_errors_total counter machine, guard
xstatemachine_action_errors_total counter machine, action
xstatemachine_service_duration_seconds histogram machine, service — completed and failed services only; a service cancelled because its state was exited is not observed (no sample, no error count; its OTel span ends with statechart.cancelled=true)
xstatemachine_service_errors_total counter machine, service
xstatemachine_chain_trips_total counter machine
xstatemachine_active_interpreters gauge (polled at scrape) machine — interpreters whose status is running
xstatemachine_queue_depth gauge (polled at scrape) machine

Metric objects are created once per CollectorRegistry and label set and shared, so several plugins on one registry never collide; a second plugin with a different labels= on the same registry is a ValueError. labels=() drops the machine label. queue_depth has no hook — it is a collector that reads interpreter.queue_depth from live interpreters at scrape time.

StructlogPlugin() / LoguruPlugin(logger=None)

Bind machine_id, state (sorted leaf ids), event and — when the event payload carries one — correlation_id from on_event_received until on_event_processed, so any log line your actions emit carries them. structlog: through structlog.contextvars (keep merge_contextvars in your processors); loguru: logger.contextualize() → record["extra"]. Unbound afterwards. The in-flight stack is per thread and per asyncio task, so several async interpreters on one loop never unwind each other’s fields. correlation_id is truncated to 128 characters. LoguruPlugin(logger=...) raises TypeError for an object without .contextualize().

SentryPlugin(level="info", *, capture_errors=False, sdk=None)

A statechart breadcrumb per transition (door.closed -> door.open (OPEN)). With capture_errors=True, action / guard / service errors and chain trips are sent with capture_exception and statechart.* tags. Works with sentry-sdk 1.x (push_scope) and 2.x (new_scope). Each failure is captured once (the action error, not also its on_error). Breadcrumb data is only machine_id and event – never payloads or context. If an sdk call raises, the plugin logs one warning and goes inert (same as OpenTelemetryPlugin with a raising tracer); the chart keeps running.

LabelGuard(max_label_values), event_label(machine, event_type), UNKNOWN, OTHER

The hygiene primitives the plugins share, exported for your own exporters.

Guarantees

What this does: observes every hook without changing behaviour — every plugin is wrapped by the engine’s _SafePlugin, so an exporter that raises is reported via on_plugin_error and the machine keeps running. instrument_all() covers every interpreter constructed after the call, on both engines, including children and restores. The hot path pays for on_event_processed only when a plugin overrides it (all of these do, by design: it carries the outcome).

What this does not do: configure an OTel SDK or exporter; retro-instrument interpreters that already exist; guarantee delivery of telemetry (exporters are best-effort); emit OTel messaging spans for brokers (the broker adapters do not trace; carry traceparent in the envelope). Per-event overhead was measured once, on a two-state chart (Windows, CPython 3.14): roughly 3× with PrometheusPlugin, 4–5× with OpenTelemetryPlugin (SDK, no exporter), about 40 % of it the engine’s per-event receipt. It is not a budgeted benchmark; measure on your chart.

See the programme-wide Guarantees and Security pages (#303).

Threat model

Who can call this: your own process; nothing here opens a port. The /metrics endpoint, if any, is yours to expose and protect.

What it exposes (X0.6 telemetry hygiene): a metrics backend is long-retention and priced by cardinality, so label values come from the chart, not from traffic — an event type the machine does not declare is unknown, engine events collapse to their family (after, done, error, xstate), and every label dimension is capped at max_label_values with the overflow bucket other (proven with 1,000 distinct event names). Never labels or attributes by default: event payloads, context, interpreter ids / store keys, correlation ids. Sentry tags and breadcrumbs carry names only. correlation_id is bound as log context (per line), never as a metric label.

You must configure: your OTel SDK and exporter; record_context=True only with a reviewed redact= list; capture_errors=True only if your Sentry project may receive exception messages (they can echo request data).

Compatibility

Package Python Tested in CI
opentelemetry-api 1.20 – latest, prometheus-client 0.17 – latest 3.9 – 3.15 ✅ oldest on 3.9, newest on 3.13 (compatibility)
structlog / loguru / sentry-sdk (soft) 3.9 – 3.15 ✅ latest in the observability cell

Troubleshooting

Symptom Cause Fix
MissingExtraError: … pip install "xstate-statemachine[observability]" extra not installed run the command
MissingExtraError: … pip install structlog soft dependency missing install the named package
No spans exported no SDK TracerProvider configured set one up before instrument_all()
An interpreter has no metrics it was built before instrument_all() call it at startup, or .use() the plugin
event="unknown" everywhere events not declared in the chart declare them in on: (or it is traffic you did not expect)
Series ending in "other" max_label_values reached raise it deliberately, or reduce label spread
structlog lines lack machine_id merge_contextvars missing from processors add structlog.contextvars.merge_contextvars
"other" in labels that are real states the per-label cardinality cap tripped see the series count; labels are first-come
active_interpreters stays at 0 for an interpreter it is not running (done, error, stopped or not started) expected: the gauge counts status == "running" at scrape time
Every counter is doubled two different plugin instances on one registry (e.g. PrometheusPlugin() in two modules both pointed at the global REGISTRY) construct one instance and share it; the same instance attached twice is deduped by use()
MissingExtraError … pip install sentry-sdk at SentryPlugin() soft dependency missing (not at import time) install the named package
Two traces per order across a broker hop spans are per event; context does not cross the wire on its own carry traceparent in the message envelope and restore it on the consumer
SentryPlugin: sentry_sdk raised … reported once transport / DSN misconfiguration fix the SDK setup; build a new plugin instance