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}