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    // Ring-buffer layer for in-process log retrieval via /v1/system/log_tail.
146    // Capacity is controlled via FEAGI_LOG_RING_BUFFER_CAPACITY (default 2000, set 0 to disable).
147    let ring_capacity = crate::ring_layer::capacity_from_env();
148    if ring_capacity > 0 {
149        let ring = crate::ring_layer::install_global_ring(ring_capacity);
150        let ring_layer = crate::ring_layer::RingBufferLayer::new(ring)
151            .with_filter(env_filter.clone())
152            .boxed();
153        layers.push(ring_layer);
154    }
155
156    // Initialize subscriber with all layers
157    Registry::default().with(layers).init();
158
159    // Keep all guards alive (they flush logs on drop)
160    file_guards.push(combined_guard);
161
162    Ok(LoggingGuard {
163        _file_guards: file_guards,
164        log_dir: run_folder,
165    })
166}
167
168/// Initialize logging with console output only (WASM-compatible)
169///
170/// For WASM builds, file logging is not available. This function provides
171/// console-only logging that works in browsers.
172#[cfg(not(feature = "file-logging"))]
173pub fn init_logging(
174    debug_flags: &CrateDebugFlags,
175    _log_dir: Option<PathBuf>,
176    _retention_days: Option<u64>,
177    _retention_runs: Option<usize>,
178) -> Result<LoggingGuard> {
179    let env_filter = resolve_env_filter(debug_flags)?;
180
181    // Console layer only (human-readable)
182    let console_layer = tracing_subscriber::fmt::layer()
183        .with_target(false)
184        .with_file(false)
185        .with_line_number(false)
186        .with_filter(env_filter.clone());
187
188    let mut layers: Vec<Box<dyn tracing_subscriber::Layer<Registry> + Send + Sync>> = Vec::new();
189    layers.push(console_layer.boxed());
190
191    // Ring-buffer layer for in-process log retrieval via /v1/system/log_tail.
192    let ring_capacity = crate::ring_layer::capacity_from_env();
193    if ring_capacity > 0 {
194        let ring = crate::ring_layer::install_global_ring(ring_capacity);
195        let ring_layer = crate::ring_layer::RingBufferLayer::new(ring)
196            .with_filter(env_filter)
197            .boxed();
198        layers.push(ring_layer);
199    }
200
201    // Initialize subscriber with combined layers
202    Registry::default().with(layers).init();
203
204    Ok(LoggingGuard {})
205}
206
207/// Clean up old log directories based on retention policy (desktop only)
208#[cfg(feature = "file-logging")]
209fn cleanup_old_logs(
210    base_log_dir: &Path,
211    retention_days: Option<u64>,
212    retention_runs: Option<usize>,
213) -> Result<()> {
214    if !base_log_dir.exists() {
215        return Ok(());
216    }
217
218    let retention_days = retention_days.unwrap_or(30);
219    let retention_runs = retention_runs.unwrap_or(10);
220    let cutoff_date = Utc::now() - chrono::Duration::days(retention_days as i64);
221
222    // Collect all run directories
223    let mut runs: Vec<(PathBuf, DateTime<Utc>)> = Vec::new();
224
225    for entry in std::fs::read_dir(base_log_dir)? {
226        let entry = entry?;
227        let path = entry.path();
228
229        if path.is_dir() {
230            if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) {
231                if dir_name.starts_with("run_") {
232                    // Parse timestamp from folder name: run_20250101_120000
233                    if let Some(timestamp_str) = dir_name.strip_prefix("run_") {
234                        if let Ok(dt) = DateTime::parse_from_str(timestamp_str, "%Y%m%d_%H%M%S") {
235                            runs.push((path, dt.with_timezone(&Utc)));
236                        }
237                    }
238                }
239            }
240        }
241    }
242
243    // Sort by date (oldest first)
244    runs.sort_by_key(|(_, dt)| *dt);
245
246    // Remove runs older than retention_days
247    let mut removed_count = 0;
248    for (path, dt) in &runs {
249        if *dt < cutoff_date {
250            if let Err(e) = std::fs::remove_dir_all(path) {
251                eprintln!(
252                    "Warning: Failed to remove old log directory {}: {}",
253                    path.display(),
254                    e
255                );
256            } else {
257                removed_count += 1;
258            }
259        }
260    }
261
262    // Keep only the most recent N runs (after removing old ones)
263    if runs.len() - removed_count > retention_runs {
264        let to_remove = runs.len() - removed_count - retention_runs;
265        for (path, dt) in runs.iter().take(to_remove) {
266            if *dt >= cutoff_date {
267                // Only remove if not already removed by date-based cleanup
268                if path.exists() {
269                    if let Err(e) = std::fs::remove_dir_all(path) {
270                        eprintln!(
271                            "Warning: Failed to remove old log directory {}: {}",
272                            path.display(),
273                            e
274                        );
275                    }
276                }
277            }
278        }
279    }
280
281    Ok(())
282}
283
284/// Initialize logging with default settings
285pub fn init_logging_default(debug_flags: &CrateDebugFlags) -> Result<LoggingGuard> {
286    init_logging(debug_flags, None, None, None)
287}