StatifierUI.Trace.Capture (StatifierUI v0.9.1)

Copy Markdown View Source

Record a trace off a live Statifier.Session, save it, load it back - one call each.

Every piece of this already existed and none of it was assembled. Taking a message list off a running session meant knowing that StatifierUI.Trace.Subscriber needs the compiled machine, which of its three attach modes to pick, and that only the catch-up one recovers the initialize burst a subscriber that started late never saw. Saving meant knowing StatifierUI.Trace.Json.encode_lines/1 is the serializer and writing the file yourself. Loading had no implementation at all. The sequence lived in test/support/, which ships to nobody.

{:ok, messages} = StatifierUI.Trace.Capture.record(session, machine, source: xml)
:ok = StatifierUI.Trace.Capture.save(messages, "run.jsonl")
{:ok, messages} = StatifierUI.Trace.Capture.load("run.jsonl")

Where the IO is

save/2 and load/1 are plain functions running in the caller's process. That is deliberate and inherited: the subscriber is a GenServer in the path of every trace message, and docs/plans/260817-sui-t36.3-session-subscriber-and-trace-normalizer.md refuses to put file IO inside it. Nothing here sends the subscriber a message it did not already answer.

Passing :source is what makes a capture reloadable

%Statifier.Machine{} does not retain the SCXML it was compiled from, so a capture only carries its own chart text when the caller hands :source to record/3. It lands in the session.start message (docs/wire-format.md), and it is what lets a saved file be reopened later with no other artefact - StatifierUI.Kino.inspect_trace/3 recompiles the machine from it. A capture taken without :source is still a valid v1 stream; it just needs its machine supplied again by whoever reads it.

The stream is a snapshot, not a subscription

record/3 returns what the session has produced so far and detaches. A session still running will produce more, and calling record/3 again returns a longer list starting from the same session.start. For a live view that keeps up, attach a StatifierUI.Trace.Subscriber and hold it - that is what StatifierUI.Kino.inspect/3 does.

Summary

Types

Why a capture or a read refused.

Functions

Reads a JSON Lines capture back into a message list.

Captures everything session has traced so far as a message list.

Writes messages to path as JSON Lines.

The SCXML a capture carries, from its own session.start message.

Types

error()

@type error() ::
  :not_recorded
  | {:json, term()}
  | {:line, pos_integer(), term()}
  | StatifierUI.Trace.Message.from_map_error()
  | File.posix()

Why a capture or a read refused.

Functions

load(path)

@spec load(path :: Path.t()) ::
  {:ok, [StatifierUI.Trace.Message.t()]} | {:error, error()}

Reads a JSON Lines capture back into a message list.

The result feeds every consumer in this package directly: the pure folds in StatifierUI.Inspector, StatifierUI.Live.State.new/2's :messages option, and StatifierUI.Kino.inspect_trace/3.

A file that cannot be read returns File.read/1's posix error; a file whose contents are not a v1 stream returns the tagged decode error naming the offending line.

record(session, machine, opts \\ [])

@spec record(session :: pid(), machine :: Statifier.Machine.t(), opts :: keyword()) ::
  {:ok, [StatifierUI.Trace.Message.t()]} | {:error, error()}

Captures everything session has traced so far as a message list.

Starts a StatifierUI.Trace.Subscriber over machine, attaches it with catch_up: true (statifier ADR-0049 - the recorded prefix and the live subscription are taken in one session call, so there is no gap and no overlap), reads the buffer, and stops the subscriber again.

opts are forwarded to StatifierUI.Trace.Subscriber.start_link/1 unchanged: :source, :fixtures, :capacity, :projection, :parent_session, :invokeid, :otel_context. See the moduledoc on why :source is the one worth remembering.

Returns {:error, :not_recorded} when session was not started with record: true. That is a refusal rather than a partial answer on purpose: without the recording there is no catch-up, so what came back would be whatever happened to arrive after this call - silently missing the initialize burst, and indistinguishable from a complete capture once written to a file.

The session must be quiescent for the capture to be reproducible; a capture taken mid-macrostep is a valid prefix, not a corrupt stream.

save(messages, path)

@spec save([StatifierUI.Trace.Message.t()], path :: Path.t()) ::
  :ok | {:error, File.posix()}

Writes messages to path as JSON Lines.

StatifierUI.Trace.Json.encode_lines/1 is canonical, so the same message list always writes the same bytes - which is what makes a saved capture diffable against another run, and what the round-trip law in docs/wire-format.md rests on.

Returns File.write/2's result unchanged; a missing directory is a {:error, :enoent}, not a raise.

source(messages)

@spec source([StatifierUI.Trace.Message.t()]) :: String.t() | nil

The SCXML a capture carries, from its own session.start message.

nil when the stream is empty, does not open with a session.start, or was captured without :source. Used by StatifierUI.Kino.inspect_trace/3 to recompile a machine from the file alone; exposed because a host reloading a trace into its own surface needs the same thing.