Skip to main content

ghpascon_rust/utils/
logger_manager.rs

1use chrono::{Duration, Local, NaiveDate};
2use serde::Serialize;
3use std::io::Write;
4use std::path::PathBuf;
5use std::sync::mpsc as std_mpsc;
6use std::sync::{Arc, Weak};
7use tokio::{fs, time};
8
9/// Log severity levels in increasing priority order.
10///
11/// Only messages at or above the configured level are recorded.
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
13pub enum LogLevel {
14    Debug,
15    Info,
16    Warn,
17    Error,
18}
19
20impl LogLevel {
21    fn as_str(&self) -> &'static str {
22        match self {
23            LogLevel::Debug => "DEBUG",
24            LogLevel::Info => "INFO",
25            LogLevel::Warn => "WARN",
26            LogLevel::Error => "ERROR",
27        }
28    }
29}
30
31impl std::fmt::Display for LogLevel {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.write_str(self.as_str())
34    }
35}
36
37#[derive(Debug, Serialize)]
38struct LogEntry {
39    timestamp: String,
40    level: String,
41    app: String,
42    message: String,
43    module: String,
44    file: String,
45    line: u32,
46    thread: String,
47}
48
49enum LogMessage {
50    Entry(LogEntry),
51    Shutdown,
52}
53
54/// Non-blocking JSON logger with daily rotation and automatic old-file cleanup.
55///
56/// # Behaviour
57///
58/// * Log files are named `{YYYY-MM-DD}_{app_name}.jsonl` and written under `log_path`.
59/// * A background task cleans files older than `retention_days` once per day.
60/// * Logging is **non-blocking**: entries use a buffered channel (`try_send`).
61///   If the buffer (4 096 entries) is full the entry is silently discarded.
62/// * Uncaught panics are captured via `std::panic::set_hook` and written as
63///   `"PANIC"` entries before the process unwinds.
64///
65/// # Example
66///
67/// ```no_run
68/// use ghpascon_rust::utils::logger_manager::{LoggerManager, LogLevel};
69///
70/// #[tokio::main]
71/// async fn main() {
72///     let logger = LoggerManager::builder("myapp")
73///         .level(LogLevel::Warn)
74///         .log_path("var/log")
75///         .retention_days(30)
76///         .build()
77///         .await;
78///
79///     logger.warn("high memory usage");
80///     logger.error("connection refused");
81/// }
82/// ```
83pub struct LoggerManager {
84    app_name: String,
85    log_path: PathBuf,
86    retention_days: i64,
87    level: LogLevel,
88    sender: std_mpsc::SyncSender<LogMessage>,
89    writer_thread: std::sync::Mutex<Option<std::thread::JoinHandle<()>>>,
90}
91
92// ── Builder ───────────────────────────────────────────────────────────────────
93
94/// Builder for [`LoggerManager`]. Obtain one via [`LoggerManager::builder`].
95pub struct LoggerManagerBuilder {
96    app_name: String,
97    level: LogLevel,
98    log_path: PathBuf,
99    retention_days: i64,
100}
101
102impl LoggerManagerBuilder {
103    /// Sets the minimum log level (default: [`LogLevel::Info`]).
104    pub fn level(mut self, level: LogLevel) -> Self {
105        self.level = level;
106        self
107    }
108
109    /// Sets the directory where log files are stored (default: `"logs"`).
110    pub fn log_path(mut self, path: impl Into<PathBuf>) -> Self {
111        self.log_path = path.into();
112        self
113    }
114
115    /// Sets how many days to keep log files before deletion (default: `7`).
116    pub fn retention_days(mut self, days: i64) -> Self {
117        self.retention_days = days;
118        self
119    }
120
121    /// Starts the logger and returns an `Arc<LoggerManager>`.
122    pub async fn build(self) -> Arc<LoggerManager> {
123        if let Err(e) = fs::create_dir_all(&self.log_path).await {
124            eprintln!(
125                "[LoggerManager] Failed to create log directory {:?}: {}",
126                self.log_path, e
127            );
128        }
129
130        let (sender, receiver) = std_mpsc::sync_channel::<LogMessage>(4096);
131        let (log_path, app_name) = (self.log_path.clone(), self.app_name.clone());
132
133        let manager = Arc::new(LoggerManager {
134            app_name: self.app_name,
135            log_path: self.log_path,
136            retention_days: self.retention_days,
137            level: self.level,
138            sender,
139            writer_thread: std::sync::Mutex::new(None),
140        });
141
142        *manager.writer_thread.lock().unwrap() = Some(std::thread::spawn(move || {
143            LoggerManager::writer_fn(receiver, log_path, app_name)
144        }));
145
146        manager.cleanup_old_logs().await;
147
148        let weak: Weak<LoggerManager> = Arc::downgrade(&manager);
149        tokio::spawn(async move {
150            let mut interval = time::interval(time::Duration::from_secs(86_400));
151            interval.tick().await;
152            loop {
153                interval.tick().await;
154                match weak.upgrade() {
155                    Some(m) => m.cleanup_old_logs().await,
156                    None => break,
157                }
158            }
159        });
160
161        let panic_sender = manager.sender.clone();
162        let panic_app = manager.app_name.clone();
163        std::panic::set_hook(Box::new(move |info| {
164            let loc = info
165                .location()
166                .map(|l| format!(" at {}:{}", l.file(), l.line()))
167                .unwrap_or_default();
168            let payload = info
169                .payload()
170                .downcast_ref::<&str>()
171                .map(|s| (*s).to_string())
172                .or_else(|| info.payload().downcast_ref::<String>().cloned())
173                .unwrap_or_else(|| "unknown panic".to_string());
174            let _ = panic_sender.try_send(LogMessage::Entry(LogEntry {
175                timestamp: Local::now().to_rfc3339(),
176                level: "PANIC".to_string(),
177                app: panic_app.clone(),
178                message: format!("panic{}: {}", loc, payload),
179                module: String::new(),
180                file: String::new(),
181                line: 0,
182                thread: std::thread::current()
183                    .name()
184                    .unwrap_or("unnamed")
185                    .to_string(),
186            }));
187            std::thread::sleep(std::time::Duration::from_millis(200));
188        }));
189
190        manager._log_internal(
191            LogLevel::Info,
192            format!(
193                "LoggerManager started | app={} path={:?} retain={}d level={}",
194                manager.app_name, manager.log_path, manager.retention_days, manager.level
195            ),
196            module_path!(),
197            file!(),
198            line!(),
199        );
200
201        manager
202    }
203}
204
205impl LoggerManager {
206    /// Returns a builder for constructing a `LoggerManager`.
207    ///
208    /// # Example
209    ///
210    /// ```no_run
211    /// use ghpascon_rust::utils::logger_manager::{LoggerManager, LogLevel};
212    ///
213    /// #[tokio::main]
214    /// async fn main() {
215    ///     let logger = LoggerManager::builder("myapp")
216    ///         .level(LogLevel::Debug)
217    ///         .build()
218    ///         .await;
219    /// }
220    /// ```
221    pub fn builder(app_name: impl Into<String>) -> LoggerManagerBuilder {
222        LoggerManagerBuilder {
223            app_name: app_name.into(),
224            level: LogLevel::Info,
225            log_path: PathBuf::from("logs"),
226            retention_days: 7,
227        }
228    }
229
230    // ── Public logging API ────────────────────────────────────────────────────
231
232    /// Internal method used by the `lgr_*!` macros for full call-site metadata.
233    #[doc(hidden)]
234    pub fn _log_internal(
235        &self,
236        level: LogLevel,
237        message: String,
238        module: &str,
239        file: &str,
240        line: u32,
241    ) {
242        if level < self.level {
243            return;
244        }
245        let _ = self.sender.try_send(LogMessage::Entry(LogEntry {
246            timestamp: Local::now().to_rfc3339(),
247            level: level.as_str().to_string(),
248            app: self.app_name.clone(),
249            message,
250            module: module.to_string(),
251            file: file.to_string(),
252            line,
253            thread: std::thread::current()
254                .name()
255                .unwrap_or("unnamed")
256                .to_string(),
257        }));
258    }
259
260    /// Emits a log entry at the given level. Non-blocking.
261    /// Use the `lgr_*!` macros for module/file metadata.
262    #[track_caller]
263    pub fn log(&self, level: LogLevel, message: impl Into<String>) {
264        let loc = std::panic::Location::caller();
265        self._log_internal(level, message.into(), "", loc.file(), loc.line());
266    }
267
268    #[track_caller]
269    pub fn debug(&self, msg: impl Into<String>) {
270        self.log(LogLevel::Debug, msg);
271    }
272    #[track_caller]
273    pub fn info(&self, msg: impl Into<String>) {
274        self.log(LogLevel::Info, msg);
275    }
276    #[track_caller]
277    pub fn warn(&self, msg: impl Into<String>) {
278        self.log(LogLevel::Warn, msg);
279    }
280    #[track_caller]
281    pub fn error(&self, msg: impl Into<String>) {
282        self.log(LogLevel::Error, msg);
283    }
284
285    // ── Background tasks ──────────────────────────────────────────────────────
286
287    fn writer_fn(receiver: std_mpsc::Receiver<LogMessage>, log_path: PathBuf, app_name: String) {
288        if let Err(e) = std::fs::create_dir_all(&log_path) {
289            eprintln!(
290                "[LoggerManager] Cannot create log dir {:?}: {}",
291                log_path, e
292            );
293        }
294        while let Ok(msg) = receiver.recv() {
295            let LogMessage::Entry(entry) = msg else { break };
296
297            // ── console ──────────────────────────────────────────────────────
298            let (pad, color) = match entry.level.as_str() {
299                "DEBUG" => ("DEBUG", "\x1b[90m"),
300                "INFO" => ("INFO ", "\x1b[32m"),
301                "WARN" => ("WARN ", "\x1b[33m"),
302                "ERROR" => ("ERROR", "\x1b[31m"),
303                "PANIC" => ("PANIC", "\x1b[1;31m"),
304                other => (other, ""),
305            };
306            let loc = if !entry.file.is_empty() && entry.line > 0 {
307                format!("  \x1b[2m{}:{}\x1b[0m", entry.file, entry.line)
308            } else if !entry.module.is_empty() {
309                format!("  \x1b[2m{}\x1b[0m", entry.module)
310            } else {
311                String::new()
312            };
313            println!(
314                "\x1b[2m{}\x1b[0m  {}{}\x1b[0m  \x1b[1m{}\x1b[0m{}  {}",
315                Local::now().format("%H:%M:%S"),
316                color,
317                pad,
318                entry.app,
319                loc,
320                entry.message,
321            );
322
323            // ── file ─────────────────────────────────────────────────────────
324            let file_path = log_path.join(format!(
325                "{}_{}.jsonl",
326                Local::now().format("%Y-%m-%d"),
327                app_name
328            ));
329            if let Ok(json) = serde_json::to_string(&entry) {
330                match std::fs::OpenOptions::new()
331                    .create(true)
332                    .append(true)
333                    .open(&file_path)
334                {
335                    Ok(mut f) => {
336                        if let Err(e) = writeln!(f, "{}", json) {
337                            eprintln!("[LoggerManager] Write error: {}", e);
338                        }
339                    }
340                    Err(e) => eprintln!("[LoggerManager] Cannot open {:?}: {}", file_path, e),
341                }
342            }
343        }
344    }
345
346    async fn cleanup_old_logs(&self) {
347        let cutoff = (Local::now() - Duration::days(self.retention_days)).date_naive();
348        let mut entries = match fs::read_dir(&self.log_path).await {
349            Ok(e) => e,
350            Err(e) => {
351                eprintln!("[LoggerManager] Cannot read log dir: {}", e);
352                return;
353            }
354        };
355        loop {
356            match entries.next_entry().await {
357                Ok(Some(entry)) => {
358                    let path = entry.path();
359                    if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
360                        continue;
361                    }
362                    let is_old = path
363                        .file_name()
364                        .and_then(|n| n.to_str())
365                        .and_then(|n| n.get(..10))
366                        .and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
367                        .map_or(false, |d| d < cutoff);
368                    if is_old {
369                        if let Err(e) = fs::remove_file(&path).await {
370                            eprintln!("[LoggerManager] Failed to delete {:?}: {}", path, e);
371                        }
372                    }
373                }
374                Ok(None) => break,
375                Err(e) => {
376                    eprintln!("[LoggerManager] Dir read error: {}", e);
377                    break;
378                }
379            }
380        }
381    }
382}
383
384// ── Macros ───────────────────────────────────────────────────────────────────
385
386/// Logs at DEBUG level with full call-site metadata.
387#[macro_export]
388macro_rules! lgr_debug {
389    ($logger:expr, $($arg:tt)*) => {
390        $logger._log_internal(
391            $crate::utils::logger_manager::LogLevel::Debug,
392            format!($($arg)*), module_path!(), file!(), line!(),
393        )
394    };
395}
396
397/// Logs at INFO level with full call-site metadata.
398#[macro_export]
399macro_rules! lgr_info {
400    ($logger:expr, $($arg:tt)*) => {
401        $logger._log_internal(
402            $crate::utils::logger_manager::LogLevel::Info,
403            format!($($arg)*), module_path!(), file!(), line!(),
404        )
405    };
406}
407
408/// Logs at WARN level with full call-site metadata.
409#[macro_export]
410macro_rules! lgr_warn {
411    ($logger:expr, $($arg:tt)*) => {
412        $logger._log_internal(
413            $crate::utils::logger_manager::LogLevel::Warn,
414            format!($($arg)*), module_path!(), file!(), line!(),
415        )
416    };
417}
418
419/// Logs at ERROR level with full call-site metadata.
420#[macro_export]
421macro_rules! lgr_error {
422    ($logger:expr, $($arg:tt)*) => {
423        $logger._log_internal(
424            $crate::utils::logger_manager::LogLevel::Error,
425            format!($($arg)*), module_path!(), file!(), line!(),
426        )
427    };
428}
429
430impl Drop for LoggerManager {
431    fn drop(&mut self) {
432        let _ = self.sender.send(LogMessage::Shutdown);
433        if let Ok(mut guard) = self.writer_thread.lock() {
434            if let Some(handle) = guard.take() {
435                let _ = handle.join();
436            }
437        }
438    }
439}
440
441// ── Tests ─────────────────────────────────────────────────────────────────────
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use tempfile::tempdir;
447
448    #[tokio::test]
449    async fn test_creates_log_file() {
450        let dir = tempdir().unwrap();
451        let logger = LoggerManager::builder("testapp")
452            .level(LogLevel::Debug)
453            .log_path(dir.path())
454            .retention_days(7)
455            .build()
456            .await;
457
458        logger.info("hello from test");
459        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
460
461        let date = Local::now().format("%Y-%m-%d");
462        let log_file = dir.path().join(format!("{}_testapp.jsonl", date));
463        assert!(log_file.exists(), "log file should have been created");
464
465        let content = std::fs::read_to_string(&log_file).unwrap();
466        assert!(content.contains("\"level\":\"INFO\""));
467        assert!(content.contains("hello from test"));
468    }
469
470    #[tokio::test]
471    async fn test_level_filtering() {
472        let dir = tempdir().unwrap();
473        let logger = LoggerManager::builder("filterapp")
474            .level(LogLevel::Warn)
475            .log_path(dir.path())
476            .retention_days(7)
477            .build()
478            .await;
479
480        logger.debug("ignored debug");
481        logger.info("ignored info");
482        logger.warn("visible warn");
483        logger.error("visible error");
484        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
485
486        let date = Local::now().format("%Y-%m-%d");
487        let log_file = dir.path().join(format!("{}_filterapp.jsonl", date));
488        let content = std::fs::read_to_string(&log_file).unwrap();
489        assert!(!content.contains("ignored"));
490        assert!(content.contains("visible warn"));
491        assert!(content.contains("visible error"));
492    }
493
494    #[tokio::test]
495    async fn test_creates_directory_if_missing() {
496        let dir = tempdir().unwrap();
497        let nested = dir.path().join("deep/nested/logs");
498        let logger = LoggerManager::builder("nestapp")
499            .log_path(nested.clone())
500            .build()
501            .await;
502        logger.info("test");
503        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
504        assert!(nested.exists());
505    }
506
507    #[tokio::test]
508    async fn test_json_format() {
509        let dir = tempdir().unwrap();
510        let logger = LoggerManager::builder("jsonapp")
511            .level(LogLevel::Debug)
512            .log_path(dir.path())
513            .build()
514            .await;
515        logger.error("boom");
516        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
517
518        let date = Local::now().format("%Y-%m-%d");
519        let log_file = dir.path().join(format!("{}_jsonapp.jsonl", date));
520        let content = std::fs::read_to_string(&log_file).unwrap();
521        let line = content.lines().find(|l| l.contains("boom")).unwrap();
522        let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
523        assert_eq!(parsed["level"], "ERROR");
524        assert_eq!(parsed["app"], "jsonapp");
525        assert_eq!(parsed["message"], "boom");
526        assert!(parsed["timestamp"].is_string());
527    }
528
529    #[tokio::test]
530    async fn test_old_logs_are_deleted() {
531        let dir = tempdir().unwrap();
532        // Nome com data antiga — o cleanup parseia a data do nome do arquivo
533        let old_file = dir.path().join("2000-01-01_oldapp.jsonl");
534        std::fs::write(&old_file, "old\n").unwrap();
535
536        let logger = LoggerManager::builder("oldapp")
537            .log_path(dir.path())
538            .retention_days(7)
539            .build()
540            .await;
541        // Cleanup runs immediately on first interval tick; give it time
542        tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
543        assert!(!old_file.exists(), "old log file should have been deleted");
544        drop(logger);
545    }
546}