use std::path::PathBuf;
use tracing::Level;
use tracing_subscriber::{
fmt::{self, format::FmtSpan},
layer::SubscriberExt,
util::SubscriberInitExt,
EnvFilter, Layer,
};
#[derive(Debug, Clone)]
pub struct LoggingConfig {
pub level: String,
pub json_format: bool,
pub file_path: Option<PathBuf>,
pub max_file_size_mb: u64,
pub max_files: u32,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: "info".to_string(),
json_format: false,
file_path: None,
max_file_size_mb: 10,
max_files: 5,
}
}
}
pub fn init_logging(config: LoggingConfig) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _log_level = parse_log_level(&config.level)?;
let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(&config.level))
.unwrap_or_else(|_| EnvFilter::new("info"));
let registry = tracing_subscriber::registry().with(env_filter);
let console_layer = if config.json_format {
fmt::layer()
.json()
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
.with_current_span(true)
.with_thread_ids(true)
.with_thread_names(true)
.with_target(true)
.with_file(true)
.with_line_number(true)
.boxed()
} else {
fmt::layer()
.with_span_events(FmtSpan::CLOSE)
.with_target(true)
.with_thread_ids(false)
.with_file(false)
.with_line_number(false)
.boxed()
};
if let Some(ref file_path) = config.file_path {
let directory = file_path.parent().ok_or("Invalid file path")?;
let file_name = file_path
.file_name()
.ok_or("Invalid file name")?
.to_str()
.ok_or("Invalid file name encoding")?;
let file_appender = tracing_appender::rolling::daily(directory, file_name);
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
Box::leak(Box::new(_guard));
let file_layer = if config.json_format {
fmt::layer()
.json()
.with_writer(non_blocking)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
.with_current_span(true)
.with_thread_ids(true)
.with_thread_names(true)
.with_target(true)
.with_file(true)
.with_line_number(true)
.boxed()
} else {
fmt::layer()
.with_writer(non_blocking)
.with_span_events(FmtSpan::CLOSE)
.with_target(true)
.with_thread_ids(false)
.with_file(false)
.with_line_number(false)
.boxed()
};
registry.with(console_layer).with(file_layer).init();
tracing::info!(
"Logging initialized: level={}, format={}, file={}",
config.level,
if config.json_format { "json" } else { "text" },
file_path.display()
);
} else {
registry.with(console_layer).init();
tracing::info!(
"Logging initialized: level={}, format={}",
config.level,
if config.json_format { "json" } else { "text" }
);
}
Ok(())
}
pub fn init_logging_with_otel<L, S>(
_config: LoggingConfig,
_otel_layer: L,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
L: Layer<S> + Send + Sync + 'static,
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
{
tracing::warn!(
"init_logging_with_otel requires manual subscriber setup. Use init_logging() for simpler cases."
);
Err("OpenTelemetry integration requires manual subscriber setup. Please use tracing_subscriber directly.".into())
}
fn parse_log_level(level: &str) -> Result<Level, Box<dyn std::error::Error + Send + Sync>> {
match level.to_lowercase().as_str() {
"trace" => Ok(Level::TRACE),
"debug" => Ok(Level::DEBUG),
"info" => Ok(Level::INFO),
"warn" => Ok(Level::WARN),
"error" => Ok(Level::ERROR),
_ => Err(format!("Invalid log level: {}", level).into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = LoggingConfig::default();
assert_eq!(config.level, "info");
assert!(!config.json_format);
assert!(config.file_path.is_none());
assert_eq!(config.max_file_size_mb, 10);
assert_eq!(config.max_files, 5);
}
#[test]
fn test_parse_log_level() {
assert!(parse_log_level("trace").is_ok());
assert!(parse_log_level("debug").is_ok());
assert!(parse_log_level("info").is_ok());
assert!(parse_log_level("warn").is_ok());
assert!(parse_log_level("error").is_ok());
assert!(parse_log_level("TRACE").is_ok());
assert!(parse_log_level("INFO").is_ok());
assert!(parse_log_level("invalid").is_err());
}
#[test]
fn test_logging_config_with_json() {
let config = LoggingConfig {
level: "debug".to_string(),
json_format: true,
file_path: Some(PathBuf::from("/tmp/test.log")),
max_file_size_mb: 20,
max_files: 10,
};
assert_eq!(config.level, "debug");
assert!(config.json_format);
assert!(config.file_path.is_some());
}
#[test]
fn test_init_logging_with_file() {
use std::fs;
use std::time::SystemTime;
let timestamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
let test_dir = PathBuf::from("/tmp/mockforge-test-logs");
fs::create_dir_all(&test_dir).ok();
let log_file = test_dir.join(format!("test-{}.log", timestamp));
let config = LoggingConfig {
level: "info".to_string(),
json_format: false,
file_path: Some(log_file.clone()),
max_file_size_mb: 10,
max_files: 5,
};
let result = init_logging(config);
assert!(result.is_ok(), "Failed to initialize logging with file: {:?}", result);
}
}