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 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}