arizona_dev_log (arizona v0.4.0)
View SourceA 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/mcpcaptures nothing. It is removed again byarizona_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
-nominal opts() :: #{count => pos_integer(), level => logger:level(), grep => binary(), since => non_neg_integer()}.
Functions
-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.
-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.
-spec log(LogEvent, Config) -> ok when LogEvent :: logger:log_event(), Config :: logger:handler_config().
-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.
-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.
-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.