Skip to main content

cortex_runtime/audit/
logger.rs

1//! JSONL audit logger — append-only log of all operations.
2//!
3//! Features:
4//! - Append-only JSONL format for easy parsing
5//! - Automatic log rotation when file exceeds `MAX_LOG_SIZE` (100MB)
6//! - Rotated files named `.1`, `.2`, etc. (max 5 rotations)
7
8use anyhow::{Context, Result};
9use chrono::Utc;
10use serde::Serialize;
11use std::fs::{File, OpenOptions};
12use std::io::Write;
13use std::path::PathBuf;
14
15/// Maximum audit log size before rotation (100 MB).
16const MAX_LOG_SIZE: u64 = 100 * 1024 * 1024;
17
18/// Maximum number of rotated log files to keep.
19const MAX_ROTATIONS: u32 = 5;
20
21/// A single audit event.
22#[derive(Debug, Clone, Serialize)]
23pub struct AuditEvent {
24    pub timestamp: String,
25    pub method: String,
26    pub domain: Option<String>,
27    pub url: Option<String>,
28    pub session_id: Option<String>,
29    pub duration_ms: u64,
30    pub status: String,
31}
32
33/// Append-only JSONL audit logger with automatic rotation.
34pub struct AuditLogger {
35    file: File,
36    path: PathBuf,
37    /// Approximate current size (may drift slightly; re-checked on rotation).
38    current_size: u64,
39}
40
41impl AuditLogger {
42    /// Open or create the audit log file.
43    pub fn open(path: &PathBuf) -> Result<Self> {
44        if let Some(parent) = path.parent() {
45            std::fs::create_dir_all(parent)?;
46        }
47
48        let file = OpenOptions::new()
49            .create(true)
50            .append(true)
51            .open(path)
52            .with_context(|| format!("failed to open audit log: {}", path.display()))?;
53
54        let current_size = file.metadata().map(|m| m.len()).unwrap_or(0);
55
56        Ok(Self {
57            file,
58            path: path.clone(),
59            current_size,
60        })
61    }
62
63    /// Open the default audit log at ~/.cortex/audit.jsonl.
64    pub fn default_logger() -> Result<Self> {
65        let path = dirs::home_dir()
66            .unwrap_or_else(|| PathBuf::from("/tmp"))
67            .join(".cortex")
68            .join("audit.jsonl");
69        Self::open(&path)
70    }
71
72    /// Log an audit event.
73    pub fn log(&mut self, event: &AuditEvent) -> Result<()> {
74        // Check if rotation is needed before writing
75        if self.current_size >= MAX_LOG_SIZE {
76            self.rotate()?;
77        }
78
79        let json = serde_json::to_string(event)?;
80        let bytes_written = writeln!(self.file, "{json}")
81            .map(|()| json.len() as u64 + 1)
82            .unwrap_or(0);
83        self.current_size += bytes_written;
84        Ok(())
85    }
86
87    /// Log a method call with timing.
88    pub fn log_method(
89        &mut self,
90        method: &str,
91        domain: Option<&str>,
92        url: Option<&str>,
93        session_id: Option<&str>,
94        duration_ms: u64,
95        status: &str,
96    ) -> Result<()> {
97        self.log(&AuditEvent {
98            timestamp: Utc::now().to_rfc3339(),
99            method: method.to_string(),
100            domain: domain.map(String::from),
101            url: url.map(String::from),
102            session_id: session_id.map(String::from),
103            duration_ms,
104            status: status.to_string(),
105        })
106    }
107
108    /// Rotate log files: audit.jsonl → audit.jsonl.1, .1 → .2, etc.
109    fn rotate(&mut self) -> Result<()> {
110        // Close current file by dropping and reopening later
111        self.file.flush()?;
112
113        // Shift existing rotated files
114        for i in (1..MAX_ROTATIONS).rev() {
115            let from = rotation_path(&self.path, i);
116            let to = rotation_path(&self.path, i + 1);
117            if from.exists() {
118                let _ = std::fs::rename(&from, &to);
119            }
120        }
121
122        // Rename current → .1
123        let first_rotation = rotation_path(&self.path, 1);
124        let _ = std::fs::rename(&self.path, &first_rotation);
125
126        // Delete oldest if over limit
127        let oldest = rotation_path(&self.path, MAX_ROTATIONS);
128        if oldest.exists() {
129            let _ = std::fs::remove_file(&oldest);
130        }
131
132        // Reopen fresh log
133        self.file = OpenOptions::new()
134            .create(true)
135            .append(true)
136            .open(&self.path)
137            .with_context(|| "failed to reopen audit log after rotation")?;
138        self.current_size = 0;
139
140        Ok(())
141    }
142}
143
144/// Build path for a rotated log file: `audit.jsonl.1`, `audit.jsonl.2`, etc.
145fn rotation_path(base: &std::path::Path, index: u32) -> PathBuf {
146    let name = format!(
147        "{}.{index}",
148        base.file_name()
149            .and_then(|n| n.to_str())
150            .unwrap_or("audit.jsonl")
151    );
152    base.with_file_name(name)
153}