The offline producer of the v1 trace wire format (ADR-0017): a pure
function that turns a persisted session event log into the same
StatifierUI.Trace.Message stream StatifierUI.Trace.Subscriber
produces from a live session - with no Statifier.Session, no process,
and no clock.
A host that persists its own session event log - because it restarts, or
because it stores runs for audit and renders them later - has the inputs
a run was driven by, the compiled chart, and the options the session ran
under, and nothing left to subscribe to. from_events/4 is what it calls
instead.
The four steps, and why they are the subscriber's four steps
from_events/4 builds a Statifier.Session.Recording through that
module's public constructors, hands it to Statifier.Replay.run/1, emits
the session.start manifest, and folds the returned stream through the
normalizer - which is exactly what
StatifierUI.Trace.Subscriber's catch-up attach path does, with the
same calls in the same order:
StatifierUI.Trace.Manifest.build/3forsession.start, stampedseq: 0;StatifierUI.Trace.Normalizer.normalize/2for every stream element, with the same four-keyctx(soerror.locationresolves the same way);StatifierUI.Trace.Otel.stamp/2and thenStatifierUI.Trace.Projection.project/2, in that order, for every message - ADR-0013 putsotelin the never-projected set, and stamping first is what proves a projected stream carries the key unchanged.
No message shape is constructed in this module. Parity with the
subscriber (ADR-0017 decision 3) is therefore structural rather than
promised: identical types, identical order, identical seq values, and
identical payload bytes under StatifierUI.Trace.Json.encode_lines/1.
What it deliberately does not do
- No
session.terminated. There is no process offline and no exit to observe; an offline stream's end is the end of the entry list.session.haltedis unaffected - it comes from a{:halted, reason}stream element and is produced normally. - No capacity, no fan-out, no diagnostics counter. A function
returning a list drops nothing and sends to nobody, so the
subscriber's bounded buffer, its listener set, and its
stats/1have no offline counterpart. The whole list is held in memory, which the subscriber's bound does not have to. - No timers and no clock.
Statifier.Replayconverts a schedule into a pending-timer credit rather than a realProcess.send_after/3, and a recorded firing is delivered at its recorded position and nowhere else. Wall-clock time never enters, which is what makes this function deterministic.
The name
This module and Statifier.Replay collide under alias. That is
intentional: the engine module is aliased here as EngineReplay at its
single call site rather than naming this one something that hides what it
is.
Summary
Functions
Produces the wire-format message stream for a recorded run, offline.
Types
@type opts() :: [ source: String.t(), fixtures: map(), parent_session: String.t(), invokeid: String.t(), projection: StatifierUI.Trace.Projection.Profile.t(), otel_context: StatifierUI.Trace.Otel.resolver() ]
This producer's own options - the subscriber's emission options and no others.
:source, :fixtures, :parent_session and :invokeid are forwarded
verbatim to StatifierUI.Trace.Manifest.build/3; :source additionally
widens the normalizer's ctx exactly as the subscriber's does.
:projection and :otel_context are the chokepoint's two options.
Deliberately absent are :capacity, :listeners and :name: a buffer,
a fan-out and a process name are process concerns, and this is a
function.
Functions
@spec from_events( machine :: Statifier.Machine.t(), initialize_opts :: keyword(), events :: [Statifier.Session.Recording.entry()], opts :: opts() ) :: {:ok, [StatifierUI.Trace.Message.t()]} | {:error, term()}
Produces the wire-format message stream for a recorded run, offline.
machine- the compiled chart the log was produced over, the same argumentStatifierUI.Trace.Manifest.build/3andStatifier.Session.Recording.new/3take.initialize_opts- the session options the recorded run was made under, inStatifier.Session.Recording.new/3's normalized vocabulary::session_id,:trace,:datamodel,:max_macrostep_rounds,:routes,:invoke_typesand:invoke_handlers.:session_idmust be a binary - every message's envelope carries it.events- the persisted log, in the session's serialized input order, asStatifier.Session.Recording.entry/0values.opts- this producer's own options; seeopts/0.
Errors are values, and the call fails closed
The first failure returns {:error, reason} and no partial list. Where
the subscriber continues past a bad effect and records a diagnostic - it
would otherwise lose a live stream it can never recover - an offline
caller still has the log in hand and can retry, report, or investigate,
and a partial list returned as {:ok, messages} would be
indistinguishable from a whole one (ADR-0017 decision 5).
The reasons:
{:initialize_opts, :trace_disabled}wheninitialize_optsdoes not carry a truthy:trace.Statifier.Session.Recording.new/3defaults the flag tofalse, and a run made without it completes successfully while emitting notrace.*messages at all - nine of the format's twenty-four types missing, silently. The flag is the caller's to supply, because defaulting it on would produce a stream the recorded run never produced.{:initialize_opts, :missing_session_id}when:session_idis absent or is not a binary.{:unknown_entry, entry}for an entry shape this module does not know.Statifier.Session.Recording.entry/0has six shapes today, and a seventh appearing upstream must not be dropped silently - the same reasoningStatifierUI.Trace.Normalizer.normalize/2gives for{:unknown_effect, tag}.- anything
Statifier.Replay.run/1returns, unwrapped - notably{:unscheduled_timer_firing, send_id}. Its{:anchor, _}arm cannot be reached from here:from_events/4takes no anchor, so the recording it builds always replays fromStatifier.Interpreter.initialize/2(a host holding an anchored recording has the blob, and wantsStatifier.Replay.run/1directly). - anything
StatifierUI.Trace.Manifest.build/3orStatifierUI.Trace.Normalizer.normalize/2returns.
A :skip from StatifierUI.Trace.Normalizer.normalize/2 is not one of
these. It means the engine emitted a trace effect the v1 wire format
deliberately does not carry, the fold moves to the next element, and no
seq is consumed - fail-closed (decision 5) applies to {:error, _}
and is untouched.
Examples
iex> {:ok, machine} = Statifier.compile(~s(<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="a"><state id="a"/></scxml>))
iex> {:ok, [manifest | _]} =
...> StatifierUI.Trace.Replay.from_events(machine, [session_id: "s", trace: true], [])
iex> {manifest.type, manifest.session, manifest.seq}
{"session.start", "s", 0}
iex> {:ok, machine} = Statifier.compile(~s(<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="a"><state id="a"/></scxml>))
iex> StatifierUI.Trace.Replay.from_events(machine, [session_id: "s"], [])
{:error, {:initialize_opts, :trace_disabled}}