ADR-043: The observability standard — four channels, a level rubric, one target taxonomy¶
Status: accepted (2026-09-12, ergo epic RAYW7I; migration phases N57KBH -> Y6TP27 -> A44VW4 -> ISESRO -> 2PDJAL).
Settled in the project-wide logging/tracing/telemetry audit and reviewed by two
peer sessions (kimi-k3, glm-5.3-flash) across two passes. The review caught
three over-designs — a numeric verbosity ladder, a degenbot::diag marker
namespace, and a scope:level string grammar — recorded under Alternatives
considered, and two contradictions in the first draft (a diag default that
would re-flood the console, and domain keys outside the closed set), now fixed.
The user sign-off checkpoint is satisfied (2026-09-12): the level rubric,
the target taxonomy, and the control surface are approved; the migration
phases may proceed.
Context¶
The Rust core, the Python driver, and OpenTelemetry each carry their own observability conventions, and the seams between them have no shared vocabulary. Four planes were in play:
Rust
tracing— forwarded to Pythonloggingthroughtracing_log::LogTracer+PythonLogLayer(rust/crates/shells/degenbot-python/src/python_log_layer.rs), plus an optional stderrfmtlayer. Inventory: ~121info!, 74warn!, 50error!, 35debug!, 55info_span!, 6tracing::instrument.Python
logging(src/degenbot/logging.py): ~58info, 25debugcall sites.OTel / Prometheus (
degenbot-bot/src/otel.rs,instruments.rs): ~90 metric instruments, servicedegenbot-bot, span export only (there is no OTel logs bridge).~25 ad-hoc
DEGENBOT_*verbosity flags in thetrace,simulation, andaaveconfig tables, each with a private default and a hardcoded emit level.
Evidence¶
A real run (logs/bot_run.log) was dominated by per-entity lines emitted at
info outside the console-capped target:
406 [verify-dbg] 398 [pool]/[path] 318 [sim]
242 [sim-revert-swap] 169 [state] 120 [solve-phase]
96 [sim-trace] 85 [sim-fail] 78 [sim-verify]
The load-bearing diagnosis: verbosity was chosen at the call site (a
bespoke env flag plus a hardcoded level) instead of at the sink. The correct
mechanism already existed — the degenbot::diag target capped at warn on
console and uncapped on the OTel layer — but only 27 sites used it against 112
INFO sites.
Defects surfaced by the audit: src/degenbot/logging.py documented a retired
pyo3-log bridge; 72 println!/eprintln! calls in rust/crates/*/src
outside examples and tests (several on production failure paths);
block_pump.rs printed in production; the two-tunnel console
(DEGENBOT_LOG_FMT) could emit a record twice at different levels.
Decision¶
1. Four channels, one fact per channel¶
Channel |
Consumer |
Default |
Content |
|---|---|---|---|
Console log |
operator |
on |
process lifecycle and outcomes |
Span (OTel) |
investigator |
when |
per-operation timing + structured context |
Metric (Prometheus) |
alerting |
on |
aggregates, low cardinality |
Forensic file |
repro |
opt-in |
full-field dumps |
A fact is emitted on exactly one channel; it is never restated as a per-event INFO line. Sanctioned asymmetry: any fact that paging depends on must be carried by both the console and a metric, because OTel can be unavailable when it matters.
No channel may carry credentials. URL userinfo and API-key path segments,
auth headers, and key material are forbidden in console, span attributes,
metric labels, and forensic dumps alike. RPC URLs are redacted at their
Display boundary by a dedicated type; a field-level deny-set
(key, secret, token, auth, authorization) applies at every emit
site. Forensic dumps carry calldata, never transport auth.
2. Level rubric (cardinality-bounded)¶
ERROR — abort, integrity loss, or the shutdown seam; every ERROR class also emits a metric so a sustained condition cannot be invisible. A failure seam that auto-recovers (e.g. a WSS disconnect while reconnecting) is WARN, never ERROR.
Crash path. A panic hook emits exactly one ERROR carrying the panic payload and thread/task name, then
record_exception. Every spawn boundary wraps and joins its task and logs theJoinErrorpayload — not a bare “task failed”.record_exceptionsets the active OTel span’sStatus::Error, so the Jaeger view of a failure seam is complete rather than a log line beside a green span. This is the class of the currenteprintln!abort infleet_solve_executor.rs.
WARN — degraded but continuing: tripwire, quarantine, retry exhaustion, verify failure, posture cordon, auto-recovering seam.
INFO — process lifecycle and outcomes only. Per block: one block summary, O(1). Per phase (not per block): boot, backfill start/end, pump start/stop. Per submit decision: one INFO line at the exec seam, O(submits) where submits are rare relative to blocks — “we attempted to take money” deserves a console line even when OTel is unavailable, with the span event as the attributed record.
DEBUG — everything O(entities): per-pool, per-path, per-candidate, per-sim, per-log, phase timing, verify diagnostics.
TRACE — per-hop/per-field dumps and raw traces.
3. One target taxonomy; diagnostics live inside an engine span¶
Targets are degenbot::<domain> for a closed domain set:
state, path, solver, sim, pump, exec, verify,
ingest, rpc, aave. Diagnostics are DEBUG events under their own
domain — there is no separate degenbot::diag marker namespace, and no
ad-hoc sub-scopes: the closed set is the only set. Per-domain escalation is
diag = { sim = "debug" }.
Invariant: engine diagnostics are emitted within an active engine span.
The OTel layer is spans-only
(OpenTelemetryLayer::new(tracer).with_context_activation(false)), so an
event reaches Jaeger only as a span event on an active span. This invariant —
not a target — is what makes the diagnostic stream trace-visible, and it
replaces what the rejected marker pretended to guarantee. Enforcement: a facade
debug_assert/one-shot WARN when a diagnostic fires with no current engine
span, plus a golden-snapshot assertion that demoted events appear as span
events in the exported trace.
4. Typed control surface¶
Knob |
Class |
Shape |
|---|---|---|
|
behavior |
closed enum |
|
verbosity |
validated map |
|
behavior + sink |
one capped, rotating file target |
|
behavior |
driver wiring |
Precedence is a branch, not an ordering: if RUST_LOG is present it is
used as-is on every sink, the config log knobs are ignored, and the active
source is named once at startup. Absent, log_level + diag compile in one
code path into one EnvFilter; the compiled directive string is an
implementation detail, never the contract. diag keys are validated at
config load against the closed domain set from §3, so a typo is a boot error
naming the offending key and the valid set — never a silent no-op. The map is
validated even when RUST_LOG overrides it, with a single WARN noting that it
is being ignored, in the same detection-not-compatibility spirit as the
retired-name scan.
The two per-layer defaults are explicit. The compiled EnvFilter is the
console filter; the OTel record filter is an independent fixed default
(warn,degenbot=debug), not a second compilation of the diag map.
Per-domain OTel throttling via diag is explicitly out of scope for this ADR.
Console filter — the wiring default (
INFOfor the Python driver,WARNfor the Rust bot) plus any operatordiagdirectives.diagships empty, so the default console posture is quiet.OTel spans layer —
warn,degenbot=debugwhentelemetry.otelis on, so domain diagnostics reach Jaeger by default.
diag is therefore the operator’s explicit console escalation; the OTel
side is already uncapped for degenbot domains.
Sampling and retention. The OTel layer uses parent-based AlwaysSample
while telemetry.otel is on: the preserve-by-traces mechanism depends on the
full diagnostic stream being exported, and cost is bounded by block frequency
rather than by volume. Retention is owned by the collector configuration, not
by the core. No head-sampler may be improvised in-process, because it would
gut the preservation guarantee.
5. Behavior flags stay; verbosity flags retire¶
Behavior flags keep their boolean config: otel, metrics_addr,
ws_completeness, sim_exit_on_fail, pump_debounce_ms, hotpath. The two
state_lock diagnostics (trace, diag) also stay: they gate diagnostic
collection cost (acquire-time backtrace capture; per-read-hold bookkeeping),
not log emission, so they are behavior flags rather than verbosity knobs. The
always-on slow-hold WARN is unaffected by either.
Verbosity flags retire hard (no aliases). Each maps to exactly one closed domain or to the forensic sink, so the migration is mechanical and auditable:
Retired key |
Domain |
Disposition |
|---|---|---|
|
|
DEBUG; default-TRUE stream |
|
|
DEBUG; default-TRUE stream |
|
|
DEBUG; default-TRUE stream |
|
|
DEBUG |
|
forensic |
full-field dump; default off |
|
forensic |
full-field dump; default off |
|
|
DEBUG |
|
|
DEBUG |
|
|
DEBUG |
|
|
DEBUG |
|
|
DEBUG |
|
|
DEBUG |
|
|
DEBUG |
|
|
DEBUG |
|
|
DEBUG |
Four of these default TRUE today
(rust/crates/foundation/degenbot-config/src/schema.rs: verify_dbg :315,
v2_calc_trace :225, dump_call_trace :211,
sim_log_reverted_swaps :328). They are not preserved by seeding the
diag map — that map is the console escalation knob, and seeding it would put
the 406-line verify stream straight back on stderr, defeating this ADR. They are
preserved on the Jaeger side by the OTel record-filter default
(degenbot=debug, §4): the console stops flooding and the diagnostic signal
survives in traces. Full-field dumps belong on the forensic target and default
off. The console behavior for these four streams changes by default, and that
change is deliberate.
Migration safety net: a boot-time WARN scans the process environment for any name on the closed retired list and prints the equivalent domain/directive. This is detection, not compatibility; the shim is scoped to the retired-name list and is deleted at 0.7 alongside the other gated cleanups.
6. Library hygiene and one console writer¶
Core crates use tracing only: no println!/eprintln! on production
paths, no level policy, no subscriber installation. Libraries own no console.
Only wiring installs, via try_init so a host subscriber always wins. The
standalone Rust bot wiring defaults to WARN; the Python driver defaults to
INFO.
Exactly one console-emitting writer per process — fmt layer or
PythonLogLayer, never both. The DEGENBOT_LOG_FMT two-tunnel hack is
removed; the owner is derived from binding-present. logging.py’s stale
pyo3-log docstring is corrected in the same pass.
The console writer is non-blocking: a bounded queue drained by a dedicated
writer task, so a stalled TTY or full pipe cannot stall the per-log pump. Drops
are counted as degenbot.log_dropped_total{sink} — a silent ceiling is not
acceptable. On shutdown, telemetry providers flush before the tokio runtime
is torn down (the panic path excepted, where the best-effort flush is
documented), so the tail of a trace is not lost to teardown ordering.
7. Naming, derived not written¶
Span degenbot.<area>.<verb>; metric degenbot.<noun>_<unit>; target
degenbot::<domain>. The message [area] tag is deleted: the console
formatter (_AreaFormatter in src/degenbot/logging.py) renders
LEVEL [area] message with the area derived from the record’s logger name —
the domain target for a bridged Rust record, the owning segment for a
Python-side one — so maintainers set one thing and a record that moves between
areas needs no message edit. Landed in 4QYTPH; a production message that
re-introduces an [area] tag fails observability_naming.rs ::messages_carry_no_area_tags.
8. Enforcement¶
Primary enforcement is compile-time:
clippy::disallowed_macroswith exact paths (tracing::info,tracing::debug,tracing::warn,tracing::error,tracing::trace, andtracing::event) in workspace lints, allow-scoped to the telemetry facade module. Banning the level macros withouttracing::eventwould leaveevent!(Level::INFO, …)as a trivial bypass. Call sites use facade macros (diag!(domain = …),op_info!(…)) that force a target.Span creation (
info_span!,tracing::instrument) stays available, but the boundary is named: spans follow the §7 naming and the §3 engine-span invariant, and are covered by the golden snapshot. Facadeop_span!wrappers are preferred where a hot path needs a static handle.clippy::print_stdout/clippy::print_stderrdenied at library crate roots; the pure-Rust binary opts out where output is the product.
Secondary gates:
a grep gate for direct
std::iowrites and Python logging misuse;a behavioral volume gate: a capture subscriber over a fixture block asserts steady-state INFO ≤ N per block and that every INFO+ record matches the allowlist;
a metric cardinality gate: a per-instrument label allowlist, and the series-count self-metric from §9 watched by an alert;
golden snapshots of boot, one block, and one revert that capture console text plus metric series and exported span events.
9. Metric cardinality¶
Metric label value sets are &'static closed constants, following the
existing telemetry::error_kind / error_reason pattern. Pool ids, path
ids, block hashes, and other unbounded values never appear as metric labels —
they belong in spans and logs. A degenbot.metric_series self-metric exposes
the live distinct-series count so cardinality blowup is visible before the
collector falls over. This is the metric-side twin of §2’s level cardinality
bounds.
10. Signal-preservation acceptance criterion¶
Every demoted or deleted log site must, in its diff, name the metric constant /
span attribute / span event that preserves the tripwire, or carry an explicit
no preserving signal, rationale: … clause. The golden snapshot captures
metric series and span events, not only console text, so a demotion that
silently blinds a detector is a visible diff. This is the difference between a
logging refactor and an observability regression.
Migration¶
This ADR plus a rewrite of
docs/logging.md(the operational form of the standard); fix the stalepyo3-logdocstring.Ship the telemetry facade macros, the clippy lints, the engine-span
debug_assert, and the panic hook.One mechanical per-crate pass: demote per-entity INFO to DEBUG, adopt
degenbot::<domain>targets, delete verbosity flags per the §5 table — every diff carries the signal-preservation clause.Add
telemetry.log_level, the validateddiagmap, the two explicit filter defaults, the boot-time retired-flag detection, the non-blocking single console writer with its drop counter, and remove production prints.Golden snapshots, the metric cardinality gate, the series-count self-metric, and naming normalization for spans and metrics.
Consequences¶
Console output becomes operator-grade: per-entity detail moves to spans and metrics, while the four previously default-ON forensic streams remain available in traces by default (OTel record filter
degenbot=debug).The ~17 verbosity env vars collapse to a typed two-knob surface plus one file sink; every removed name is detected at boot rather than failing silently.
Metric cardinality and log volume gain gates, so regressions are diffs rather than surprises; console drops are counted, never silent.
The core crates become genuinely library-safe for a
cargo add degenbotconsumer: no sink, no level policy, no direct writes.
Alternatives considered¶
Numeric verbosity ladder (k8s
-v). Rejected: a third encoding of the same knob alongsidelog_levelanddiag; k8s needs it because it lacks a structured filter language, which EnvFilter provides.degenbot::diag::<domain>marker namespace. Rejected: the OTel layer is spans-only, so target-level markers never conferred trace visibility; visibility is span membership. Kept as a one-line revisit note: if a deliberate OTel logs bridge lands (e.g. for standalone systemd operation), marker-target filtering may become meaningful again.A raw
log_directivesEnvFilter string. Rejected: unvalidated string in a typed schema fails at runtime rather than at config load.scope:levelstring micro-grammar. Rejected in favour of a validated map that compiles toEnvFilterinternally.Seeding
diagto preserve the default-TRUE streams. Rejected:diagescalates the console, so a seed would re-flood stderr; preservation is a property of the OTel record filter.Aliasing retired verbosity flags. Rejected per the project’s hard-cutover rule; replaced by boot-time detection of the retired names.
References¶
Prometheus, Metric and label naming — app prefix, base units,
_totalsuffix, cardinality warning.OpenTelemetry, Logs data model (severity mapping) and Metrics API (
unitas a first-class field).Envoy,
--component-log-level component:level— per-component levels.Kubernetes, Logging conventions + KEP-1602 —
V(n)ladder, structured key/values, “shared libraries should not log errors themselves”.Git,
api-trace2— unified targets replacing ad-hocGIT_TRACE_*flags.Rust
tracing/logcrate guidance — libraries emit, never install a global subscriber.CPython
loggingHOWTO — “WARNING and greater … is regarded as the best default behaviour” for a library.