use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::OnceLock;
use tracing::Level;
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
use tracing_subscriber::{
filter::Targets,
fmt::{format::FmtSpan, time},
prelude::*,
};
use crate::{Context, Result};
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
Json,
Pretty,
}
impl std::fmt::Display for LogFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(self, f)
}
}
static LOG_FILE_GUARD: OnceLock<WorkerGuard> = OnceLock::new();
pub fn init(
log_level: &str,
log_format: &LogFormat,
log_with_ansi: bool,
log_file: Option<&Path>,
) -> Result {
let log_level = log_level.to_lowercase();
configure(&log_level, log_format, log_with_ansi, log_file)
.with_context(|| "failed to initialize logging")?;
Ok(())
}
fn configure(
level: &str,
format: &LogFormat,
enable_ansi: bool,
log_file: Option<&Path>,
) -> Result {
let level = level
.parse::<Level>()
.with_context(|| "failed to parse log level")?;
let make_filter = || Targets::default().with_default(level);
let timer = time::LocalTime::rfc_3339();
let (file_writer, file_guard) = match log_file {
Some(path) => {
let (w, guard) = build_file_writer(path)
.with_context(|| format!("failed to open log file: {}", path.display()))?;
(Some(w), Some(guard))
}
None => (None, None),
};
let registry = tracing_subscriber::registry();
let result = match format {
LogFormat::Json => {
let stderr_layer = tracing_subscriber::fmt::layer()
.json()
.flatten_event(true)
.with_current_span(false)
.with_span_list(false)
.with_writer(std::io::stderr)
.with_timer(timer.clone())
.with_filter(make_filter());
let file_layer = file_writer.map(|w| {
tracing_subscriber::fmt::layer()
.json()
.flatten_event(true)
.with_current_span(false)
.with_span_list(false)
.with_ansi(false)
.with_writer(w)
.with_timer(timer)
.with_filter(make_filter())
});
registry.with(stderr_layer).with(file_layer).try_init()
}
LogFormat::Pretty => {
let stderr_layer = tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_span_events(FmtSpan::CLOSE)
.with_ansi(enable_ansi)
.with_timer(timer.clone())
.with_filter(make_filter());
let file_layer = file_writer.map(|w| {
tracing_subscriber::fmt::layer()
.with_writer(w)
.with_span_events(FmtSpan::CLOSE)
.with_ansi(false)
.with_timer(timer)
.with_filter(make_filter())
});
registry.with(stderr_layer).with(file_layer).try_init()
}
};
match result {
Ok(()) => {
if let Some(g) = file_guard {
let _ = LOG_FILE_GUARD.set(g);
}
Ok(())
}
Err(err) => Err(anyhow!(err)),
}
}
fn build_file_writer(path: &Path) -> Result<(NonBlocking, WorkerGuard)> {
let (dir, file_name) = split_path(path)?;
if !dir.as_os_str().is_empty() {
std::fs::create_dir_all(dir)
.with_context(|| format!("failed to create log directory: {}", dir.display()))?;
}
let appender = tracing_appender::rolling::never(dir, file_name);
let (writer, guard) = tracing_appender::non_blocking(appender);
Ok((writer, guard))
}
fn split_path(path: &Path) -> Result<(&Path, &std::ffi::OsStr)> {
let file_name = path.file_name().with_context(|| {
format!(
"log file path has no file name component: {}",
path.display()
)
})?;
let dir = path.parent().unwrap_or_else(|| Path::new(""));
Ok((dir, file_name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_path_extracts_dir_and_name() {
let path = Path::new("/var/log/sws/server.log");
let (dir, name) = split_path(path).expect("split should succeed");
assert_eq!(dir, Path::new("/var/log/sws"));
assert_eq!(name, std::ffi::OsStr::new("server.log"));
}
#[test]
fn split_path_handles_bare_filename() {
let path = Path::new("server.log");
let (dir, name) = split_path(path).expect("split should succeed");
assert_eq!(dir, Path::new(""));
assert_eq!(name, std::ffi::OsStr::new("server.log"));
}
#[test]
fn split_path_rejects_paths_without_file_name() {
for bad in ["/", "..", "."] {
let res = split_path(Path::new(bad));
assert!(
res.is_err(),
"path {bad:?} should be rejected (no file-name component)"
);
}
}
#[test]
fn build_file_writer_creates_parent_dirs() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("nested/a/b/server.log");
let (_writer, _guard) = build_file_writer(&path).expect("build writer");
assert!(
tmp.path().join("nested/a/b").is_dir(),
"parent directories should be created"
);
}
#[test]
fn file_writer_streams_events_to_disk() {
use std::io::Read;
let tmp = tempfile::tempdir().expect("tempdir");
let log_path = tmp.path().join("server.log");
let (writer, guard) = build_file_writer(&log_path).expect("writer");
let layer = tracing_subscriber::fmt::layer()
.json()
.flatten_event(true)
.with_current_span(false)
.with_span_list(false)
.with_ansi(false)
.with_writer(writer)
.with_filter(Targets::default().with_default(Level::INFO));
let subscriber = tracing_subscriber::registry().with(layer);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(event = "ready", "first message");
tracing::info!(event = "ready", "second message");
for i in 0..8 {
tracing::info!(worker = i, "burst message");
}
});
drop(guard);
let mut contents = String::new();
std::fs::File::open(&log_path)
.expect("open log file")
.read_to_string(&mut contents)
.expect("read log file");
assert!(
contents.contains("first message"),
"expected first message in:\n{contents}"
);
assert!(
contents.contains("second message"),
"expected second message in:\n{contents}"
);
let burst_count = contents.matches("burst message").count();
assert_eq!(
burst_count, 8,
"expected all 8 burst messages; got {burst_count} in:\n{contents}"
);
for line in contents.lines().filter(|l| !l.is_empty()) {
let parsed: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|err| panic!("line is not JSON ({err}): {line}"));
assert!(parsed.is_object(), "JSON line must be an object: {line}");
}
}
}