Skip to main content

zlayer_observability/
logging.rs

1//! Structured logging with JSON/pretty output and file rotation
2
3use std::io;
4use tracing_appender::non_blocking::WorkerGuard;
5use tracing_subscriber::{
6    fmt::{self, format::FmtSpan},
7    layer::SubscriberExt,
8    util::SubscriberInitExt,
9    EnvFilter,
10};
11
12use crate::config::{FileLoggingConfig, LogFormat, LoggingConfig, RotationStrategy};
13use crate::error::Result;
14
15/// Guard that must be held to keep the async file writer running
16pub struct LogGuard {
17    /// Held for its `Drop` implementation to flush async log writes.
18    #[allow(dead_code)]
19    guard: Option<WorkerGuard>,
20}
21
22impl LogGuard {
23    fn new(guard: Option<WorkerGuard>) -> Self {
24        Self { guard }
25    }
26}
27
28/// Initialize logging with the given configuration
29///
30/// Returns a guard that must be held for the lifetime of the application
31/// to ensure logs are flushed properly.
32///
33/// # Errors
34/// Returns an error if file logging is configured but the log directory cannot be created.
35///
36/// # Panics
37/// Panics if the environment filter directives are malformed (only when `RUST_LOG` is set).
38#[allow(clippy::too_many_lines)]
39pub fn init_logging(config: &LoggingConfig) -> Result<LogGuard> {
40    let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
41        if let Some(ref directives) = config.filter_directives {
42            EnvFilter::new(directives)
43        } else {
44            EnvFilter::new(level_to_string(config.level))
45        }
46    });
47
48    // Handle file logging setup
49    let (file_writer, guard) = if let Some(file_config) = &config.file {
50        let (writer, guard) = create_file_writer(file_config)?;
51        (Some(writer), Some(guard))
52    } else {
53        (None, None)
54    };
55
56    // Initialize based on format and file configuration
57    // We need separate branches because of tracing-subscriber's complex type system
58    match (config.format, file_writer) {
59        (LogFormat::Pretty, Some(file_writer)) => {
60            let console_layer = fmt::layer()
61                .with_writer(io::stdout)
62                .with_target(config.include_target)
63                .with_file(config.include_location)
64                .with_line_number(config.include_location)
65                .with_span_events(FmtSpan::CLOSE)
66                .pretty();
67
68            let file_layer = fmt::layer()
69                .with_writer(file_writer)
70                .with_target(config.include_target)
71                .with_file(config.include_location)
72                .with_line_number(config.include_location)
73                .with_span_events(FmtSpan::CLOSE)
74                .with_ansi(false)
75                .json();
76
77            tracing_subscriber::registry()
78                .with(env_filter)
79                .with(console_layer)
80                .with(file_layer)
81                .init();
82        }
83        (LogFormat::Pretty, None) => {
84            let console_layer = fmt::layer()
85                .with_writer(io::stdout)
86                .with_target(config.include_target)
87                .with_file(config.include_location)
88                .with_line_number(config.include_location)
89                .with_span_events(FmtSpan::CLOSE)
90                .pretty();
91
92            tracing_subscriber::registry()
93                .with(env_filter)
94                .with(console_layer)
95                .init();
96        }
97        (LogFormat::Json, Some(file_writer)) => {
98            let console_layer = fmt::layer()
99                .with_writer(io::stdout)
100                .with_target(config.include_target)
101                .with_file(config.include_location)
102                .with_line_number(config.include_location)
103                .with_span_events(FmtSpan::CLOSE)
104                .json();
105
106            let file_layer = fmt::layer()
107                .with_writer(file_writer)
108                .with_target(config.include_target)
109                .with_file(config.include_location)
110                .with_line_number(config.include_location)
111                .with_span_events(FmtSpan::CLOSE)
112                .with_ansi(false)
113                .json();
114
115            tracing_subscriber::registry()
116                .with(env_filter)
117                .with(console_layer)
118                .with(file_layer)
119                .init();
120        }
121        (LogFormat::Json, None) => {
122            let console_layer = fmt::layer()
123                .with_writer(io::stdout)
124                .with_target(config.include_target)
125                .with_file(config.include_location)
126                .with_line_number(config.include_location)
127                .with_span_events(FmtSpan::CLOSE)
128                .json();
129
130            tracing_subscriber::registry()
131                .with(env_filter)
132                .with(console_layer)
133                .init();
134        }
135        (LogFormat::Compact, Some(file_writer)) => {
136            let console_layer = fmt::layer()
137                .with_writer(io::stdout)
138                .with_target(config.include_target)
139                .with_file(config.include_location)
140                .with_line_number(config.include_location)
141                .with_span_events(FmtSpan::CLOSE)
142                .compact();
143
144            let file_layer = fmt::layer()
145                .with_writer(file_writer)
146                .with_target(config.include_target)
147                .with_file(config.include_location)
148                .with_line_number(config.include_location)
149                .with_span_events(FmtSpan::CLOSE)
150                .with_ansi(false)
151                .json();
152
153            tracing_subscriber::registry()
154                .with(env_filter)
155                .with(console_layer)
156                .with(file_layer)
157                .init();
158        }
159        (LogFormat::Compact, None) => {
160            let console_layer = fmt::layer()
161                .with_writer(io::stdout)
162                .with_target(config.include_target)
163                .with_file(config.include_location)
164                .with_line_number(config.include_location)
165                .with_span_events(FmtSpan::CLOSE)
166                .compact();
167
168            tracing_subscriber::registry()
169                .with(env_filter)
170                .with(console_layer)
171                .init();
172        }
173    }
174
175    Ok(LogGuard::new(guard))
176}
177
178fn level_to_string(level: crate::config::LogLevel) -> String {
179    match level {
180        crate::config::LogLevel::Trace => "trace",
181        crate::config::LogLevel::Debug => "debug",
182        crate::config::LogLevel::Info => "info",
183        crate::config::LogLevel::Warn => "warn",
184        crate::config::LogLevel::Error => "error",
185    }
186    .to_string()
187}
188
189#[allow(clippy::unnecessary_wraps)]
190fn create_file_writer(
191    config: &FileLoggingConfig,
192) -> Result<(tracing_appender::non_blocking::NonBlocking, WorkerGuard)> {
193    // Clean up old rotated files before creating the appender.
194    if let Some(max) = config.max_files {
195        cleanup_rotated_files(&config.directory, &config.prefix, max);
196    }
197
198    let file_appender = match config.rotation {
199        RotationStrategy::Daily => {
200            tracing_appender::rolling::daily(&config.directory, &config.prefix)
201        }
202        RotationStrategy::Hourly => {
203            tracing_appender::rolling::hourly(&config.directory, &config.prefix)
204        }
205        RotationStrategy::Never => {
206            tracing_appender::rolling::never(&config.directory, &config.prefix)
207        }
208    };
209
210    Ok(tracing_appender::non_blocking(file_appender))
211}
212
213/// Delete the oldest rotated log files beyond `max_files`.
214///
215/// `tracing-appender` names rotated files as `{prefix}.{date}` (e.g.
216/// `daemon.2026-04-03`). We list all files matching the prefix, sort
217/// lexicographically (dates sort naturally), and remove the oldest.
218fn cleanup_rotated_files(directory: &std::path::Path, prefix: &str, max_files: usize) {
219    let Ok(entries) = std::fs::read_dir(directory) else {
220        return;
221    };
222
223    let dot_prefix = format!("{prefix}.");
224    let mut files: Vec<std::path::PathBuf> = entries
225        .filter_map(std::result::Result::ok)
226        .map(|e| e.path())
227        .filter(|p| {
228            p.file_name()
229                .and_then(|n| n.to_str())
230                .is_some_and(|n| n.starts_with(&dot_prefix))
231        })
232        .collect();
233
234    if files.len() <= max_files {
235        return;
236    }
237
238    // Sort ascending by name (oldest dates first).
239    files.sort();
240
241    let to_remove = files.len() - max_files;
242    for path in files.into_iter().take(to_remove) {
243        let _ = std::fs::remove_file(&path);
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_log_level_conversion() {
253        assert_eq!(level_to_string(crate::config::LogLevel::Info), "info");
254        assert_eq!(level_to_string(crate::config::LogLevel::Debug), "debug");
255        assert_eq!(level_to_string(crate::config::LogLevel::Trace), "trace");
256        assert_eq!(level_to_string(crate::config::LogLevel::Warn), "warn");
257        assert_eq!(level_to_string(crate::config::LogLevel::Error), "error");
258    }
259
260    #[test]
261    fn test_log_guard_creation() {
262        let guard = LogGuard::new(None);
263        assert!(guard.guard.is_none());
264    }
265}