Files
telemt/src/logging.rs
T

465 lines
15 KiB
Rust

//! 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<LogCliDestination>,
path: Option<String>,
rotation: Option<LogRotation>,
max_size_bytes: Option<u64>,
max_files: Option<usize>,
max_age_secs: Option<u64>,
}
/// 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<tracing_appender::non_blocking::WorkerGuard>,
}
impl LoggingGuard {
fn new(guard: Option<tracing_appender::non_blocking::WorkerGuard>) -> 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<EnvFilter, impl tracing::Subscriber + Send + Sync>,
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<usize> {
// 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<LogCliOptions, String> {
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, String> {
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<u64, String> {
value
.parse::<u64>()
.map_err(|_| format!("Invalid {flag} value '{value}'. Expected unsigned integer"))
}
fn parse_usize_cli_value(flag: &str, value: &str) -> Result<usize, String> {
value
.parse::<usize>()
.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<LogDestination, String> {
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),
},
})
}
}
}