//! Logging configuration for telemt. //! //! Supports multiple log destinations: //! - stderr (default, works with systemd journald) //! - syslog (Unix only, for traditional init systems) //! - file (with optional rotation) // Infrastructure module used via CLI flags. #![allow(dead_code)] use std::path::Path; use crate::config::{LogRotation, LoggingConfig, LoggingDestination}; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{EnvFilter, fmt, reload}; // Submodules: // - file: bounded file appender for size and retention controls. mod file; #[cfg(test)] mod tests; /// File logging and retention options resolved from config and CLI. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FileLogOptions { /// Log file path or rolling filename prefix path. pub path: String, /// Time rotation interval. pub rotation: LogRotation, /// Maximum active file size before size rotation. `0` disables it. pub max_size_bytes: u64, /// Maximum number of matching log files to keep. `0` disables it. pub max_files: usize, /// Maximum rotated file age in seconds. `0` disables it. pub max_age_secs: u64, } /// Log destination configuration. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum LogDestination { /// Log to stderr (default, captured by systemd journald). #[default] Stderr, /// Log to syslog (Unix only). #[cfg(unix)] Syslog, /// Log to a file with optional rotation. File { /// Resolved file logging options. options: FileLogOptions, }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum LogCliDestination { Stderr, Syslog, File, } /// Logging-related CLI overrides. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct LogCliOptions { destination: Option, path: Option, rotation: Option, max_size_bytes: Option, max_files: Option, max_age_secs: Option, } /// Logging options parsed from CLI/config. #[derive(Debug, Clone, Default)] pub struct LoggingOptions { /// Where to send logs. pub destination: LogDestination, /// Disable ANSI colors. pub disable_colors: bool, } /// Guard that must be held to keep file logging active. /// When dropped, flushes and closes log files. pub struct LoggingGuard { _guard: Option, } impl LoggingGuard { fn new(guard: Option) -> Self { Self { _guard: guard } } /// Creates a no-op guard for stderr/syslog logging. pub fn noop() -> Self { Self { _guard: None } } } /// Initialize the tracing subscriber with the specified options. /// /// Returns a reload handle for dynamic log level changes and a guard /// that must be kept alive for file logging. pub fn init_logging( opts: &LoggingOptions, initial_filter: &str, ) -> ( reload::Handle, LoggingGuard, ) { let (filter_layer, filter_handle) = reload::Layer::new(EnvFilter::new(initial_filter)); match &opts.destination { LogDestination::Stderr => { let fmt_layer = fmt::Layer::default() .with_ansi(!opts.disable_colors) .with_target(true); tracing_subscriber::registry() .with(filter_layer) .with(fmt_layer) .init(); (filter_handle, LoggingGuard::noop()) } #[cfg(unix)] LogDestination::Syslog => { // Use a custom fmt layer that writes to syslog let fmt_layer = fmt::Layer::default() .with_ansi(false) .with_target(false) .with_level(false) .without_time() .with_writer(SyslogMakeWriter::new()); tracing_subscriber::registry() .with(filter_layer) .with(fmt_layer) .init(); (filter_handle, LoggingGuard::noop()) } LogDestination::File { options } => { let (non_blocking, guard) = if options.max_size_bytes > 0 || options.max_files > 0 || options.max_age_secs > 0 { let file_appender = file::BoundedFileAppender::new(options.clone()) .expect("Failed to open log file"); tracing_appender::non_blocking(file_appender) } else if !matches!(options.rotation, LogRotation::Never) { let path = Path::new(&options.path); let dir = log_file_dir(path); let prefix = log_file_name(path); let file_appender = tracing_appender::rolling::RollingFileAppender::builder() .rotation(to_tracing_rotation(options.rotation)) .filename_prefix(prefix) .build(dir) .expect("Failed to open log file"); tracing_appender::non_blocking(file_appender) } else { let file = std::fs::OpenOptions::new() .create(true) .append(true) .open(&options.path) .expect("Failed to open log file"); tracing_appender::non_blocking(file) }; let fmt_layer = fmt::Layer::default() .with_ansi(false) .with_target(true) .with_writer(non_blocking); tracing_subscriber::registry() .with(filter_layer) .with(fmt_layer) .init(); (filter_handle, LoggingGuard::new(Some(guard))) } } } fn log_file_dir(path: &Path) -> &Path { path.parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")) } fn log_file_name(path: &Path) -> &str { path.file_name() .and_then(|s| s.to_str()) .unwrap_or("telemt") } fn to_tracing_rotation(rotation: LogRotation) -> tracing_appender::rolling::Rotation { match rotation { LogRotation::Never => tracing_appender::rolling::Rotation::NEVER, LogRotation::Minutely => tracing_appender::rolling::Rotation::MINUTELY, LogRotation::Hourly => tracing_appender::rolling::Rotation::HOURLY, LogRotation::Daily => tracing_appender::rolling::Rotation::DAILY, LogRotation::Weekly => tracing_appender::rolling::Rotation::WEEKLY, } } /// Syslog writer for tracing. #[cfg(unix)] #[derive(Clone, Copy)] struct SyslogMakeWriter; #[cfg(unix)] #[derive(Clone, Copy)] struct SyslogWriter { priority: libc::c_int, } #[cfg(unix)] impl SyslogMakeWriter { fn new() -> Self { // Open syslog connection on first use static INIT: std::sync::Once = std::sync::Once::new(); INIT.call_once(|| { unsafe { // Open syslog with ident "telemt", LOG_PID, LOG_DAEMON facility let ident = b"telemt\0".as_ptr() as *const libc::c_char; libc::openlog(ident, libc::LOG_PID | libc::LOG_NDELAY, libc::LOG_DAEMON); } }); Self } } #[cfg(unix)] fn syslog_priority_for_level(level: &tracing::Level) -> libc::c_int { match *level { tracing::Level::ERROR => libc::LOG_ERR, tracing::Level::WARN => libc::LOG_WARNING, tracing::Level::INFO => libc::LOG_INFO, tracing::Level::DEBUG => libc::LOG_DEBUG, tracing::Level::TRACE => libc::LOG_DEBUG, } } #[cfg(unix)] impl std::io::Write for SyslogWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { // Convert to C string, stripping newlines let msg = String::from_utf8_lossy(buf); let msg = msg.trim_end(); if msg.is_empty() { return Ok(buf.len()); } // Write to syslog let c_msg = std::ffi::CString::new(msg.as_bytes()) .unwrap_or_else(|_| std::ffi::CString::new("(invalid utf8)").unwrap()); unsafe { libc::syslog( self.priority, b"%s\0".as_ptr() as *const libc::c_char, c_msg.as_ptr(), ); } Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } #[cfg(unix)] impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SyslogMakeWriter { type Writer = SyslogWriter; fn make_writer(&'a self) -> Self::Writer { SyslogWriter { priority: libc::LOG_INFO, } } fn make_writer_for(&'a self, meta: &tracing::Metadata<'_>) -> Self::Writer { SyslogWriter { priority: syslog_priority_for_level(meta.level()), } } } /// Parse logging overrides from CLI arguments. pub fn parse_log_cli_options(args: &[String]) -> Result { let mut options = LogCliOptions::default(); let mut i = 0; while i < args.len() { match args[i].as_str() { #[cfg(unix)] "--syslog" => { options.destination = Some(LogCliDestination::Syslog); } #[cfg(not(unix))] "--syslog" => { options.destination = Some(LogCliDestination::Syslog); } "--log-file" => { i += 1; if i < args.len() { options.destination = Some(LogCliDestination::File); options.path = Some(args[i].clone()); } else { return Err("Missing value for --log-file".to_string()); } } s if s.starts_with("--log-file=") => { options.destination = Some(LogCliDestination::File); options.path = Some(s.trim_start_matches("--log-file=").to_string()); } "--log-file-daily" => { i += 1; if i < args.len() { options.destination = Some(LogCliDestination::File); options.path = Some(args[i].clone()); options.rotation = Some(LogRotation::Daily); } else { return Err("Missing value for --log-file-daily".to_string()); } } s if s.starts_with("--log-file-daily=") => { options.destination = Some(LogCliDestination::File); options.path = Some(s.trim_start_matches("--log-file-daily=").to_string()); options.rotation = Some(LogRotation::Daily); } "--log-rotation" => { i += 1; if i < args.len() { options.rotation = Some(parse_rotation_cli_value(&args[i])?); } else { return Err("Missing value for --log-rotation".to_string()); } } s if s.starts_with("--log-rotation=") => { options.rotation = Some(parse_rotation_cli_value( s.trim_start_matches("--log-rotation="), )?); } "--log-max-size-bytes" => { i += 1; if i < args.len() { options.max_size_bytes = Some(parse_u64_cli_value("--log-max-size-bytes", &args[i])?); } else { return Err("Missing value for --log-max-size-bytes".to_string()); } } s if s.starts_with("--log-max-size-bytes=") => { options.max_size_bytes = Some(parse_u64_cli_value( "--log-max-size-bytes", s.trim_start_matches("--log-max-size-bytes="), )?); } "--log-max-files" => { i += 1; if i < args.len() { options.max_files = Some(parse_usize_cli_value("--log-max-files", &args[i])?); } else { return Err("Missing value for --log-max-files".to_string()); } } s if s.starts_with("--log-max-files=") => { options.max_files = Some(parse_usize_cli_value( "--log-max-files", s.trim_start_matches("--log-max-files="), )?); } "--log-max-age-secs" => { i += 1; if i < args.len() { options.max_age_secs = Some(parse_u64_cli_value("--log-max-age-secs", &args[i])?); } else { return Err("Missing value for --log-max-age-secs".to_string()); } } s if s.starts_with("--log-max-age-secs=") => { options.max_age_secs = Some(parse_u64_cli_value( "--log-max-age-secs", s.trim_start_matches("--log-max-age-secs="), )?); } _ => {} } i += 1; } Ok(options) } fn parse_rotation_cli_value(value: &str) -> Result { LogRotation::from_cli_arg(value).ok_or_else(|| { format!( "Invalid --log-rotation value '{value}'. Expected never|minutely|hourly|daily|weekly" ) }) } fn parse_u64_cli_value(flag: &str, value: &str) -> Result { value .parse::() .map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer")) } fn parse_usize_cli_value(flag: &str, value: &str) -> Result { value .parse::() .map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer")) } /// Resolve effective logging destination from config and CLI overrides. pub fn resolve_log_destination( config: &LoggingConfig, cli: &LogCliOptions, ) -> Result { let destination = cli.destination.unwrap_or(match config.destination { LoggingDestination::Stderr => LogCliDestination::Stderr, LoggingDestination::Syslog => LogCliDestination::Syslog, LoggingDestination::File => LogCliDestination::File, }); match destination { LogCliDestination::Stderr => Ok(LogDestination::Stderr), LogCliDestination::Syslog => { #[cfg(unix)] { Ok(LogDestination::Syslog) } #[cfg(not(unix))] { Err("Syslog logging is only supported on Unix platforms".to_string()) } } LogCliDestination::File => { let path = cli.path.as_ref().or(config.path.as_ref()).ok_or_else(|| { "logging.path or --log-file must be set when file logging is enabled".to_string() })?; if path.trim().is_empty() { return Err("Log file path cannot be empty".to_string()); } Ok(LogDestination::File { options: FileLogOptions { path: path.clone(), rotation: cli.rotation.unwrap_or(config.rotation), max_size_bytes: cli.max_size_bytes.unwrap_or(config.max_size_bytes), max_files: cli.max_files.unwrap_or(config.max_files), max_age_secs: cli.max_age_secs.unwrap_or(config.max_age_secs), }, }) } } }