static_web_server/
logger.rs1use 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#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, ValueEnum)]
32#[serde(rename_all = "lowercase")]
33pub enum LogFormat {
34 Json,
36 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
46static LOG_FILE_GUARD: OnceLock<WorkerGuard> = OnceLock::new();
53
54pub 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
73fn 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 let make_filter = || Targets::default().with_default(level);
86 let timer = time::LocalTime::rfc_3339();
87
88 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 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
160fn 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 let appender = tracing_appender::rolling::never(dir, file_name);
177 let (writer, guard) = tracing_appender::non_blocking(appender);
181 Ok((writer, guard))
182}
183
184fn 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 #[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 #[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 #[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 #[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 #[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(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 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}