Skip to main content

shunt/
logging.rs

1use anyhow::Result;
2use std::path::Path;
3use std::time::SystemTime;
4use tracing_appender::non_blocking::WorkerGuard;
5use tracing_subscriber::layer::SubscriberExt;
6use tracing_subscriber::util::SubscriberInitExt;
7use tracing_subscriber::{EnvFilter, Layer};
8
9/// Initialise logging: JSON lines to file + human-readable to stderr.
10///
11/// Returns a `WorkerGuard` that must be kept alive for the duration of the
12/// process — dropping it flushes and closes the log file writer.
13pub fn setup(log_file: &Path, level: &str) -> Result<WorkerGuard> {
14    if let Some(parent) = log_file.parent() {
15        std::fs::create_dir_all(parent)?;
16    }
17
18    let file_appender = tracing_appender::rolling::daily(
19        log_file.parent().unwrap_or(Path::new(".")),
20        log_file.file_name().unwrap_or(std::ffi::OsStr::new("proxy.log")),
21    );
22    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
23
24    let filter = EnvFilter::try_from_default_env()
25        .unwrap_or_else(|_| EnvFilter::new(level));
26
27    let file_layer = tracing_subscriber::fmt::layer()
28        .json()
29        .with_writer(non_blocking)
30        .with_filter(filter.clone());
31
32    let stderr_layer = tracing_subscriber::fmt::layer()
33        .with_target(false)
34        .with_filter(filter);
35
36    tracing_subscriber::registry()
37        .with(file_layer)
38        .with(stderr_layer)
39        .init();
40
41    Ok(guard)
42}
43
44/// Delete rotated log files older than `keep_days` days in the same directory
45/// as the log file. Files are matched by the log file name prefix (e.g. "proxy.log").
46/// This prevents unbounded accumulation of daily-rotated log files.
47pub fn prune_old_logs(log_file: &Path, keep_days: u64) {
48    let dir = match log_file.parent() {
49        Some(d) => d,
50        None => return,
51    };
52    let prefix = match log_file.file_name().and_then(|n| n.to_str()) {
53        Some(p) => p.to_owned(),
54        None => return,
55    };
56    let cutoff = SystemTime::now()
57        .checked_sub(std::time::Duration::from_secs(keep_days * 24 * 3600))
58        .unwrap_or(SystemTime::UNIX_EPOCH);
59
60    let Ok(entries) = std::fs::read_dir(dir) else { return };
61    for entry in entries.flatten() {
62        let path = entry.path();
63        let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
64        // Only prune files that start with our log prefix but are not the current file
65        // (e.g. "proxy.log.2024-01-01", not "proxy.log" itself)
66        if !name.starts_with(&prefix) || name == prefix { continue }
67        if let Ok(meta) = entry.metadata() {
68            if let Ok(modified) = meta.modified() {
69                if modified < cutoff {
70                    let _ = std::fs::remove_file(&path);
71                }
72            }
73        }
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use std::fs;
81    use std::time::{Duration, SystemTime};
82
83    fn set_mtime(path: &std::path::Path, age_secs: u64) {
84        // Back-date the file by writing via filetime crate is not available,
85        // but we can use std::fs::File + set_modified via the filetime workaround:
86        // Instead we directly set via libc on unix.
87        #[cfg(unix)]
88        {
89            let past = SystemTime::now()
90                .checked_sub(Duration::from_secs(age_secs))
91                .unwrap();
92            let secs = past.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
93            let ts = libc::timespec { tv_sec: secs as libc::time_t, tv_nsec: 0 };
94            let times = [ts, ts];
95            let path_cstr = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
96            unsafe { libc::utimensat(libc::AT_FDCWD, path_cstr.as_ptr(), times.as_ptr(), 0) };
97        }
98        // On non-unix platforms the test is a no-op (we just don't back-date).
99        let _ = path; let _ = age_secs;
100    }
101
102    #[test]
103    fn test_prune_old_logs_removes_stale_rotated_files() {
104        let dir = std::env::temp_dir().join(format!(
105            "shunt_prune_test_{}",
106            SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_nanos()
107        ));
108        fs::create_dir_all(&dir).unwrap();
109
110        let log_file = dir.join("proxy.log");
111        // Current log file — must NOT be deleted
112        fs::write(&log_file, b"current").unwrap();
113
114        // Old rotated file (10 days old) — MUST be deleted
115        let old = dir.join("proxy.log.2020-01-01");
116        fs::write(&old, b"old").unwrap();
117        set_mtime(&old, 10 * 24 * 3600);
118
119        // Recent rotated file (1 day old) — must NOT be deleted
120        let recent = dir.join("proxy.log.2024-12-31");
121        fs::write(&recent, b"recent").unwrap();
122        set_mtime(&recent, 24 * 3600);
123
124        // Unrelated file — must NOT be deleted
125        let other = dir.join("other.log");
126        fs::write(&other, b"unrelated").unwrap();
127
128        prune_old_logs(&log_file, 7);
129
130        #[cfg(unix)]
131        {
132            assert!(log_file.exists(), "current log must survive");
133            assert!(!old.exists(),    "old rotated log must be pruned");
134            assert!(recent.exists(),  "recent rotated log must survive");
135        }
136        assert!(other.exists(), "unrelated file must survive");
137
138        fs::remove_dir_all(&dir).ok();
139    }
140
141    #[test]
142    fn test_prune_old_logs_keeps_current_log() {
143        let dir = std::env::temp_dir().join(format!(
144            "shunt_prune_current_{}",
145            SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_nanos()
146        ));
147        fs::create_dir_all(&dir).unwrap();
148
149        let log_file = dir.join("proxy.log");
150        fs::write(&log_file, b"current").unwrap();
151        // Back-date the main log file too — it should still be kept because name == prefix
152        set_mtime(&log_file, 30 * 24 * 3600);
153
154        prune_old_logs(&log_file, 1);
155        assert!(log_file.exists(), "exact log file name must never be pruned");
156
157        fs::remove_dir_all(&dir).ok();
158    }
159}