| State | Accepted |
| Architectural Significance | MEDIUM |
| Domain | Developer Tooling |
| Document version | 1.0 |
Introduces an opt-in second logging sink: alongside the human-readable text on
stdout (unchanged), Roteiro can also write logs to a rotating file in a
structured, OpenTelemetry-shaped JSON format that a future collector can
ingest. It is the deliberate groundwork step for observability — the network
OTLP exporter and metrics/traces are explicitly deferred to a later ADR; this
one only lands the file sink and the seam. Configured via a new [telemetry]
table, governed by the layered-config rules of
ADR-0007 and the offline/deterministic principles
of ADR-0001.
tracing + tracing-subscriber +
tracing-appender. The subscriber is built in one place:
crates/roteiro/src/telemetry.rs::init.tracing_appender::non_blocking, whose WorkerGuard is held for the whole
process lifetime (a slow disk can never stall the CLI).trace_id
correlation yet — the JSON shape and the single init seam are chosen so those
drop in later without touching call sites.Today every diagnostic in Roteiro is a println!/eprintln! to the process's
standard streams. That is fine for interactive use but gives an operator running
roteiro serve (ADR-0006) or the MCP server (ADR-0002) nothing durable or
machine-parsable to collect. The eventual goal is full OpenTelemetry — logs,
metrics, and traces over OTLP — but that is a large, network-facing dependency
surface we do not want to adopt in one step.
Forces to reconcile:
tracing stack, which is the Rust ecosystem standard.Introduce a [telemetry] config table and a telemetry module that owns the
single subscriber-build seam.
[telemetry], not [log]The table is named telemetry because it is the home for the whole deferred
observability story — OTLP logs and metrics/traces — not merely "the log
file". Naming it telemetry now avoids a rename (or a confusing second table)
when the exporter and metrics land.
[telemetry]
# Path to the rotating log file. Unset ⇒ file logging is OFF (stdout only).
# A leading `~/` expands to home; a relative path resolves under $ROTEIRO_HOME.
file = "~/.roteiro/logs/roteiro.log"
# daily (default) | hourly | minutely | never
rotation = "daily"
# otel (default) | json (alias of otel) | text (the same format stdout uses)
format = "otel"
Every field is optional. Overrides, in precedence order (flag beats env beats config):
| Flag | Env var | Config key | Effect |
|---|---|---|---|
--log-file <PATH> | ROTEIRO_LOG_FILE | [telemetry] file | Enable + set path |
--log | — | — | Enable at the default path $ROTEIRO_HOME/logs/roteiro.log |
--log-rotation <CADENCE> | ROTEIRO_LOG_ROTATION | [telemetry] rotation | Rotation cadence |
--log-format <FORMAT> | ROTEIRO_LOG_FORMAT | [telemetry] format | On-disk format |
| — | ROTEIRO_LOG | — | EnvFilter level directives for both layers (e.g. debug) |
An invalid rotation/format value is a hard error at startup (never a
silent fallback), matching ADR-0007's "malformed config fails fast".
otel/json format)One JSON object per line, mapped onto the OpenTelemetry log data model:
| JSON field | OTEL field | Source |
|---|---|---|
time_unix_nano | TimeUnixNano | wall-clock at emit, integer ns since the Unix epoch (OTEL's native representation) |
observed_time_unix_nano | ObservedTimeUnixNano | same instant (we emit as we observe) |
severity_number | SeverityNumber | tracing level → OTEL 1/5/9/13/17 (TRACE/DEBUG/INFO/WARN/ERROR) |
severity_text | SeverityText | tracing level name |
body | Body | the event's message |
attributes | Attributes | remaining event fields + code.namespace/code.filepath/code.lineno source location + span.name/span.path context |
resource | Resource | service.name = roteiro, service.version = crate version |
Integer nanoseconds (rather than an RFC3339 string) is chosen deliberately: it is OTLP's own wire representation and needs no date-formatting dependency.
Rotation is time-based, delegated to tracing_appender::rolling:
daily/hourly/minutely append a date suffix to the file name (e.g.
roteiro.log.2026-08-14); never writes a single, unrotated file at the exact
path. Size-based rotation is intentionally out of scope — tracing-appender
does not offer it — and is noted as a candidate for the OTLP step. Old-file
pruning/retention is likewise deferred.
The dominant source of stdout/stderr noise today is not Rust code — it is the
native C logging from llama.cpp + ggml. Loading a model floods the terminal
with hundreds of llama_model_loader: / create_tensor: / print_info: /
ggml_metal_* lines emitted straight from the C library, bypassing the tracing
subscriber entirely.
We tame this by calling llama_cpp_2::send_logs_to_tracing(LogOptions::default())
once, at engine construction in crates/rto-llama/src/llama.rs
(install_native_log_bridge, feature-gated on llama). It installs both the
llama_log_set and ggml_log_set callbacks, so every native line becomes a
tracing event on the llama.cpp / ggml target at its mapped level (ggml
DEBUG/INFO/WARN/ERROR → tracing DEBUG/INFO/WARN/ERROR). It is then
gated by exactly the same subscriber as everything else:
roteiro serve (stdout filter warn): the verbose model-loader
INFO wall is suppressed; only genuine native warnings/errors surface;--log, file filter info): the wall is captured in
the rotating OTEL file at its proper level, off the terminal;ROTEIRO_LOG=debug (or info): the wall is surfaced on stdout too.Ordering matters two ways, both handled: the subscriber is installed in
roteiro's main before any engine is built, and the bridge is installed
before LlamaBackend::init() — the backend's device probe (e.g. ggml-metal's
ggml_metal_device_init block) logs during init, so a later install would let
that first batch escape to stderr. A hand-rolled llama_log_set callback was
rejected: it needs unsafe, which is forbidden workspace-wide, whereas
send_logs_to_tracing is a safe wrapper (and also handles llama.cpp's CONT
continuation-line buffering and per-submodule targets for us).
The bridge lives entirely behind the llama feature, so non-llama builds are
unaffected.
telemetry::init is the only place layers are assembled, so the future exporter
is a third layer added there — no call site changes. The JSON already carries
OTEL field names and a resource, so a collector mapping is thin. Real
trace_id/span_id correlation arrives with the OpenTelemetry layer
(tracing-opentelemetry) at that time; until then span context is surfaced as
the span.name/span.path attributes so the shape is already collector-friendly.
Metrics (an OTEL MeterProvider) attach at the same seam.
Positive
tracing, so it obeys the log-level filter (quiet by default,
opt-in at debug) and lands in the OTEL file when file logging is on.Negative / costs
cargo deny-clean) dependencies: tracing,
tracing-subscriber, tracing-appender.println!/eprintln! diagnostics are not yet
routed through tracing (the native llama.cpp/ggml logs now are). So the file
captures the startup breadcrumb + native engine logs, but not yet the CLI's own
eprintln! warnings. Migrating those Rust call sites to tracing is follow-up
work, deliberately out of this ADR's scope.never own the file's growth.Accepted — implemented in crates/roteiro/src/telemetry.rs with the [telemetry]
config table in crates/roteiro/src/config.rs, and the native llama.cpp/ggml
log bridge in crates/roteiro/../rto-llama/src/llama.rs (feature llama). The
OTLP exporter, metrics, and the Rust-side println!/eprintln!→tracing
migration are tracked as follow-up.