arizona_dev_log (arizona v0.4.0)

View Source

A bounded in-memory log buffer for the dev MCP's get_logs tool.

An agent driving a dev node over MCP has no terminal. Every other diagnostic is reachable -- routes, docs, source locations, reloader state, arbitrary eval -- but runtime log output goes to logger_std_h, which in a default dev config writes to stdout and nowhere else. So the one artifact that names the module and line of a crash is the one an agent cannot read, and a live process dying on every interaction reads back as a healthy app.

This module closes that: a logger handler that keeps the last 1024 formatted events in a fixed-size ETS ring, queried by arizona_dev_mcp's get_logs tool.

Ownership and lifetime

Split in two, because the two halves want different lifetimes and different gates:

  • The table is created by arizona_sup:init/1 (create_table/0) so it is owned by a process that spans the node's lifetime, the same reason the reloader's beam-facts cache lives there. An empty table costs nothing.
  • The handler is installed by arizona_dev_mcp:init/1 (install/0), so only an app that actually mounts the dev MCP route pays the per-event write. A production node that never mounts /mcp captures nothing. It is removed again by arizona_app:prep_stop/1 (uninstall/0), before the table's owner goes down.

The consequence worth knowing: capture starts at the first MCP session, not at boot. Events logged before an agent first connects are not in the buffer. Call install/0 from your own dev boot code if you need coverage from startup.

Level

The handler sets no level of its own, and deliberately does not touch the primary logger level. It cannot: logger applies the primary level before consulting any handler, so a handler asking for debug under a notice primary still receives nothing below notice. Raising the primary level to widen capture would change the running system's behaviour in order to observe it. So the buffer holds whatever the app's own configuration already allows, and tail/1 filters at read time. Crash reports are error, which passes any sane primary level, so the case this exists for is unaffected.

Self-exclusion

Output produced by the MCP's own tool calls is skipped, so an agent reading get_logs after an eval sees the app's behaviour rather than its own. arizona_dev_mcp marks the calling process via mark_self/0 and log/2 drops events carrying that metadata. Marking is per-process and logger metadata is not inherited, so a process the tool call spawns is still captured -- which is usually what you want, since that is where a crash surfaces.

Summary

Functions

Creates the ring table. Called from arizona_sup:init/1 so the supervisor owns it and it outlives the short-lived processes that read and write it.

Installs the logger handler. Idempotent, so every MCP session can call it without checking whether an earlier one already did.

Marks the calling process so its log output is kept out of the buffer. Called by arizona_dev_mcp before dispatching a tool, so an agent does not read back its own eval output as if it were the app's behaviour.

The buffered events, oldest first, after filtering, plus a cursor.

Removes the handler. Called from arizona_app:prep_stop/1, before the supervision tree that owns the table goes down -- otherwise the next log event during shutdown writes to a dead table, and logger removes the handler for crashing rather than because it was asked to. Tolerates not being installed, which is the ordinary case for an app that never mounts the dev MCP route.

Types

opts()

-nominal opts() ::
             #{count => pos_integer(),
               level => logger:level(),
               grep => binary(),
               since => non_neg_integer()}.

Functions

create_table()

-spec create_table() -> ok.

Creates the ring table. Called from arizona_sup:init/1 so the supervisor owns it and it outlives the short-lived processes that read and write it.

install()

-spec install() -> ok.

Installs the logger handler. Idempotent, so every MCP session can call it without checking whether an earlier one already did.

Expects create_table/0 to have run, which arizona_sup:init/1 does: the handler writes on every log event, so installing it without the table would leave logger removing it again at the first one.

log(LogEvent, Config)

-spec log(LogEvent, Config) -> ok when LogEvent :: logger:log_event(), Config :: logger:handler_config().

mark_self()

-spec mark_self() -> ok.

Marks the calling process so its log output is kept out of the buffer. Called by arizona_dev_mcp before dispatching a tool, so an agent does not read back its own eval output as if it were the app's behaviour.

tail(Opts)

-spec tail(Opts) -> {ok, [binary()], Cursor} | {error, binary()}
              when Opts :: opts(), Cursor :: non_neg_integer().

The buffered events, oldest first, after filtering, plus a cursor.

count (default 50) takes from the newest end. level keeps events at least as severe as the one given. grep keeps events matching a case-insensitive regular expression. A malformed grep answers {error, _} rather than raising, so the tool can report it in-band.

since keeps only events newer than a cursor from an earlier call, which is what makes a repeated read cheap: an agent polling after each action would otherwise re-read the same entries every time and have to spot the new ones by eye.

The returned cursor is the newest sequence observed by this call, before level/grep filtering, so passing it back skips everything this call could have seen rather than only what it returned. Deriving it from the rows actually read (rather than the live counter) is what keeps a concurrent write from being either skipped or repeated.

uninstall()

-spec uninstall() -> ok.

Removes the handler. Called from arizona_app:prep_stop/1, before the supervision tree that owns the table goes down -- otherwise the next log event during shutdown writes to a dead table, and logger removes the handler for crashing rather than because it was asked to. Tolerates not being installed, which is the ordinary case for an app that never mounts the dev MCP route.