Skip to main content

ai_memory/
logging.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Operational logging facility (PR-5 of issue #487).
5//!
6//! Routes the binary's existing `tracing::info!` / `tracing::warn!` /
7//! `tracing::error!` call sites through a rotating, on-disk file
8//! appender so operators can ingest server logs into Splunk, Datadog,
9//! Loki, etc.
10//!
11//! **Default-OFF.** Without a `[logging]` block in `config.toml` the
12//! daemon keeps the legacy `tracing-subscriber::fmt` setup that writes
13//! to stderr. Enabling file logging is opt-in:
14//!
15//! ```toml
16//! [logging]
17//! enabled = true
18//! path = "~/.local/state/ai-memory/logs/"
19//! max_size_mb = 100
20//! max_files = 30
21//! retention_days = 90
22//! structured = false
23//! level = "info"
24//! ```
25//!
26//! See [`docs/security/audit-trail.md`](../docs/security/audit-trail.md)
27//! for the SIEM ingestion guide.
28
29use std::path::{Path, PathBuf};
30
31use anyhow::{Context, Result};
32use tracing_appender::non_blocking::WorkerGuard;
33use tracing_appender::rolling::{RollingFileAppender, Rotation};
34
35use crate::config::LoggingConfig;
36use crate::log_paths;
37
38/// Default file prefix written by the rolling appender. Concrete
39/// rotated filenames look like `ai-memory.log.2026-04-30`.
40const DEFAULT_PREFIX: &str = "ai-memory.log";
41
42/// Initialise the file logging facility. Returns a [`WorkerGuard`] that
43/// the caller MUST keep alive for the lifetime of the process — when
44/// dropped it flushes the in-memory buffer to disk. Returns `None`
45/// when logging is disabled.
46///
47/// # Errors
48/// - The configured log directory cannot be created.
49/// - The rolling file appender cannot be constructed.
50pub fn init_file_logging(cfg: &LoggingConfig) -> Result<Option<WorkerGuard>> {
51    if !cfg.enabled.unwrap_or(false) {
52        return Ok(None);
53    }
54    let dir = resolve_log_dir(cfg);
55    log_paths::ensure_dir_secure(&dir)
56        .with_context(|| format!("creating log dir {}", dir.display()))?;
57    let appender = build_appender(&dir, cfg)?;
58    let (writer, guard) = tracing_appender::non_blocking(appender);
59    // Capture the writer in the static slot so the daemon's tracing
60    // subscriber can drain it. `try_init` so multiple test runs
61    // (each spinning a fresh subscriber) don't poison the global.
62    let level = cfg.level.as_deref().unwrap_or("info");
63    let filter = tracing_subscriber::EnvFilter::try_new(level).unwrap_or_else(|_| {
64        tracing_subscriber::EnvFilter::try_new("info").expect("info is a valid filter")
65    });
66    let structured = cfg.structured.unwrap_or(false);
67    let res = if structured {
68        tracing_subscriber::fmt()
69            .with_env_filter(filter)
70            .with_writer(writer)
71            .json()
72            .try_init()
73    } else {
74        tracing_subscriber::fmt()
75            .with_env_filter(filter)
76            .with_writer(writer)
77            .try_init()
78    };
79    if let Err(e) = res {
80        tracing::debug!("file logging subscriber already initialised: {e}");
81    }
82    Ok(Some(guard))
83}
84
85/// Resolve the configured log directory honouring the user-mandated
86/// precedence ladder: CLI > env (`AI_MEMORY_LOG_DIR`) > `[logging]
87/// path` in config > platform default. The `cfg`-only entry point is
88/// kept for callers that don't have a CLI override; subcommand wiring
89/// uses [`resolve_log_dir_with_override`] directly.
90///
91/// Falls back to a best-effort default if the security guard rejects
92/// the configured path — the `init_file_logging` path will then re-run
93/// the strict resolver and surface the error to the operator.
94#[must_use]
95pub fn resolve_log_dir(cfg: &LoggingConfig) -> PathBuf {
96    log_paths::resolve_log_dir(None, cfg.path.as_deref())
97        .map(|r| r.path)
98        .unwrap_or_else(|_| log_paths::platform_default(log_paths::DirKind::Log).path)
99}
100
101/// Strict version: returns the [`log_paths::ResolvedDir`] so callers
102/// can surface the resolution layer in error messages, and propagates
103/// the world-writable-refusal error.
104///
105/// # Errors
106/// - Resolved path is world-writable.
107pub fn resolve_log_dir_with_override(
108    cli_override: Option<&Path>,
109    cfg: &LoggingConfig,
110) -> Result<log_paths::ResolvedDir> {
111    log_paths::resolve_log_dir(cli_override, cfg.path.as_deref())
112}
113
114/// Build the rolling file appender with the rotation policy from
115/// `cfg`. Defaults to daily rotation with `max_files` retained on
116/// disk.
117pub fn build_appender(dir: &Path, cfg: &LoggingConfig) -> Result<RollingFileAppender> {
118    let rotation = rotation_for(cfg);
119    let max_files = cfg.max_files.unwrap_or(30);
120    let prefix = cfg
121        .filename_prefix
122        .clone()
123        .unwrap_or_else(|| DEFAULT_PREFIX.to_string());
124
125    RollingFileAppender::builder()
126        .filename_prefix(prefix)
127        .rotation(rotation)
128        .max_log_files(max_files)
129        .build(dir)
130        .with_context(|| format!("building rolling appender at {}", dir.display()))
131}
132
133fn rotation_for(cfg: &LoggingConfig) -> Rotation {
134    match cfg.rotation.as_deref().unwrap_or("daily") {
135        "minutely" => Rotation::MINUTELY,
136        "hourly" => Rotation::HOURLY,
137        "never" => Rotation::NEVER,
138        _ => Rotation::DAILY,
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn rotation_for_default_is_daily() {
148        let cfg = LoggingConfig::default();
149        // Rotation enum doesn't impl PartialEq, so format-compare.
150        let r = rotation_for(&cfg);
151        assert!(format!("{r:?}").to_lowercase().contains("daily"));
152    }
153
154    #[test]
155    fn rotation_for_hourly() {
156        let cfg = LoggingConfig {
157            rotation: Some("hourly".to_string()),
158            ..Default::default()
159        };
160        let r = rotation_for(&cfg);
161        assert!(format!("{r:?}").to_lowercase().contains("hourly"));
162    }
163
164    #[test]
165    fn resolve_log_dir_default_under_home() {
166        let cfg = LoggingConfig::default();
167        let p = resolve_log_dir(&cfg);
168        // Default contains the well-known suffix even on bare-min
169        // home setups.
170        assert!(p.to_string_lossy().contains("ai-memory"));
171    }
172
173    #[test]
174    fn build_appender_creates_file_under_tmp() {
175        let tmp = tempfile::tempdir().unwrap();
176        let cfg = LoggingConfig {
177            enabled: Some(true),
178            path: Some(tmp.path().to_string_lossy().into_owned()),
179            rotation: Some("never".to_string()),
180            ..Default::default()
181        };
182        let _appender = build_appender(tmp.path(), &cfg).unwrap();
183        // The appender lazily creates the log file on first write. Just
184        // ensure construction succeeded and the dir is writable.
185        assert!(tmp.path().is_dir());
186    }
187
188    #[test]
189    fn init_file_logging_returns_none_when_disabled() {
190        let cfg = LoggingConfig {
191            enabled: Some(false),
192            ..Default::default()
193        };
194        let guard = init_file_logging(&cfg).unwrap();
195        assert!(guard.is_none());
196    }
197
198    /// Process-wide lock so tests that swap the global tracing
199    /// subscriber via `try_init` don't race each other.
200    fn subscriber_lock() -> std::sync::MutexGuard<'static, ()> {
201        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
202        LOCK.get_or_init(|| std::sync::Mutex::new(()))
203            .lock()
204            .unwrap_or_else(|p| p.into_inner())
205    }
206
207    #[test]
208    fn init_file_logging_returns_guard_when_enabled() {
209        let _g = subscriber_lock();
210        let tmp = tempfile::tempdir().unwrap();
211        let cfg = LoggingConfig {
212            enabled: Some(true),
213            path: Some(tmp.path().to_string_lossy().into_owned()),
214            rotation: Some("never".to_string()),
215            level: Some("info".to_string()),
216            structured: Some(false),
217            ..Default::default()
218        };
219        // The first call returns Some(guard); the appender lazily
220        // creates the file on first write so we just verify a guard
221        // came back and the configured dir survives.
222        let guard = init_file_logging(&cfg).unwrap();
223        assert!(
224            guard.is_some(),
225            "init_file_logging must return a WorkerGuard when enabled"
226        );
227        assert!(tmp.path().is_dir());
228        // Guard drop flushes the buffer; explicit drop confirms no
229        // panic on shutdown.
230        drop(guard);
231    }
232
233    #[test]
234    fn init_file_logging_emits_structured_json_when_configured() {
235        let _g = subscriber_lock();
236        let tmp = tempfile::tempdir().unwrap();
237        let cfg = LoggingConfig {
238            enabled: Some(true),
239            path: Some(tmp.path().to_string_lossy().into_owned()),
240            rotation: Some("never".to_string()),
241            level: Some("info".to_string()),
242            structured: Some(true),
243            ..Default::default()
244        };
245        let guard = init_file_logging(&cfg).unwrap();
246        assert!(guard.is_some(), "structured branch must produce a guard");
247        drop(guard);
248    }
249
250    #[test]
251    fn init_file_logging_accepts_invalid_level_falling_back_to_info() {
252        let _g = subscriber_lock();
253        let tmp = tempfile::tempdir().unwrap();
254        let cfg = LoggingConfig {
255            enabled: Some(true),
256            path: Some(tmp.path().to_string_lossy().into_owned()),
257            rotation: Some("never".to_string()),
258            // Garbage level — exercises the EnvFilter fallback branch.
259            level: Some("not-a-real-level".to_string()),
260            ..Default::default()
261        };
262        // Must not panic; fallback path swaps in `info`.
263        let guard = init_file_logging(&cfg).unwrap();
264        assert!(guard.is_some());
265    }
266
267    #[test]
268    fn rotation_for_minutely() {
269        let cfg = LoggingConfig {
270            rotation: Some("minutely".to_string()),
271            ..Default::default()
272        };
273        let r = rotation_for(&cfg);
274        assert!(format!("{r:?}").to_lowercase().contains("minutely"));
275    }
276
277    #[test]
278    fn rotation_for_never() {
279        let cfg = LoggingConfig {
280            rotation: Some("never".to_string()),
281            ..Default::default()
282        };
283        let r = rotation_for(&cfg);
284        assert!(format!("{r:?}").to_lowercase().contains("never"));
285    }
286
287    #[test]
288    fn rotation_for_unknown_falls_back_to_daily() {
289        let cfg = LoggingConfig {
290            rotation: Some("garbage".to_string()),
291            ..Default::default()
292        };
293        let r = rotation_for(&cfg);
294        assert!(format!("{r:?}").to_lowercase().contains("daily"));
295    }
296
297    #[test]
298    fn build_appender_honours_explicit_filename_prefix() {
299        let tmp = tempfile::tempdir().unwrap();
300        let cfg = LoggingConfig {
301            enabled: Some(true),
302            path: Some(tmp.path().to_string_lossy().into_owned()),
303            rotation: Some("never".to_string()),
304            filename_prefix: Some("custom-prefix".to_string()),
305            ..Default::default()
306        };
307        // Constructing succeeds for an alternate prefix.
308        let _appender = build_appender(tmp.path(), &cfg).unwrap();
309    }
310
311    #[test]
312    fn resolve_log_dir_with_override_uses_cli_layer() {
313        let tmp = tempfile::tempdir().unwrap();
314        let cfg = LoggingConfig::default();
315        let r = resolve_log_dir_with_override(Some(tmp.path()), &cfg).unwrap();
316        assert_eq!(r.path, tmp.path());
317        assert_eq!(r.source, log_paths::PathSource::CliFlag);
318    }
319
320    #[cfg(unix)]
321    #[test]
322    fn resolve_log_dir_with_override_propagates_world_writable_error() {
323        use std::os::unix::fs::PermissionsExt;
324        let tmp = tempfile::tempdir().unwrap();
325        let bad = tmp.path().join("worldwrite");
326        std::fs::create_dir(&bad).unwrap();
327        std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
328        let cfg = LoggingConfig::default();
329        let err = resolve_log_dir_with_override(Some(&bad), &cfg).unwrap_err();
330        let msg = format!("{err}");
331        assert!(msg.contains("world-writable"), "got: {msg}");
332    }
333}