Skip to main content

static_web_server/
logger.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// This file is part of Static Web Server.
3// See https://static-web-server.net/ for more information
4// Copyright (C) 2019-present Jose Quintana <joseluisq.net>
5
6//! Provides logging initialization for the web server.
7//!
8//! Logs are emitted to stderr by default. When a file path is supplied via
9//! [`init`]'s `log_file` parameter (CLI: `--log-file`, env:
10//! `SERVER_LOG_FILE`, config: `log-file`) the server additionally streams logs
11//! to that file using [`tracing_appender::non_blocking`]. A background thread
12//! drains a lock-free queue so the request path is never blocked by disk I/O.
13//! ANSI escape codes are always disabled for file output regardless of
14//! `--log-with-ansi`.
15
16use clap::ValueEnum;
17use serde::{Deserialize, Serialize};
18use std::path::Path;
19use std::sync::OnceLock;
20use tracing::Level;
21use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
22use tracing_subscriber::{
23    filter::Targets,
24    fmt::{format::FmtSpan, time},
25    prelude::*,
26};
27
28use crate::{Context, Result};
29
30/// Logging output format.
31#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, ValueEnum)]
32#[serde(rename_all = "lowercase")]
33pub enum LogFormat {
34    /// Structured single-line JSON, suited for production and log aggregation.
35    Json,
36    /// Human-readable text, suited for local development.
37    Pretty,
38}
39
40impl std::fmt::Display for LogFormat {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        std::fmt::Debug::fmt(self, f)
43    }
44}
45
46/// Holds the background worker guard for the non-blocking file appender.
47///
48/// The guard MUST live for the entire program duration; dropping it shuts
49/// down the writer thread. Using a `OnceLock` ties its lifetime to the
50/// process. The OS reclaims it on exit. Initialization is intentionally a
51/// one-shot (matching `tracing`'s global subscriber).
52static LOG_FILE_GUARD: OnceLock<WorkerGuard> = OnceLock::new();
53
54/// Logging system initialization.
55///
56/// Sets up a global tracing subscriber that streams events to stderr and,
57/// optionally, to a file. Returns an error if the global subscriber was
58/// already initialized or if the log file cannot be opened.
59pub fn init(
60    log_level: &str,
61    log_format: &LogFormat,
62    log_with_ansi: bool,
63    log_file: Option<&Path>,
64) -> Result {
65    let log_level = log_level.to_lowercase();
66
67    configure(&log_level, log_format, log_with_ansi, log_file)
68        .with_context(|| "failed to initialize logging")?;
69
70    Ok(())
71}
72
73/// Initialize logging builder with its level, output format, and optional
74/// file destination.
75fn configure(
76    level: &str,
77    format: &LogFormat,
78    enable_ansi: bool,
79    log_file: Option<&Path>,
80) -> Result {
81    let level = level
82        .parse::<Level>()
83        .with_context(|| "failed to parse log level")?;
84    // The same level is applied to both stderr and file layers.
85    let make_filter = || Targets::default().with_default(level);
86    let timer = time::LocalTime::rfc_3339();
87
88    // Build the optional file writer first so any I/O failure (path
89    // resolution, file open) is reported before installing the global
90    // subscriber.
91    let (file_writer, file_guard) = match log_file {
92        Some(path) => {
93            let (w, guard) = build_file_writer(path)
94                .with_context(|| format!("failed to open log file: {}", path.display()))?;
95            (Some(w), Some(guard))
96        }
97        None => (None, None),
98    };
99
100    let registry = tracing_subscriber::registry();
101
102    let result = match format {
103        LogFormat::Json => {
104            let stderr_layer = tracing_subscriber::fmt::layer()
105                .json()
106                .flatten_event(true)
107                .with_current_span(false)
108                .with_span_list(false)
109                .with_writer(std::io::stderr)
110                .with_timer(timer.clone())
111                .with_filter(make_filter());
112
113            let file_layer = file_writer.map(|w| {
114                tracing_subscriber::fmt::layer()
115                    .json()
116                    .flatten_event(true)
117                    .with_current_span(false)
118                    .with_span_list(false)
119                    .with_ansi(false)
120                    .with_writer(w)
121                    .with_timer(timer)
122                    .with_filter(make_filter())
123            });
124
125            registry.with(stderr_layer).with(file_layer).try_init()
126        }
127        LogFormat::Pretty => {
128            let stderr_layer = tracing_subscriber::fmt::layer()
129                .with_writer(std::io::stderr)
130                .with_span_events(FmtSpan::CLOSE)
131                .with_ansi(enable_ansi)
132                .with_timer(timer.clone())
133                .with_filter(make_filter());
134
135            let file_layer = file_writer.map(|w| {
136                tracing_subscriber::fmt::layer()
137                    .with_writer(w)
138                    .with_span_events(FmtSpan::CLOSE)
139                    .with_ansi(false)
140                    .with_timer(timer)
141                    .with_filter(make_filter())
142            });
143
144            registry.with(stderr_layer).with(file_layer).try_init()
145        }
146    };
147
148    match result {
149        Ok(()) => {
150            // Store the guard only after the subscriber is installed.
151            if let Some(g) = file_guard {
152                let _ = LOG_FILE_GUARD.set(g);
153            }
154            Ok(())
155        }
156        Err(err) => Err(anyhow!(err)),
157    }
158}
159
160/// Build a non-blocking file writer for the given path.
161///
162/// Creates any missing parent directories. Uses
163/// [`tracing_appender::rolling::never`] (no rotation, single file) wrapped in
164/// [`tracing_appender::non_blocking`] so log emission never blocks the request
165/// path; a dedicated background thread drains the queue. The returned guard
166/// keeps the worker thread alive and must outlive every emitter.
167fn build_file_writer(path: &Path) -> Result<(NonBlocking, WorkerGuard)> {
168    let (dir, file_name) = split_path(path)?;
169
170    if !dir.as_os_str().is_empty() {
171        std::fs::create_dir_all(dir)
172            .with_context(|| format!("failed to create log directory: {}", dir.display()))?;
173    }
174
175    // `rolling::never` keeps the file name as-is (no rotation, no date suffix).
176    let appender = tracing_appender::rolling::never(dir, file_name);
177    // Default buffered-lines limit (128k) trades latency for durability:
178    // messages are dropped only under extreme back-pressure, which is the
179    // right trade-off for a server hot path.
180    let (writer, guard) = tracing_appender::non_blocking(appender);
181    Ok((writer, guard))
182}
183
184/// Split a log file path into `(directory, file_name)`.
185///
186/// Returns an error when the path has no file-name component (e.g. ends in a
187/// separator) so misconfiguration is reported at startup rather than producing
188/// silently broken file output.
189fn split_path(path: &Path) -> Result<(&Path, &std::ffi::OsStr)> {
190    let file_name = path.file_name().with_context(|| {
191        format!(
192            "log file path has no file name component: {}",
193            path.display()
194        )
195    })?;
196    let dir = path.parent().unwrap_or_else(|| Path::new(""));
197    Ok((dir, file_name))
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    /// `split_path` returns the parent directory and the file-name component
205    /// for a well-formed path.
206    #[test]
207    fn split_path_extracts_dir_and_name() {
208        let path = Path::new("/var/log/sws/server.log");
209        let (dir, name) = split_path(path).expect("split should succeed");
210        assert_eq!(dir, Path::new("/var/log/sws"));
211        assert_eq!(name, std::ffi::OsStr::new("server.log"));
212    }
213
214    /// A bare file name (no directory) is split into an empty directory
215    /// component and the file name itself. Build code skips `create_dir_all`
216    /// in that case.
217    #[test]
218    fn split_path_handles_bare_filename() {
219        let path = Path::new("server.log");
220        let (dir, name) = split_path(path).expect("split should succeed");
221        assert_eq!(dir, Path::new(""));
222        assert_eq!(name, std::ffi::OsStr::new("server.log"));
223    }
224
225    /// A path with no file-name component (e.g. `/`, `..`, `.`) is rejected
226    /// rather than silently producing broken file output. Note that trailing
227    /// separators on otherwise valid paths are normalized by `Path::file_name`
228    /// on Unix, so `/var/log/sws/` is accepted as `sws` in `/var/log`.
229    #[test]
230    fn split_path_rejects_paths_without_file_name() {
231        for bad in ["/", "..", "."] {
232            let res = split_path(Path::new(bad));
233            assert!(
234                res.is_err(),
235                "path {bad:?} should be rejected (no file-name component)"
236            );
237        }
238    }
239
240    /// `build_file_writer` creates missing parent directories on demand.
241    #[test]
242    fn build_file_writer_creates_parent_dirs() {
243        let tmp = tempfile::tempdir().expect("tempdir");
244        let path = tmp.path().join("nested/a/b/server.log");
245        let (_writer, _guard) = build_file_writer(&path).expect("build writer");
246        assert!(
247            tmp.path().join("nested/a/b").is_dir(),
248            "parent directories should be created"
249        );
250    }
251
252    /// The non-blocking file writer end-to-end: install a scoped subscriber
253    /// that writes JSON events through `build_file_writer`, emit several log
254    /// statements, drop the guard so the worker thread flushes, then verify
255    /// the file content.
256    ///
257    /// Uses `tracing::subscriber::with_default` (scoped, not global) so this
258    /// test does not collide with the global subscriber installed by other
259    /// integration tests. The scoped subscriber is per-thread, so we emit
260    /// from the calling thread only — thread safety of the underlying queue
261    /// is the responsibility of `tracing-appender::non_blocking` and is
262    /// covered by that crate's own tests.
263    #[test]
264    fn file_writer_streams_events_to_disk() {
265        use std::io::Read;
266
267        let tmp = tempfile::tempdir().expect("tempdir");
268        let log_path = tmp.path().join("server.log");
269
270        let (writer, guard) = build_file_writer(&log_path).expect("writer");
271        let layer = tracing_subscriber::fmt::layer()
272            .json()
273            .flatten_event(true)
274            .with_current_span(false)
275            .with_span_list(false)
276            .with_ansi(false)
277            .with_writer(writer)
278            .with_filter(Targets::default().with_default(Level::INFO));
279
280        let subscriber = tracing_subscriber::registry().with(layer);
281
282        tracing::subscriber::with_default(subscriber, || {
283            tracing::info!(event = "ready", "first message");
284            tracing::info!(event = "ready", "second message");
285            for i in 0..8 {
286                tracing::info!(worker = i, "burst message");
287            }
288        });
289
290        // Drop the guard so the background worker flushes and closes.
291        drop(guard);
292
293        let mut contents = String::new();
294        std::fs::File::open(&log_path)
295            .expect("open log file")
296            .read_to_string(&mut contents)
297            .expect("read log file");
298
299        assert!(
300            contents.contains("first message"),
301            "expected first message in:\n{contents}"
302        );
303        assert!(
304            contents.contains("second message"),
305            "expected second message in:\n{contents}"
306        );
307        let burst_count = contents.matches("burst message").count();
308        assert_eq!(
309            burst_count, 8,
310            "expected all 8 burst messages; got {burst_count} in:\n{contents}"
311        );
312        // JSON format check: every non-empty line must be a valid JSON object.
313        for line in contents.lines().filter(|l| !l.is_empty()) {
314            let parsed: serde_json::Value = serde_json::from_str(line)
315                .unwrap_or_else(|err| panic!("line is not JSON ({err}): {line}"));
316            assert!(parsed.is_object(), "JSON line must be an object: {line}");
317        }
318    }
319}