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}