Skip to main content

autopulse_utils/
logs.rs

1use anyhow::{Context, Ok};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4use tracing_appender::non_blocking::WorkerGuard;
5use tracing_appender::rolling::RollingFileAppender;
6pub use tracing_appender::rolling::Rotation;
7use tracing_subscriber::util::SubscriberInitExt;
8use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter};
9
10#[derive(Clone, Serialize, Deserialize, Default)]
11#[serde(rename_all = "lowercase")]
12pub enum LogLevel {
13    Trace,
14    Debug,
15    #[default]
16    Info,
17    Warn,
18    Error,
19}
20
21impl std::fmt::Display for LogLevel {
22    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
23        match self {
24            Self::Trace => write!(f, "trace"),
25            Self::Debug => write!(f, "debug"),
26            Self::Info => write!(f, "info"),
27            Self::Warn => write!(f, "warn"),
28            Self::Error => write!(f, "error"),
29        }
30    }
31}
32
33impl std::str::FromStr for LogLevel {
34    type Err = anyhow::Error;
35
36    fn from_str(s: &str) -> Result<Self, Self::Err> {
37        match s {
38            "trace" => Ok(Self::Trace),
39            "debug" => Ok(Self::Debug),
40            "info" => Ok(Self::Info),
41            "warn" => Ok(Self::Warn),
42            "error" => Ok(Self::Error),
43            _ => Err(anyhow::anyhow!("Invalid log level")),
44        }
45    }
46}
47
48pub fn setup_logs(
49    log_level: &LogLevel,
50    log_file: &Option<PathBuf>,
51    log_file_rollover: &Rotation,
52    log_file_max_files: usize,
53    api_logging: bool,
54) -> anyhow::Result<Option<WorkerGuard>> {
55    let timer = tracing_subscriber::fmt::time::OffsetTime::local_rfc_3339()
56        .context("failed to initialize the timer")?;
57
58    let mut file_guard = None;
59
60    let mut filter =
61        EnvFilter::from_default_env().add_directive(format!("autopulse={log_level}").parse()?);
62
63    if api_logging {
64        filter = filter
65            .add_directive("actix_web=info".parse()?)
66            .add_directive("actix_server::builder=info".parse()?);
67    }
68
69    let registry = tracing_subscriber::registry().with(filter);
70
71    if let Some(log_file) = log_file {
72        // `tracing-appender` is pinned to a fork (see workspace Cargo.toml)
73        // that enables a `local-time` feature, making the rolled filename
74        // suffix and rotation boundary track the local timezone instead of
75        // UTC. Without that feature, log filenames would be a day off the
76        // record timestamps inside them (#208).
77        let writer = rolling_file_appender(log_file, log_file_rollover, log_file_max_files)?;
78
79        let (non_blocking, guard) = tracing_appender::non_blocking(writer);
80        file_guard = Some(guard);
81
82        let file_layer = fmt::layer()
83            .with_writer(non_blocking)
84            .with_ansi(false)
85            .with_timer(timer.clone());
86
87        let registry = registry.with(file_layer);
88
89        let console_layer = fmt::layer()
90            .with_writer(std::io::stdout)
91            .with_ansi(true)
92            .with_timer(timer);
93
94        registry.with(console_layer).init();
95    } else {
96        let console_layer = fmt::layer()
97            .with_writer(std::io::stdout)
98            .with_ansi(true)
99            .with_timer(timer);
100
101        registry.with(console_layer).init();
102    }
103    Ok(file_guard)
104}
105
106fn rolling_file_appender(
107    log_file: &Path,
108    rotation: &Rotation,
109    max_log_files: usize,
110) -> anyhow::Result<RollingFileAppender> {
111    let directory = log_file
112        .parent()
113        .ok_or_else(|| anyhow::anyhow!("failed to get parent directory of log file"))?;
114    let filename = log_file
115        .file_name()
116        .ok_or_else(|| anyhow::anyhow!("failed to get file name of log file"))?;
117    let filename = filename.to_string_lossy();
118
119    RollingFileAppender::builder()
120        .rotation(rotation.clone())
121        .filename_prefix(filename)
122        .max_log_files(max_log_files)
123        .build(directory)
124        .context("failed to initialize rolling file appender")
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use std::fs;
131
132    #[test]
133    fn rolling_appender_prunes_files_beyond_the_configured_limit() {
134        let directory = tempfile::tempdir().expect("temp directory should be created");
135        let log_file = directory.path().join("autopulse.log");
136
137        for day in 1..=31 {
138            fs::write(
139                directory
140                    .path()
141                    .join(format!("autopulse.log.2020-01-{day:02}")),
142                "old log",
143            )
144            .expect("old log should be created");
145        }
146
147        let appender = rolling_file_appender(&log_file, &Rotation::DAILY, 30)
148            .expect("appender should be created");
149        drop(appender);
150
151        let retained_logs = fs::read_dir(directory.path())
152            .expect("log directory should be readable")
153            .filter_map(Result::ok)
154            .filter(|entry| {
155                entry
156                    .file_name()
157                    .to_string_lossy()
158                    .starts_with("autopulse.log")
159            })
160            .count();
161
162        assert_eq!(retained_logs, 30);
163    }
164
165    #[cfg(unix)]
166    #[test]
167    fn rolling_appender_accepts_non_utf8_log_filenames() {
168        use std::ffi::OsString;
169        use std::os::unix::ffi::OsStringExt;
170
171        let directory = tempfile::tempdir().expect("temp directory should be created");
172        let filename = OsString::from_vec(b"autopulse-\xff.log".to_vec());
173        let log_file = directory.path().join(filename);
174
175        let appender = rolling_file_appender(&log_file, &Rotation::NEVER, 30)
176            .expect("appender should accept a non-UTF-8 filename");
177        drop(appender);
178    }
179}