Skip to main content

netget/
logging.rs

1//! Dual logging macros that log to both tracing (file) and status channel (TUI)
2//!
3//! Also includes shared log pattern constants for test assertions, and a
4//! size-based rotating file writer (see [`RotatingFileWriter`]) that bounds
5//! total on-disk log usage.
6
7pub mod emit;
8pub mod patterns;
9
10use std::ffi::OsString;
11use std::fs::{self, File, OpenOptions};
12use std::io::{self, Write};
13use std::path::PathBuf;
14use std::sync::{Arc, Mutex};
15
16/// Default per-file size cap before rotation: 50 MiB.
17///
18/// `netget.log` is written at TRACE level by default in development builds
19/// and can contain full request/response payloads, so it grows quickly.
20/// 50 MiB keeps a single file comfortably viewable/greppable while still
21/// holding a useful amount of recent history.
22pub const DEFAULT_MAX_LOG_BYTES: u64 = 50 * 1024 * 1024;
23
24/// Default number of rotated (non-active) files retained: 5.
25///
26/// Combined with `DEFAULT_MAX_LOG_BYTES`, this bounds total log disk usage
27/// to `(DEFAULT_MAX_LOG_FILES + 1) * DEFAULT_MAX_LOG_BYTES` = ~300 MiB
28/// (1 active file + up to 5 rotated files), a hard ceiling well below the
29/// 481 MB the unbounded file reached in a single day of use, while still
30/// keeping several rotations of history around for debugging.
31pub const DEFAULT_MAX_LOG_FILES: usize = 5;
32
33/// Internal mutable state for a [`RotatingFileWriter`], guarded by a mutex
34/// so concurrent writers (many tokio tasks logging at once) never interleave
35/// writes or race on rotation.
36struct RotatingState {
37    /// Path of the active log file (e.g. `netget.log`).
38    path: PathBuf,
39    /// Rotate once the active file reaches this many bytes. `0` disables
40    /// rotation entirely.
41    max_bytes: u64,
42    /// Number of rotated files to retain (`netget.log.1` .. `netget.log.N`).
43    max_files: usize,
44    /// Open handle to the active file, always positioned for appending.
45    file: File,
46    /// Bytes written to the active file so far (avoids an `fstat` per write).
47    written: u64,
48}
49
50impl RotatingState {
51    fn open(path: PathBuf, max_bytes: u64, max_files: usize) -> io::Result<Self> {
52        let file = OpenOptions::new().create(true).append(true).open(&path)?;
53        let written = file.metadata()?.len();
54        Ok(Self {
55            path,
56            max_bytes,
57            max_files,
58            file,
59            written,
60        })
61    }
62
63    /// Path for the Nth rotated file, e.g. `netget.log.1`.
64    fn rotated_path(&self, n: usize) -> PathBuf {
65        let mut name: OsString = self.path.clone().into_os_string();
66        name.push(format!(".{n}"));
67        PathBuf::from(name)
68    }
69
70    /// Roll the active file out to `.1`, shifting existing rotated files up
71    /// and dropping the oldest once `max_files` is exceeded, then open a
72    /// fresh active file in its place.
73    ///
74    /// The current file handle is flushed (not dropped) before the rename so
75    /// no buffered bytes are lost; on Unix, renaming a file that is still
76    /// open is safe (the inode stays valid for the open handle) and the
77    /// fresh handle opened afterwards points at the new (recreated) path.
78    fn rotate(&mut self) -> io::Result<()> {
79        self.file.flush()?;
80
81        if self.max_files == 0 {
82            // No retention requested: just drop the current contents.
83            let _ = fs::remove_file(&self.path);
84        } else {
85            let oldest = self.rotated_path(self.max_files);
86            if oldest.exists() {
87                fs::remove_file(&oldest)?;
88            }
89            for i in (1..self.max_files).rev() {
90                let from = self.rotated_path(i);
91                if from.exists() {
92                    fs::rename(&from, self.rotated_path(i + 1))?;
93                }
94            }
95            if self.path.exists() {
96                fs::rename(&self.path, self.rotated_path(1))?;
97            }
98        }
99
100        self.file = OpenOptions::new()
101            .create(true)
102            .append(true)
103            .open(&self.path)?;
104        self.written = 0;
105        Ok(())
106    }
107
108    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
109        // Check-then-roll before writing: guarantees the file we are about
110        // to append to is never allowed to grow past max_bytes, without
111        // ever truncating or losing the bytes about to be written (they are
112        // written to the fresh file after rotation completes).
113        if self.max_bytes > 0 && self.written >= self.max_bytes {
114            self.rotate()?;
115        }
116        let n = self.file.write(buf)?;
117        self.written += n as u64;
118        Ok(n)
119    }
120
121    /// Write the whole buffer as a single logical record while the caller
122    /// holds the mutex for the entire call (see
123    /// [`RotatingFileWriter::write_all`]/`write_fmt` below): this is what
124    /// keeps a multi-fragment record (e.g. one `tracing` line, or the
125    /// several `write_str` calls `std::fmt` makes for a single `write!()`)
126    /// from being torn apart by another thread's write landing in the
127    /// middle of it.
128    fn write_all(&mut self, mut buf: &[u8]) -> io::Result<()> {
129        while !buf.is_empty() {
130            match self.write(buf) {
131                Ok(0) => {
132                    return Err(io::Error::new(
133                        io::ErrorKind::WriteZero,
134                        "failed to write whole buffer",
135                    ))
136                }
137                Ok(n) => buf = &buf[n..],
138                Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {}
139                Err(e) => return Err(e),
140            }
141        }
142        Ok(())
143    }
144}
145
146/// A `Write` + `Clone` file writer that rotates itself by size.
147///
148/// All clones share the same underlying state behind an `Arc<Mutex<..>>>`,
149/// so it is safe to hand a clone to every `tracing` writer/task: writes are
150/// serialized through the mutex, which both prevents interleaved/corrupted
151/// lines under concurrent logging from many tokio tasks and makes the
152/// size-check-then-rotate sequence atomic with respect to other writers (no
153/// writer can observe a mid-rotation state).
154///
155/// Also implements `tracing_subscriber::fmt::MakeWriter` directly so it can
156/// be passed straight to `fmt::layer().with_writer(..)`, or wrapped by
157/// another `MakeWriter` (e.g. a color-processing writer) that delegates its
158/// inner writes to a clone of this type.
159#[derive(Clone)]
160pub struct RotatingFileWriter {
161    state: Arc<Mutex<RotatingState>>,
162}
163
164impl RotatingFileWriter {
165    /// Open (or create) `path` as a rotating log file using the crate
166    /// defaults ([`DEFAULT_MAX_LOG_BYTES`] / [`DEFAULT_MAX_LOG_FILES`]).
167    ///
168    /// If the file already exists and is already at or past the size cap
169    /// (e.g. an old unbounded log), the existing contents are preserved,
170    /// never truncated or deleted: the first write rotates it out to
171    /// `<path>.1` before appending, exactly like any other rotation.
172    pub fn new<P: Into<PathBuf>>(path: P) -> io::Result<Self> {
173        Self::with_limits(path, DEFAULT_MAX_LOG_BYTES, DEFAULT_MAX_LOG_FILES)
174    }
175
176    /// Same as [`Self::new`] but with explicit limits, primarily for tests
177    /// and callers that want non-default rotation behavior.
178    ///
179    /// `max_bytes = 0` disables rotation (unbounded growth). `max_files = 0`
180    /// rotates by discarding the old file's contents instead of retaining
181    /// history.
182    pub fn with_limits<P: Into<PathBuf>>(
183        path: P,
184        max_bytes: u64,
185        max_files: usize,
186    ) -> io::Result<Self> {
187        let state = RotatingState::open(path.into(), max_bytes, max_files)?;
188        Ok(Self {
189            state: Arc::new(Mutex::new(state)),
190        })
191    }
192}
193
194impl Write for RotatingFileWriter {
195    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
196        let mut guard = self
197            .state
198            .lock()
199            .unwrap_or_else(|poisoned| poisoned.into_inner());
200        guard.write(buf)
201    }
202
203    /// Overridden (rather than relying on the default loop-over-`write`) so
204    /// the mutex is acquired exactly once for the whole buffer: no other
205    /// writer can interleave a write — or observe/trigger a rotation — in
206    /// the middle of this record.
207    fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
208        let mut guard = self
209            .state
210            .lock()
211            .unwrap_or_else(|poisoned| poisoned.into_inner());
212        guard.write_all(buf)
213    }
214
215    /// Overridden so a single `write!()`/`writeln!()` call — which
216    /// `std::fmt` may internally split into several `write_str` fragments
217    /// (one per literal segment and per formatted argument) — is first
218    /// assembled into one buffer and then written via [`Self::write_all`]
219    /// under a single lock acquisition, instead of each fragment taking and
220    /// releasing the lock separately (which would let another thread's
221    /// fragment land in between and corrupt the line). This mirrors how
222    /// `tracing-subscriber`'s fmt layer already formats a full event into a
223    /// buffer before issuing one `write_all` call.
224    fn write_fmt(&mut self, fmt: std::fmt::Arguments<'_>) -> io::Result<()> {
225        use std::fmt::Write as _;
226        let mut formatted = String::new();
227        formatted
228            .write_fmt(fmt)
229            .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
230        self.write_all(formatted.as_bytes())
231    }
232
233    fn flush(&mut self) -> io::Result<()> {
234        let mut guard = self
235            .state
236            .lock()
237            .unwrap_or_else(|poisoned| poisoned.into_inner());
238        guard.file.flush()
239    }
240}
241
242impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RotatingFileWriter {
243    type Writer = RotatingFileWriter;
244
245    fn make_writer(&'a self) -> Self::Writer {
246        self.clone()
247    }
248}
249
250/// Log at TRACE level to both file and TUI
251#[macro_export]
252macro_rules! console_trace {
253    ($status_tx:expr, $($arg:tt)*) => {{
254        let msg = format!($($arg)*);
255        tracing::trace!("{}", msg);
256        if !msg.starts_with("__") {
257            let _ = $status_tx.send(format!("[TRACE] {}", msg));
258        } else {
259            let _ = $status_tx.send(msg);
260        }
261    }};
262}
263
264/// Log at DEBUG level to both file and TUI
265#[macro_export]
266macro_rules! console_debug {
267    ($status_tx:expr, $($arg:tt)*) => {{
268        let msg = format!($($arg)*);
269        tracing::debug!("{}", msg);
270        if !msg.starts_with("__") {
271            let _ = $status_tx.send(format!("[DEBUG] {}", msg));
272        } else {
273            let _ = $status_tx.send(msg);
274        }
275    }};
276}
277
278/// Log at INFO level to both file and TUI
279#[macro_export]
280macro_rules! console_info {
281    ($status_tx:expr, $($arg:tt)*) => {{
282        let msg = format!($($arg)*);
283        tracing::info!("{}", msg);
284        if !msg.starts_with("__") {
285            let _ = $status_tx.send(format!("[INFO] {}", msg));
286        } else {
287            let _ = $status_tx.send(msg);
288        }
289    }};
290}
291
292/// Log at WARN level to both file and TUI
293#[macro_export]
294macro_rules! console_warn {
295    ($status_tx:expr, $($arg:tt)*) => {{
296        let msg = format!($($arg)*);
297        tracing::warn!("{}", msg);
298        if !msg.starts_with("__") {
299            let _ = $status_tx.send(format!("[WARN] {}", msg));
300        } else {
301            let _ = $status_tx.send(msg);
302        }
303    }};
304}
305
306/// Log at ERROR level to both file and TUI
307#[macro_export]
308macro_rules! console_error {
309    ($status_tx:expr, $($arg:tt)*) => {{
310        let msg = format!($($arg)*);
311        tracing::error!("{}", msg);
312        if !msg.starts_with("__") {
313            let _ = $status_tx.send(format!("[ERROR] {}", msg));
314        } else {
315            let _ = $status_tx.send(msg);
316        }
317    }};
318}