Skip to main content

robit_ai/
logging.rs

1//! Shared logging initialization for Robit binaries.
2//!
3//! Provides a unified way to initialize logging with support for:
4//! - Config file `app.log_level` setting
5//! - Config file `app.log_file` setting (log to file, daily rotation, local time)
6//! - Local-time timestamps and log file names (falls back to UTC if the system
7//!   offset can't be determined)
8//! - Config file `app.log_retention_days` setting (delete old `robit-*.log`
9//!   files on startup; default 14 days, `0` disables)
10//! - Environment variable `RUST_LOG` (takes precedence)
11//! - Sensible defaults for third-party crates
12
13use crate::config::AppConfig;
14use std::fs::OpenOptions;
15use std::path::{Path, PathBuf};
16use std::time::{Duration, SystemTime};
17use time::format_description;
18use time::UtcOffset;
19use tracing_subscriber::fmt::time::OffsetTime;
20use tracing_subscriber::prelude::*;
21use tracing_subscriber::{filter::Directive, EnvFilter};
22
23/// Get the log file path: {working_dir}/.robit/logs/robit-YYYY-MM-DD.log
24///
25/// Creates the directory if it doesn't exist. The date uses `offset` (local
26/// time by default) so the file rolls at local midnight, not UTC midnight.
27fn get_log_file_path(
28    working_dir: &PathBuf,
29    offset: UtcOffset,
30) -> Result<PathBuf, Box<dyn std::error::Error>> {
31    let logs_dir = working_dir.join(".robit").join("logs");
32
33    // Create logs directory if it doesn't exist
34    std::fs::create_dir_all(&logs_dir)?;
35
36    // Format date as YYYY-MM-DD (local time)
37    let now = time::OffsetDateTime::now_utc().to_offset(offset);
38    let format = format_description::parse_borrowed::<3>("[year]-[month]-[day]").unwrap();
39    let date = now.format(&format).unwrap();
40    let log_file = logs_dir.join(format!("robit-{}.log", date));
41
42    Ok(log_file)
43}
44
45/// Determine the system's local UTC offset for log timestamps and file naming.
46///
47/// Falls back to UTC (with a stderr warning) if the offset can't be determined.
48/// In `time` 0.3.37+ this works on any thread: Unix uses the reentrant
49/// `localtime_r`, Windows uses `SystemTimeToTzSpecificLocalTime` - both
50/// thread-safe - so it's safe to call even after the tokio runtime starts.
51fn local_utc_offset() -> UtcOffset {
52    match UtcOffset::current_local_offset() {
53        Ok(offset) => offset,
54        Err(_) => {
55            eprintln!(
56                "Could not determine local time offset; log timestamps will be UTC."
57            );
58            UtcOffset::UTC
59        }
60    }
61}
62
63/// Build a tracing timer that stamps events with local time. The format matches
64/// the prior default (`YYYY-MM-DDTHH:MM:SS.ffffff`) but carries the local
65/// offset (e.g. `+08:00`) instead of `Z`. The format description is parsed
66/// once at init from a `&'static str` literal, so the result is `'static` -
67/// no leak needed.
68fn local_timer(
69    offset: UtcOffset,
70) -> OffsetTime<format_description::FormatDescriptionV3<'static>> {
71    let format = format_description::parse_borrowed::<3>(
72        "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6][offset_hour sign:mandatory]:[offset_minute]",
73    )
74    .expect("hardcoded log timestamp format is valid");
75    OffsetTime::new(offset, format)
76}
77
78/// Delete `robit-YYYY-MM-DD.log` files whose modification time is older than
79/// `retention_days` days. Best-effort: unreadable entries and deletion
80/// failures are logged via `tracing::warn!` and skipped. `retention_days == 0`
81/// disables cleanup. Only files matching `robit-*.log` are considered, so
82/// `err.log` and other files are left untouched. Called once at startup.
83fn cleanup_old_logs(logs_dir: &Path, retention_days: u32) {
84    if retention_days == 0 {
85        return;
86    }
87    let cutoff = SystemTime::now() - Duration::from_secs(retention_days as u64 * 86_400);
88    let entries = match std::fs::read_dir(logs_dir) {
89        Ok(e) => e,
90        Err(e) => {
91            tracing::warn!("Failed to scan log dir for cleanup: {}", e);
92            return;
93        }
94    };
95    let mut removed = 0u32;
96    for entry in entries.flatten() {
97        let name = entry.file_name();
98        let name = name.to_string_lossy();
99        if !(name.starts_with("robit-") && name.ends_with(".log")) {
100            continue;
101        }
102        let modified = match entry.metadata().and_then(|m| m.modified()) {
103            Ok(m) => m,
104            Err(_) => continue,
105        };
106        if modified < cutoff {
107            if let Err(e) = std::fs::remove_file(entry.path()) {
108                tracing::warn!("Failed to delete old log {}: {}", name, e);
109            } else {
110                removed += 1;
111            }
112        }
113    }
114    if removed > 0 {
115        tracing::info!(
116            "Cleaned up {} old log file(s) (retention {} days).",
117            removed,
118            retention_days
119        );
120    }
121}
122
123/// Build the EnvFilter from config and defaults.
124fn build_filter(
125    app_config: Option<&AppConfig>,
126    target_crate: &str,
127    additional_directives: &[&str],
128) -> EnvFilter {
129    let mut filter = EnvFilter::from_default_env();
130
131    // If no RUST_LOG is set, build from config and defaults
132    if std::env::var("RUST_LOG").is_err() {
133        // Use log_level from config if present, otherwise default to info
134        let global_level = app_config
135            .and_then(|c| c.log_level.as_deref())
136            .unwrap_or("info");
137
138        // Add target crate directive
139        if let Ok(dir) = format!("{}={}", target_crate, global_level).parse() {
140            filter = filter.add_directive(dir);
141        }
142
143        // Also set robit crates to the same level
144        for robit_crate in &["robit_agent", "robit_chatbot", "robit_ai"] {
145            if robit_crate != &target_crate {
146                if let Ok(dir) = format!("{}={}", robit_crate, global_level).parse() {
147                    filter = filter.add_directive(dir);
148                }
149            }
150        }
151
152        // Add additional directives
153        for dir_str in additional_directives {
154            if let Ok(dir) = dir_str.parse::<Directive>() {
155                filter = filter.add_directive(dir);
156            }
157        }
158
159        // Default third-party crates to warn
160        for dep_crate in &[
161            "reqwest",
162            "hyper",
163            "hyper_util",
164            "tungstenite",
165            "tokio_tungstenite",
166            "tokio",
167            "tauri",
168        ] {
169            if let Ok(dir) = format!("{}=warn", dep_crate).parse() {
170                filter = filter.add_directive(dir);
171            }
172        }
173    }
174
175    filter
176}
177
178/// Install a panic hook that records panics through the tracing subscriber
179/// (so they land in the log file) before chaining to the previous hook (which
180/// prints to stderr).
181///
182/// Without this, a panic in a spawned task goes to stderr only. A binary run
183/// detached (e.g. the robit-qq server under nohup/systemd with stderr in a
184/// separate file) then leaves no trace in the tracing log, and the failure
185/// looks like an unexplained silent stall. This is exactly what masked the QQ
186/// gateway supervisor panic ("JoinHandle polled after completion"): it
187/// appeared in err.log (stderr) but never in the tracing log, so the server
188/// silently lost its QQ connection after every ~30-min gateway rotation.
189///
190/// Must be called AFTER the tracing subscriber is initialized, otherwise the
191/// `tracing::error!` is dropped (no subscriber). The hook chains to whatever
192/// hook was installed before it, so an existing hook still runs - e.g. the TUI
193/// installs its terminal-restore hook after `init_logging_silent`, so its
194/// `take_hook` captures this one and the chain becomes: TUI restore -> tracing
195/// log -> default stderr.
196fn install_panic_hook() {
197    let previous_hook = std::panic::take_hook();
198    std::panic::set_hook(Box::new(move |info| {
199        // Best-effort message extraction. `panic!("literal")` yields `&'static str`,
200        // `panic!("{}", x)` yields `String`; anything else falls back to a placeholder
201        // so the log line is still useful.
202        let payload_msg: String = if let Some(s) = info.payload().downcast_ref::<&str>() {
203            (*s).to_string()
204        } else if let Some(s) = info.payload().downcast_ref::<String>() {
205            s.clone()
206        } else {
207            "<non-string panic payload>".to_string()
208        };
209        let location = info
210            .location()
211            .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
212            .unwrap_or_else(|| "<unknown location>".to_string());
213        let thread_name = std::thread::current().name().unwrap_or("<unnamed>").to_string();
214
215        // The default target is the module path (`robit_ai::logging`), which
216        // the `robit_ai` EnvFilter directive (always added by `build_filter`)
217        // matches, so this passes the filter in every config. `error!` is used
218        // because a panic is always severe, and error >= any configured level
219        // except `off`.
220        tracing::error!(
221            "thread '{}' panicked at {}: {}",
222            thread_name,
223            location,
224            payload_msg
225        );
226
227        // Chain to the previous hook (default: stderr) so existing behavior is
228        // preserved.
229        previous_hook(info);
230    }));
231}
232
233/// Initialize logging with optional app config and a target crate name.
234///
235/// Priority order:
236/// 1. `RUST_LOG` environment variable (full control)
237/// 2. `app.log_level` from config.toml (sets global level)
238/// 3. Defaults to `info` for the target crate and `warn` for third-party crates
239///
240/// If `app.log_file = true`, logs are also written to:
241///   {working_dir}/.robit/logs/robit-YYYY-MM-DD.log (daily rotation)
242///
243/// # Arguments
244/// - `app_config`: Optional `AppConfig` from config.toml
245/// - `target_crate`: Name of the target crate (e.g. "robit_tui", "robit_qq")
246/// - `working_dir`: Working directory for the agent (where .robit/logs is created)
247/// - `additional_directives`: Optional additional `Directive`s for specific crates
248pub fn init_logging(
249    app_config: Option<&AppConfig>,
250    target_crate: &str,
251    working_dir: &PathBuf,
252    additional_directives: &[&str],
253) {
254    let filter = build_filter(app_config, target_crate, additional_directives);
255
256    // Local time offset, used for both the timestamp format and the daily log
257    // file name. Falls back to UTC if undeterminable.
258    let offset = local_utc_offset();
259    let timer = local_timer(offset);
260
261    // Check if file logging is enabled
262    let log_file_enabled = app_config.and_then(|c| c.log_file).unwrap_or(false);
263
264    if log_file_enabled {
265        // Log to both console and file
266        match get_log_file_path(working_dir, offset) {
267            Ok(log_path) => {
268                match OpenOptions::new()
269                    .create(true)
270                    .append(true)
271                    .open(&log_path)
272                {
273                    Ok(file) => {
274                        let file_writer = tracing_subscriber::fmt::writer::MakeWriterExt::with_max_level(file, tracing::Level::TRACE);
275
276                        // Create layers
277                        let console_layer = tracing_subscriber::fmt::layer()
278                            .with_writer(std::io::stdout)
279                            .with_timer(timer.clone())
280                            .with_filter(filter.clone());
281
282                        let file_layer = tracing_subscriber::fmt::layer()
283                            .with_writer(file_writer)
284                            .with_ansi(false)
285                            .with_timer(timer.clone())
286                            .with_filter(filter);
287
288                        // Combine layers
289                        let registry = tracing_subscriber::registry()
290                            .with(console_layer)
291                            .with(file_layer);
292
293                        registry.init();
294
295                        tracing::info!("Logging to file: {}", log_path.display());
296
297                        // Best-effort cleanup of old daily log files.
298                        let retention = app_config
299                            .and_then(|c| c.log_retention_days)
300                            .unwrap_or(14);
301                        if let Some(dir) = log_path.parent() {
302                            cleanup_old_logs(dir, retention);
303                        }
304                    }
305                    Err(e) => {
306                        // Fallback to console-only logging
307                        eprintln!("Failed to open log file: {}. Falling back to console-only logging.", e);
308                        tracing_subscriber::fmt()
309                            .with_env_filter(filter)
310                            .with_timer(timer.clone())
311                            .init();
312                    }
313                }
314            }
315            Err(e) => {
316                // Fallback to console-only logging
317                eprintln!("Failed to prepare log path: {}. Falling back to console-only logging.", e);
318                tracing_subscriber::fmt()
319                    .with_env_filter(filter)
320                    .with_timer(timer.clone())
321                    .init();
322            }
323        }
324    } else {
325        // Console-only logging (default)
326        tracing_subscriber::fmt()
327            .with_env_filter(filter)
328            .with_timer(timer.clone())
329            .init();
330    }
331
332    // Install after the subscriber is set up so panics are captured in the log.
333    install_panic_hook();
334}
335
336/// Initialize logging but discard console output (for TUI mode).
337///
338/// Same as `init_logging` but logs go to `/dev/null` instead of stdout, so
339/// they don't corrupt the terminal UI. When `app.log_file = true`, logs are
340/// still written to `{working_dir}/.robit/logs/robit-YYYY-MM-DD.log` - only
341/// console output is suppressed.
342pub fn init_logging_silent(
343    app_config: Option<&AppConfig>,
344    target_crate: &str,
345    working_dir: &PathBuf,
346    additional_directives: &[&str],
347) {
348    let filter = build_filter(app_config, target_crate, additional_directives);
349
350    let offset = local_utc_offset();
351    let timer = local_timer(offset);
352
353    let log_file_enabled = app_config.and_then(|c| c.log_file).unwrap_or(false);
354
355    if log_file_enabled {
356        // TUI mode: no console output, but write to file if enabled.
357        match get_log_file_path(working_dir, offset) {
358            Ok(log_path) => match OpenOptions::new().create(true).append(true).open(&log_path) {
359                Ok(file) => {
360                    let file_writer =
361                        tracing_subscriber::fmt::writer::MakeWriterExt::with_max_level(
362                            file,
363                            tracing::Level::TRACE,
364                        );
365                    tracing_subscriber::fmt()
366                        .with_env_filter(filter)
367                        .with_writer(file_writer)
368                        .with_ansi(false)
369                        .with_timer(timer.clone())
370                        .init();
371                    tracing::info!("Logging to file: {}", log_path.display());
372
373                    // Best-effort cleanup of old daily log files.
374                    let retention = app_config
375                        .and_then(|c| c.log_retention_days)
376                        .unwrap_or(14);
377                    if let Some(dir) = log_path.parent() {
378                        cleanup_old_logs(dir, retention);
379                    }
380                }
381                Err(e) => {
382                    eprintln!(
383                        "Failed to open log file: {}. Falling back to silent logging.",
384                        e
385                    );
386                    tracing_subscriber::fmt()
387                        .with_env_filter(filter)
388                        .with_writer(std::io::sink)
389                        .with_timer(timer.clone())
390                        .init();
391                }
392            },
393            Err(e) => {
394                eprintln!(
395                    "Failed to prepare log path: {}. Falling back to silent logging.",
396                    e
397                );
398                tracing_subscriber::fmt()
399                    .with_env_filter(filter)
400                    .with_writer(std::io::sink)
401                    .with_timer(timer.clone())
402                    .init();
403            }
404        }
405    } else {
406        // No file logging configured: discard all logs (TUI mode).
407        tracing_subscriber::fmt()
408            .with_env_filter(filter)
409            .with_writer(std::io::sink)
410            .with_timer(timer.clone())
411            .init();
412    }
413
414    // Install after the subscriber is set up so panics are captured in the log.
415    install_panic_hook();
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    /// `cleanup_old_logs` must delete only old `robit-*.log` files: keep recent
423    /// robit logs, and leave non-matching files (e.g. `err.log`) untouched
424    /// regardless of age. `retention_days == 0` disables cleanup entirely.
425    #[test]
426    fn cleanup_old_logs_deletes_old_keeps_recent_and_ignores_others() {
427        let dir = std::env::temp_dir().join(format!("robit-log-test-{}", std::process::id()));
428        let _ = std::fs::remove_dir_all(&dir);
429        std::fs::create_dir_all(&dir).unwrap();
430
431        // mtime far in the past => older than any reasonable retention window.
432        let ancient = SystemTime::now() - Duration::from_secs(60 * 86_400); // 60 days ago
433
434        let write_with_mtime = |name: &str, mtime: SystemTime| {
435            let path = dir.join(name);
436            let f = std::fs::File::create(&path).unwrap();
437            f.set_modified(mtime).unwrap();
438            path
439        };
440
441        let old = write_with_mtime("robit-2000-01-01.log", ancient); // should be deleted
442        let recent = write_with_mtime("robit-2099-01-01.log", SystemTime::now()); // keep
443        let other = write_with_mtime("err.log", ancient); // untouched (non-robit)
444
445        cleanup_old_logs(&dir, 14);
446
447        assert!(!old.exists(), "old robit-*.log should be deleted");
448        assert!(recent.exists(), "recent robit-*.log should be kept");
449        assert!(other.exists(), "non-robit file should be untouched");
450
451        // retention_days == 0 disables cleanup: even ancient files survive.
452        let old2 = write_with_mtime("robit-2001-01-01.log", ancient);
453        cleanup_old_logs(&dir, 0);
454        assert!(old2.exists(), "retention_days=0 should disable cleanup");
455
456        let _ = std::fs::remove_dir_all(&dir);
457    }
458}