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::{NonBlocking, WorkerGuard};
5use tracing_subscriber::{
6    fmt::{self, format::FmtSpan, writer::BoxMakeWriter},
7    layer::SubscriberExt,
8    util::SubscriberInitExt,
9    EnvFilter, Layer, Registry,
10};
11
12use crate::config::{FileLoggingConfig, LogFormat, LoggingConfig, RotationStrategy};
13use crate::error::Result;
14
15/// A single composed subscriber layer, boxed so layers of differing formats /
16/// writers can live in one `Vec` and be installed together.
17type BoxedLayer = Box<dyn Layer<Registry> + Send + Sync>;
18
19/// Guard that must be held to keep the async file writer running
20pub struct LogGuard {
21    /// Held for its `Drop` implementation to flush async log writes.
22    #[allow(dead_code)]
23    guard: Option<WorkerGuard>,
24}
25
26impl LogGuard {
27    fn new(guard: Option<WorkerGuard>) -> Self {
28        Self { guard }
29    }
30
31    /// A guard that holds nothing — used when subscriber installation is
32    /// deferred to the in-runtime OTLP activation (which owns the
33    /// global subscriber when OTLP is enabled).
34    #[must_use]
35    pub(crate) fn noop() -> Self {
36        Self { guard: None }
37    }
38}
39
40/// Initialize logging with the given configuration.
41///
42/// Returns a guard that must be held for the lifetime of the application
43/// to ensure logs are flushed properly.
44///
45/// # Errors
46/// Returns an error if file logging is configured but the log directory cannot be created.
47///
48/// # Panics
49/// Panics if the environment filter directives are malformed (only when `RUST_LOG` is set).
50pub fn init_logging(config: &LoggingConfig) -> Result<LogGuard> {
51    init_logging_inner(config)
52}
53
54/// Install ZLayer's console (+ optional rotated file) `tracing` subscriber.
55///
56/// This is the subscriber used whenever OTLP forwarding is **off**. When OTLP is
57/// enabled, [`crate::tracing_otel::init_otlp_in_runtime`] installs the OTel
58/// subscriber instead; the two are mutually exclusive because there is only one
59/// global `tracing` subscriber slot.
60///
61/// Each output layer carries its own [`EnvFilter`] (built from `RUST_LOG`, then
62/// the config's directives/level) so filtering stays global without relying on
63/// a single shared filter — the layers are boxed into one `Vec` to keep the
64/// type machinery flat.
65///
66/// # Errors
67/// Returns an error if file logging is configured but the log directory cannot be created.
68///
69/// # Panics
70/// Panics if the environment filter directives are malformed (only when `RUST_LOG` is set).
71pub(crate) fn init_logging_inner(config: &LoggingConfig) -> Result<LogGuard> {
72    // Handle file logging setup. Presence of a file sink also flips the console
73    // onto stdout (see `build_console_layer`).
74    let (file_writer, guard) = if let Some(file_config) = &config.file {
75        let (writer, guard) = create_file_writer(file_config)?;
76        (Some(writer), Some(guard))
77    } else {
78        (None, None)
79    };
80
81    let mut layers: Vec<BoxedLayer> = Vec::new();
82    layers.push(build_console_layer(config, file_writer.is_some()));
83    if let Some(writer) = file_writer {
84        layers.push(build_file_layer(config, writer));
85    }
86
87    tracing_subscriber::registry().with(layers).init();
88
89    Ok(LogGuard::new(guard))
90}
91
92/// Build the human/structured console layer.
93///
94/// When a file sink is present (`to_stdout == true`, i.e. the daemon path) the
95/// console writes to STDOUT, NOT stderr: `serve` installs a stderr->tracing
96/// redirect (`install_stderr_redirect_to_tracing`) that dup2's fd 2 onto a pipe
97/// whose reader re-emits each line as a `tracing::error!`. If the console layer
98/// also wrote to fd 2, every event would loop back through that pipe and
99/// deadlock on the global stderr mutex once the pipe fills. Keeping the daemon
100/// console on stdout (fd 1) keeps it disjoint from the fd-2 capture. The CLI /
101/// satellite path (no file sink) stays on stderr so command stdout (e.g. `ps
102/// --format json`) is clean.
103fn build_console_layer(config: &LoggingConfig, to_stdout: bool) -> BoxedLayer {
104    let writer: BoxMakeWriter = if to_stdout {
105        BoxMakeWriter::new(io::stdout)
106    } else {
107        BoxMakeWriter::new(io::stderr)
108    };
109
110    let base = fmt::layer()
111        .with_writer(writer)
112        .with_target(config.include_target)
113        .with_file(config.include_location)
114        .with_line_number(config.include_location)
115        .with_span_events(FmtSpan::CLOSE);
116
117    let filter = make_filter(config);
118    match config.format {
119        LogFormat::Pretty => base.pretty().with_filter(filter).boxed(),
120        LogFormat::Json => base.json().with_filter(filter).boxed(),
121        LogFormat::Compact => base.compact().with_filter(filter).boxed(),
122    }
123}
124
125/// Build the rotated JSON file layer (ANSI off so the on-disk logs stay clean).
126fn build_file_layer(config: &LoggingConfig, writer: NonBlocking) -> BoxedLayer {
127    fmt::layer()
128        .with_writer(writer)
129        .with_target(config.include_target)
130        .with_file(config.include_location)
131        .with_line_number(config.include_location)
132        .with_span_events(FmtSpan::CLOSE)
133        .with_ansi(false)
134        .json()
135        .with_filter(make_filter(config))
136        .boxed()
137}
138
139/// Build a fresh `EnvFilter` from `RUST_LOG`, falling back to the config's
140/// per-crate directives, then the global level. A fresh instance is produced per
141/// output layer (`EnvFilter` is not `Clone`) so every sink filters identically.
142fn make_filter(config: &LoggingConfig) -> EnvFilter {
143    EnvFilter::try_from_default_env().unwrap_or_else(|_| {
144        config.filter_directives.as_ref().map_or_else(
145            || EnvFilter::new(level_to_string(config.level)),
146            EnvFilter::new,
147        )
148    })
149}
150
151fn level_to_string(level: crate::config::LogLevel) -> String {
152    match level {
153        crate::config::LogLevel::Trace => "trace",
154        crate::config::LogLevel::Debug => "debug",
155        crate::config::LogLevel::Info => "info",
156        crate::config::LogLevel::Warn => "warn",
157        crate::config::LogLevel::Error => "error",
158    }
159    .to_string()
160}
161
162#[allow(clippy::unnecessary_wraps)]
163fn create_file_writer(
164    config: &FileLoggingConfig,
165) -> Result<(tracing_appender::non_blocking::NonBlocking, WorkerGuard)> {
166    // Clean up old rotated files before creating the appender.
167    if let Some(max) = config.max_files {
168        cleanup_rotated_files(&config.directory, &config.prefix, max);
169    }
170
171    let file_appender = match config.rotation {
172        RotationStrategy::Daily => {
173            tracing_appender::rolling::daily(&config.directory, &config.prefix)
174        }
175        RotationStrategy::Hourly => {
176            tracing_appender::rolling::hourly(&config.directory, &config.prefix)
177        }
178        RotationStrategy::Never => {
179            tracing_appender::rolling::never(&config.directory, &config.prefix)
180        }
181    };
182
183    Ok(tracing_appender::non_blocking(file_appender))
184}
185
186/// Delete the oldest rotated log files beyond `max_files`.
187///
188/// `tracing-appender` names rotated files as `{prefix}.{date}` (e.g.
189/// `daemon.2026-04-03`). We list all files matching the prefix, sort
190/// lexicographically (dates sort naturally), and remove the oldest.
191fn cleanup_rotated_files(directory: &std::path::Path, prefix: &str, max_files: usize) {
192    let Ok(entries) = std::fs::read_dir(directory) else {
193        return;
194    };
195
196    let dot_prefix = format!("{prefix}.");
197    let mut files: Vec<std::path::PathBuf> = entries
198        .filter_map(std::result::Result::ok)
199        .map(|e| e.path())
200        .filter(|p| {
201            p.file_name()
202                .and_then(|n| n.to_str())
203                .is_some_and(|n| n.starts_with(&dot_prefix))
204        })
205        .collect();
206
207    if files.len() <= max_files {
208        return;
209    }
210
211    // Sort ascending by name (oldest dates first).
212    files.sort();
213
214    let to_remove = files.len() - max_files;
215    for path in files.into_iter().take(to_remove) {
216        let _ = std::fs::remove_file(&path);
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_log_level_conversion() {
226        assert_eq!(level_to_string(crate::config::LogLevel::Info), "info");
227        assert_eq!(level_to_string(crate::config::LogLevel::Debug), "debug");
228        assert_eq!(level_to_string(crate::config::LogLevel::Trace), "trace");
229        assert_eq!(level_to_string(crate::config::LogLevel::Warn), "warn");
230        assert_eq!(level_to_string(crate::config::LogLevel::Error), "error");
231    }
232
233    #[test]
234    fn test_log_guard_creation() {
235        let guard = LogGuard::new(None);
236        assert!(guard.guard.is_none());
237    }
238}