March Docs

Trace

Trace: watch which boundary functions run, and on whose behalf, with a limit you cannot leave out.

A session watches named functions of a --hot-reload build (the ones forge deploy hot can replace: your app's, not the stdlib's). Each call into one writes an event into the session's ring: the calling actor's pid, the function, the code epoch it ran at, and when. NEVER ITS ARGUMENTS: they can hold anything the program holds, passwords and tokens included.

Every session has a limit, and start takes it as an argument so there is no way to call it without one:

MaxEvents(n)       stops itself after exactly n events (1..65536);
Rate(n, secs)  keeps at most n events per `secs`-second window (n in
               1..100000, secs in 1..3600) and counts the rest as dropped.

Anything else (0, negative, over the caps) is refused with LimitOutOfRange. A session also ends by itself after 600 seconds, or 60 seconds without a drain, and at most 4 exist at once. A session belongs to the process, not to your connection: stop frees it early; a forgotten one frees itself.

You pull events with drain; the tracer is never waited for. A full ring drops the event and counts it (dropped), so a slow reader costs completeness, never the traced program's speed.

Actor events. A session can also record what actors do: Sent, Received, Spawned, Exited and Dropped, with the pids, the message's constructor tag, why (for the last two) and when. NEVER a message's contents. They are recorded only for the actors you NAME (pids, one to eight live pids as pid_to_int gives them): an event is kept when its pid or its peer is one of them. There is no "every actor" form, because a stream of the whole node would make every send on it pay for the tracer. Build the options with actors, calls and with_calls and start with start_with:

Trace.start_with(d, Trace.actors([Trace.Sent, Trace.Received], [pid_to_int(a)]),
                 Trace.MaxEvents(100))

The limit counts boundary calls and actor events together. See ActorEvent.

Backends. Compiled with --hot-reload only: other builds, and the interpreter, have no dispatch table to watch and answer Unavailable. Over the observe socket the same operations are the signed debug verbs TRACE_START, TRACE_DRAIN and TRACE_STOP (docs/observe.md).

Debug tier: every function takes Cap(Actor.Debug) (mint it with Actor.debug(io) in main; the remote shell has it pre-bound as debug).

R8 of specs/plans/2026-09-28-observe-recon-shell-plan.md.

Types

typeLimitLimit = MaxEvents(Int) | Rate(Int, Int)#
typeEventKindEventKind = Sent | Received | Spawned | Exited | Dropped#
typeOptionsOptions = {#
typeActorEventActorEvent = {#
typeTraceIdTraceId = TraceId(Int)#
typeCallEventCallEvent = {#
typeBatchBatch = {#
typeTraceErrorTraceError =#

Functions

fnactorsactors(kinds : List(EventKind), pids : List(Int)) : Options#

Options that watch actor events of the kinds kinds for the actors pids (pid indices, as pid_to_int gives them; one to eight, all live).

fncallscalls(names : List(String)) : Options#

Options that watch only the boundary functions names.

fndraindrain(_c : Cap(Actor.Debug), s : TraceId) : Result(Batch, TraceError)#

The events recorded since the last drain (at most 1024; drain_max takes a bound up to 4096), oldest first, and the session's state.

fndrain_maxdrain_max(_c : Cap(Actor.Debug), s : TraceId, max : Int) : Result(Batch, TraceError)#

Like drain, taking at most max events (1..4096).

fnformatformat(events : List(CallEvent)) : String#

One line per event: <at_ns>ns pid <pid> <function> epoch <epoch>.

fnformat_actorformat_actor(events : List(ActorEvent)) : String#

One line per actor event: <at_ns>ns <kind> pid <pid> [peer <p>] [tag <t>] [<reason>].

fnstartstart(c : Cap(Actor.Debug), names : List(String), limit : Limit) : Result(TraceId, TraceError)#

Start watching the boundary functions names (exact names, as Recon's and forge deploy hot print them) under limit. At most 32 names and 4 sessions.

    match Trace.start(d, ["Billing.charge"], Trace.MaxEvents(10)) do
      Ok(s) -> ...
      Err(Trace.UnknownFunction) -> println("not a boundary function")
      Err(e) -> println("cannot trace")
    end
fnstart_withstart_with(_c : Cap(Actor.Debug), opts : Options, limit : Limit) : Result(TraceId, TraceError)#

Start a session over opts (see calls, actors, with_calls) under limit. Actor events without pids, or pids without events, are refused; so are more than 8 pids and pids that are not live actors.

    match Trace.start_with(d, Trace.actors([Trace.Sent, Trace.Exited], [pid_to_int(a)]),
                           Trace.MaxEvents(50)) do
      Ok(s) -> ...
      Err(Trace.PidsRequired) -> println("name the actors to watch")
      Err(e) -> println("cannot trace")
    end
fnstopstop(_c : Cap(Actor.Debug), s : TraceId) : Result(Batch, TraceError)#

Stop the session and free it; events not yet drained are discarded. The final state.

fnwith_callswith_calls(o : Options, names : List(String)) : Options#

The same options, also watching the boundary functions names.