Skip to main content

logger_nx/
logger.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::{BufWriter, Write};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::Arc;
6use std::thread;
7use std::time::Duration;
8
9use chrono::Local;
10use crossbeam_channel::{bounded, Receiver, Sender};
11use log::{LevelFilter, Metadata, Record};
12
13use crate::cleaner;
14use crate::formatter;
15
16// ── Messages sent to the writer thread ───────────────────────────────────────
17
18enum Msg {
19    Line(String),
20    Shutdown,
21}
22
23// ── Public config ─────────────────────────────────────────────────────────────
24
25/// Configuration for [`Logger`].
26///
27/// Mirrors `@imcooder/node-logger` options.
28///
29/// # Example
30/// ```rust,no_run
31/// use logger_nx::Config;
32/// use log::LevelFilter;
33/// use std::path::PathBuf;
34///
35/// let cfg = Config {
36///     app_name: "my-app".to_string(),
37///     log_dir: PathBuf::from("/var/log/my-app"),
38///     ttl_hours: 72,
39///     level: LevelFilter::Info,
40///     console: true,
41/// };
42/// ```
43#[derive(Debug, Clone)]
44pub struct Config {
45    /// Application / category name.  Used in the log line and as the base
46    /// filename: `<app_name>.log`, `<app_name>.log.YYYYMMDDHH`.
47    ///
48    /// Equivalent to the `app` argument of `NodeLogger.getLogger(app)`.
49    pub app_name: String,
50
51    /// Directory where log files are written.
52    pub log_dir: PathBuf,
53
54    /// How many hours of rotated log files to retain (default: **72**).
55    ///
56    /// Files older than `ttl_hours` are deleted automatically on startup and
57    /// at every hourly rotation (and at most every 30 min).
58    pub ttl_hours: i64,
59
60    /// Minimum level written to the log file (default: **Info**).
61    pub level: LevelFilter,
62
63    /// Also print log lines to stderr.
64    ///
65    /// Defaults to `true` in debug builds, `false` in release builds.
66    pub console: bool,
67}
68
69impl Default for Config {
70    fn default() -> Self {
71        Self {
72            app_name: "app".to_string(),
73            log_dir: std::env::temp_dir().join("app-logs"),
74            ttl_hours: 72,
75            level: LevelFilter::Info,
76            #[cfg(debug_assertions)]
77            console: true,
78            #[cfg(not(debug_assertions))]
79            console: false,
80        }
81    }
82}
83
84// ── Logger ────────────────────────────────────────────────────────────────────
85
86/// A `log::Log` implementation that writes to hourly-rotating files.
87///
88/// All file I/O is performed on a dedicated background thread via a lock-free
89/// channel, so calling threads are never blocked.
90pub struct Logger {
91    sender: Sender<Msg>,
92    console: bool,
93    level: LevelFilter,
94    app_name: String,
95    shutdown_flag: Arc<AtomicBool>,
96}
97
98impl Logger {
99    /// Create a logger and spawn the background writer thread.
100    pub fn new(config: Config) -> Self {
101        let log_dir = config.log_dir.clone();
102        let ttl_hours = config.ttl_hours;
103        let app_name = config.app_name.clone();
104
105        let (tx, rx): (Sender<Msg>, Receiver<Msg>) = bounded(8192);
106        let shutdown_flag = Arc::new(AtomicBool::new(false));
107        let shutdown_clone = Arc::clone(&shutdown_flag);
108        let app_name_thread = app_name.clone();
109
110        thread::Builder::new()
111            .name(format!("logger-nx/{}", app_name))
112            .spawn(move || {
113                writer_thread(rx, &log_dir, &app_name_thread, ttl_hours, shutdown_clone);
114            })
115            .expect("failed to spawn logger thread");
116
117        Self {
118            sender: tx,
119            console: config.console,
120            level: config.level,
121            app_name,
122            shutdown_flag,
123        }
124    }
125
126    /// Flush pending writes and stop the background thread gracefully.
127    ///
128    /// Waits up to 2 s for the thread to drain.  Call this before process exit.
129    pub fn shutdown(&self) {
130        self.shutdown_flag.store(true, Ordering::SeqCst);
131        let _ = self.sender.send(Msg::Shutdown);
132        thread::sleep(Duration::from_millis(2000));
133    }
134}
135
136impl log::Log for Logger {
137    fn enabled(&self, metadata: &Metadata) -> bool {
138        metadata.level() <= self.level
139    }
140
141    fn log(&self, record: &Record) {
142        if !self.enabled(record.metadata()) {
143            return;
144        }
145        let line = formatter::format_record(record, &self.app_name);
146        if self.console {
147            eprint!("{line}");
148        }
149        // Non-blocking: drop the message if the channel is full (8 192 entries)
150        // rather than stalling the caller.
151        let _ = self.sender.try_send(Msg::Line(line));
152    }
153
154    fn flush(&self) {}
155}
156
157// ── Background writer thread ──────────────────────────────────────────────────
158
159struct FileState {
160    writer: BufWriter<File>,
161    current_hour_tag: String, // YYYYMMDDHH
162}
163
164fn current_hour_tag() -> String {
165    Local::now().format("%Y%m%d%H").to_string()
166}
167
168fn active_log_path(log_dir: &Path) -> PathBuf {
169    log_dir.join("app.log")
170}
171
172fn archive_log_path(log_dir: &Path, tag: &str) -> PathBuf {
173    log_dir.join(format!("app.log.{tag}"))
174}
175
176fn open_active(log_dir: &Path, _app_name: &str) -> std::io::Result<FileState> {
177    let path = active_log_path(log_dir);
178    let file = OpenOptions::new().create(true).append(true).open(&path)?;
179    Ok(FileState {
180        writer: BufWriter::with_capacity(64 * 1024, file),
181        current_hour_tag: current_hour_tag(),
182    })
183}
184
185fn rotate(log_dir: &Path, app_name: &str, state: &mut FileState) {
186    let _ = state.writer.flush();
187    let old_tag = state.current_hour_tag.clone();
188    let src = active_log_path(log_dir);
189    let dst = archive_log_path(log_dir, &old_tag);
190    let _ = fs::rename(&src, &dst);
191    match open_active(log_dir, app_name) {
192        Ok(new_state) => *state = new_state,
193        Err(e) => eprintln!("[logger-nx] Failed to open new log file: {e}"),
194    }
195}
196
197fn writer_thread(
198    rx: Receiver<Msg>,
199    log_dir: &Path,
200    app_name: &str,
201    ttl_hours: i64,
202    shutdown: Arc<AtomicBool>,
203) {
204    let _ = fs::create_dir_all(log_dir);
205
206    let mut state = match open_active(log_dir, app_name) {
207        Ok(s) => s,
208        Err(e) => {
209            eprintln!("[logger-nx] Cannot open log file: {e}");
210            return;
211        }
212    };
213
214    // Initial cleanup on startup (like node-logger's 1-min-then-30-min schedule;
215    // we just clean on startup + every rotate + every 30 min).
216    cleaner::cleanup(log_dir, app_name, ttl_hours);
217
218    let mut last_cleanup = std::time::Instant::now();
219    const CLEANUP_INTERVAL: Duration = Duration::from_secs(30 * 60);
220
221    for msg in &rx {
222        match msg {
223            Msg::Line(line) => {
224                let tag = current_hour_tag();
225                if tag != state.current_hour_tag {
226                    rotate(log_dir, app_name, &mut state);
227                    cleaner::cleanup(log_dir, app_name, ttl_hours);
228                    last_cleanup = std::time::Instant::now();
229                }
230
231                if last_cleanup.elapsed() > CLEANUP_INTERVAL {
232                    cleaner::cleanup(log_dir, app_name, ttl_hours);
233                    last_cleanup = std::time::Instant::now();
234                }
235
236                let _ = state.writer.write_all(line.as_bytes());
237                let _ = state.writer.flush();
238            }
239            Msg::Shutdown => {
240                let _ = state.writer.flush();
241                break;
242            }
243        }
244
245        if shutdown.load(Ordering::Relaxed) {
246            let _ = state.writer.flush();
247            break;
248        }
249    }
250}