Skip to main content

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        // The sentry-tracing layer forwards `tracing::error!` events to Sentry
148        // when the `sentry` feature is on. It's a no-op at runtime when
149        // SENTRY_DSN was unset at init_sentry() time.
150        let composed = registry.with(console_layer).with(file_layer);
151        #[cfg(feature = "sentry")]
152        let composed = composed.with(sentry_tracing::layer());
153        composed.init();
154
155        tracing::info!(
156            "Logging initialized: level={}, format={}, file={}",
157            config.level,
158            if config.json_format { "json" } else { "text" },
159            file_path.display()
160        );
161    } else {
162        let composed = registry.with(console_layer);
163        #[cfg(feature = "sentry")]
164        let composed = composed.with(sentry_tracing::layer());
165        composed.init();
166
167        tracing::info!(
168            "Logging initialized: level={}, format={}",
169            config.level,
170            if config.json_format { "json" } else { "text" }
171        );
172    }
173
174    Ok(())
175}
176
177/// Initialize logging with OpenTelemetry tracing layer
178///
179/// This function sets up logging with an additional OpenTelemetry layer for distributed tracing.
180///
181/// # Arguments
182/// * `config` - Logging configuration
183/// * `otel_layer` - OpenTelemetry tracing layer
184///
185/// # Example
186/// ```no_run
187/// use mockforge_observability::logging::{LoggingConfig, init_logging_with_otel};
188/// use tracing_subscriber::layer::SubscriberExt;
189///
190/// // Initialize OpenTelemetry tracer first
191/// // let tracer = ...;
192/// // let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
193///
194/// // Then initialize logging with the layer
195/// // init_logging_with_otel(config, otel_layer).expect("Failed to initialize logging");
196/// ```
197pub fn init_logging_with_otel<L, S>(
198    _config: LoggingConfig,
199    _otel_layer: L,
200) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
201where
202    L: Layer<S> + Send + Sync + 'static,
203    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
204{
205    // Note: This function is provided for advanced users who want to integrate OpenTelemetry.
206    // Due to trait bound complexity, we recommend using init_logging() for most cases.
207
208    tracing::warn!(
209        "init_logging_with_otel requires manual subscriber setup. Use init_logging() for simpler cases."
210    );
211
212    // Return early - users should set up their own subscriber when using OpenTelemetry
213    Err("OpenTelemetry integration requires manual subscriber setup. Please use tracing_subscriber directly.".into())
214}
215
216/// Parse log level from string
217fn parse_log_level(level: &str) -> Result<Level, Box<dyn std::error::Error + Send + Sync>> {
218    match level.to_lowercase().as_str() {
219        "trace" => Ok(Level::TRACE),
220        "debug" => Ok(Level::DEBUG),
221        "info" => Ok(Level::INFO),
222        "warn" => Ok(Level::WARN),
223        "error" => Ok(Level::ERROR),
224        _ => Err(format!("Invalid log level: {}", level).into()),
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_default_config() {
234        let config = LoggingConfig::default();
235        assert_eq!(config.level, "info");
236        assert!(!config.json_format);
237        assert!(config.file_path.is_none());
238        assert_eq!(config.max_file_size_mb, 10);
239        assert_eq!(config.max_files, 5);
240    }
241
242    #[test]
243    fn test_parse_log_level() {
244        assert!(parse_log_level("trace").is_ok());
245        assert!(parse_log_level("debug").is_ok());
246        assert!(parse_log_level("info").is_ok());
247        assert!(parse_log_level("warn").is_ok());
248        assert!(parse_log_level("error").is_ok());
249        assert!(parse_log_level("TRACE").is_ok());
250        assert!(parse_log_level("INFO").is_ok());
251        assert!(parse_log_level("invalid").is_err());
252    }
253
254    #[test]
255    fn test_logging_config_with_json() {
256        let config = LoggingConfig {
257            level: "debug".to_string(),
258            json_format: true,
259            file_path: Some(PathBuf::from("/tmp/test.log")),
260            max_file_size_mb: 20,
261            max_files: 10,
262        };
263
264        assert_eq!(config.level, "debug");
265        assert!(config.json_format);
266        assert!(config.file_path.is_some());
267    }
268
269    #[test]
270    fn test_init_logging_with_file() {
271        use std::fs;
272        use std::time::SystemTime;
273
274        // Create a unique test log file path
275        let timestamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
276        let test_dir = PathBuf::from("/tmp/mockforge-test-logs");
277        fs::create_dir_all(&test_dir).ok();
278        let log_file = test_dir.join(format!("test-{}.log", timestamp));
279
280        let config = LoggingConfig {
281            level: "info".to_string(),
282            json_format: false,
283            file_path: Some(log_file.clone()),
284            max_file_size_mb: 10,
285            max_files: 5,
286        };
287
288        // This test verifies that init_logging completes without error
289        // when file logging is configured
290        let result = init_logging(config);
291        assert!(result.is_ok(), "Failed to initialize logging with file: {:?}", result);
292    }
293}