1use 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#[derive(Debug, Clone, Copy)]
17pub enum LogFormat {
18 Json,
20 Pretty,
22}
23
24#[derive(Debug, Clone)]
26pub struct LoggingConfig {
27 pub format: LogFormat,
29 pub filter: EnvFilter,
31 pub file: Option<PathBuf>,
33}
34
35impl LoggingConfig {
36 #[must_use]
38 pub fn from_env(verbose: bool) -> Self {
39 Self::from_settings(None, verbose)
40 }
41
42 #[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
72pub struct Logger;
74
75impl Logger {
76 #[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#[derive(Debug, Clone)]
91pub struct LogRedactor {
92 sensitive_fields: Vec<&'static str>,
93 max_len: usize,
94}
95
96impl LogRedactor {
97 #[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 #[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#[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}