soma_observability/logging/formatter.rs
1//! Aurora console log formatter — pretty, colored, human-readable.
2//!
3//! # CUSTOMIZE: Reference implementation
4//!
5//! This is the canonical log formatter for the rmcp server family.
6//! It mirrors `lab/crates/lab/src/log_fmt/formatter.rs` exactly so that
7//! all servers in the family produce identically-formatted console logs.
8//!
9//! When adapting Soma for your service:
10//! 1. Copy this file unchanged — it needs no service-specific edits
11//! 2. Adjust `style_value()` if you have additional semantic field names
12//!
13//! # Log format produced
14//!
15//! ```text
16//! HH:MM:SS INFO starting bind=0.0.0.0:3000 auth=bearer
17//! HH:MM:SS INFO tool call action=greet elapsed_ms=12
18//! HH:MM:SS WARN upstream slow action=status elapsed_ms=3200
19//! HH:MM:SS ERROR upstream failed action=echo error="connection refused"
20//! ```
21//!
22//! Columns:
23//! - `HH:MM:SS` — local time, dim grey
24//! - `LEVEL ` — 5 chars wide; ERROR=bold red, WARN=bold amber, INFO=plain, DEBUG/TRACE=dim
25//! - `message` — first token in pink+bold, inline `key=val` tokens get dim key
26//! - `key=val` — priority fields first, then alphabetical; keys dim, values semantic-colored
27//!
28//! # Why a custom formatter instead of tracing_subscriber::fmt defaults?
29//!
30//! The default tracing subscriber writes structured fields in a format like:
31//! ```text
32//! 2026-05-13T14:32:01.123456Z INFO soma: starting bind="0.0.0.0:3000"
33//! ```
34//!
35//! Problems with the default:
36//! - Full ISO timestamp is verbose (our HH:MM:SS is sufficient for dev logs)
37//! - Module path (`soma:`) adds noise
38//! - String values are always quoted (our formatter only quotes whitespace-containing values)
39//! - No semantic coloring for field values
40//!
41//! The `AuroraFormatter` fixes all of these while staying compatible with
42//! tracing's `FormatEvent` trait so it slots into the standard subscriber stack.
43
44use std::collections::BTreeMap;
45use std::fmt as stdfmt;
46
47use tracing::{
48 field::{Field, Visit},
49 Event, Subscriber,
50};
51use tracing_subscriber::{
52 fmt::{
53 format::{FormatEvent, FormatFields, Writer},
54 FmtContext,
55 },
56 registry::LookupSpan,
57};
58
59use super::aurora;
60
61// ── Raw ANSI helpers ──────────────────────────────────────────────────────────
62//
63// CUSTOMIZE: We use raw ANSI codes rather than the `console` crate because:
64// 1. `console::colors_enabled()` checks stdout's TTY state, ignoring our
65// ANSI flag (which is set based on stderr's TTY state)
66// 2. We want ANSI 256, not TrueColor — ANSI 256 survives `docker compose logs`
67//
68// These helpers are intentionally private — callers use `aurora::CONSTANT`
69// for the color values and call these helpers for the formatting.
70
71/// Apply ANSI 256 foreground color to `text`.
72fn ansi256(n: u8, text: &str) -> String {
73 format!("\x1b[38;5;{n}m{text}\x1b[0m")
74}
75
76/// Apply ANSI 256 foreground color + bold to `text`.
77fn ansi256_bold(n: u8, text: &str) -> String {
78 format!("\x1b[1;38;5;{n}m{text}\x1b[0m")
79}
80
81/// Apply ANSI dim (low intensity) to `text`.
82fn ansi_dim(text: &str) -> String {
83 format!("\x1b[2m{text}\x1b[0m")
84}
85
86// ── Field collection ──────────────────────────────────────────────────────────
87
88/// Collects all structured fields from a tracing `Event` into a `BTreeMap`.
89///
90/// Using `BTreeMap` gives us two properties:
91/// 1. Deterministic iteration order (alphabetical) for remaining fields
92/// 2. O(log n) `take()` for extracting priority fields in a fixed order
93///
94/// # CUSTOMIZE: Adding custom field types
95///
96/// If you add custom tracing fields with types beyond str/bool/i64/u64/f64,
97/// override `record_debug` — tracing calls it for any type implementing `Debug`.
98#[derive(Default)]
99struct EventFieldCollector {
100 fields: BTreeMap<&'static str, String>,
101}
102
103impl EventFieldCollector {
104 fn insert(&mut self, field: &Field, value: String) {
105 self.fields.insert(field.name(), value);
106 }
107
108 /// Remove and return a field by key (used to extract priority fields first).
109 fn take(&mut self, key: &str) -> Option<String> {
110 self.fields.remove(key)
111 }
112}
113
114impl Visit for EventFieldCollector {
115 fn record_str(&mut self, field: &Field, value: &str) {
116 self.insert(field, value.to_string());
117 }
118
119 fn record_bool(&mut self, field: &Field, value: bool) {
120 self.insert(field, value.to_string());
121 }
122
123 fn record_i64(&mut self, field: &Field, value: i64) {
124 self.insert(field, value.to_string());
125 }
126
127 fn record_u64(&mut self, field: &Field, value: u64) {
128 self.insert(field, value.to_string());
129 }
130
131 fn record_f64(&mut self, field: &Field, value: f64) {
132 self.insert(field, value.to_string());
133 }
134
135 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
136 self.insert(field, format!("{value:?}"));
137 }
138}
139
140// ── ANSI injection prevention ─────────────────────────────────────────────────
141
142/// Strip Unicode control characters from upstream-controlled field values.
143///
144/// # Why this matters
145///
146/// Field values come from external sources (API responses, user input, etc.).
147/// A malicious upstream could inject ANSI escape sequences into field values,
148/// causing the log output to color arbitrary text or hide log entries.
149///
150/// # CUSTOMIZE: Injection attack example
151///
152/// Without sanitization, this log line:
153/// ```text
154/// info!(error = "\x1b[31mFAKE\x1b[0m", "upstream failed")
155/// ```
156/// would inject red "FAKE" text into adjacent log output.
157///
158/// # What we strip
159///
160/// All Unicode control characters EXCEPT:
161/// - Tab (`\t` / 0x09) — preserved, valid in field values
162/// - Newline (`\n` / 0x0A) — preserved, valid in multiline values
163///
164/// ESC (`\x1b` / 0x1B) — stripped, replaced with Unicode replacement char (U+FFFD)
165/// Other C0 controls — stripped, replaced with U+FFFD
166///
167/// # Performance
168///
169/// Returns `Cow::Borrowed` when the input is clean (zero allocation).
170/// Only allocates when control characters are found.
171pub(crate) fn sanitize_field_value(value: &str) -> std::borrow::Cow<'_, str> {
172 if value
173 .chars()
174 .any(|c| c.is_control() && c != '\t' && c != '\n')
175 {
176 std::borrow::Cow::Owned(
177 value
178 .chars()
179 .map(|c| {
180 if c.is_control() && c != '\t' && c != '\n' {
181 '\u{FFFD}'
182 } else {
183 c
184 }
185 })
186 .collect(),
187 )
188 } else {
189 std::borrow::Cow::Borrowed(value)
190 }
191}
192
193/// Quote values that contain whitespace (otherwise leave bare).
194///
195/// This matches Rust's Debug format for strings: only quote when necessary.
196/// `"hello"` → `hello` (no quotes)
197/// `"hello world"` → `"hello world"` (quoted)
198pub(crate) fn format_field_value(value: &str) -> String {
199 if value.contains(char::is_whitespace) {
200 format!("{value:?}")
201 } else {
202 value.to_string()
203 }
204}
205
206/// Return true if this field+value should be suppressed in output.
207///
208/// # CUSTOMIZE: Noise suppression
209///
210/// Some boolean fields are always emitted even when false. Suppressing
211/// `field=false` reduces noise for fields that only matter when true.
212///
213/// Add additional suppression rules here as needed for your service.
214pub(crate) fn should_skip_field(key: &str, value: &str) -> bool {
215 // Suppress boolean flags that are always present — only show when true
216 matches!((key, value), ("subject_scoped" | "destructive", "false"))
217}
218
219// ── Semantic field coloring ───────────────────────────────────────────────────
220
221/// Apply aurora palette colors to structured field values based on field name.
222///
223/// # CUSTOMIZE: Semantic coloring rules
224///
225/// The color applied depends on the field's *semantic role*, not its value.
226/// This gives operators an immediate visual hierarchy:
227///
228/// | Color | Role | Fields |
229/// |--------|-------------|------------------------------------------------|
230/// | Pink | Identity | `service` |
231/// | Blue | Action/path | `action`, `tool`, `route`, `addr`, etc. |
232/// | Grey | Metadata | `subsystem`, `phase`, `transport`, `operation` |
233/// | Teal | Success | `status` 2xx |
234/// | Amber | Warning | `status` 3xx–4xx, `kind` on WARN/ERROR |
235/// | Red | Error | `error`, `status` 5xx |
236///
237/// # CUSTOMIZE: Adding field colors for your service
238///
239/// If your service has additional domain-specific fields with semantic meaning,
240/// add them here. Example for a Gotify server:
241/// ```rust,ignore
242/// "app_id" | "app_token" => ansi256(aurora::ACCENT_PRIMARY, value),
243/// "priority" if value == "10" => ansi256(aurora::ERROR, value),
244/// "priority" if value >= "7" => ansi256(aurora::WARN, value),
245/// ```
246fn style_value(key: &str, value: &str, level: tracing::Level) -> String {
247 match key {
248 // Pink: service name (identity)
249 "service" => ansi256(aurora::SERVICE_NAME, value),
250
251 // Blue: primary action/route/resource identifiers
252 "tool" | "prompt" | "resource_uri" | "upstream" | "route" | "action" | "addr"
253 | "instance" | "target" | "capability" => ansi256(aurora::ACCENT_PRIMARY, value),
254
255 // Grey: secondary metadata
256 "subsystem" | "phase" | "transport" | "operation" => ansi256(aurora::TEXT_MUTED, value),
257
258 // HTTP status: semantic color by range
259 "status" => {
260 if let Ok(n) = value.parse::<u16>() {
261 let color = if n < 300 {
262 aurora::SUCCESS
263 } else if n < 500 {
264 aurora::WARN
265 } else {
266 aurora::ERROR
267 };
268 ansi256(color, value)
269 } else {
270 value.to_string()
271 }
272 }
273
274 // Red: error messages
275 "error" => ansi256(aurora::ERROR, value),
276
277 // Amber: `kind` field on warning/error events
278 "kind" if matches!(level, tracing::Level::WARN | tracing::Level::ERROR) => {
279 ansi256(aurora::WARN, value)
280 }
281
282 // Everything else: no color (plain)
283 _ => value.to_string(),
284 }
285}
286
287// ── Level rendering ───────────────────────────────────────────────────────────
288
289/// Write the log level to the output writer, 5 chars wide.
290///
291/// Level formatting:
292/// - `ERROR` → aurora::ERROR (muted red), bold, no leading space
293/// - ` WARN` → aurora::WARN (amber), bold, 1 leading space for alignment
294/// - ` INFO` → plain, 1 leading space
295/// - `DEBUG` → dim
296/// - `TRACE` → dim
297///
298/// Two trailing spaces separate the level from the message.
299fn write_level(writer: &mut Writer<'_>, level: tracing::Level, ansi: bool) -> stdfmt::Result {
300 let s = if ansi {
301 match level {
302 tracing::Level::ERROR => ansi256_bold(aurora::ERROR, "ERROR"),
303 tracing::Level::WARN => ansi256_bold(aurora::WARN, " WARN"),
304 tracing::Level::INFO => " INFO".to_string(),
305 tracing::Level::DEBUG => ansi_dim("DEBUG"),
306 tracing::Level::TRACE => ansi_dim("TRACE"),
307 }
308 } else {
309 match level {
310 tracing::Level::ERROR => "ERROR".to_string(),
311 tracing::Level::WARN => " WARN".to_string(),
312 tracing::Level::INFO => " INFO".to_string(),
313 tracing::Level::DEBUG => "DEBUG".to_string(),
314 tracing::Level::TRACE => "TRACE".to_string(),
315 }
316 };
317 write!(writer, "{s} ")
318}
319
320// ── AuroraFormatter ───────────────────────────────────────────────────────────
321
322/// The Aurora console log formatter.
323///
324/// Implements tracing_subscriber's [`FormatEvent`] trait so it can be used
325/// as a drop-in replacement for the default formatter:
326///
327/// ```rust,ignore
328/// use tracing_subscriber::fmt;
329/// use soma_observability::logging::formatter::AuroraFormatter;
330///
331/// fmt::layer()
332/// .with_ansi(should_colorize())
333/// .with_writer(std::io::stderr)
334/// .event_format(AuroraFormatter)
335/// ```
336///
337/// # Output anatomy
338///
339/// ```text
340/// 14:32:01 INFO starting bind=0.0.0.0:3000 auth=bearer service=soma-mcp
341/// ──────── ──── ───────── ─────────────────────────────────────────────────
342/// dim level message structured fields (priority order, then alphabetical)
343/// ```
344///
345/// # Thread safety
346///
347/// `AuroraFormatter` is `Clone + Copy` with no mutable state. It is safe to
348/// share across threads without any synchronization.
349#[derive(Clone, Copy)]
350pub struct AuroraFormatter;
351
352impl<S, N> FormatEvent<S, N> for AuroraFormatter
353where
354 S: Subscriber + for<'a> LookupSpan<'a>,
355 N: for<'a> FormatFields<'a> + 'static,
356{
357 fn format_event(
358 &self,
359 _ctx: &FmtContext<'_, S, N>,
360 mut writer: Writer<'_>,
361 event: &Event<'_>,
362 ) -> stdfmt::Result {
363 let ansi = writer.has_ansi_escapes();
364
365 // ── 1. Collect all fields ─────────────────────────────────────────────
366 let mut fields = EventFieldCollector::default();
367 event.record(&mut fields);
368
369 let level = *event.metadata().level();
370 let message = fields
371 .take("message")
372 .map(|m| sanitize_field_value(&m).into_owned())
373 .unwrap_or_default();
374
375 // ── 2. Timestamp: HH:MM:SS (local time, dim) ─────────────────────────
376 //
377 // CUSTOMIZE: We use local HH:MM:SS rather than UTC ISO 8601 because:
378 // - Development logs are easier to read in local time
379 // - The file log (JSON) records full UTC timestamps for analysis
380 // - HH:MM:SS is compact; ISO 8601 adds 15 chars of noise per line
381 let now = chrono::Local::now();
382 let ts = now.format("%H:%M:%S").to_string();
383 if ansi {
384 write!(writer, "{} ", ansi_dim(&ts))?;
385 } else {
386 write!(writer, "{ts} ")?;
387 }
388
389 // ── 3. Level ──────────────────────────────────────────────────────────
390 write_level(&mut writer, level, ansi)?;
391
392 // ── 4. Message: first token pink+bold, inline key=val tokens get dim ─
393 //
394 // CUSTOMIZE: Message token coloring convention:
395 //
396 // The first word of the message becomes the visual "action verb" of the
397 // log line. Making it pink+bold helps operators scan log streams quickly:
398 // the eye jumps to the pink word to understand what happened.
399 //
400 // Inline `key=val` tokens within the message body (e.g. written as part
401 // of the message string rather than as structured fields) get dim key+eq
402 // treatment so they don't visually conflict with structured fields.
403 if ansi && !message.is_empty() {
404 for (i, token) in message.split_whitespace().enumerate() {
405 if i > 0 {
406 write!(writer, " ")?;
407 }
408 if i == 0 {
409 // First token: pink + bold (action verb)
410 write!(writer, "{}", ansi256_bold(aurora::SERVICE_NAME, token))?;
411 } else if let Some(eq) = token.find('=') {
412 // Inline key=val: dim key, dim equals, plain value
413 write!(
414 writer,
415 "{}{}{}",
416 ansi_dim(&token[..eq]),
417 ansi_dim("="),
418 &token[eq + 1..],
419 )?;
420 } else {
421 // Normal word: plain
422 write!(writer, "{token}")?;
423 }
424 }
425 } else {
426 write!(writer, "{message}")?;
427 }
428
429 // ── 5. Structured fields: priority first, then alphabetical ───────────
430 //
431 // CUSTOMIZE: Priority field order
432 //
433 // High-priority fields appear first, left-to-right, so the most useful
434 // information is immediately visible without horizontal scrolling.
435 // Alphabetical ordering of remaining fields ensures deterministic output.
436 //
437 // Add service-specific high-priority fields to this list.
438 // Fields not in the list still appear — just after the priority ones.
439 let priority = [
440 "kind",
441 "request_id",
442 "tool",
443 "prompt",
444 "resource_uri",
445 "upstream",
446 "route",
447 "instance",
448 "addr",
449 "method",
450 "status",
451 "operation",
452 "capability",
453 "transport",
454 "response_bytes",
455 "elapsed_ms",
456 "error",
457 ];
458
459 // Closure: write one key=val pair with appropriate styling
460 let write_kv = |writer: &mut Writer<'_>, key: &str, raw: &str| -> stdfmt::Result {
461 let safe = sanitize_field_value(raw);
462 let formatted = format_field_value(&safe);
463 if ansi {
464 write!(
465 writer,
466 " {}{}{}",
467 ansi_dim(key),
468 ansi_dim("="),
469 style_value(key, &formatted, level),
470 )
471 } else {
472 write!(writer, " {key}={formatted}")
473 }
474 };
475
476 // Write priority fields in declared order (skipping missing ones)
477 for key in priority {
478 if let Some(val) = fields.take(key) {
479 if should_skip_field(key, &val) {
480 continue;
481 }
482 write_kv(&mut writer, key, &val)?;
483 }
484 }
485
486 // Write remaining fields in alphabetical order (BTreeMap guarantees this)
487 let remaining: Vec<_> = fields.fields.iter().map(|(k, v)| (*k, v.clone())).collect();
488 for (key, val) in remaining {
489 if should_skip_field(key, &val) {
490 continue;
491 }
492 write_kv(&mut writer, key, &val)?;
493 }
494
495 writeln!(writer)
496 }
497}
498
499#[cfg(test)]
500#[path = "formatter_tests.rs"]
501mod tests;