Statifier.Telemetry (Statifier v2.1.1)

Copy Markdown View Source

The :telemetry bridge (ADR-0040, amended by ADR-0067) - the single authoritative reference for every [:statifier, :session, ...] event any stepping driver can emit. This module is the caller-agnostic emission half of the ADR-0003 effect-interpreter role: it holds no state, drives no core function, and is called from wherever a driver steps a chart - Statifier.Session included, through the Statifier.Session.Telemetry facade that pins driver: :session.

events/0 returns every name below, built from the same literal-atom lists the emitters use, so a consumer can :telemetry.attach_many/4 to the whole surface without hand-copying names out of this table.

The prefix names the logical session, not a process (ADR-0067 decision 1)

The second segment of every event name, :session, names the logical SCXML session - datamodel["_sessionid"], the identity a position blob persists and a resume preserves (ADR-0052/ADR-0060) - not the Statifier.Session GenServer. The process was the first driver of a session, not the definition of one: a host stepping the pure interpreter directly, with no Statifier.Session anywhere, is stepping the same kind of thing, and its events belong on the same prefix.

Applicability across drivers (ADR-0067 decision 3)

Every event keeps one shape across every driver; not every driver emits every event:

EventStatifier.SessionProcess-less driver
:initat process boot, resumed per ADR-0060once per logical run, at Interpreter.initialize/2 time, resumed: false; never per load
:haltyesyes - the step whose outcome is terminal
:terminateyes (GenServer contract)never
:macrostep, :start / :stopyesyes - brackets each core advance call
:interpretyes (ADR-0029 seam)only if the driver exposes an equivalent injection seam
:unroutableyesyes - the driver routes effects too
:effect, _ (11)yesyes
:trace, _ (9)yes, under trace: trueyes, under trace: true - the flag rides the position (ADR-0060), and the gate stays in the core

:init marks initialization, not loading: a durable driver rehydrates a position on every step, and an :init per load would both fire thousands of times per logical run and misname "process boot", which does not exist there - the durable :init marks the one Interpreter.initialize/2 call at run creation. :terminate names a GenServer callback and gets no durable analog; ADR-0040 already directs "session finished" metrics at :halt, driver-uniform. :interpret is conditional on a driver having an ADR-0029-style injection seam; none exists outside Statifier.Session today.

A span's start and stop halves must be emitted within one driver invocation (ADR-0067 decision 5): span_ref is a make_ref/0 reference, node- and VM-local, and can never cross a persist boundary. A driver brackets its in-memory core advance call with the two halves in the same call - never a span opened before persist and closed after a later load.

Measurements are numbers, metadata is everything else

Every measurement is a number - :telemetry_metrics-aggregatable. Every identity - a session id, an effect struct, a resolved location, and every constraint-3 index (state_index, t_index, c_index, invoke_index) - is metadata, including the ones that happen to be integers: an opaque index has no numeric meaning to average.

A configuration in metadata (on :halt, on the :macrostep, :stop event, and on the :effect, :done/:trace, :done events below) is translated from the raw MapSet.t(non_neg_integer()) the machine state or the Done/Trace.Done payload's own configuration field carries into a MapSet.t(String.t()) of state ids via Statifier.Machine.id/2, so a subscriber never needs a Machine handle to read a configuration out of an event - including the terminal one, where MachineState.configuration is already empty and the payload's own field is the only carrier left.

Span correlation

The macrostep span's start and stop halves both carry span_ref, a make_ref/0 reference generated once per span and passed unchanged from macrostep_start/5 to the matching macrostep_stop/8 - the telemetry_span_context convention :telemetry.span/3 uses, so a subscriber (including an OTel bridge) pairs a span's two halves on span_ref rather than on (session_id, macrostep), which is unusable for this because macrostep is not stable across the span (below) and ADR-0039 re-entry can nest an :internal span inside an :event span already in flight, producing start, start, stop, stop with no other way to tell which start belongs to which stop. Each span - including a nested one - gets its own span_ref. Both halves also carry monotonic_time (System.monotonic_time/0), matching :telemetry_metrics/OTel convention.

The start half deliberately carries no macrostep. The counter this span opens against advances inside the core call the span brackets (MachineState.begin_macrostep/1, called from Interpreter.handle_event/2 among others), so a macrostep read at the start half can be the pre-increment value while the stop half reports the post-increment one - the two would silently disagree on a pairing key. span_ref is the correlation mechanism; the stop half is the only place macrostep is authoritative for this span.

This bridge hand-rolls the span pair rather than calling :telemetry.span/3: that helper wraps its span function in a rescue, so it always emits a matching :exception event, and this bridge has no :exception half to offer (below) - forwarding decisions the caller already made deterministically, not evaluating one under a rescue of its own would invent a failure mode the driver does not have.

Two consumer-facing caveats

There is deliberately no fourth, :exception-named member of the :macrostep span family alongside :start/:stop (previous section): a crash mid-span - inside Statifier.Session's perform/3, say - leaves that span's start unmatched by any stop. A subscriber pairing on span_ref should not expect every span_ref it sees on a start to arrive again on a stop.

The :initialize span's start event is emitted after Interpreter.initialize/2 has already returned, for the Statifier.Session driver (Statifier.Session.init needs the machine_state that call produces before it can call Statifier.Session.Telemetry.init/5, which happens first), so its system_time is not the wall-clock instant the span actually opened at, even though duration on the matching stop is still measured from a System.monotonic_time/0 reading taken before that call.

Locations are resolved at emission, for single-index effects only

An effect carrying exactly one resolvable index gets metadata.location, resolved through Statifier.Machine.content/2, Statifier.Machine.at/2, Statifier.Machine.transition/2, or Statifier.Machine.data/2 as appropriate, and carried as the %Statifier.Parser.Location{} struct verbatim. Where no index resolves - an effect with no index field at all, or an index field present but nil - the two families answer differently, and the difference is the contract, not an inconsistency to smooth over. A core effect still carries the key, resolving to location: nil: every core_shape/2 clause sets it unconditionally, so metadata.location is safe to read without a Map.has_key?/2 guard. A trace effect omits the key entirely - there, a key that can never hold a value is contract noise a consumer would otherwise have to learn to ignore, so it is absent rather than present and nil (ADR-0040 and its amendments). An effect carrying a list of indexes carries the list in metadata (nested in the effect struct) and its length as a size measurement, with no location key, at any cardinality - zero, one, or many entries. No [:statifier, :session, :trace, _] event ever carries a location key, because every trace effect carrying a resolvable index carries it as a list: a set-valued trace event names a phase (an exit set, an entry set, an invoke-pass walk, a selection round's result), not a chart element, so a location resolved from a list that happens to hold one entry would describe a coincidence of the chart and the round rather than a property of the event, and the rule would need a per-event footnote the moment a second list field entered the picture (Trace.InvokePass already carries two). Locations on this surface live exclusively on the single-index core effect events and :unroutable, resolved by the rule above (ADR-0040 Decision 4, amended). cond_location is never resolved: no effect in the vocabulary is emitted from guard evaluation.

Lifecycle and span events (7), emitted regardless of trace

EventMeasurementsMetadata
[:statifier, :session, :init]system_timedriver, session_id, machine_name, trace, invoked_by, resumed
[:statifier, :session, :halt]macrostep, microstep, rounddriver, session_id, reason, configuration
[:statifier, :session, :terminate]macrostep, microstep, rounddriver, session_id, reason, status
[:statifier, :session, :macrostep, :start]system_time, monotonic_timedriver, session_id, trigger, event_name, caller_context, span_ref
[:statifier, :session, :macrostep, :stop]duration, macrostep, microsteps, rounds, monotonic_timedriver, session_id, trigger, outcome, event_name, configuration, caller_context, span_ref
[:statifier, :session, :interpret]effect_count, macrostep, microstepdriver, session_id
[:statifier, :session, :unroutable]macrostep, microstepdriver, session_id, effect, target, send_id, location

[:statifier, :session, :unroutable] names a routing failure the driver itself detected (the {:unroutable, effect} instruction), not an effect the core produced - a consumer alerting on it wants one name to attach to rather than a filter across the nine effect names below.

[:statifier, :session, :terminate] fires from Statifier.Session's terminate/2 and is therefore subject to the GenServer contract: it does not fire on a brutal kill, and no process-less driver emits it at all. :halt is the event to build a "session finished" metric on.

duration (on :macrostep, :stop) is :native units, per :telemetry's own convention - unit conversion (System.convert_time_unit/3) is left to the consumer.

caller_context (on both macrostep halves here, and on the :send_delayed/:cancel effect events below) is ADR-0063's opaque host slot: the triggering external event's caller_context, nil for the :initialize/:cancel/:internal/:resume triggers and for an event sent without one. Identity, never a number, so it is metadata everywhere and a measurement nowhere (ADR-0040's split). Both halves carry it for the same reason both carry trigger and event_name: a consumer attaching to only one half still attributes. On the two effect events the value also rides in metadata.effect verbatim; the explicit key keeps a bridge's read uniform with the macrostep events. A consumer treats the term as opaque - something to parent or link with, never to flatten into exported attributes.

Core effect events (11), emitted regardless of trace

EventMeasurementsMetadata
[:statifier, :session, :effect, :send]macrostep, microstep, rounddriver, session_id, effect, location, send_id, target, c_index, owner
[:statifier, :session, :effect, :send_delayed]macrostep, microstep, round, delay_ms, ordinaldriver, session_id, effect, location, send_id, target, c_index, owner, caller_context
[:statifier, :session, :effect, :cancel]macrostep, microstep, round, ordinaldriver, session_id, effect, location, send_id, c_index, owner, caller_context
[:statifier, :session, :effect, :invoke]macrostep, microstep, rounddriver, session_id, effect, location, invoke_id, state_index, invoke_index
[:statifier, :session, :effect, :cancel_invoke]macrostep, microstep, rounddriver, session_id, effect, location, invoke_id, state_index
[:statifier, :session, :effect, :autoforward]macrostep, microstep, rounddriver, session_id, effect, location, invoke_id, state_index
[:statifier, :session, :effect, :budget_exhausted]macrostep, microstep, round, budgetdriver, session_id, effect, location
[:statifier, :session, :effect, :done]macrostep, microstep, rounddriver, session_id, effect, location, configuration
[:statifier, :session, :effect, :log]macrostep, microstep, rounddriver, session_id, effect, location, label, c_index, owner
[:statifier, :session, :effect, :datamodel_change]macrostep, microstep, rounddriver, session_id, effect, location, location_path, location_source, new_value, prior_value, d_index, c_index, owner
[:statifier, :session, :effect, :datamodel_init]macrostep, microstep, rounddriver, session_id, effect, location, datamodel

Trace effect events (9), emitted only under trace: true

The trace family vanishes in the core, not at this bridge: Statifier.Effect.trace/3 expands to no effect at all when machine_state.trace is false, so there is nothing here to forward. This module contains no if trace of its own.

EventMeasurementsMetadata
[:statifier, :session, :trace, :event_dequeued]macrostep, microstep, rounddriver, session_id, effect
[:statifier, :session, :trace, :transitions_selected]macrostep, microstep, round, sizedriver, session_id, effect
[:statifier, :session, :trace, :exit_set]macrostep, microstep, round, sizedriver, session_id, effect
[:statifier, :session, :trace, :content_executed]macrostep, microstep, round, sizedriver, session_id, effect
[:statifier, :session, :trace, :entry_set]macrostep, microstep, round, sizedriver, session_id, effect
[:statifier, :session, :trace, :macrostep_stable]macrostep, microstep, rounddriver, session_id, effect
[:statifier, :session, :trace, :done]macrostep, microstep, rounddriver, session_id, effect, configuration
[:statifier, :session, :trace, :invoke_pass]macrostep, microstep, round, sizedriver, session_id, effect
[:statifier, :session, :trace, :finalize_autoforward]macrostep, microstep, rounddriver, session_id, effect

kind (the fourth segment) is derived by a private, multi-clause function pattern-matching each Statifier.Effect.Trace.* struct to a literal atom - never Module.split/1 composed with String.to_atom/1, which Credo.Check.Warning.UnsafeToAtom forbids and which would make this module's event list impossible to enumerate ahead of a call.

Summary

Types

One of the eleven core effect payload structs (Statifier.Effect.core/0, unwrapped).

The stepping driver that emitted an event. :session is reserved for this library's own Statifier.Session process driver; an external driver names itself with one stable atom of its own (ADR-0067 decision 4).

One :telemetry event name this module can emit.

One of the nine trace payload structs (Statifier.Effect.trace/0, unwrapped).

Functions

Emits [:statifier, :session, :effect, kind] or [:statifier, :session, :trace, kind], dispatching on effect's own tag. machine resolves location per the moduledoc's single-index rule.

Every event name this module can ever emit - the 7 lifecycle/span names, the 11 [:statifier, :session, :effect, kind] names, and the 9 [:statifier, :session, :trace, kind] names, built from @lifecycle_events/@effect_kinds/@trace_kinds, the module's single definition site for the vocabulary.

Emits [:statifier, :session, :halt].

Emits [:statifier, :session, :init]. resumed is ADR-0060's metadatum - true when this session booted from a :resume option rather than running Statifier.Interpreter.initialize/2. Additive: an existing handler reading only the four original keys is unaffected by the fifth.

Emits [:statifier, :session, :interpret].

Emits [:statifier, :session, :macrostep, :start]. span_ref is a make_ref/0 reference generated once by the caller and passed unchanged to the matching macrostep_stop/8 - the pairing mechanism a subscriber needs (Decision 2 below), since macrostep cannot serve that role here: the counter this span opens against advances inside the core call the span brackets, so the start half is emitted before the increment and the stop half after it. Carrying the pre-increment value on start would look paired with the wrong stop on every span whose trigger's core call advances the counter (:event, and any other trigger with the same shape); carrying the post-increment value on start would require reading it out of a machine state the start half does not otherwise need. Rather than pick one and leave the other case's start event silently wrong, the start half carries no macrostep at all - span_ref is the only correlation mechanism, and it is exact by construction.

Emits [:statifier, :session, :macrostep, :stop]. start_time is a System.monotonic_time/0 reading taken at the matching macrostep_start/5; duration is the difference, in :native units. span_ref is the same reference passed to that macrostep_start/5 call, carried again here so a subscriber can pair this stop with its start (Decision 2) - nested spans (an :internal span opened by ADR-0039 re-entry inside an :event span already in flight) each get their own reference, so start, start, stop, stop is disambiguated by span_ref rather than by call order.

Emits [:statifier, :session, :terminate].

Emits [:statifier, :session, :unroutable] for an effect the driver could not route.

Types

core_payload()

One of the eleven core effect payload structs (Statifier.Effect.core/0, unwrapped).

driver()

@type driver() :: atom()

The stepping driver that emitted an event. :session is reserved for this library's own Statifier.Session process driver; an external driver names itself with one stable atom of its own (ADR-0067 decision 4).

event_name()

@type event_name() :: [atom(), ...]

One :telemetry event name this module can emit.

trace_payload()

One of the nine trace payload structs (Statifier.Effect.trace/0, unwrapped).

Functions

effect(driver, session_id, machine, arg)

@spec effect(
  driver :: driver(),
  session_id :: String.t(),
  machine :: Statifier.Machine.t(),
  effect :: Statifier.Effect.t()
) :: :ok

Emits [:statifier, :session, :effect, kind] or [:statifier, :session, :trace, kind], dispatching on effect's own tag. machine resolves location per the moduledoc's single-index rule.

events()

@spec events() :: [event_name()]

Every event name this module can ever emit - the 7 lifecycle/span names, the 11 [:statifier, :session, :effect, kind] names, and the 9 [:statifier, :session, :trace, kind] names, built from @lifecycle_events/@effect_kinds/@trace_kinds, the module's single definition site for the vocabulary.

halt(driver, session_id, reason, machine_state)

@spec halt(
  driver :: driver(),
  session_id :: String.t(),
  reason :: :done | :cancelled | :budget_exhausted,
  machine_state :: Statifier.MachineState.t()
) :: :ok

Emits [:statifier, :session, :halt].

init(driver, session_id, machine, machine_state, invoked_by, resumed)

@spec init(
  driver :: driver(),
  session_id :: String.t(),
  machine :: Statifier.Machine.t(),
  machine_state :: Statifier.MachineState.t(),
  invoked_by :: {pid(), String.t()} | nil,
  resumed :: boolean()
) :: :ok

Emits [:statifier, :session, :init]. resumed is ADR-0060's metadatum - true when this session booted from a :resume option rather than running Statifier.Interpreter.initialize/2. Additive: an existing handler reading only the four original keys is unaffected by the fifth.

interpret(driver, session_id, effect_count, machine_state)

@spec interpret(
  driver :: driver(),
  session_id :: String.t(),
  effect_count :: non_neg_integer(),
  machine_state :: Statifier.MachineState.t()
) :: :ok

Emits [:statifier, :session, :interpret].

macrostep_start(driver, session_id, trigger, event, span_ref)

@spec macrostep_start(
  driver :: driver(),
  session_id :: String.t(),
  trigger :: :initialize | :event | :cancel | :internal | :resume,
  event :: Statifier.Event.t() | nil,
  span_ref :: reference()
) :: :ok

Emits [:statifier, :session, :macrostep, :start]. span_ref is a make_ref/0 reference generated once by the caller and passed unchanged to the matching macrostep_stop/8 - the pairing mechanism a subscriber needs (Decision 2 below), since macrostep cannot serve that role here: the counter this span opens against advances inside the core call the span brackets, so the start half is emitted before the increment and the stop half after it. Carrying the pre-increment value on start would look paired with the wrong stop on every span whose trigger's core call advances the counter (:event, and any other trigger with the same shape); carrying the post-increment value on start would require reading it out of a machine state the start half does not otherwise need. Rather than pick one and leave the other case's start event silently wrong, the start half carries no macrostep at all - span_ref is the only correlation mechanism, and it is exact by construction.

macrostep_stop(driver, session_id, trigger, machine_state, event, outcome, start_time, span_ref)

@spec macrostep_stop(
  driver :: driver(),
  session_id :: String.t(),
  trigger :: :initialize | :event | :cancel | :internal | :resume,
  machine_state :: Statifier.MachineState.t(),
  event :: Statifier.Event.t() | nil,
  outcome :: :quiescent | :done | :cancelled | :budget_exhausted,
  start_time :: integer(),
  span_ref :: reference()
) :: :ok

Emits [:statifier, :session, :macrostep, :stop]. start_time is a System.monotonic_time/0 reading taken at the matching macrostep_start/5; duration is the difference, in :native units. span_ref is the same reference passed to that macrostep_start/5 call, carried again here so a subscriber can pair this stop with its start (Decision 2) - nested spans (an :internal span opened by ADR-0039 re-entry inside an :event span already in flight) each get their own reference, so start, start, stop, stop is disambiguated by span_ref rather than by call order.

terminate(driver, session_id, reason, status, machine_state)

@spec terminate(
  driver :: driver(),
  session_id :: String.t(),
  reason :: term(),
  status :: term(),
  machine_state :: Statifier.MachineState.t()
) :: :ok

Emits [:statifier, :session, :terminate].

unroutable(driver, session_id, machine, arg)

@spec unroutable(
  driver :: driver(),
  session_id :: String.t(),
  machine :: Statifier.Machine.t(),
  effect :: Statifier.Effect.t()
) :: :ok

Emits [:statifier, :session, :unroutable] for an effect the driver could not route.