# Controlling logging, tracing, and telemetry The operational form of the observability standard; the binding decision record is [ADR-043](adr/ADR-043-observability-standard.md). This is the single source of truth for what the bot emits, at what level, and how to dial it up or down. degenbot has two first-class consumers — the pure-Rust MEV bot and the Python-driven bot — so the contract is split between the **core** (which emits signals and owns no sink) and the **wiring** (which installs sinks and chooses levels). ## The four channels | Channel | Consumer | Default | Content | | --- | --- | --- | --- | | Console log | operator | on | process lifecycle and outcomes | | Span (OTel) | investigator | when `telemetry.otel` | 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. Any fact that paging depends on is carried by *both* the console and a metric, because OTel can be unavailable when it matters. The Rust core reaches Python `logging` and the stderr `fmt` writer through one `tracing` subscriber (`init_logging_subscriber` in `rust/crates/shells/degenbot-python/src/python_log_layer.rs`); `log::` records are bridged in by `tracing_log::LogTracer`. Prometheus metrics are declared in `degenbot-bot/src/instruments.rs`. ## Levels | Level | Use | Budget | | --- | --- | --- | | ERROR | abort, integrity loss, shutdown seam; mirrored by a metric | rare | | WARN | degraded but continuing: tripwire, quarantine, retry exhaustion, verify failure, posture cordon, auto-recovering seam | O(events) | | INFO | process lifecycle and outcomes: one block summary per block, one line per submit decision, per-phase boot/backfill/pump lines | O(1)/block + O(submits) | | DEBUG | per-entity detail: per-pool, per-path, per-candidate, per-sim, per-log, phase timing, verify diagnostics | O(entities) | | TRACE | per-hop/per-field dumps and raw traces | unbounded (opt-in) | The rubric is deliberately asymmetric: if a line is per-pool or per-path, it is DEBUG. The console firehose that motivated the standard was per-entity lines emitted at INFO. ## Targets The closed domain set is `degenbot::state`, `degenbot::path`, `degenbot::solver`, `degenbot::sim`, `degenbot::pump`, `degenbot::exec`, `degenbot::verify`, `degenbot::ingest`, `degenbot::rpc`, and `degenbot::aave`. Every diagnostic is a DEBUG event under its domain; there is no separate `diag` target. Engine diagnostics are emitted within an active engine span, which is what makes them visible in Jaeger. Span names are `degenbot..` and metric names are `degenbot._`. The console formatter derives its `[area]` prefix from the target, so there is no hand-written tag to keep in sync. ## The control surface | Knob | Class | Shape | | --- | --- | --- | | `telemetry.log_level` | behavior | closed enum `off\|error\|warn\|info\|debug\|trace` | | `telemetry.diag` | verbosity | validated map `{ domain = "level" }`, ships empty | | `telemetry.forensic` | behavior + sink | one capped, rotating file target | | `telemetry.otel`, `telemetry.metrics_addr` | behavior | driver wiring | `telemetry.diag` is the **console escalation** knob: it raises named domains on the console without touching the OTel side (which already carries `degenbot` at `debug`). Keys are validated at config load against the closed domain set, 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 one WARN that it is being ignored. ### Precedence Precedence is a branch, not an ordering: 1. 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. 2. Otherwise `telemetry.log_level` + `telemetry.diag` compile into one `EnvFilter` (the console filter). 3. The OTel spans layer is an independent fixed default, `warn,degenbot=debug`, whenever `telemetry.otel` is on. `RUST_LOG` is directives of the form `target=level`, comma-separated, applied most-specific-first; see [`tracing-subscriber`'s EnvFilter docs](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html). ## Operator recipes | I want to … | Do this | | --- | --- | | Quietest useful run (warn/error only) | `RUST_LOG=warn ./run_bot.sh` | | Default operator posture | `./run_bot.sh` | | Keep `info` but raise one domain on the console | `[telemetry.diag] sim = "debug"` (or the env equivalent) | | Full Rust + Python debug | `RUST_LOG=debug DEGENBOT_DEBUG=1` then the bot | | Just the Rust core at debug | `RUST_LOG=info,degenbot=debug` | | Full diagnostic stream in Jaeger only | leave the defaults — the OTel layer already runs `degenbot=debug` | | Prometheus scrape | `curl http://127.0.0.1:9464/metrics` (override `telemetry.metrics_addr`) | | Jaeger traces | see the [bot-telemetry skill](../.agents/skills/bot-telemetry/SKILL.md) | ## The console writer **Exactly one console-emitting writer per process** (ADR-043 §6). In the Python driver the binding is present, so the Rust→Python bridge owned by `logging.py` writes the console and the `fmt` layer is routed to `io::sink()`; in a standalone Rust binary the `fmt` layer owns it. A record is never written twice — the owner is *derived* from binding-present, not switched by an env var. Every console line is `LEVEL [area] message`, where the area is derived from the record's logger name — the closed domain target for bridged Rust records (`degenbot::solver` → `solver`), the owning segment for a Python-side record. The message itself carries no `[tag]`: a tag would state the routing decision a second time and drift from it (ADR-043 §7, enforced by the message sweep in `observability_naming.rs`). The bridge's queue is **bounded** (8192 records): a stalled TTY or full pipe cannot stall the per-log pump. A push past the ceiling drops the record and counts it as `degenbot.log_dropped_total{sink="console"}`, so the ceiling is visible in Prometheus rather than silent. ## Retired verbosity flags The former per-diagnostic `DEGENBOT_*` env flags are retired (no aliases); each maps to a domain and is now a DEBUG event under it. A boot-time WARN scans the process environment for any retired name and prints the equivalent domain. The mapping and the disposition of the four default-ON streams (`verify_dbg`, `v2_calc_trace`, `dump_call_trace`, `sim_log_reverted_swaps`) live in [ADR-043 §5](adr/ADR-043-observability-standard.md). `DEGENBOT_LOG_FMT` is retired with them: the console owner is now derived, so the two-tunnel switch has no equivalent knob. Setting it warns at boot. ## Implementation status The standard is being migrated in five phases (ergo epic `RAYW7I`). Until a phase lands, its knob is not yet live; the tables above describe the target contract and this section tracks the gap. - **Phase 1** (docs + ADR) — landed. - **Phase 2** — telemetry facade + closed domain targets landed. - **Phase 3** — landed. Every verbosity key in ADR-043 §5 is retired; the probes are unconditional DEBUG (or TRACE for the forensic dumps) events on their domain, and the per-entity INFO firehose is demoted. Two keys were reclassified from the §5 retire table into the behavior-flag list: `state_lock.trace` and `state_lock.diag` gate diagnostic *collection* cost, not log emission, so they keep their boolean config (the ADR table is corrected accordingly). - **Phase 4** — landed: `telemetry.log_level` (closed enum, defaults to the wiring default) and `telemetry.diag` (validated map, ships empty) resolve both record-layer filters through `degenbot_bot::telemetry::resolve_filters`; an explicit `RUST_LOG` is a branch that wins verbatim on every sink (the config knobs are ignored, with one WARN naming the active source). The console writer is single-owner and bounded with its drop counter; `DEGENBOT_LOG_FMT` is retired (boot-time WARN). `telemetry.forensic` remains the one unimplemented knob of this phase. - **Phase 5** — landed: the naming gate (`degenbot-bot/tests/ observability_naming.rs` sweeps production sources; spans are `degenbot..`, metrics `degenbot..` — the block-epoch root span is now `degenbot.epoch.run`), the message sweep (no `[area]` tag in a production message; the console `_AreaFormatter` derives it from the logger name instead), the metric-cardinality gate (`metric_cardinality.rs`: label names are a closed reviewed allowlist and unbounded-looking names fail outright), and the `degenbot.metric_series` self-metric + `DegenbotMetricCardinalityHigh` alert. Tracked successors for the rest: `ZJUEXH` (behavioral volume gate), `RL7X4C` (golden snapshots for boot / one block / one revert) — both landed; every `[area]` message tag is deleted (147 in `src`, plus tests/examples) and the console prefix is derived (`4QYTPH`). ## See also - [ADR-043](adr/ADR-043-observability-standard.md) — the decision record. - [Bot configuration keys](rust-config-keys.md) — the generated key table. - [Bot telemetry skill](../.agents/skills/bot-telemetry/SKILL.md) — Jaeger and Prometheus workflows.