Skip to main content

soma_observability/
logging.rs

1//! Dual-output logging — console (colored) + file (JSON).
2//!
3//! # CUSTOMIZE: Why dual logging?
4//!
5//! Two simultaneous log destinations serve different audiences:
6//!
7//! | Destination | Format     | Audience              | Purpose                    |
8//! |-------------|------------|-----------------------|----------------------------|
9//! | stderr      | Pretty     | Developer / operator  | Human-readable, colorized  |
10//! | File        | JSON lines | Log aggregator / AI   | Machine-parseable, indexed |
11//!
12//! The console output is optimized for human scanning: compact timestamps,
13//! semantic colors, noise suppression. The file output preserves all fields
14//! for programmatic analysis (grep, jq, log aggregators, AI agents).
15//!
16//! # CUSTOMIZE: Usage in main.rs
17//!
18//! Replace the existing `tracing_subscriber` setup in `main.rs` with:
19//!
20//! ```rust,ignore
21//! use soma::logging;
22//!
23//! let data_dir = config.data_dir(); // e.g. ~/.soma
24//! logging::init(&data_dir, "example")?;
25//! ```
26//!
27//! In stdio mode, suppress all logs to avoid polluting the MCP JSON stream:
28//!
29//! ```rust,ignore
30//! if stdio_mode {
31//!     // Don't call logging::init() — tracing stays at warn level on stderr only
32//!     tracing_subscriber::fmt()
33//!         .with_env_filter(EnvFilter::new("warn"))
34//!         .with_writer(std::io::stderr)
35//!         .init();
36//! } else {
37//!     logging::init(&data_dir, "example")?;
38//! }
39//! ```
40//!
41//! # CUSTOMIZE: Log file location
42//!
43//! Logs are written to `{data_dir}/logs/{service}.log`.
44//! For the example service this resolves to `~/.soma/logs/example.log`.
45//!
46//! The file is truncated (not rotated) when it exceeds 10MB. This keeps
47//! disk usage predictable and eliminates the complexity of log rotation.
48//! For production deployments that need persistent logs, configure a log
49//! aggregator (e.g. Loki, Datadog, CloudWatch) to ship from stderr instead.
50
51pub mod aurora;
52pub mod formatter;
53
54use std::io::IsTerminal;
55use std::path::Path;
56
57use anyhow::{Context, Result};
58use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
59
60use formatter::AuroraFormatter;
61
62/// Initialise dual logging: pretty console (stderr) + JSON file.
63///
64/// # Arguments
65///
66/// - `data_dir` — service data directory (e.g. `~/.soma`). Logs go into
67///   `{data_dir}/logs/{service_name}.log`.
68/// - `service_name` — used as the log file name (e.g. `"example"`).
69///
70/// # Errors
71///
72/// Returns an error if the log directory cannot be created or the log file
73/// cannot be opened for writing.
74///
75/// # CUSTOMIZE: EnvFilter precedence
76///
77/// Log levels are controlled by `RUST_LOG`. If unset, defaults to `"info"`.
78/// Examples:
79/// - `RUST_LOG=debug` — show all debug logs
80/// - `RUST_LOG=info,rmcp=warn` — info level, suppress rmcp crate noise
81/// - `RUST_LOG=soma=trace` — trace this crate only
82///
83/// Both the console and file writers share the same `EnvFilter`, so they
84/// always emit the same set of events.
85pub fn init(data_dir: &Path, service_name: &str) -> Result<()> {
86    let log_dir = data_dir.join("logs");
87    std::fs::create_dir_all(&log_dir)
88        .with_context(|| format!("failed to create log directory: {}", log_dir.display()))?;
89
90    let log_path = log_dir.join(format!("{service_name}.log"));
91
92    // Truncate the log file if it has grown past the 10MB cap.
93    // See `truncate_log_if_needed()` for rationale.
94    truncate_log_if_needed(&log_path)?;
95
96    // Open the log file for appending (creates it if it doesn't exist).
97    let log_file = std::fs::OpenOptions::new()
98        .create(true)
99        .append(true)
100        .open(&log_path)
101        .with_context(|| format!("failed to open log file: {}", log_path.display()))?;
102
103    let console_ansi = should_colorize();
104
105    // CUSTOMIZE: Subscriber stack
106    //
107    // The stack is built as:
108    //   registry()          — the base subscriber that stores span data
109    //     .with(env_filter) — shared level filter for ALL layers
110    //     .with(console)    — pretty, colored stderr output
111    //     .with(file)       — JSON lines file output
112    //
113    // Both layers share the same filter. To give them independent filters,
114    // see `tracing_subscriber::layer::Filtered`.
115    tracing_subscriber::registry()
116        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
117        .with(
118            // Console layer: pretty, colored, human-readable
119            //
120            // CUSTOMIZE: Console layer configuration
121            // - `with_ansi(console_ansi)` — enables ANSI codes only when stderr is a TTY
122            //   or FORCE_COLOR is set. The AuroraFormatter reads `writer.has_ansi_escapes()`
123            //   to conditionally apply colors.
124            // - `with_writer(std::io::stderr)` — logs go to stderr, not stdout.
125            //   stdout is reserved for CLI output and MCP JSON streams.
126            // - `.event_format(AuroraFormatter)` — our custom formatter (see formatter.rs)
127            tracing_subscriber::fmt::layer()
128                .with_ansi(console_ansi)
129                .with_writer(std::io::stderr)
130                .event_format(AuroraFormatter),
131        )
132        .with(
133            // File layer: structured JSON, machine-readable
134            //
135            // CUSTOMIZE: File layer configuration
136            // - `.json()` — emit one JSON object per log line (NDJSON format)
137            // - `.with_ansi(false)` — never emit ANSI codes to the file
138            // - `.with_writer(log_file)` — write to the log file we opened above
139            //
140            // JSON format example:
141            // {"timestamp":"2026-05-13T14:32:01.123Z","level":"INFO","fields":{"message":"starting","bind":"0.0.0.0:3000"}}
142            tracing_subscriber::fmt::layer()
143                .json()
144                .with_ansi(false)
145                .with_writer(log_file),
146        )
147        .init();
148
149    tracing::debug!(
150        log_file = %log_path.display(),
151        ansi = console_ansi,
152        "logging initialised"
153    );
154
155    Ok(())
156}
157
158// ── Log file rotation ─────────────────────────────────────────────────────────
159
160/// Maximum log file size in bytes before truncation.
161///
162/// # CUSTOMIZE: Why 10MB?
163///
164/// 10MB is large enough to contain several hours of busy server logs at INFO
165/// level, but small enough that disk pressure is never a concern. The file is
166/// truncated (not rotated), so disk usage is bounded at exactly one file.
167///
168/// If you need longer retention, configure log shipping to an external system
169/// (Loki, Datadog, etc.) and keep this cap. The file is for local debugging.
170const LOG_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024; // 10 MiB
171
172/// Truncate the log file to zero if it exceeds [`LOG_FILE_MAX_BYTES`].
173///
174/// # CUSTOMIZE: Truncation vs rotation
175///
176/// Traditional log rotation creates `service.log.1`, `service.log.2`, etc.
177/// We truncate instead because:
178/// 1. Simpler — no need to manage multiple files or `logrotate` config
179/// 2. Predictable — disk usage is always ≤ 10MB, never grows unboundedly
180/// 3. Safe for agents — agents reading the log file always find a single file
181///
182/// When the file is truncated, the server logs a WARN message so the operator
183/// knows why the log history starts from the current process.
184fn truncate_log_if_needed(path: &std::path::PathBuf) -> Result<()> {
185    if !path.exists() {
186        return Ok(());
187    }
188
189    let size = path
190        .metadata()
191        .with_context(|| format!("failed to stat log file: {}", path.display()))?
192        .len();
193
194    if size >= LOG_FILE_MAX_BYTES {
195        std::fs::write(path, b"")
196            .with_context(|| format!("failed to truncate log file: {}", path.display()))?;
197        // Note: we can't use tracing here (subscriber not yet initialised).
198        // Write to stderr directly so the truncation event is never lost.
199        eprintln!(
200            "WARN  log file exceeded {LOG_FILE_MAX_BYTES} bytes — truncated: {}",
201            path.display()
202        );
203    }
204
205    Ok(())
206}
207
208// ── Colorization detection ────────────────────────────────────────────────────
209
210/// Determine whether console log output should include ANSI color codes.
211///
212/// Priority order (highest to lowest):
213///
214/// 1. `NO_COLOR` env var set → **no color** (<https://no-color.org/> convention)
215/// 2. `FORCE_COLOR` env var set → **force color** (useful in Docker/CI)
216/// 3. `stderr` is a TTY → **color** (interactive terminal)
217/// 4. `stderr` is not a TTY → **no color** (piped/redirected)
218///
219/// # CUSTOMIZE: Docker containers
220///
221/// Docker containers often do NOT have a TTY attached to stderr, which would
222/// disable color by rule 4. But `docker compose logs` renders ANSI codes
223/// correctly, so operators benefit from colors.
224///
225/// Set `FORCE_COLOR=1` in your `docker-compose.yml` or Dockerfile:
226/// ```yaml
227/// environment:
228///   FORCE_COLOR: "1"
229/// ```
230///
231/// # CUSTOMIZE: CI/CD pipelines
232///
233/// Most CI systems (GitHub Actions, GitLab CI) support ANSI codes.
234/// Set `FORCE_COLOR=1` in your CI environment variables to enable color logs.
235pub fn should_colorize() -> bool {
236    // NO_COLOR takes precedence over everything (https://no-color.org)
237    if std::env::var_os("NO_COLOR").is_some() {
238        return false;
239    }
240
241    // FORCE_COLOR overrides TTY detection (for Docker, CI, etc.)
242    if std::env::var_os("FORCE_COLOR").is_some() {
243        return true;
244    }
245
246    // Fall back to TTY detection
247    std::io::stderr().is_terminal()
248}
249
250#[cfg(test)]
251#[path = "logging_tests.rs"]
252mod tests;