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