mockforge_observability/
logging.rs

1//! Structured logging initialization with JSON support and OpenTelemetry integration
2//!
3//! This module provides comprehensive logging capabilities including:
4//! - Structured JSON logging
5//! - File output with rotation
6//! - OpenTelemetry tracing integration
7//! - Configurable log levels
8
9use std::path::PathBuf;
10use tracing::Level;
11use tracing_subscriber::{
12    fmt::{self, format::FmtSpan},
13    layer::SubscriberExt,
14    util::SubscriberInitExt,
15    EnvFilter, Layer,
16};
17
18/// Logging configuration
19#[derive(Debug, Clone)]
20pub struct LoggingConfig {
21    /// Log level (trace, debug, info, warn, error)
22    pub level: String,
23    /// Enable JSON format for structured logging
24    pub json_format: bool,
25    /// Optional file path for log output
26    pub file_path: Option<PathBuf>,
27    /// Maximum log file size in MB (for rotation)
28    pub max_file_size_mb: u64,
29    /// Maximum number of log files to keep
30    pub max_files: u32,
31}
32
33impl Default for LoggingConfig {
34    fn default() -> Self {
35        Self {
36            level: "info".to_string(),
37            json_format: false,
38            file_path: None,
39            max_file_size_mb: 10,
40            max_files: 5,
41        }
42    }
43}
44
45/// Initialize logging with the given configuration
46///
47/// This function sets up the tracing subscriber with the appropriate layers based on configuration:
48/// - Console output (plain text or JSON)
49/// - Optional file output with rotation
50/// - Optional OpenTelemetry tracing layer
51///
52/// # Arguments
53/// * `config` - Logging configuration
54///
55/// # Example
56/// ```no_run
57/// use mockforge_observability::logging::{LoggingConfig, init_logging};
58///
59/// let config = LoggingConfig {
60///     level: "info".to_string(),
61///     json_format: true,
62///     file_path: Some("logs/mockforge.log".into()),
63///     max_file_size_mb: 10,
64///     max_files: 5,
65/// };
66///
67/// init_logging(config).expect("Failed to initialize logging");
68/// ```
69pub fn init_logging(config: LoggingConfig) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
70    // Parse log level
71    let _log_level = parse_log_level(&config.level)?;
72
73    // Create environment filter
74    let env_filter = EnvFilter::try_from_default_env()
75        .or_else(|_| EnvFilter::try_new(&config.level))
76        .unwrap_or_else(|_| EnvFilter::new("info"));
77
78    // Build the subscriber with layers using Registry
79    let registry = tracing_subscriber::registry().with(env_filter);
80
81    // Add console layer (JSON or plain text)
82    let console_layer = if config.json_format {
83        // JSON formatted console output
84        fmt::layer()
85            .json()
86            .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
87            .with_current_span(true)
88            .with_thread_ids(true)
89            .with_thread_names(true)
90            .with_target(true)
91            .with_file(true)
92            .with_line_number(true)
93            .boxed()
94    } else {
95        // Plain text console output
96        fmt::layer()
97            .with_span_events(FmtSpan::CLOSE)
98            .with_target(true)
99            .with_thread_ids(false)
100            .with_file(false)
101            .with_line_number(false)
102            .boxed()
103    };
104
105    // Add file layer if configured
106    if let Some(ref file_path) = config.file_path {
107        // Extract directory and file name
108        let directory = file_path.parent().ok_or("Invalid file path")?;
109        let file_name = file_path
110            .file_name()
111            .ok_or("Invalid file name")?
112            .to_str()
113            .ok_or("Invalid file name encoding")?;
114
115        // Create rolling file appender with daily rotation
116        let file_appender = tracing_appender::rolling::daily(directory, file_name);
117        let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
118
119        // Store the guard to prevent it from being dropped
120        // Note: In a production application, you would want to keep this guard alive
121        // for the lifetime of your application. Here we use Box::leak to ensure it's never dropped.
122        Box::leak(Box::new(_guard));
123
124        let file_layer = if config.json_format {
125            fmt::layer()
126                .json()
127                .with_writer(non_blocking)
128                .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
129                .with_current_span(true)
130                .with_thread_ids(true)
131                .with_thread_names(true)
132                .with_target(true)
133                .with_file(true)
134                .with_line_number(true)
135                .boxed()
136        } else {
137            fmt::layer()
138                .with_writer(non_blocking)
139                .with_span_events(FmtSpan::CLOSE)
140                .with_target(true)
141                .with_thread_ids(false)
142                .with_file(false)
143                .with_line_number(false)
144                .boxed()
145        };
146
147        registry.with(console_layer).with(file_layer).init();
148
149        tracing::info!(
150            "Logging initialized: level={}, format={}, file={}",
151            config.level,
152            if config.json_format { "json" } else { "text" },
153            file_path.display()
154        );
155    } else {
156        registry.with(console_layer).init();
157
158        tracing::info!(
159            "Logging initialized: level={}, format={}",
160            config.level,
161            if config.json_format { "json" } else { "text" }
162        );
163    }
164
165    Ok(())
166}
167
168/// Initialize logging with OpenTelemetry tracing layer
169///
170/// This function sets up logging with an additional OpenTelemetry layer for distributed tracing.
171///
172/// # Arguments
173/// * `config` - Logging configuration
174/// * `otel_layer` - OpenTelemetry tracing layer
175///
176/// # Example
177/// ```no_run
178/// use mockforge_observability::logging::{LoggingConfig, init_logging_with_otel};
179/// use tracing_subscriber::layer::SubscriberExt;
180///
181/// // Initialize OpenTelemetry tracer first
182/// // let tracer = ...;
183/// // let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
184///
185/// // Then initialize logging with the layer
186/// // init_logging_with_otel(config, otel_layer).expect("Failed to initialize logging");
187/// ```
188pub fn init_logging_with_otel<L, S>(
189    _config: LoggingConfig,
190    _otel_layer: L,
191) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
192where
193    L: tracing_subscriber::Layer<S> + Send + Sync + 'static,
194    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
195{
196    // Note: This function is provided for advanced users who want to integrate OpenTelemetry.
197    // Due to trait bound complexity, we recommend using init_logging() for most cases.
198
199    tracing::warn!(
200        "init_logging_with_otel requires manual subscriber setup. Use init_logging() for simpler cases."
201    );
202
203    // Return early - users should set up their own subscriber when using OpenTelemetry
204    Err("OpenTelemetry integration requires manual subscriber setup. Please use tracing_subscriber directly.".into())
205}
206
207/// Parse log level from string
208fn parse_log_level(level: &str) -> Result<Level, Box<dyn std::error::Error + Send + Sync>> {
209    match level.to_lowercase().as_str() {
210        "trace" => Ok(Level::TRACE),
211        "debug" => Ok(Level::DEBUG),
212        "info" => Ok(Level::INFO),
213        "warn" => Ok(Level::WARN),
214        "error" => Ok(Level::ERROR),
215        _ => Err(format!("Invalid log level: {}", level).into()),
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_default_config() {
225        let config = LoggingConfig::default();
226        assert_eq!(config.level, "info");
227        assert!(!config.json_format);
228        assert!(config.file_path.is_none());
229        assert_eq!(config.max_file_size_mb, 10);
230        assert_eq!(config.max_files, 5);
231    }
232
233    #[test]
234    fn test_parse_log_level() {
235        assert!(parse_log_level("trace").is_ok());
236        assert!(parse_log_level("debug").is_ok());
237        assert!(parse_log_level("info").is_ok());
238        assert!(parse_log_level("warn").is_ok());
239        assert!(parse_log_level("error").is_ok());
240        assert!(parse_log_level("TRACE").is_ok());
241        assert!(parse_log_level("INFO").is_ok());
242        assert!(parse_log_level("invalid").is_err());
243    }
244
245    #[test]
246    fn test_logging_config_with_json() {
247        let config = LoggingConfig {
248            level: "debug".to_string(),
249            json_format: true,
250            file_path: Some(PathBuf::from("/tmp/test.log")),
251            max_file_size_mb: 20,
252            max_files: 10,
253        };
254
255        assert_eq!(config.level, "debug");
256        assert!(config.json_format);
257        assert!(config.file_path.is_some());
258    }
259
260    #[test]
261    fn test_init_logging_with_file() {
262        use std::fs;
263        use std::time::SystemTime;
264
265        // Create a unique test log file path
266        let timestamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
267        let test_dir = PathBuf::from("/tmp/mockforge-test-logs");
268        fs::create_dir_all(&test_dir).ok();
269        let log_file = test_dir.join(format!("test-{}.log", timestamp));
270
271        let config = LoggingConfig {
272            level: "info".to_string(),
273            json_format: false,
274            file_path: Some(log_file.clone()),
275            max_file_size_mb: 10,
276            max_files: 5,
277        };
278
279        // This test verifies that init_logging completes without error
280        // when file logging is configured
281        let result = init_logging(config);
282        assert!(result.is_ok(), "Failed to initialize logging with file: {:?}", result);
283    }
284}