mockforge_observability/
logging.rs1use 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#[derive(Debug, Clone)]
20pub struct LoggingConfig {
21 pub level: String,
23 pub json_format: bool,
25 pub file_path: Option<PathBuf>,
27 pub max_file_size_mb: u64,
29 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
45pub fn init_logging(config: LoggingConfig) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
70 let _log_level = parse_log_level(&config.level)?;
72
73 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 let registry = tracing_subscriber::registry().with(env_filter);
80
81 let console_layer = if config.json_format {
83 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 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 if let Some(ref file_path) = config.file_path {
107 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 let file_appender = tracing_appender::rolling::daily(directory, file_name);
117 let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
118
119 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
168pub 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 tracing::warn!(
200 "init_logging_with_otel requires manual subscriber setup. Use init_logging() for simpler cases."
201 );
202
203 Err("OpenTelemetry integration requires manual subscriber setup. Please use tracing_subscriber directly.".into())
205}
206
207fn 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 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 let result = init_logging(config);
282 assert!(result.is_ok(), "Failed to initialize logging with file: {:?}", result);
283 }
284}