Skip to main content

dcp/observability/
logging.rs

1//! Structured JSON logging for DCP server.
2//!
3//! Provides structured logging compatible with log aggregation systems.
4
5use std::collections::HashMap;
6use std::fmt;
7use std::sync::{Arc, RwLock};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use crate::security::{sanitize_field_key, sanitize_field_value, sanitize_text};
11
12/// Log level
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub enum LogLevel {
15    /// Trace level - most verbose
16    Trace,
17    /// Debug level
18    Debug,
19    /// Info level
20    Info,
21    /// Warning level
22    Warn,
23    /// Error level
24    Error,
25}
26
27impl fmt::Display for LogLevel {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            LogLevel::Trace => write!(f, "TRACE"),
31            LogLevel::Debug => write!(f, "DEBUG"),
32            LogLevel::Info => write!(f, "INFO"),
33            LogLevel::Warn => write!(f, "WARN"),
34            LogLevel::Error => write!(f, "ERROR"),
35        }
36    }
37}
38
39impl LogLevel {
40    /// Parse from string
41    pub fn from_str(s: &str) -> Option<Self> {
42        match s.to_uppercase().as_str() {
43            "TRACE" => Some(LogLevel::Trace),
44            "DEBUG" => Some(LogLevel::Debug),
45            "INFO" => Some(LogLevel::Info),
46            "WARN" | "WARNING" => Some(LogLevel::Warn),
47            "ERROR" => Some(LogLevel::Error),
48            _ => None,
49        }
50    }
51}
52
53/// Log configuration
54#[derive(Debug, Clone)]
55pub struct LogConfig {
56    /// Minimum log level
57    pub level: LogLevel,
58    /// Output format (json or text)
59    pub format: LogFormat,
60    /// Include timestamps
61    pub include_timestamp: bool,
62    /// Include source location
63    pub include_location: bool,
64    /// Service name
65    pub service_name: String,
66}
67
68impl Default for LogConfig {
69    fn default() -> Self {
70        Self {
71            level: LogLevel::Info,
72            format: LogFormat::Json,
73            include_timestamp: true,
74            include_location: false,
75            service_name: "dcp-server".to_string(),
76        }
77    }
78}
79
80/// Log output format
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum LogFormat {
83    /// JSON format for log aggregation
84    Json,
85    /// Human-readable text format
86    Text,
87}
88
89/// Log field value
90#[derive(Debug, Clone)]
91pub enum LogValue {
92    String(String),
93    Int(i64),
94    Float(f64),
95    Bool(bool),
96    Null,
97}
98
99impl From<&str> for LogValue {
100    fn from(s: &str) -> Self {
101        LogValue::String(s.to_string())
102    }
103}
104
105impl From<String> for LogValue {
106    fn from(s: String) -> Self {
107        LogValue::String(s)
108    }
109}
110
111impl From<i64> for LogValue {
112    fn from(v: i64) -> Self {
113        LogValue::Int(v)
114    }
115}
116
117impl From<i32> for LogValue {
118    fn from(v: i32) -> Self {
119        LogValue::Int(v as i64)
120    }
121}
122
123impl From<u64> for LogValue {
124    fn from(v: u64) -> Self {
125        LogValue::Int(v as i64)
126    }
127}
128
129impl From<f64> for LogValue {
130    fn from(v: f64) -> Self {
131        LogValue::Float(v)
132    }
133}
134
135impl From<bool> for LogValue {
136    fn from(v: bool) -> Self {
137        LogValue::Bool(v)
138    }
139}
140
141impl fmt::Display for LogValue {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            LogValue::String(s) => write!(f, "{}", s),
145            LogValue::Int(i) => write!(f, "{}", i),
146            LogValue::Float(fl) => write!(f, "{}", fl),
147            LogValue::Bool(b) => write!(f, "{}", b),
148            LogValue::Null => write!(f, "null"),
149        }
150    }
151}
152
153/// A structured log entry
154#[derive(Debug, Clone)]
155pub struct LogEntry {
156    /// Log level
157    pub level: LogLevel,
158    /// Log message
159    pub message: String,
160    /// Timestamp (Unix milliseconds)
161    pub timestamp: u64,
162    /// Request ID for correlation
163    pub request_id: Option<String>,
164    /// Trace ID for distributed tracing
165    pub trace_id: Option<String>,
166    /// Additional fields
167    pub fields: HashMap<String, LogValue>,
168    /// Source file
169    pub file: Option<String>,
170    /// Source line
171    pub line: Option<u32>,
172}
173
174impl LogEntry {
175    /// Create a new log entry
176    pub fn new(level: LogLevel, message: impl Into<String>) -> Self {
177        let timestamp = SystemTime::now()
178            .duration_since(UNIX_EPOCH)
179            .unwrap_or_default()
180            .as_millis() as u64;
181        let message = message.into();
182
183        Self {
184            level,
185            message: sanitize_text(&message),
186            timestamp,
187            request_id: None,
188            trace_id: None,
189            fields: HashMap::new(),
190            file: None,
191            line: None,
192        }
193    }
194
195    /// Set request ID
196    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
197        let request_id = request_id.into();
198        self.request_id = Some(sanitize_text(&request_id));
199        self
200    }
201
202    /// Set trace ID
203    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
204        let trace_id = trace_id.into();
205        self.trace_id = Some(sanitize_text(&trace_id));
206        self
207    }
208
209    /// Add a field
210    pub fn with_field(mut self, key: impl Into<String>, value: impl Into<LogValue>) -> Self {
211        let key = key.into();
212        let value = sanitize_log_value(&key, value.into());
213        self.fields.insert(sanitize_field_key(&key), value);
214        self
215    }
216
217    /// Set source location
218    pub fn with_location(mut self, file: impl Into<String>, line: u32) -> Self {
219        self.file = Some(file.into());
220        self.line = Some(line);
221        self
222    }
223
224    fn sanitized_copy(&self) -> Self {
225        let mut fields = HashMap::with_capacity(self.fields.len());
226        for (key, value) in &self.fields {
227            fields.insert(
228                sanitize_field_key(key),
229                sanitize_log_value(key, value.clone()),
230            );
231        }
232
233        Self {
234            level: self.level,
235            message: sanitize_text(&self.message),
236            timestamp: self.timestamp,
237            request_id: self.request_id.as_ref().map(|id| sanitize_text(id)),
238            trace_id: self.trace_id.as_ref().map(|id| sanitize_text(id)),
239            fields,
240            file: self.file.as_ref().map(|file| sanitize_text(file)),
241            line: self.line,
242        }
243    }
244
245    /// Format as JSON
246    pub fn to_json(&self, service_name: &str) -> String {
247        let entry = self.sanitized_copy();
248        let mut json = String::from("{");
249
250        // Required fields
251        json.push_str(&format!("\"timestamp\":{},", entry.timestamp));
252        json.push_str(&format!("\"level\":\"{}\",", entry.level));
253        json.push_str(&format!(
254            "\"message\":{},",
255            escape_json_string(&entry.message)
256        ));
257        json.push_str(&format!(
258            "\"service\":{},",
259            escape_json_string(&sanitize_text(service_name))
260        ));
261
262        // Optional fields
263        if let Some(ref request_id) = entry.request_id {
264            json.push_str(&format!(
265                "\"request_id\":{},",
266                escape_json_string(request_id)
267            ));
268        }
269        if let Some(ref trace_id) = entry.trace_id {
270            json.push_str(&format!("\"trace_id\":{},", escape_json_string(trace_id)));
271        }
272        if let Some(ref file) = entry.file {
273            json.push_str(&format!("\"file\":{},", escape_json_string(file)));
274        }
275        if let Some(line) = entry.line {
276            json.push_str(&format!("\"line\":{},", line));
277        }
278
279        // Additional fields
280        for (safe_key, value) in &entry.fields {
281            let value_str = match value {
282                LogValue::String(s) => escape_json_string(s),
283                LogValue::Int(i) => i.to_string(),
284                LogValue::Float(f) => f.to_string(),
285                LogValue::Bool(b) => b.to_string(),
286                LogValue::Null => "null".to_string(),
287            };
288            json.push_str(&format!("{}:{},", escape_json_string(&safe_key), value_str));
289        }
290
291        // Remove trailing comma and close
292        if json.ends_with(',') {
293            json.pop();
294        }
295        json.push('}');
296
297        json
298    }
299
300    /// Format as text
301    pub fn to_text(&self) -> String {
302        let entry = self.sanitized_copy();
303        let mut text = format!(
304            "{} [{}] {}",
305            format_timestamp(entry.timestamp),
306            entry.level,
307            entry.message
308        );
309
310        if let Some(ref request_id) = entry.request_id {
311            text.push_str(&format!(" request_id={}", request_id));
312        }
313
314        for (key, value) in &entry.fields {
315            text.push_str(&format!(" {}={}", key, value));
316        }
317
318        text
319    }
320}
321
322/// Escape a string for JSON
323fn escape_json_string(s: &str) -> String {
324    let mut result = String::with_capacity(s.len() + 2);
325    result.push('"');
326    for c in s.chars() {
327        match c {
328            '"' => result.push_str("\\\""),
329            '\\' => result.push_str("\\\\"),
330            '\n' => result.push_str("\\n"),
331            '\r' => result.push_str("\\r"),
332            '\t' => result.push_str("\\t"),
333            c if c.is_control() => {
334                result.push_str(&format!("\\u{:04x}", c as u32));
335            }
336            c => result.push(c),
337        }
338    }
339    result.push('"');
340    result
341}
342
343fn sanitize_log_value(key: &str, value: LogValue) -> LogValue {
344    match value {
345        LogValue::String(value) => LogValue::String(sanitize_field_value(key, &value)),
346        other => {
347            if crate::security::is_sensitive_key(key) {
348                LogValue::String(crate::security::REDACTED.to_string())
349            } else {
350                other
351            }
352        }
353    }
354}
355
356/// Format timestamp as ISO 8601
357fn format_timestamp(millis: u64) -> String {
358    let secs = millis / 1000;
359    let ms = millis % 1000;
360
361    // Simple UTC timestamp formatting
362    let days_since_epoch = secs / 86400;
363    let time_of_day = secs % 86400;
364    let hours = time_of_day / 3600;
365    let minutes = (time_of_day % 3600) / 60;
366    let seconds = time_of_day % 60;
367
368    // Calculate year, month, day from days since epoch (1970-01-01)
369    let (year, month, day) = days_to_ymd(days_since_epoch as i64);
370
371    format!(
372        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
373        year, month, day, hours, minutes, seconds, ms
374    )
375}
376
377/// Convert days since epoch to year, month, day
378fn days_to_ymd(days: i64) -> (i32, u32, u32) {
379    // Simplified calculation - good enough for logging
380    let mut remaining = days;
381    let mut year = 1970i32;
382
383    loop {
384        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
385        if remaining < days_in_year {
386            break;
387        }
388        remaining -= days_in_year;
389        year += 1;
390    }
391
392    let days_in_months: [i64; 12] = if is_leap_year(year) {
393        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
394    } else {
395        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
396    };
397
398    let mut month = 1u32;
399    for days_in_month in days_in_months.iter() {
400        if remaining < *days_in_month {
401            break;
402        }
403        remaining -= days_in_month;
404        month += 1;
405    }
406
407    (year, month, (remaining + 1) as u32)
408}
409
410fn is_leap_year(year: i32) -> bool {
411    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
412}
413
414/// Structured logger
415pub struct StructuredLogger {
416    config: LogConfig,
417    /// Log sink for testing
418    sink: Arc<RwLock<Vec<LogEntry>>>,
419}
420
421impl StructuredLogger {
422    /// Create a new structured logger
423    pub fn new(config: LogConfig) -> Self {
424        Self {
425            config,
426            sink: Arc::new(RwLock::new(Vec::new())),
427        }
428    }
429
430    /// Create with default configuration
431    pub fn with_defaults() -> Self {
432        Self::new(LogConfig::default())
433    }
434
435    /// Log at trace level
436    pub fn trace(&self, message: impl Into<String>) -> LogEntryBuilder<'_> {
437        LogEntryBuilder::new(self, LogLevel::Trace, message.into())
438    }
439
440    /// Log at debug level
441    pub fn debug(&self, message: impl Into<String>) -> LogEntryBuilder<'_> {
442        LogEntryBuilder::new(self, LogLevel::Debug, message.into())
443    }
444
445    /// Log at info level
446    pub fn info(&self, message: impl Into<String>) -> LogEntryBuilder<'_> {
447        LogEntryBuilder::new(self, LogLevel::Info, message.into())
448    }
449
450    /// Log at warn level
451    pub fn warn(&self, message: impl Into<String>) -> LogEntryBuilder<'_> {
452        LogEntryBuilder::new(self, LogLevel::Warn, message.into())
453    }
454
455    /// Log at error level
456    pub fn error(&self, message: impl Into<String>) -> LogEntryBuilder<'_> {
457        LogEntryBuilder::new(self, LogLevel::Error, message.into())
458    }
459
460    /// Check if level is enabled
461    pub fn is_enabled(&self, level: LogLevel) -> bool {
462        level >= self.config.level
463    }
464
465    /// Get configuration
466    pub fn config(&self) -> &LogConfig {
467        &self.config
468    }
469
470    /// Set log level
471    pub fn set_level(&mut self, level: LogLevel) {
472        self.config.level = level;
473    }
474
475    /// Format and emit a log entry
476    pub fn emit(&self, entry: LogEntry) {
477        let entry = entry.sanitized_copy();
478        if entry.level < self.config.level {
479            return;
480        }
481
482        // Store in sink for testing
483        self.sink.write().unwrap().push(entry.clone());
484
485        // Format output
486        let output = match self.config.format {
487            LogFormat::Json => entry.to_json(&self.config.service_name),
488            LogFormat::Text => entry.to_text(),
489        };
490
491        // In a real implementation, this would write to stdout/stderr/file
492        // For now, we just store it
493        let _ = output;
494    }
495
496    /// Get logged entries (for testing)
497    pub fn entries(&self) -> Vec<LogEntry> {
498        self.sink.read().unwrap().clone()
499    }
500
501    /// Clear logged entries
502    pub fn clear(&self) {
503        self.sink.write().unwrap().clear();
504    }
505}
506
507impl Default for StructuredLogger {
508    fn default() -> Self {
509        Self::with_defaults()
510    }
511}
512
513/// Builder for log entries
514pub struct LogEntryBuilder<'a> {
515    logger: &'a StructuredLogger,
516    entry: LogEntry,
517}
518
519impl<'a> LogEntryBuilder<'a> {
520    fn new(logger: &'a StructuredLogger, level: LogLevel, message: String) -> Self {
521        Self {
522            logger,
523            entry: LogEntry::new(level, message),
524        }
525    }
526
527    /// Set request ID
528    pub fn request_id(mut self, request_id: impl Into<String>) -> Self {
529        let request_id = request_id.into();
530        self.entry.request_id = Some(sanitize_text(&request_id));
531        self
532    }
533
534    /// Set trace ID
535    pub fn trace_id(mut self, trace_id: impl Into<String>) -> Self {
536        let trace_id = trace_id.into();
537        self.entry.trace_id = Some(sanitize_text(&trace_id));
538        self
539    }
540
541    /// Add a field
542    pub fn field(mut self, key: impl Into<String>, value: impl Into<LogValue>) -> Self {
543        let key = key.into();
544        let value = sanitize_log_value(&key, value.into());
545        self.entry.fields.insert(sanitize_field_key(&key), value);
546        self
547    }
548
549    /// Emit the log entry
550    pub fn emit(self) {
551        self.logger.emit(self.entry);
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[test]
560    fn test_log_level_ordering() {
561        assert!(LogLevel::Trace < LogLevel::Debug);
562        assert!(LogLevel::Debug < LogLevel::Info);
563        assert!(LogLevel::Info < LogLevel::Warn);
564        assert!(LogLevel::Warn < LogLevel::Error);
565    }
566
567    #[test]
568    fn test_log_level_from_str() {
569        assert_eq!(LogLevel::from_str("INFO"), Some(LogLevel::Info));
570        assert_eq!(LogLevel::from_str("info"), Some(LogLevel::Info));
571        assert_eq!(LogLevel::from_str("WARNING"), Some(LogLevel::Warn));
572        assert_eq!(LogLevel::from_str("invalid"), None);
573    }
574
575    #[test]
576    fn test_log_entry_json() {
577        let entry = LogEntry::new(LogLevel::Info, "Test message")
578            .with_request_id("req-123")
579            .with_field("user_id", "user-456");
580
581        let json = entry.to_json("test-service");
582
583        assert!(json.contains("\"level\":\"INFO\""));
584        assert!(json.contains("\"message\":\"Test message\""));
585        assert!(json.contains("\"request_id\":\"req-123\""));
586        assert!(json.contains("\"user_id\":\"user-456\""));
587        assert!(json.contains("\"service\":\"test-service\""));
588        assert!(json.contains("\"timestamp\":"));
589    }
590
591    #[test]
592    fn test_log_entry_text() {
593        let entry =
594            LogEntry::new(LogLevel::Error, "Something went wrong").with_request_id("req-789");
595
596        let text = entry.to_text();
597
598        assert!(text.contains("[ERROR]"));
599        assert!(text.contains("Something went wrong"));
600        assert!(text.contains("request_id=req-789"));
601    }
602
603    #[test]
604    fn test_json_escaping() {
605        let entry = LogEntry::new(LogLevel::Info, "Message with \"quotes\" and \nnewline");
606        let json = entry.to_json("test");
607
608        assert!(json.contains("\\\"quotes\\\""));
609        assert!(json.contains("\\n"));
610    }
611
612    #[test]
613    fn test_structured_logger() {
614        let logger = StructuredLogger::with_defaults();
615
616        logger
617            .info("Test info message")
618            .request_id("req-001")
619            .field("method", "tools/call")
620            .emit();
621
622        let entries = logger.entries();
623        assert_eq!(entries.len(), 1);
624        assert_eq!(entries[0].level, LogLevel::Info);
625        assert_eq!(entries[0].message, "Test info message");
626        assert_eq!(entries[0].request_id, Some("req-001".to_string()));
627    }
628
629    #[test]
630    fn test_log_level_filtering() {
631        let mut logger = StructuredLogger::new(LogConfig {
632            level: LogLevel::Warn,
633            ..Default::default()
634        });
635
636        logger.debug("Debug message").emit();
637        logger.info("Info message").emit();
638        logger.warn("Warn message").emit();
639        logger.error("Error message").emit();
640
641        let entries = logger.entries();
642        assert_eq!(entries.len(), 2);
643        assert_eq!(entries[0].level, LogLevel::Warn);
644        assert_eq!(entries[1].level, LogLevel::Error);
645    }
646
647    #[test]
648    fn test_timestamp_formatting() {
649        // Test a known timestamp: 2024-01-15 12:30:45.123 UTC
650        // This is approximately 1705321845123 milliseconds since epoch
651        let formatted = format_timestamp(1705321845123);
652        assert!(formatted.contains("2024-01-15"));
653        assert!(formatted.contains("12:30:45.123Z"));
654    }
655
656    #[test]
657    fn test_log_value_types() {
658        let entry = LogEntry::new(LogLevel::Info, "Test")
659            .with_field("string", "value")
660            .with_field("int", 42i64)
661            .with_field("float", 3.14f64)
662            .with_field("bool", true);
663
664        assert_eq!(entry.fields.len(), 4);
665    }
666}