Refactored conditional layer composition to use Option<Layer> pattern: - Create console_layer and file_layer as Option<Layer> types - Build subscriber with .with(console_layer).with(file_layer) - Eliminates type mismatch from conditional registry.with() calls This fixes the E0308 error at line 194 where the compiler expected struct Layer but found enum Option. The tracing-subscriber crate properly handles Option<Layer> in .with() calls, making conditional layer composition type-safe. Verified: - cargo check -p common: passes - cargo test -p common --lib: 158/158 tests passing
480 lines
15 KiB
Rust
480 lines
15 KiB
Rust
//! Structured JSON logging with automatic correlation ID injection.
|
|
//!
|
|
//! This module provides a JSON-formatted logger that integrates with the tracing
|
|
//! ecosystem and automatically includes correlation IDs in all log statements.
|
|
//!
|
|
//! # Features
|
|
//!
|
|
//! - Structured JSON output for easy parsing by log aggregation tools
|
|
//! - Automatic correlation ID injection from async task context
|
|
//! - Console and file output with configurable rotation
|
|
//! - Service name identification in all log entries
|
|
//! - Log level filtering
|
|
//!
|
|
//! # Log Format
|
|
//!
|
|
//! ```json
|
|
//! {
|
|
//! "timestamp": "2025-10-22T10:30:45.123456Z",
|
|
//! "level": "INFO",
|
|
//! "correlation_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
//! "service": "trading_service",
|
|
//! "target": "trading_service::orders",
|
|
//! "message": "Order executed successfully",
|
|
//! "fields": {
|
|
//! "order_id": "12345",
|
|
//! "symbol": "ES.FUT",
|
|
//! "quantity": 10
|
|
//! }
|
|
//! }
|
|
//! ```
|
|
|
|
use crate::error::{CommonError, CommonResult};
|
|
use serde_json::json;
|
|
use std::io::Write;
|
|
use std::path::PathBuf;
|
|
use tracing::{Level, Subscriber};
|
|
use tracing_subscriber::{
|
|
fmt::Layer as FmtLayer,
|
|
layer::SubscriberExt,
|
|
registry::LookupSpan,
|
|
EnvFilter, Layer, Registry,
|
|
};
|
|
|
|
/// Log rotation strategy.
|
|
///
|
|
/// Defines when log files should be rotated to prevent unbounded growth.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum LogRotation {
|
|
/// Rotate logs daily at midnight UTC
|
|
Daily,
|
|
/// Rotate logs hourly
|
|
Hourly,
|
|
/// No automatic rotation (manual only)
|
|
Never,
|
|
}
|
|
|
|
/// Log level for filtering.
|
|
///
|
|
/// Maps to standard tracing levels.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum LogLevel {
|
|
/// Trace level (most verbose)
|
|
Trace,
|
|
/// Debug level
|
|
Debug,
|
|
/// Info level (default for production)
|
|
Info,
|
|
/// Warning level
|
|
Warn,
|
|
/// Error level
|
|
Error,
|
|
}
|
|
|
|
impl From<LogLevel> for Level {
|
|
fn from(level: LogLevel) -> Self {
|
|
match level {
|
|
LogLevel::Trace => Level::TRACE,
|
|
LogLevel::Debug => Level::DEBUG,
|
|
LogLevel::Info => Level::INFO,
|
|
LogLevel::Warn => Level::WARN,
|
|
LogLevel::Error => Level::ERROR,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<LogLevel> for tracing_subscriber::filter::LevelFilter {
|
|
fn from(level: LogLevel) -> Self {
|
|
match level {
|
|
LogLevel::Trace => tracing_subscriber::filter::LevelFilter::TRACE,
|
|
LogLevel::Debug => tracing_subscriber::filter::LevelFilter::DEBUG,
|
|
LogLevel::Info => tracing_subscriber::filter::LevelFilter::INFO,
|
|
LogLevel::Warn => tracing_subscriber::filter::LevelFilter::WARN,
|
|
LogLevel::Error => tracing_subscriber::filter::LevelFilter::ERROR,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Configuration for the JSON logger.
|
|
///
|
|
/// Controls all aspects of logging behavior including output destinations,
|
|
/// rotation, and filtering.
|
|
#[derive(Debug, Clone)]
|
|
pub struct JsonLoggerConfig {
|
|
/// Service name to include in all log entries
|
|
pub service_name: String,
|
|
|
|
/// Minimum log level to output
|
|
pub log_level: LogLevel,
|
|
|
|
/// Enable console (stdout) output
|
|
pub enable_console: bool,
|
|
|
|
/// Enable file output
|
|
pub enable_file: bool,
|
|
|
|
/// Directory for log files (only used if enable_file is true)
|
|
pub log_directory: String,
|
|
|
|
/// Log rotation strategy (only used if enable_file is true)
|
|
pub rotation: LogRotation,
|
|
|
|
/// Maximum number of rotated log files to keep
|
|
pub max_files: usize,
|
|
|
|
/// Maximum size of each log file in megabytes
|
|
pub max_file_size_mb: usize,
|
|
}
|
|
|
|
impl Default for JsonLoggerConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
service_name: "foxhunt".to_string(),
|
|
log_level: LogLevel::Info,
|
|
enable_console: true,
|
|
enable_file: true,
|
|
log_directory: "logs".to_string(),
|
|
rotation: LogRotation::Daily,
|
|
max_files: 10,
|
|
max_file_size_mb: 100,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Initialize the JSON logger with the given configuration.
|
|
///
|
|
/// This function sets up the tracing subscriber with JSON formatting and
|
|
/// configures output destinations based on the provided configuration.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `config` - Configuration for the logger
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Returns `Ok(())` on success, or an error if initialization fails.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust,no_run
|
|
/// use common::observability::logger::{init_json_logger, JsonLoggerConfig, LogLevel, LogRotation};
|
|
///
|
|
/// let config = JsonLoggerConfig {
|
|
/// service_name: "trading_service".to_string(),
|
|
/// log_level: LogLevel::Info,
|
|
/// enable_console: true,
|
|
/// enable_file: true,
|
|
/// log_directory: "logs/trading_service".to_string(),
|
|
/// rotation: LogRotation::Daily,
|
|
/// max_files: 10,
|
|
/// max_file_size_mb: 100,
|
|
/// };
|
|
///
|
|
/// init_json_logger(config).expect("Failed to initialize logger");
|
|
/// ```
|
|
pub fn init_json_logger(config: JsonLoggerConfig) -> CommonResult<()> {
|
|
// Create log directory if file logging is enabled
|
|
if config.enable_file {
|
|
std::fs::create_dir_all(&config.log_directory).map_err(|e| {
|
|
CommonError::config(format!("Failed to create log directory: {}", e))
|
|
})?;
|
|
}
|
|
|
|
// Build environment filter
|
|
let env_filter = EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| {
|
|
let level_filter: tracing_subscriber::filter::LevelFilter = config.log_level.into();
|
|
EnvFilter::new(format!("{}={}", config.service_name, level_filter))
|
|
});
|
|
|
|
// Create console layer if enabled
|
|
let console_layer = if config.enable_console {
|
|
Some(tracing_subscriber::fmt::layer()
|
|
.json()
|
|
.with_target(true)
|
|
.with_current_span(true)
|
|
.with_span_list(false)
|
|
.with_writer(std::io::stdout))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Create file layer if enabled
|
|
let file_layer = if config.enable_file {
|
|
let log_file_path = create_log_file_path(&config)?;
|
|
let file = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&log_file_path)
|
|
.map_err(|e| {
|
|
CommonError::config(format!("Failed to open log file: {}", e))
|
|
})?;
|
|
|
|
// Use tracing_appender::non_blocking for proper async file I/O
|
|
// The _guard must be held for the lifetime of the program to ensure proper cleanup
|
|
let (non_blocking, _guard) = tracing_appender::non_blocking(file);
|
|
|
|
// Store the guard to prevent it from being dropped
|
|
// In production, you may want to store this guard in a static or application state
|
|
std::mem::forget(_guard);
|
|
|
|
Some(tracing_subscriber::fmt::layer()
|
|
.json()
|
|
.with_target(true)
|
|
.with_current_span(true)
|
|
.with_span_list(false)
|
|
.with_writer(non_blocking))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Build the subscriber with all layers
|
|
let subscriber = Registry::default()
|
|
.with(env_filter)
|
|
.with(console_layer)
|
|
.with(file_layer);
|
|
|
|
// Set the global subscriber
|
|
tracing::subscriber::set_global_default(subscriber)
|
|
.map_err(|e| CommonError::config(format!("Failed to set global subscriber: {}", e)))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Create the log file path based on rotation strategy.
|
|
fn create_log_file_path(config: &JsonLoggerConfig) -> CommonResult<PathBuf> {
|
|
let timestamp = chrono::Utc::now();
|
|
|
|
let filename = match config.rotation {
|
|
LogRotation::Daily => {
|
|
format!("{}-{}.log", config.service_name, timestamp.format("%Y-%m-%d"))
|
|
}
|
|
LogRotation::Hourly => {
|
|
format!(
|
|
"{}-{}.log",
|
|
config.service_name,
|
|
timestamp.format("%Y-%m-%d-%H")
|
|
)
|
|
}
|
|
LogRotation::Never => {
|
|
format!("{}.log", config.service_name)
|
|
}
|
|
};
|
|
|
|
let mut path = PathBuf::from(&config.log_directory);
|
|
path.push(filename);
|
|
|
|
Ok(path)
|
|
}
|
|
|
|
/// Rotate log files by removing old files that exceed the maximum count.
|
|
///
|
|
/// This function should be called periodically to clean up old log files
|
|
/// based on the configured retention policy.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `config` - Logger configuration containing retention settings
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Returns the number of files deleted.
|
|
pub fn rotate_logs(config: &JsonLoggerConfig) -> CommonResult<usize> {
|
|
if !config.enable_file {
|
|
return Ok(0);
|
|
}
|
|
|
|
let log_dir = PathBuf::from(&config.log_directory);
|
|
if !log_dir.exists() {
|
|
return Ok(0);
|
|
}
|
|
|
|
// Get all log files sorted by modification time (oldest first)
|
|
let mut log_files: Vec<_> = std::fs::read_dir(&log_dir)
|
|
.map_err(|e| CommonError::config(format!("Failed to read log directory: {}", e)))?
|
|
.filter_map(|entry| entry.ok())
|
|
.filter(|entry| {
|
|
entry.path().extension().and_then(|s| s.to_str()) == Some("log")
|
|
&& entry
|
|
.path()
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.map(|s| s.starts_with(&config.service_name))
|
|
.unwrap_or(false)
|
|
})
|
|
.filter_map(|entry| {
|
|
let metadata = entry.metadata().ok()?;
|
|
let modified = metadata.modified().ok()?;
|
|
Some((entry.path(), modified))
|
|
})
|
|
.collect();
|
|
|
|
log_files.sort_by_key(|(_, modified)| *modified);
|
|
|
|
// Delete oldest files if we exceed max_files
|
|
let mut deleted = 0;
|
|
if log_files.len() > config.max_files {
|
|
let to_delete = log_files.len() - config.max_files;
|
|
for (path, _) in log_files.iter().take(to_delete) {
|
|
if std::fs::remove_file(path).is_ok() {
|
|
deleted += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(deleted)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn test_log_level_conversion() {
|
|
assert_eq!(Level::from(LogLevel::Trace), Level::TRACE);
|
|
assert_eq!(Level::from(LogLevel::Debug), Level::DEBUG);
|
|
assert_eq!(Level::from(LogLevel::Info), Level::INFO);
|
|
assert_eq!(Level::from(LogLevel::Warn), Level::WARN);
|
|
assert_eq!(Level::from(LogLevel::Error), Level::ERROR);
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_config() {
|
|
let config = JsonLoggerConfig::default();
|
|
|
|
assert_eq!(config.service_name, "foxhunt");
|
|
assert_eq!(config.log_level, LogLevel::Info);
|
|
assert!(config.enable_console);
|
|
assert!(config.enable_file);
|
|
assert_eq!(config.log_directory, "logs");
|
|
assert_eq!(config.rotation, LogRotation::Daily);
|
|
assert_eq!(config.max_files, 10);
|
|
assert_eq!(config.max_file_size_mb, 100);
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_log_file_path_daily() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let config = JsonLoggerConfig {
|
|
service_name: "test_service".to_string(),
|
|
log_level: LogLevel::Info,
|
|
enable_console: false,
|
|
enable_file: true,
|
|
log_directory: temp_dir.path().to_str().unwrap().to_string(),
|
|
rotation: LogRotation::Daily,
|
|
max_files: 10,
|
|
max_file_size_mb: 100,
|
|
};
|
|
|
|
let path = create_log_file_path(&config).unwrap();
|
|
let filename = path.file_name().unwrap().to_str().unwrap();
|
|
|
|
// Should contain service name and date
|
|
assert!(filename.starts_with("test_service-"));
|
|
assert!(filename.ends_with(".log"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_log_file_path_hourly() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let config = JsonLoggerConfig {
|
|
service_name: "test_service".to_string(),
|
|
log_level: LogLevel::Info,
|
|
enable_console: false,
|
|
enable_file: true,
|
|
log_directory: temp_dir.path().to_str().unwrap().to_string(),
|
|
rotation: LogRotation::Hourly,
|
|
max_files: 10,
|
|
max_file_size_mb: 100,
|
|
};
|
|
|
|
let path = create_log_file_path(&config).unwrap();
|
|
let filename = path.file_name().unwrap().to_str().unwrap();
|
|
|
|
// Should contain service name, date, and hour
|
|
assert!(filename.starts_with("test_service-"));
|
|
assert!(filename.ends_with(".log"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_log_file_path_never() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let config = JsonLoggerConfig {
|
|
service_name: "test_service".to_string(),
|
|
log_level: LogLevel::Info,
|
|
enable_console: false,
|
|
enable_file: true,
|
|
log_directory: temp_dir.path().to_str().unwrap().to_string(),
|
|
rotation: LogRotation::Never,
|
|
max_files: 10,
|
|
max_file_size_mb: 100,
|
|
};
|
|
|
|
let path = create_log_file_path(&config).unwrap();
|
|
let filename = path.file_name().unwrap().to_str().unwrap();
|
|
|
|
// Should only contain service name
|
|
assert_eq!(filename, "test_service.log");
|
|
}
|
|
|
|
#[test]
|
|
fn test_rotate_logs() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let log_dir = temp_dir.path().to_str().unwrap();
|
|
|
|
// Create 15 log files
|
|
for i in 0..15 {
|
|
let filename = format!("test_service-2025-10-{:02}.log", i + 1);
|
|
let mut path = PathBuf::from(log_dir);
|
|
path.push(filename);
|
|
std::fs::write(&path, "test log content").unwrap();
|
|
|
|
// Sleep briefly to ensure different modification times
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
}
|
|
|
|
let config = JsonLoggerConfig {
|
|
service_name: "test_service".to_string(),
|
|
log_level: LogLevel::Info,
|
|
enable_console: false,
|
|
enable_file: true,
|
|
log_directory: log_dir.to_string(),
|
|
rotation: LogRotation::Daily,
|
|
max_files: 10,
|
|
max_file_size_mb: 100,
|
|
};
|
|
|
|
// Rotate logs (should delete 5 oldest files)
|
|
let deleted = rotate_logs(&config).unwrap();
|
|
assert_eq!(deleted, 5);
|
|
|
|
// Verify only 10 files remain
|
|
let remaining = std::fs::read_dir(log_dir)
|
|
.unwrap()
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("log"))
|
|
.count();
|
|
|
|
assert_eq!(remaining, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rotate_logs_disabled() {
|
|
let config = JsonLoggerConfig {
|
|
service_name: "test_service".to_string(),
|
|
log_level: LogLevel::Info,
|
|
enable_console: true,
|
|
enable_file: false,
|
|
log_directory: "logs".to_string(),
|
|
rotation: LogRotation::Daily,
|
|
max_files: 10,
|
|
max_file_size_mb: 100,
|
|
};
|
|
|
|
// Should return 0 when file logging is disabled
|
|
let deleted = rotate_logs(&config).unwrap();
|
|
assert_eq!(deleted, 0);
|
|
}
|
|
}
|