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            // Daemon path (file logging present == `zlayer serve`). The console
61            // layer writes to STDOUT, NOT stderr: `serve` installs a stderr->tracing
62            // redirect (`install_stderr_redirect_to_tracing`) that dup2's fd 2 onto a
63            // pipe whose reader re-emits each line as a `tracing::error!`. If the
64            // console layer also wrote to fd 2, every event would loop back through
65            // that pipe and deadlock on the global stderr mutex once the pipe fills.
66            // Keeping the daemon console on stdout (fd 1) keeps it disjoint from the
67            // fd-2 capture. The CLI (`None`) arms below stay on stderr so command
68            // stdout (e.g. `ps --format json`) is clean.
69            let console_layer = fmt::layer()
70                .with_writer(io::stdout)
71                .with_target(config.include_target)
72                .with_file(config.include_location)
73                .with_line_number(config.include_location)
74                .with_span_events(FmtSpan::CLOSE)
75                .pretty();
76
77            let file_layer = fmt::layer()
78                .with_writer(file_writer)
79                .with_target(config.include_target)
80                .with_file(config.include_location)
81                .with_line_number(config.include_location)
82                .with_span_events(FmtSpan::CLOSE)
83                .with_ansi(false)
84                .json();
85
86            tracing_subscriber::registry()
87                .with(env_filter)
88                .with(console_layer)
89                .with(file_layer)
90                .init();
91        }
92        (LogFormat::Pretty, None) => {
93            let console_layer = fmt::layer()
94                .with_writer(io::stderr)
95                .with_target(config.include_target)
96                .with_file(config.include_location)
97                .with_line_number(config.include_location)
98                .with_span_events(FmtSpan::CLOSE)
99                .pretty();
100
101            tracing_subscriber::registry()
102                .with(env_filter)
103                .with(console_layer)
104                .init();
105        }
106        (LogFormat::Json, Some(file_writer)) => {
107            // Daemon path: console on STDOUT to stay disjoint from the fd-2
108            // stderr->tracing capture (see the Pretty/Some arm for the full why).
109            let console_layer = fmt::layer()
110                .with_writer(io::stdout)
111                .with_target(config.include_target)
112                .with_file(config.include_location)
113                .with_line_number(config.include_location)
114                .with_span_events(FmtSpan::CLOSE)
115                .json();
116
117            let file_layer = fmt::layer()
118                .with_writer(file_writer)
119                .with_target(config.include_target)
120                .with_file(config.include_location)
121                .with_line_number(config.include_location)
122                .with_span_events(FmtSpan::CLOSE)
123                .with_ansi(false)
124                .json();
125
126            tracing_subscriber::registry()
127                .with(env_filter)
128                .with(console_layer)
129                .with(file_layer)
130                .init();
131        }
132        (LogFormat::Json, None) => {
133            let console_layer = fmt::layer()
134                .with_writer(io::stderr)
135                .with_target(config.include_target)
136                .with_file(config.include_location)
137                .with_line_number(config.include_location)
138                .with_span_events(FmtSpan::CLOSE)
139                .json();
140
141            tracing_subscriber::registry()
142                .with(env_filter)
143                .with(console_layer)
144                .init();
145        }
146        (LogFormat::Compact, Some(file_writer)) => {
147            // Daemon path: console on STDOUT to stay disjoint from the fd-2
148            // stderr->tracing capture (see the Pretty/Some arm for the full why).
149            let console_layer = fmt::layer()
150                .with_writer(io::stdout)
151                .with_target(config.include_target)
152                .with_file(config.include_location)
153                .with_line_number(config.include_location)
154                .with_span_events(FmtSpan::CLOSE)
155                .compact();
156
157            let file_layer = fmt::layer()
158                .with_writer(file_writer)
159                .with_target(config.include_target)
160                .with_file(config.include_location)
161                .with_line_number(config.include_location)
162                .with_span_events(FmtSpan::CLOSE)
163                .with_ansi(false)
164                .json();
165
166            tracing_subscriber::registry()
167                .with(env_filter)
168                .with(console_layer)
169                .with(file_layer)
170                .init();
171        }
172        (LogFormat::Compact, None) => {
173            let console_layer = fmt::layer()
174                .with_writer(io::stderr)
175                .with_target(config.include_target)
176                .with_file(config.include_location)
177                .with_line_number(config.include_location)
178                .with_span_events(FmtSpan::CLOSE)
179                .compact();
180
181            tracing_subscriber::registry()
182                .with(env_filter)
183                .with(console_layer)
184                .init();
185        }
186    }
187
188    Ok(LogGuard::new(guard))
189}
190
191fn level_to_string(level: crate::config::LogLevel) -> String {
192    match level {
193        crate::config::LogLevel::Trace => "trace",
194        crate::config::LogLevel::Debug => "debug",
195        crate::config::LogLevel::Info => "info",
196        crate::config::LogLevel::Warn => "warn",
197        crate::config::LogLevel::Error => "error",
198    }
199    .to_string()
200}
201
202#[allow(clippy::unnecessary_wraps)]
203fn create_file_writer(
204    config: &FileLoggingConfig,
205) -> Result<(tracing_appender::non_blocking::NonBlocking, WorkerGuard)> {
206    // Clean up old rotated files before creating the appender.
207    if let Some(max) = config.max_files {
208        cleanup_rotated_files(&config.directory, &config.prefix, max);
209    }
210
211    let file_appender = match config.rotation {
212        RotationStrategy::Daily => {
213            tracing_appender::rolling::daily(&config.directory, &config.prefix)
214        }
215        RotationStrategy::Hourly => {
216            tracing_appender::rolling::hourly(&config.directory, &config.prefix)
217        }
218        RotationStrategy::Never => {
219            tracing_appender::rolling::never(&config.directory, &config.prefix)
220        }
221    };
222
223    Ok(tracing_appender::non_blocking(file_appender))
224}
225
226/// Delete the oldest rotated log files beyond `max_files`.
227///
228/// `tracing-appender` names rotated files as `{prefix}.{date}` (e.g.
229/// `daemon.2026-04-03`). We list all files matching the prefix, sort
230/// lexicographically (dates sort naturally), and remove the oldest.
231fn cleanup_rotated_files(directory: &std::path::Path, prefix: &str, max_files: usize) {
232    let Ok(entries) = std::fs::read_dir(directory) else {
233        return;
234    };
235
236    let dot_prefix = format!("{prefix}.");
237    let mut files: Vec<std::path::PathBuf> = entries
238        .filter_map(std::result::Result::ok)
239        .map(|e| e.path())
240        .filter(|p| {
241            p.file_name()
242                .and_then(|n| n.to_str())
243                .is_some_and(|n| n.starts_with(&dot_prefix))
244        })
245        .collect();
246
247    if files.len() <= max_files {
248        return;
249    }
250
251    // Sort ascending by name (oldest dates first).
252    files.sort();
253
254    let to_remove = files.len() - max_files;
255    for path in files.into_iter().take(to_remove) {
256        let _ = std::fs::remove_file(&path);
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn test_log_level_conversion() {
266        assert_eq!(level_to_string(crate::config::LogLevel::Info), "info");
267        assert_eq!(level_to_string(crate::config::LogLevel::Debug), "debug");
268        assert_eq!(level_to_string(crate::config::LogLevel::Trace), "trace");
269        assert_eq!(level_to_string(crate::config::LogLevel::Warn), "warn");
270        assert_eq!(level_to_string(crate::config::LogLevel::Error), "error");
271    }
272
273    #[test]
274    fn test_log_guard_creation() {
275        let guard = LogGuard::new(None);
276        assert!(guard.guard.is_none());
277    }
278}