Skip to main content

feagi_observability/
init.rs

1// Copyright 2025 Neuraville Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Unified logging initialization for FEAGI
5//!
6//! Provides file logging with rotation, per-crate log files, and configurable retention.
7
8use anyhow::anyhow;
9#[cfg(feature = "file-logging")]
10use anyhow::Context;
11use anyhow::Result;
12#[cfg(feature = "file-logging")]
13use chrono::Utc;
14use std::path::{Path, PathBuf};
15#[cfg(feature = "file-logging")]
16use tracing_appender::rolling;
17use tracing_subscriber::layer::SubscriberExt;
18use tracing_subscriber::util::SubscriberInitExt;
19use tracing_subscriber::{EnvFilter, Layer, Registry};
20
21use crate::cli::CrateDebugFlags;
22
23/// Resolve tracing EnvFilter with explicit RUST_LOG precedence.
24///
25/// If RUST_LOG is present, it is parsed and used verbatim.
26/// Otherwise, fall back to the per-crate debug flags filter string.
27fn resolve_env_filter(debug_flags: &CrateDebugFlags) -> Result<EnvFilter> {
28    if let Ok(rust_log) = std::env::var("RUST_LOG") {
29        return EnvFilter::try_new(rust_log.clone())
30            .map_err(|e| anyhow!("Invalid RUST_LOG '{}': {}", rust_log, e));
31    }
32
33    let filter = debug_flags.to_filter_string();
34    Ok(EnvFilter::new(&filter))
35}
36
37/// Logging initialization result
38pub struct LoggingGuard {
39    #[cfg(feature = "file-logging")]
40    _file_guards: Vec<tracing_appender::non_blocking::WorkerGuard>,
41    #[cfg(feature = "file-logging")]
42    log_dir: PathBuf,
43}
44
45impl LoggingGuard {
46    /// Get the log directory path (desktop only)
47    #[cfg(feature = "file-logging")]
48    pub fn log_dir(&self) -> &Path {
49        &self.log_dir
50    }
51
52    #[cfg(not(feature = "file-logging"))]
53    pub fn log_dir(&self) -> &Path {
54        // WASM builds don't have file logging
55        Path::new(".")
56    }
57}
58
59/// Initialize logging with file output and console output
60///
61/// Creates a timestamped folder structure:
62/// ```
63/// ./logs/
64///   └── run_20250101_120000/
65///       ├── feagi-api.log
66///       ├── feagi-services.log
67///       ├── feagi-bdu.log
68///       └── feagi.log (combined)
69/// ```
70///
71/// # Arguments
72/// * `debug_flags` - Per-crate debug flags for filtering
73/// * `log_dir` - Base directory for logs (default: `./logs`)
74/// * `retention_days` - Keep logs for N days (default: 30)
75/// * `retention_runs` - Keep N most recent runs (default: 10)
76#[cfg(feature = "file-logging")]
77pub fn init_logging(
78    debug_flags: &CrateDebugFlags,
79    log_dir: Option<PathBuf>,
80    retention_days: Option<u64>,
81    retention_runs: Option<usize>,
82) -> Result<LoggingGuard> {
83    let base_log_dir = log_dir.unwrap_or_else(|| PathBuf::from("./logs"));
84
85    // Create timestamped run folder
86    let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
87    let run_folder = base_log_dir.join(format!("run_{}", timestamp));
88    std::fs::create_dir_all(&run_folder)
89        .with_context(|| format!("Failed to create log directory: {}", run_folder.display()))?;
90
91    // Clean up old logs based on retention policy
92    cleanup_old_logs(&base_log_dir, retention_days, retention_runs)?;
93
94    let env_filter = resolve_env_filter(debug_flags)?;
95
96    // Create per-crate log files
97    let mut layers = Vec::new();
98    let mut file_guards = Vec::new();
99
100    // Console layer (human-readable)
101    let console_layer = tracing_subscriber::fmt::layer()
102        .with_target(false)
103        .with_file(false)
104        .with_line_number(false)
105        .with_filter(env_filter.clone());
106    layers.push(console_layer.boxed());
107
108    // File layers - one per crate
109    for crate_name in crate::KNOWN_CRATES {
110        // Create file appender with daily rotation
111        let file_appender = rolling::daily(&run_folder, format!("{}.log", crate_name));
112
113        let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
114        file_guards.push(guard);
115
116        // JSON formatter for file
117        let file_layer = tracing_subscriber::fmt::layer()
118            .with_writer(non_blocking)
119            .with_target(true)
120            .with_file(true)
121            .with_line_number(true)
122            .json()
123            // Filter only this crate's logs
124            .with_filter(EnvFilter::new(format!("{}=debug,info", crate_name)))
125            .boxed();
126
127        layers.push(file_layer);
128    }
129
130    // Combined log file (all crates)
131    let combined_appender = rolling::daily(&run_folder, "feagi.log");
132    let (combined_non_blocking, combined_guard) = tracing_appender::non_blocking(combined_appender);
133
134    let combined_layer = tracing_subscriber::fmt::layer()
135        .with_writer(combined_non_blocking)
136        .with_target(true)
137        .with_file(true)
138        .with_line_number(true)
139        .json()
140        .with_filter(env_filter.clone())
141        .boxed();
142
143    layers.push(combined_layer);
144
145    // Initialize subscriber with all layers
146    Registry::default().with(layers).init();
147
148    // Keep all guards alive (they flush logs on drop)
149    file_guards.push(combined_guard);
150
151    Ok(LoggingGuard {
152        _file_guards: file_guards,
153        log_dir: run_folder,
154    })
155}
156
157/// Initialize logging with console output only (WASM-compatible)
158///
159/// For WASM builds, file logging is not available. This function provides
160/// console-only logging that works in browsers.
161#[cfg(not(feature = "file-logging"))]
162pub fn init_logging(
163    debug_flags: &CrateDebugFlags,
164    _log_dir: Option<PathBuf>,
165    _retention_days: Option<u64>,
166    _retention_runs: Option<usize>,
167) -> Result<LoggingGuard> {
168    let env_filter = resolve_env_filter(debug_flags)?;
169
170    // Console layer only (human-readable)
171    let console_layer = tracing_subscriber::fmt::layer()
172        .with_target(false)
173        .with_file(false)
174        .with_line_number(false)
175        .with_filter(env_filter);
176
177    // Initialize subscriber with console layer only
178    Registry::default().with(console_layer.boxed()).init();
179
180    Ok(LoggingGuard {})
181}
182
183/// Clean up old log directories based on retention policy (desktop only)
184#[cfg(feature = "file-logging")]
185fn cleanup_old_logs(
186    base_log_dir: &Path,
187    retention_days: Option<u64>,
188    retention_runs: Option<usize>,
189) -> Result<()> {
190    if !base_log_dir.exists() {
191        return Ok(());
192    }
193
194    let retention_days = retention_days.unwrap_or(30);
195    let retention_runs = retention_runs.unwrap_or(10);
196    let cutoff_date = Utc::now() - chrono::Duration::days(retention_days as i64);
197
198    // Collect all run directories
199    let mut runs: Vec<(PathBuf, DateTime<Utc>)> = Vec::new();
200
201    for entry in std::fs::read_dir(base_log_dir)? {
202        let entry = entry?;
203        let path = entry.path();
204
205        if path.is_dir() {
206            if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) {
207                if dir_name.starts_with("run_") {
208                    // Parse timestamp from folder name: run_20250101_120000
209                    if let Some(timestamp_str) = dir_name.strip_prefix("run_") {
210                        if let Ok(dt) = DateTime::parse_from_str(timestamp_str, "%Y%m%d_%H%M%S") {
211                            runs.push((path, dt.with_timezone(&Utc)));
212                        }
213                    }
214                }
215            }
216        }
217    }
218
219    // Sort by date (oldest first)
220    runs.sort_by_key(|(_, dt)| *dt);
221
222    // Remove runs older than retention_days
223    let mut removed_count = 0;
224    for (path, dt) in &runs {
225        if *dt < cutoff_date {
226            if let Err(e) = std::fs::remove_dir_all(path) {
227                eprintln!(
228                    "Warning: Failed to remove old log directory {}: {}",
229                    path.display(),
230                    e
231                );
232            } else {
233                removed_count += 1;
234            }
235        }
236    }
237
238    // Keep only the most recent N runs (after removing old ones)
239    if runs.len() - removed_count > retention_runs {
240        let to_remove = runs.len() - removed_count - retention_runs;
241        for (path, dt) in runs.iter().take(to_remove) {
242            if *dt >= cutoff_date {
243                // Only remove if not already removed by date-based cleanup
244                if path.exists() {
245                    if let Err(e) = std::fs::remove_dir_all(path) {
246                        eprintln!(
247                            "Warning: Failed to remove old log directory {}: {}",
248                            path.display(),
249                            e
250                        );
251                    }
252                }
253            }
254        }
255    }
256
257    Ok(())
258}
259
260/// Initialize logging with default settings
261pub fn init_logging_default(debug_flags: &CrateDebugFlags) -> Result<LoggingGuard> {
262    init_logging(debug_flags, None, None, None)
263}