feat(nunciod): layered tracing subscriber with EnvFilter, rotating file, and JSON #368

Merged
KofTwentyTwo merged 1 commits from story/obs-1-subscriber-foundation into dev 2026-07-31 23:12:16 +00:00
KofTwentyTwo commented 2026-07-31 23:10:53 +00:00 (Migrated from github.com)

Summary

Keystone of the observability program (OBS-1). Replaces the stderr-only tracing_subscriber::fmt::init() bootstrap in nunciod with a layered Registry subscriber. That old one-liner pinned the level at INFO, ignored RUST_LOG (the env-filter feature wasn't even enabled), had no file sink, no JSON, and no runtime control — the daemon was effectively unfollowable and unauditable.

New module crates/nunciod/src/logging.rs builds three layers:

  1. Reloadable EnvFilter — precedence NUNCIO_LOG > RUST_LOG > default info. Wrapped in a reload::Layer so a future control RPC can change verbosity at runtime; the reload::Handle is returned from init (not wired to an RPC in this story, just exposed). A malformed filter string never panics — it degrades to the default and emits a warn! once the subscriber is live.
  2. Console layer — human-readable, stderr, ANSI, target + span context.
  3. Rotating file layer — tracing_appender::rolling::daily into <data_dir>/logs/ (sibling of the SQLite DB, i.e. ~/.nuncio/logs/) through tracing_appender::non_blocking.

JSON toggle: NUNCIO_LOG_FORMAT=json switches the file sink to JSON. The console sink stays human-readable (it's for an operator watching a terminal); the file is the machine-parsed sink, so JSON lands there.

WorkerGuard lifetime: non_blocking returns a WorkerGuard that must be held for the whole process or buffered logs are dropped. init returns it inside LoggingGuards; main binds let _log_guards = ... so it lives until shutdown. Documented in the module.

Fail-safe: logging never crashes the daemon. Unwritable log dir → stderr-only + warn!. Subscriber already installed (e.g. test harness) → left untouched, empty guards returned.

Cargo

  • Workspace tracing-subscriber gains features env-filter, json (default fmt/ansi/registry retained); added tracing-appender to workspace deps. nunciod now consumes both via workspace = true (previously pinned tracing-subscriber = "0.3" locally — now consistent with the rest of the crate).

Tests

Layer/filter construction is split into pure functions so behavior is unit-tested without ever calling global init() twice:

  • NUNCIO_LOG beats RUST_LOG beats default (and empty var treated as unset).
  • Malformed filter falls back to default without panicking.
  • NUNCIO_LOG_FORMAT=json (case-insensitive) selects JSON; other values stay human.
  • Log dir resolves to a logs/ sibling of the DB file.

Gate green: cargo fmt --all -- --check, cargo check-all, cargo test-all — 546 passed, 0 failed, 0 ignored. No unsafe, no unwrap/expect/panic in non-test code.

Refs #357

🤖 Generated with Claude Code

## Summary Keystone of the observability program (OBS-1). Replaces the stderr-only `tracing_subscriber::fmt::init()` bootstrap in `nunciod` with a layered `Registry` subscriber. That old one-liner pinned the level at INFO, ignored `RUST_LOG` (the `env-filter` feature wasn't even enabled), had no file sink, no JSON, and no runtime control — the daemon was effectively unfollowable and unauditable. New module `crates/nunciod/src/logging.rs` builds three layers: 1. **Reloadable EnvFilter** — precedence `NUNCIO_LOG` > `RUST_LOG` > default `info`. Wrapped in a `reload::Layer` so a future control RPC can change verbosity at runtime; the `reload::Handle` is returned from `init` (not wired to an RPC in this story, just exposed). A malformed filter string never panics — it degrades to the default and emits a `warn!` once the subscriber is live. 2. **Console layer** — human-readable, stderr, ANSI, target + span context. 3. **Rotating file layer** — `tracing_appender::rolling::daily` into `<data_dir>/logs/` (sibling of the SQLite DB, i.e. `~/.nuncio/logs/`) through `tracing_appender::non_blocking`. **JSON toggle:** `NUNCIO_LOG_FORMAT=json` switches the *file* sink to JSON. The console sink stays human-readable (it's for an operator watching a terminal); the file is the machine-parsed sink, so JSON lands there. **WorkerGuard lifetime:** `non_blocking` returns a `WorkerGuard` that must be held for the whole process or buffered logs are dropped. `init` returns it inside `LoggingGuards`; `main` binds `let _log_guards = ...` so it lives until shutdown. Documented in the module. **Fail-safe:** logging never crashes the daemon. Unwritable log dir → stderr-only + `warn!`. Subscriber already installed (e.g. test harness) → left untouched, empty guards returned. ## Cargo - Workspace `tracing-subscriber` gains features `env-filter`, `json` (default `fmt`/`ansi`/`registry` retained); added `tracing-appender` to workspace deps. `nunciod` now consumes both via `workspace = true` (previously pinned `tracing-subscriber = "0.3"` locally — now consistent with the rest of the crate). ## Tests Layer/filter construction is split into pure functions so behavior is unit-tested without ever calling global `init()` twice: - `NUNCIO_LOG` beats `RUST_LOG` beats default (and empty var treated as unset). - Malformed filter falls back to default without panicking. - `NUNCIO_LOG_FORMAT=json` (case-insensitive) selects JSON; other values stay human. - Log dir resolves to a `logs/` sibling of the DB file. Gate green: `cargo fmt --all -- --check`, `cargo check-all`, `cargo test-all` — 546 passed, 0 failed, 0 ignored. No `unsafe`, no `unwrap`/`expect`/`panic` in non-test code. Refs #357 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.