Skip to main content

navi_core/
logging.rs

1use crate::config::LoggingConfig;
2use crate::security::redact_secrets;
3use anyhow::{Context, Result};
4use std::fs::{self, OpenOptions};
5use std::path::{Path, PathBuf};
6use tracing_subscriber::layer::SubscriberExt;
7use tracing_subscriber::{EnvFilter, Layer, Registry};
8
9/// Guard that keeps the tracing file appender alive. Dropping this stops logging.
10pub struct LoggingGuard {
11    path: Option<PathBuf>,
12    _guard: Option<tracing_appender::non_blocking::WorkerGuard>,
13}
14
15/// Runtime logging configuration that can be adjusted without restarting.
16#[derive(Debug, Clone)]
17pub struct LoggingRuntimeConfig {
18    /// Whether to log to stdout.
19    pub stdout_enabled: bool,
20    /// Whether to log to a file.
21    pub file_enabled: bool,
22    /// Override log level filter, or `None` to keep the current level.
23    pub level: Option<String>,
24    /// Whether to include raw payloads in log output.
25    pub include_payloads: bool,
26}
27
28impl LoggingGuard {
29    /// Returns the path to the log file, if file logging is active.
30    pub fn path(&self) -> Option<&Path> {
31        self.path.as_deref()
32    }
33}
34
35impl Default for LoggingRuntimeConfig {
36    fn default() -> Self {
37        Self {
38            stdout_enabled: false,
39            file_enabled: true,
40            level: None,
41            include_payloads: false,
42        }
43    }
44}
45
46/// Returns the log directory path: `<data_dir>/logs/`.
47pub fn log_dir(data_dir: &Path) -> PathBuf {
48    data_dir.join("logs")
49}
50
51/// Returns the log file path: `<data_dir>/logs/navi.log`.
52pub fn log_path(data_dir: &Path) -> PathBuf {
53    log_dir(data_dir).join("navi.log")
54}
55
56/// Initializes the tracing subscriber with file and/or stdout layers based on
57/// the logging config. Returns a [`LoggingGuard`] that must be kept alive.
58pub fn init_logging(
59    config: &LoggingConfig,
60    data_dir: &Path,
61    runtime: LoggingRuntimeConfig,
62) -> Result<LoggingGuard> {
63    if !config.enabled {
64        return Ok(LoggingGuard {
65            path: None,
66            _guard: None,
67        });
68    }
69
70    let level = runtime.level.unwrap_or_else(|| config.level.clone());
71    let filter =
72        EnvFilter::try_new(level.clone()).unwrap_or_else(|_| EnvFilter::new("navi=info,info"));
73    let mut layers: Vec<Box<dyn Layer<Registry> + Send + Sync>> = Vec::new();
74    let mut guard = None;
75    let mut path = None;
76
77    if config.file_enabled && runtime.file_enabled {
78        let dir = log_dir(data_dir);
79        fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
80        crate::fs_util::set_private_dir_permissions(&dir)?;
81        cleanup_old_logs(&dir, config.max_files)?;
82
83        let path_for_writer = log_path(data_dir);
84        let file = OpenOptions::new()
85            .create(true)
86            .append(true)
87            .open(&path_for_writer)
88            .with_context(|| format!("failed to open {}", path_for_writer.display()))?;
89        crate::fs_util::set_private_file_permissions(&path_for_writer)?;
90        let (writer, writer_guard) = tracing_appender::non_blocking(file);
91        layers.push(
92            tracing_subscriber::fmt::layer()
93                .with_ansi(false)
94                .with_target(true)
95                .with_writer(writer)
96                .boxed(),
97        );
98        guard = Some(writer_guard);
99        path = Some(path_for_writer);
100    }
101
102    if config.stdout_enabled || runtime.stdout_enabled {
103        layers.push(
104            tracing_subscriber::fmt::layer()
105                .with_ansi(true)
106                .with_target(true)
107                .boxed(),
108        );
109    }
110
111    if layers.is_empty() {
112        return Ok(LoggingGuard {
113            path: None,
114            _guard: None,
115        });
116    }
117
118    let subscriber = Registry::default().with(layers).with(filter);
119    let _ = tracing::subscriber::set_global_default(subscriber);
120
121    tracing::info!(
122        log_path = path.as_ref().map(|p| p.display().to_string()),
123        level,
124        include_payloads = config.include_payloads || runtime.include_payloads,
125        "logging initialized"
126    );
127
128    Ok(LoggingGuard {
129        path,
130        _guard: guard,
131    })
132}
133
134/// Redacts secrets from a log value string.
135pub fn redact_log_value(value: impl AsRef<str>) -> String {
136    redact_secrets(value.as_ref())
137}
138
139fn cleanup_old_logs(dir: &Path, max_files: usize) -> Result<()> {
140    if max_files == 0 {
141        return Ok(());
142    }
143    let mut logs = Vec::new();
144    for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? {
145        let entry = entry?;
146        let path = entry.path();
147        if path.file_name().and_then(|name| name.to_str()) != Some("navi.log") {
148            continue;
149        }
150        let modified = entry.metadata()?.modified()?;
151        logs.push((modified, path));
152    }
153    logs.sort_by_key(|b| std::cmp::Reverse(b.0));
154    for (_, path) in logs.into_iter().skip(max_files) {
155        let _ = fs::remove_file(path);
156    }
157    Ok(())
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn log_path_lives_under_data_dir() {
166        let path = log_path(Path::new("/tmp/navi-data"));
167        assert_eq!(path, PathBuf::from("/tmp/navi-data/logs/navi.log"));
168    }
169
170    #[test]
171    fn redacts_secret_values_for_logs() {
172        assert_eq!(
173            redact_log_value("OPENAI_API_KEY=sk-proj-1234567890abcdef"),
174            "OPENAI_API_KEY=<redacted>"
175        );
176    }
177
178    #[cfg(unix)]
179    #[test]
180    fn init_logging_creates_private_log_file() {
181        use std::os::unix::fs::PermissionsExt;
182
183        let tempdir = tempfile::tempdir().expect("tempdir");
184        let config = LoggingConfig::default();
185        let guard = init_logging(
186            &config,
187            tempdir.path(),
188            LoggingRuntimeConfig {
189                stdout_enabled: false,
190                file_enabled: true,
191                level: Some("info".to_string()),
192                include_payloads: false,
193            },
194        )
195        .expect("init logging");
196        drop(guard);
197
198        let dir_mode = fs::metadata(log_dir(tempdir.path()))
199            .expect("dir metadata")
200            .permissions()
201            .mode()
202            & 0o777;
203        let file_mode = fs::metadata(log_path(tempdir.path()))
204            .expect("file metadata")
205            .permissions()
206            .mode()
207            & 0o777;
208
209        assert_eq!(dir_mode, 0o700);
210        assert_eq!(file_mode, 0o600);
211    }
212}