Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Logging Levels

Nexus logs with stdlib log/slog only — no third-party logging library. core.log_level (see the Configuration Reference) sets the engine-wide floor; every plugin logs through the *slog.Logger handed to it on PluginContext.Logger.

This page is the level-assignment rubric: which of the five levels a given log call belongs at. It exists so contributors have a standard to log against instead of defaulting everything to Info. It is the standard the log-levels-audit effort’s reclassification stories apply call site by call site — if you’re adding or moving a log call anywhere in the tree, this is the page to check.

The five levels

LevelWhen to use it
TRACEFires on every event or iteration — bus dispatch, every tool-call argument, wire payloads. Off even during normal debugging; only for that goes-nowhere-else last resort.
DEBUGUseful while actively troubleshooting: plugin init detail, config resolution, which branch was taken, cache hit/miss. Frequent, but not so voluminous it drowns itself.
INFOOps-relevant lifecycle and state-change only: session start/stop, plugin activated, turn boundary. Explicitly not per-turn or per-tool-call chatter.
WARNUnexpected but recovered or degraded: retry, fallback engaged, default assumed, stale cache used.
ERRORFailed and unrecoverable — likely needs operator attention.

Read top to bottom as increasing severity and decreasing volume: TRACE should be the noisiest level by a wide margin, and ERROR the rarest. If a call site logs on literally every agent iteration or every tool call, it almost never belongs above DEBUG — and usually belongs at TRACE.

The TRACE emission convention

*slog.Logger only has convenience methods for the four standard levels (Debug, Info, Warn, Error). engine.LevelTrace is a plain slog.Level constant below slog.LevelDebug (pkg/engine/engine.go), not a new method, so emitting at TRACE goes through the generic Log method with an explicit level and a context.Context:

logger.Log(ctx, engine.LevelTrace, "msg", "key", val)

Use context.Background() (or a plugin’s ambient request context) if no richer context is available — slog’s Log signature requires one regardless of whether the configured handler does anything with it.

core.log_level: trace in config parses to engine.LevelTrace via engine.ParseLogLevel; any handler built on the standard slog.Handler interface (including the engine’s FanoutHandler, pkg/engine/loghandler.go) passes the lower level through with no extra wiring.

Before/after examples

These are real call sites, shown as they read today and how the rubric above would classify them. They’re illustrative, not a to-do list — the mechanical reclassification of existing call sites across the tree is separate, later work; this page only establishes the standard.

TRACE vs. DEBUG: a per-request log

plugins/providers/anthropic/plugin.go, inside the code path that resolves every outgoing LLM request:

p.logger.Debug("resolving LLM request", "role", req.Role, "model", model, "max_tokens", maxTokens)

This fires once per LLM call — which, in a ReAct loop, is once per agent iteration, not once per turn. Under the rubric that’s “fires every iteration,” which is the TRACE bar, not DEBUG’s. A call site that a contributor would actually want on while troubleshooting a single hard session (without drowning in per-iteration noise) belongs at DEBUG; one that only earns its keep when inspecting every single LLM round-trip belongs at TRACE:

p.logger.Log(ctx, engine.LevelTrace, "resolving LLM request", "role", req.Role, "model", model, "max_tokens", maxTokens)

DEBUG vs. INFO: per-turn chatter

plugins/agents/react/plugin.go, in the skill-context handler:

p.logger.Info("loaded skill context", "name", content.Name)

Skill loading can happen multiple times within a single turn as the agent pulls in different skills. INFO is reserved for ops-relevant lifecycle events — session start/stop, plugin activation, turn boundaries — and explicitly excludes per-turn chatter like this. It’s useful while troubleshooting which skills got pulled in, which is exactly DEBUG’s bar:

p.logger.Debug("loaded skill context", "name", content.Name)

INFO vs. WARN: a retry loop

plugins/providers/anthropic/retry.go, inside the HTTP retry loop:

p.logger.Info("retrying API request",
	"attempt", attempt,
	"max_retries", rc.MaxRetries,
	"delay", delay,
)

A retry is the textbook WARN case from the rubric: something unexpected happened (the first attempt failed) and the system is recovering rather than failing outright. INFO would bury this among routine lifecycle logging; WARN is where an operator scanning logs for degraded behavior would look:

p.logger.Warn("retrying API request",
	"attempt", attempt,
	"max_retries", rc.MaxRetries,
	"delay", delay,
)

Applying this elsewhere

When you add a new log call or touch an existing one:

  1. Ask “how often does this fire?” first — per-event/per-iteration pushes toward TRACE, per-turn toward DEBUG or INFO, per-session toward INFO.
  2. Ask “would an operator watching production logs at the default level (info) want to see this?” If the answer is “only while I’m debugging,” it’s DEBUG or TRACE, not INFO.
  3. Reserve WARN for degraded-but-recovered paths and ERROR for failures that need attention — don’t use either for expected, successful control flow.
  4. If in doubt between two adjacent levels, prefer the quieter one. The rubric’s failure mode in the wild has been contributors defaulting to Info; erring toward DEBUG or TRACE keeps that from recurring.

Gate vetoes: WARN vs. INFO

The rubric’s INFO row calls out “a gate actually vetoed/blocked something” as the rare, ops-relevant case. In practice that splits further, and the gate plugins (plugins/gates/**) settled the split this way: if the gate arranges its own automatic recovery around the veto, it’s WARN, not INFO — a rate limiter that pauses and auto- retries, a context-window gate that triggers compaction and auto- retries, an approval-policy gate that applies a default answer on timeout, a token-budget gate that downgrades the model, or a content- safety/prompt-injection gate running in a non-blocking mode. The system keeps going on its own, so it’s the same “unexpected but recovered” case as a retry — WARN, not INFO. INFO stays reserved for the terminal case: the veto stops the turn with no gate-arranged recovery — an iteration cap hit, a schema/stop-words gate blocking after exhausting retries, a content-safety/prompt-injection gate in hard-block mode, an approval-policy gate with no default timing out, or a rate limiter’s queue-full hard reject.

The same logic applies outside gates: infrastructure-level recovery from an external failure — a tool-execution timeout that gets caught and handled — is WARN too. It isn’t a policy veto at all, just the ordinary “unexpected but recovered” WARN case.

Exceptions

The rubric above is the default, not an absolute. One call site is a deliberate, named exception — recorded here (2026-09-10) so a future contributor doesn’t “fix” it by demoting it:

cmd/nexus-broker/auth.go’s wrap method logs g.logger.Info("auth allowed", ...) on every authenticated request, including routine polling such as /metrics scrapes. Under “no per-request chatter at INFO” that reads like a DEBUG demotion candidate. It stays at INFO because it is, by design, the broker’s only security audit trail — there is no separate audit sink. As the code comment on deny (same file, line ~227) states, every allow and every deny emits exactly one structured slog record, and that record is the audit trail. Completeness of that trail was judged to outweigh the noise-reduction goal for this one call site.