ADR-0011: Structured file logging — OpenTelemetry-shaped JSON, rotated, groundwork for OTLP

StateAccepted
Architectural SignificanceMEDIUM
DomainDeveloper Tooling
Document version1.0

Reference

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.

Summary

Context

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:

  1. Don't regress the default. Stdout must stay human-readable and on; file logging is strictly additive and opt-in.
  2. Offline & dependency-light (ADR-0001). No network exporter, no heavy OTEL SDK yet — just the tracing stack, which is the Rust ecosystem standard.
  3. Never stall the app. Disk I/O for logs must not block a command; hence a non-blocking appender with a lifetime-held flush guard.
  4. Forward-compatible shape. The on-disk records should already speak the OpenTelemetry log data model, so a future collector needs a thin mapping, not a reshape.
  5. One config surface, layered. Reuse ADR-0007 precedence (CLI/env > project > user > default) rather than inventing a new mechanism.

Decision makers

Introduce a [telemetry] config table and a telemetry module that owns the single subscriber-build seam.

Why [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.

Config surface

[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):

FlagEnv varConfig keyEffect
--log-file <PATH>ROTEIRO_LOG_FILE[telemetry] fileEnable + set path
--log——Enable at the default path $ROTEIRO_HOME/logs/roteiro.log
--log-rotation <CADENCE>ROTEIRO_LOG_ROTATION[telemetry] rotationRotation cadence
--log-format <FORMAT>ROTEIRO_LOG_FORMAT[telemetry] formatOn-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 log field mapping (otel/json format)

One JSON object per line, mapped onto the OpenTelemetry log data model:

JSON fieldOTEL fieldSource
time_unix_nanoTimeUnixNanowall-clock at emit, integer ns since the Unix epoch (OTEL's native representation)
observed_time_unix_nanoObservedTimeUnixNanosame instant (we emit as we observe)
severity_numberSeverityNumbertracing level → OTEL 1/5/9/13/17 (TRACE/DEBUG/INFO/WARN/ERROR)
severity_textSeverityTexttracing level name
bodyBodythe event's message
attributesAttributesremaining event fields + code.namespace/code.filepath/code.lineno source location + span.name/span.path context
resourceResourceservice.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 behaviour

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.

Native llama.cpp / ggml log routing

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:

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.

The deferred-OTLP seam

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.

Consequences

Positive

Negative / costs

Status

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.