Skip to main content

backbone_observability/
logging.rs

1//! Structured logging collection
2//!
3//! Provides JSON-formatted structured logging with field extraction
4//! and log aggregation support for centralized collection.
5
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, VecDeque};
8use std::sync::{Arc, Mutex};
9use std::time::SystemTime;
10use thiserror::Error;
11
12/// Logging initialization error
13#[derive(Debug, Error)]
14pub enum LoggingError {
15    /// Failed to setup logging
16    #[error("Failed to setup logging: {0}")]
17    SetupError(String),
18}
19
20/// Structured log entry
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct LogEntry {
23    /// Timestamp (ISO 8601)
24    #[serde(rename = "@timestamp")]
25    pub timestamp: String,
26
27    /// Log level
28    pub level: String,
29
30    /// Target/module
31    pub target: String,
32
33    /// Log message
34    pub message: String,
35
36    /// Service name
37    pub service: String,
38
39    /// Environment
40    pub environment: String,
41
42    /// Hostname
43    pub hostname: String,
44
45    /// Span/trace ID if available
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub span_id: Option<String>,
48
49    /// Trace ID if available
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub trace_id: Option<String>,
52
53    /// Request ID if available
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub request_id: Option<String>,
56
57    /// User ID if available
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub user_id: Option<String>,
60
61    /// Additional fields from the log event
62    #[serde(flatten)]
63    pub fields: HashMap<String, serde_json::Value>,
64}
65
66/// Logging configuration for structured output
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct LoggingConfig {
69    /// Enable JSON structured logging
70    pub json_format: bool,
71
72    /// Include timestamp in all logs
73    pub include_timestamp: bool,
74
75    /// Include hostname in all logs
76    pub include_hostname: bool,
77
78    /// Include span context (trace_id, span_id)
79    pub include_spans: bool,
80
81    /// Field names to extract as top-level fields
82    pub extract_fields: Vec<String>,
83
84    /// Buffer size for log aggregation
85    pub buffer_size: usize,
86}
87
88impl Default for LoggingConfig {
89    fn default() -> Self {
90        Self {
91            json_format: true,
92            include_timestamp: true,
93            include_hostname: true,
94            include_spans: true,
95            extract_fields: vec![
96                "user_id".to_string(),
97                "request_id".to_string(),
98                "module".to_string(),
99                "action".to_string(),
100            ],
101            buffer_size: 1000,
102        }
103    }
104}
105
106/// Log buffer for aggregation
107pub struct LogBuffer {
108    buffer: Arc<Mutex<VecDeque<LogEntry>>>,
109    config: LoggingConfig,
110}
111
112impl LogBuffer {
113    /// Create a new log buffer with the given configuration
114    pub fn new(config: LoggingConfig) -> Self {
115        Self {
116            buffer: Arc::new(Mutex::new(VecDeque::with_capacity(config.buffer_size))),
117            config,
118        }
119    }
120
121    /// Push a log entry to the buffer
122    pub fn push(&self, entry: LogEntry) {
123        let mut buffer = self.buffer.lock().unwrap();
124        // Drop oldest if buffer is full
125        if buffer.len() >= self.config.buffer_size {
126            buffer.pop_front();
127        }
128        buffer.push_back(entry);
129    }
130
131    /// Get all buffered log entries
132    pub fn drain(&self) -> Vec<LogEntry> {
133        let mut buffer = self.buffer.lock().unwrap();
134        let entries: Vec<LogEntry> = buffer.drain(..).collect();
135        entries
136    }
137
138    /// Get current buffer length
139    pub fn len(&self) -> usize {
140        let buffer = self.buffer.lock().unwrap();
141        buffer.len()
142    }
143}
144
145/// Create a structured log entry from current context
146pub fn create_log_entry(
147    level: &str,
148    target: &str,
149    message: &str,
150    service: &str,
151    environment: &str,
152) -> LogEntry {
153    let timestamp = format_timestamp(SystemTime::now());
154
155    // Extract span context if available
156    let current_span = tracing::Span::current();
157    let span_id = if current_span.is_none() {
158        None
159    } else {
160        Some(format!("{:?}", current_span.id()))
161    };
162
163    // Extract trace_id from span (simplified)
164    let trace_id = span_id.clone(); // In real implementation, would extract from span context
165
166    LogEntry {
167        timestamp,
168        level: level.to_string(),
169        target: target.to_string(),
170        message: message.to_string(),
171        service: service.to_string(),
172        environment: environment.to_string(),
173        hostname: get_hostname(),
174        span_id,
175        trace_id,
176        request_id: None, // Would be extracted from span fields
177        user_id: None,   // Would be extracted from span fields
178        fields: HashMap::new(),
179    }
180}
181
182/// Get hostname safely
183fn get_hostname() -> String {
184    std::env::var("HOSTNAME")
185        .unwrap_or_else(|_| "localhost".to_string())
186}
187
188/// Format timestamp as ISO 8601
189fn format_timestamp(time: SystemTime) -> String {
190    use std::time::UNIX_EPOCH;
191
192    let duration = time
193        .duration_since(UNIX_EPOCH)
194        .unwrap_or_default();
195
196    let secs = duration.as_secs();
197    let millis = duration.as_millis();
198
199    // ISO 8601 format with millisecond precision
200    format!(
201        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
202        1970 + (secs / 31536000) % 10000,
203        (secs / 2592000) % 12 + 1,
204        (secs / 86400) % 31 + 1,
205        (secs / 3600) % 24,
206        (secs / 60) % 60,
207        secs % 60,
208        millis % 1000
209    )
210}
211
212/// Initialize structured logging with JSON output
213///
214/// # Arguments
215/// * `config` - Logging configuration
216/// * `service_name` - Name of the service for log labels
217/// * `environment` - Environment name (dev, staging, prod)
218///
219/// # Returns
220/// Returns a log buffer handle for accessing aggregated logs
221///
222/// # Example
223/// ```no_run
224/// use backbone_observability::logging::{init_structured_logging, LoggingConfig};
225///
226/// # #[tokio::main]
227/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
228/// let config = LoggingConfig {
229///     json_format: true,
230///     ..Default::default()
231/// };
232///
233/// let buffer = init_structured_logging(&config, "my-service", "production", "info")?;
234/// # Ok(())
235/// # }
236/// ```
237pub fn init_structured_logging(
238    config: &LoggingConfig,
239    service_name: &str,
240    environment: &str,
241    log_level: &str,
242) -> Result<LogBuffer, LoggingError> {
243    use tracing_subscriber::{Registry, EnvFilter, layer::SubscriberExt};
244    use tracing_subscriber::fmt;
245    use tracing_subscriber::Layer;
246
247    let filter = EnvFilter::try_from_default_env()
248        .unwrap_or_else(|_| EnvFilter::new(log_level));
249
250    if config.json_format {
251        // JSON formatter for production
252        let json_layer = fmt::layer()
253            .json()
254            .with_target(true)
255            .with_thread_ids(false)
256            .with_thread_names(false)
257            .with_file(false)
258            .with_line_number(false)
259            .with_span_events(fmt::format::FmtSpan::CLOSE)
260            .boxed();
261
262        let subscriber = Registry::default()
263            .with(filter)
264            .with(json_layer);
265
266        tracing::subscriber::set_global_default(subscriber)
267            .map_err(|e| LoggingError::SetupError(e.to_string()))?;
268    } else {
269        // Pretty formatter for development
270        let fmt_layer = fmt::layer()
271            .with_target(true)
272            .with_thread_ids(false)
273            .with_span_events(fmt::format::FmtSpan::CLOSE)
274            .boxed();
275
276        let subscriber = Registry::default()
277            .with(filter)
278            .with(fmt_layer);
279
280        tracing::subscriber::set_global_default(subscriber)
281            .map_err(|e| LoggingError::SetupError(e.to_string()))?;
282    }
283
284    tracing::info!(
285        service = service_name,
286        environment = environment,
287        format = if config.json_format { "json" } else { "pretty" },
288        "Structured logging initialized"
289    );
290
291    Ok(LogBuffer::new(config.clone()))
292}
293
294/// Get aggregated logs from a buffer
295///
296/// Convenience function for accessing logs from a buffer handle.
297pub fn get_aggregated_logs(buffer: &LogBuffer) -> Vec<LogEntry> {
298    buffer.drain()
299}
300
301/// Create a new request ID
302///
303/// Generates a UUID v4 for request tracking
304pub fn create_request_id() -> String {
305    use std::time::SystemTime;
306    // Simple nanosecond-based ID
307    format!("{:x}", SystemTime::now()
308        .duration_since(SystemTime::UNIX_EPOCH)
309        .unwrap_or_default()
310        .as_nanos())
311}
312
313/// Request ID extension for tracing spans
314///
315/// Helper to add request_id to spans
316pub trait WithRequestId {
317    /// Add request ID to the current span
318    fn with_request_id(self, request_id: &str) -> Self;
319}
320
321/// Helper macro to create a request-scoped span with request ID
322#[macro_export]
323macro_rules! request_span {
324    ($name:expr, $request_id:expr) => {
325        tracing::info_span!(
326            $name,
327            request_id = %$request_id,
328        )
329    };
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_log_entry_serialization() {
338        let entry = LogEntry {
339            timestamp: "2024-01-01T00:00:00.000Z".to_string(),
340            level: "INFO".to_string(),
341            target: "test".to_string(),
342            message: "Test message".to_string(),
343            service: "test-service".to_string(),
344            environment: "test".to_string(),
345            hostname: "localhost".to_string(),
346            span_id: None,
347            trace_id: None,
348            request_id: None,
349            user_id: None,
350            fields: HashMap::new(),
351        };
352
353        let json = serde_json::to_string(&entry).unwrap();
354        assert!(json.contains("Test message"));
355        assert!(json.contains("INFO"));
356        assert!(json.contains("@timestamp"));
357    }
358
359    #[test]
360    fn test_logging_config_defaults() {
361        let config = LoggingConfig::default();
362        assert!(config.json_format);
363        assert!(config.include_timestamp);
364        assert!(config.include_hostname);
365        assert!(config.include_spans);
366        assert_eq!(config.buffer_size, 1000);
367    }
368
369    #[test]
370    fn test_log_buffer() {
371        let config = LoggingConfig::default();
372        let buffer = LogBuffer::new(config);
373
374        assert_eq!(buffer.len(), 0);
375
376        let entry = LogEntry {
377            timestamp: "2024-01-01T00:00:00.000Z".to_string(),
378            level: "INFO".to_string(),
379            target: "test".to_string(),
380            message: "Test message".to_string(),
381            service: "test-service".to_string(),
382            environment: "test".to_string(),
383            hostname: "localhost".to_string(),
384            span_id: None,
385            trace_id: None,
386            request_id: None,
387            user_id: None,
388            fields: HashMap::new(),
389        };
390
391        buffer.push(entry.clone());
392        assert_eq!(buffer.len(), 1);
393
394        let entries = buffer.drain();
395        assert_eq!(entries.len(), 1);
396        assert_eq!(entries[0].message, "Test message");
397        assert_eq!(buffer.len(), 0);
398    }
399
400    #[test]
401    fn test_create_request_id() {
402        let id = create_request_id();
403        assert!(!id.is_empty());
404    }
405
406    #[test]
407    fn test_timestamp_format() {
408        let time = SystemTime::UNIX_EPOCH;
409        let timestamp = format_timestamp(time);
410        assert!(timestamp.starts_with("1970-01-01T00:00:00."));
411    }
412
413    #[test]
414    fn test_create_log_entry_with_defaults() {
415        let entry = create_log_entry("INFO", "test_target", "test message", "test-service", "dev");
416
417        assert_eq!(entry.level, "INFO");
418        assert_eq!(entry.target, "test_target");
419        assert_eq!(entry.message, "test message");
420        assert_eq!(entry.service, "test-service");
421        assert_eq!(entry.environment, "dev");
422        assert_eq!(entry.hostname, "localhost");
423        assert!(entry.fields.is_empty());
424    }
425
426    #[test]
427    fn test_log_buffer_overflow() {
428        let config = LoggingConfig {
429            buffer_size: 3,
430            ..Default::default()
431        };
432        let buffer = LogBuffer::new(config);
433
434        for i in 0..5 {
435            buffer.push(LogEntry {
436                timestamp: format!("2024-01-01T00:00:{}.000Z", i),
437                level: "INFO".to_string(),
438                target: "test".to_string(),
439                message: format!("Message {}", i),
440                service: "test-service".to_string(),
441                environment: "test".to_string(),
442                hostname: "localhost".to_string(),
443                span_id: None,
444                trace_id: None,
445                request_id: None,
446                user_id: None,
447                fields: HashMap::new(),
448            });
449        }
450
451        // Buffer should only contain last 3 entries
452        assert_eq!(buffer.len(), 3);
453
454        let entries = buffer.drain();
455        assert_eq!(entries.len(), 3);
456        assert_eq!(entries[0].message, "Message 2");
457        assert_eq!(entries[1].message, "Message 3");
458        assert_eq!(entries[2].message, "Message 4");
459    }
460}