StatifierUI.Trace.Replay (StatifierUI v0.9.1)

Copy Markdown View Source

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, and recording/3 is the step in the middle it can reach for on its own.

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:

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.halted is 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/1 have 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.Replay converts a schedule into a pending-timer credit rather than a real Process.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 recording, on its own

recording/3 is the first of the four steps, exposed. A host that stores its run as rows rather than as a Statifier.Session.Recording.to_binary/1 blob has to turn those rows back into Statifier.Session.Recording.entry/0 values before anything can replay them, and the six-clause fold that puts them into a recording through the module's public constructors is the same fold whether the caller wants messages afterwards or the recording itself

  • to hand to Statifier.Replay.run/1 directly, to to_binary/1 for storage, or to compare against one it already had.

from_events/4 calls it, so the two cannot drift. It stops where the recording is built: :trace and :session_id are checked by from_events/4 and not here, because they are requirements of the message stream rather than of the recording (a recording made without trace: true replays perfectly well; it just carries no trace effects).

docs/ops-embedding.md's "From a persisted event log" section carries the row-to-entry mapping table for all six shapes, and the timer rule below.

The timer rule

A fired delayed send is {:timer, send_id, event, routes} and never {:event, event, routes}, even though the engine delivered an ordinary external event either way. The difference is the matching: Statifier.Replay turns each recorded <send> with a delay into a pending-timer credit under its send_id, and only a {:timer, ...} entry draws one (statifier lib/statifier/replay.ex:337-346, :359-374).

So a firing named as an event is not checked against anything. The replay accepts a firing the chart never scheduled, instead of returning {:error, {:unscheduled_timer_firing, send_id}}; and the credit it should have consumed stays outstanding, where a later {:cancel_timers, send_id} moves it into the raced pool and a subsequent firing can still draw it. A single fired timer with nothing after it produces the same stream under either name, which is exactly why the rule has to be stated rather than discovered.

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

Types

This producer's own options - the subscriber's emission options and no others.

Functions

Produces the wire-format message stream for a recorded run, offline.

Builds the Statifier.Session.Recording a host's stored inputs describe.

Types

opts()

@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

from_events(machine, initialize_opts, events, opts \\ [])

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

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:

StatifierUI.Trace.Normalizer.normalize/2 has no third answer to fold in: it returned :skip while one engine trace effect had no message to map onto, and ADR-0018 gave it one. 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}}

recording(machine, initialize_opts, events)

@spec recording(
  machine :: Statifier.Machine.t(),
  initialize_opts :: keyword(),
  events :: [Statifier.Session.Recording.entry()]
) :: {:ok, Statifier.Session.Recording.t()} | {:error, {:unknown_entry, term()}}

Builds the Statifier.Session.Recording a host's stored inputs describe.

Built through the public constructors, never as a struct literal: Statifier.Session.Recording.t() is @opaque upstream, and building it by hand would couple this repository to a struct deliberately closed (ADR-0017 decision 2). One clause per Statifier.Session.Recording.entry/0 shape, and an unrecognized shape is {:error, {:unknown_entry, entry}} rather than a skip - the same fail-closed reading from_events/4 gives, and for the same reason: a seventh shape appearing upstream must not be dropped silently.

The arguments are from_events/4's first three, unchanged - see there for what each carries, and docs/ops-embedding.md's "From a persisted event log" for the table that says which stored row becomes which entry shape. A fired delayed send is {:timer, send_id, event, routes}; see this module's "The timer rule".

This is where from_events/4 gets its recording, so the two cannot drift. Call it directly when the recording rather than the message stream is what you want: Statifier.Replay.run/1 on it, Recording.to_binary/1 to store it, or a comparison against one you already hold.

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, recording} = StatifierUI.Trace.Replay.recording(machine, [session_id: "s", trace: true], [])
iex> Statifier.Session.Recording.entries(recording)
[]

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.recording(machine, [session_id: "s", trace: true], [{:teleport, :somewhere}])
{:error, {:unknown_entry, {:teleport, :somewhere}}}