Skip to main content

subcog/observability/
logging.rs

1//! Structured logging.
2
3use std::fmt;
4use std::path::PathBuf;
5
6use serde_json::{Map, Number, Value};
7use tracing::field::{Field, Visit};
8use tracing_subscriber::EnvFilter;
9use tracing_subscriber::field::RecordFields;
10use tracing_subscriber::fmt::FormattedFields;
11use tracing_subscriber::fmt::format::{FormatFields, Writer};
12
13use crate::config::{LoggingSettings, expand_config_path};
14
15/// Logging output format.
16#[derive(Debug, Clone, Copy)]
17pub enum LogFormat {
18    /// JSON structured logs.
19    Json,
20    /// Human-friendly logs for local debugging.
21    Pretty,
22}
23
24/// Logging configuration.
25#[derive(Debug, Clone)]
26pub struct LoggingConfig {
27    /// Log format.
28    pub format: LogFormat,
29    /// Log filter (e.g., `subcog=info`).
30    pub filter: EnvFilter,
31    /// Optional log file path (logs to stderr if None).
32    pub file: Option<PathBuf>,
33}
34
35impl LoggingConfig {
36    /// Builds logging configuration from environment variables.
37    #[must_use]
38    pub fn from_env(verbose: bool) -> Self {
39        Self::from_settings(None, verbose)
40    }
41
42    /// Builds logging configuration from config settings with env overrides.
43    #[must_use]
44    pub fn from_settings(settings: Option<&LoggingSettings>, verbose: bool) -> Self {
45        let format = settings
46            .and_then(|config| config.format.as_deref())
47            .and_then(parse_log_format)
48            .unwrap_or(LogFormat::Json);
49
50        let filter = settings
51            .and_then(|config| config.filter.as_ref())
52            .map(|filter| EnvFilter::new(filter.clone()))
53            .or_else(|| {
54                settings
55                    .and_then(|config| config.level.as_ref())
56                    .map(|level| EnvFilter::new(normalize_level(level)))
57            })
58            .unwrap_or_else(|| default_filter(verbose));
59
60        let file = settings
61            .and_then(|config| config.file.as_ref())
62            .map(|f| PathBuf::from(expand_config_path(f)));
63
64        Self {
65            format: log_format_from_env_override(format),
66            filter: filter_from_env_override(filter),
67            file: log_file_from_env_override(file),
68        }
69    }
70}
71
72/// Logger for structured logging.
73pub struct Logger;
74
75impl Logger {
76    /// Creates a new logger.
77    #[must_use]
78    pub const fn new() -> Self {
79        Self
80    }
81}
82
83impl Default for Logger {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89/// Redactor for sensitive log fields.
90#[derive(Debug, Clone)]
91pub struct LogRedactor {
92    sensitive_fields: Vec<&'static str>,
93    max_len: usize,
94}
95
96impl LogRedactor {
97    /// Creates a redactor with default rules.
98    #[must_use]
99    pub fn new() -> Self {
100        Self {
101            sensitive_fields: vec![
102                "content",
103                "prompt",
104                "token",
105                "secret",
106                "password",
107                "authorization",
108                "api_key",
109                "api-key",
110                "jwt",
111            ],
112            max_len: 120,
113        }
114    }
115
116    /// Redacts a value based on field name.
117    #[must_use]
118    pub fn redact_field(&self, field: &str, value: &str) -> String {
119        let field_lower = field.to_lowercase();
120        if self
121            .sensitive_fields
122            .iter()
123            .any(|needle| field_lower.contains(needle))
124        {
125            return "[REDACTED]".to_string();
126        }
127
128        if value.chars().count() > self.max_len {
129            let truncated: String = value.chars().take(self.max_len).collect();
130            return format!("{truncated}...(truncated)");
131        }
132
133        value.to_string()
134    }
135}
136
137impl Default for LogRedactor {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143/// JSON field formatter with redaction support.
144#[derive(Debug, Clone, Default)]
145pub struct RedactingJsonFields {
146    redactor: LogRedactor,
147}
148
149impl<'writer> FormatFields<'writer> for RedactingJsonFields {
150    fn format_fields<R: RecordFields>(
151        &self,
152        mut writer: Writer<'writer>,
153        fields: R,
154    ) -> fmt::Result {
155        let mut visitor = RedactingVisitor::new(&self.redactor);
156        fields.record(&mut visitor);
157        let json = serde_json::to_string(&visitor.values).map_err(|_| fmt::Error)?;
158        writer.write_str(&json)
159    }
160
161    fn add_fields(
162        &self,
163        current: &'writer mut FormattedFields<Self>,
164        fields: &tracing::span::Record<'_>,
165    ) -> fmt::Result {
166        if current.is_empty() {
167            let mut writer = current.as_writer();
168            let mut visitor = RedactingVisitor::new(&self.redactor);
169            fields.record(&mut visitor);
170            let json = serde_json::to_string(&visitor.values).map_err(|_| fmt::Error)?;
171            writer.write_str(&json)?;
172            return Ok(());
173        }
174
175        let map: Map<String, Value> = serde_json::from_str(current).map_err(|_| fmt::Error)?;
176        let mut visitor = RedactingVisitor::new(&self.redactor);
177        visitor.values = map;
178        fields.record(&mut visitor);
179        let json = serde_json::to_string(&visitor.values).map_err(|_| fmt::Error)?;
180        current.fields = json;
181        Ok(())
182    }
183}
184
185struct RedactingVisitor<'a> {
186    values: Map<String, Value>,
187    redactor: &'a LogRedactor,
188}
189
190impl<'a> RedactingVisitor<'a> {
191    fn new(redactor: &'a LogRedactor) -> Self {
192        Self {
193            values: Map::new(),
194            redactor,
195        }
196    }
197
198    fn insert_str(&mut self, field: &Field, value: &str) {
199        let redacted = self.redactor.redact_field(field.name(), value);
200        self.values
201            .insert(field.name().to_string(), Value::String(redacted));
202    }
203
204    fn insert_number(&mut self, field: &Field, number: Number) {
205        self.values
206            .insert(field.name().to_string(), Value::Number(number));
207    }
208}
209
210impl Visit for RedactingVisitor<'_> {
211    fn record_i64(&mut self, field: &Field, value: i64) {
212        self.insert_number(field, Number::from(value));
213    }
214
215    fn record_u64(&mut self, field: &Field, value: u64) {
216        self.insert_number(field, Number::from(value));
217    }
218
219    fn record_bool(&mut self, field: &Field, value: bool) {
220        self.values
221            .insert(field.name().to_string(), Value::Bool(value));
222    }
223
224    fn record_f64(&mut self, field: &Field, value: f64) {
225        let number = Number::from_f64(value).unwrap_or_else(|| Number::from(0_u64));
226        self.insert_number(field, number);
227    }
228
229    fn record_str(&mut self, field: &Field, value: &str) {
230        self.insert_str(field, value);
231    }
232
233    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
234        let formatted = format!("{value:?}");
235        self.insert_str(field, &formatted);
236    }
237
238    fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
239        self.insert_str(field, &value.to_string());
240    }
241}
242
243fn parse_log_format(value: &str) -> Option<LogFormat> {
244    match value.to_lowercase().as_str() {
245        "pretty" => Some(LogFormat::Pretty),
246        "json" => Some(LogFormat::Json),
247        _ => None,
248    }
249}
250
251fn log_format_from_env_override(default: LogFormat) -> LogFormat {
252    std::env::var("SUBCOG_LOG_FORMAT")
253        .map_or(default, |value| parse_log_format(&value).unwrap_or(default))
254}
255
256fn filter_from_env_override(default_filter: EnvFilter) -> EnvFilter {
257    if let Ok(filter) = std::env::var("SUBCOG_LOG_FILTER") {
258        return EnvFilter::new(filter);
259    }
260
261    if let Ok(level) = std::env::var("SUBCOG_LOG_LEVEL") {
262        return EnvFilter::new(normalize_level(&level));
263    }
264
265    if let Ok(filter) = EnvFilter::try_from_default_env() {
266        return filter;
267    }
268
269    default_filter
270}
271
272fn normalize_level(level: &str) -> String {
273    let normalized = level.trim().to_lowercase();
274    if normalized.contains('=') || normalized.contains(',') {
275        normalized
276    } else {
277        format!("subcog={normalized}")
278    }
279}
280
281fn default_filter(verbose: bool) -> EnvFilter {
282    let default_level = if verbose {
283        "subcog=debug"
284    } else {
285        "subcog=info"
286    };
287    EnvFilter::new(default_level)
288}
289
290fn log_file_from_env_override(default: Option<PathBuf>) -> Option<PathBuf> {
291    std::env::var("SUBCOG_LOG_FILE")
292        .ok()
293        .map(|value| value.trim().to_string())
294        .filter(|value| !value.is_empty())
295        .map(|value| PathBuf::from(expand_config_path(&value)))
296        .or(default)
297}
298
299#[cfg(test)]
300mod tests {
301    use super::RedactingJsonFields;
302    use std::sync::{Arc, Mutex};
303    use tracing_subscriber::prelude::*;
304
305    #[derive(Clone)]
306    struct SharedWriter(Arc<Mutex<Vec<u8>>>);
307
308    impl std::io::Write for SharedWriter {
309        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
310            self.0.lock().unwrap().extend_from_slice(buf);
311            Ok(buf.len())
312        }
313
314        fn flush(&mut self) -> std::io::Result<()> {
315            Ok(())
316        }
317    }
318
319    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedWriter {
320        type Writer = Self;
321
322        fn make_writer(&'a self) -> Self::Writer {
323            self.clone()
324        }
325    }
326
327    #[test]
328    fn test_json_log_format_includes_required_fields() {
329        let buffer = Arc::new(Mutex::new(Vec::new()));
330        let writer = SharedWriter(buffer.clone());
331        let json_format = tracing_subscriber::fmt::format()
332            .json()
333            .with_current_span(true)
334            .with_span_list(true);
335        let subscriber = tracing_subscriber::registry().with(
336            tracing_subscriber::fmt::layer()
337                .event_format(json_format)
338                .fmt_fields(RedactingJsonFields::default())
339                .with_writer(writer),
340        );
341
342        let _guard = tracing::subscriber::set_default(subscriber);
343        let span = tracing::info_span!(
344            "subcog.test",
345            request_id = "req-test",
346            component = "test",
347            operation = "unit"
348        );
349        let _span_guard = span.enter();
350
351        tracing::info!(
352            event = "test_event",
353            memory_id = "mem-1",
354            domain = "project",
355            "hello"
356        );
357
358        let output = String::from_utf8(buffer.lock().unwrap().clone()).unwrap();
359        let line = output.lines().next().expect("log line");
360        let value: serde_json::Value = serde_json::from_str(line).unwrap();
361
362        assert!(value.get("level").is_some(), "level missing");
363        let fields = value
364            .get("fields")
365            .and_then(|v| v.as_object())
366            .expect("fields missing");
367        assert_eq!(
368            fields.get("event").and_then(|v| v.as_str()),
369            Some("test_event")
370        );
371        assert!(fields.get("message").is_some(), "message missing");
372
373        let span_request_id = value
374            .get("span")
375            .and_then(|span| span.as_object())
376            .and_then(|span| {
377                span.get("request_id").and_then(|v| v.as_str()).or_else(|| {
378                    span.get("fields")
379                        .and_then(|f| f.as_object())
380                        .and_then(|f| f.get("request_id"))
381                        .and_then(|v| v.as_str())
382                })
383            })
384            .or_else(|| {
385                value
386                    .get("spans")
387                    .and_then(|spans| spans.as_array())
388                    .and_then(|spans| spans.last())
389                    .and_then(|span| span.get("request_id"))
390                    .and_then(|v| v.as_str())
391            });
392        assert_eq!(span_request_id, Some("req-test"));
393    }
394}