Skip to main content

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;