Skip to main content

cortexai_audit/backends/
file.rs

1//! Plain text file logger backend.
2//!
3//! Simple file-based logging with human-readable output format.
4
5use crate::error::AuditError;
6use crate::traits::{AuditConfig, AuditLogger, AuditStats};
7use crate::types::AuditEvent;
8use async_trait::async_trait;
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicU64, Ordering};
11use tokio::fs::{File, OpenOptions};
12use tokio::io::AsyncWriteExt;
13use tokio::sync::Mutex;
14
15/// A simple file-based audit logger.
16///
17/// Writes audit events as human-readable text lines to a file.
18pub struct FileLogger {
19    path: PathBuf,
20    file: Mutex<Option<File>>,
21    config: AuditConfig,
22    stats: FileLoggerStats,
23}
24
25struct FileLoggerStats {
26    total_events: AtomicU64,
27    failed_events: AtomicU64,
28    bytes_written: AtomicU64,
29}
30
31impl FileLogger {
32    /// Create a new file logger.
33    pub async fn new(path: impl Into<PathBuf>, config: AuditConfig) -> Result<Self, AuditError> {
34        let path = path.into();
35
36        // Ensure parent directory exists
37        if let Some(parent) = path.parent() {
38            tokio::fs::create_dir_all(parent).await?;
39        }
40
41        let file = OpenOptions::new()
42            .create(true)
43            .append(true)
44            .open(&path)
45            .await?;
46
47        Ok(Self {
48            path,
49            file: Mutex::new(Some(file)),
50            config,
51            stats: FileLoggerStats {
52                total_events: AtomicU64::new(0),
53                failed_events: AtomicU64::new(0),
54                bytes_written: AtomicU64::new(0),
55            },
56        })
57    }
58
59    /// Create with default configuration.
60    pub async fn with_path(path: impl Into<PathBuf>) -> Result<Self, AuditError> {
61        Self::new(path, AuditConfig::default()).await
62    }
63
64    /// Format an event as a human-readable line.
65    fn format_event(&self, event: &AuditEvent) -> String {
66        let timestamp = event.timestamp.format("%Y-%m-%d %H:%M:%S%.3f UTC");
67        let level = event.level.to_string();
68
69        let context_parts: Vec<String> = [
70            event
71                .context
72                .trace_id
73                .as_ref()
74                .map(|s| format!("trace={}", s)),
75            event
76                .context
77                .session_id
78                .as_ref()
79                .map(|s| format!("session={}", s)),
80            event
81                .context
82                .agent_id
83                .as_ref()
84                .map(|s| format!("agent={}", s)),
85            event
86                .context
87                .user_id
88                .as_ref()
89                .map(|s| format!("user={}", s)),
90        ]
91        .into_iter()
92        .flatten()
93        .collect();
94
95        let context_str = if context_parts.is_empty() {
96            String::new()
97        } else {
98            format!(" [{}]", context_parts.join(" "))
99        };
100
101        let event_str = match &event.kind {
102            crate::types::EventKind::ToolCall {
103                tool_name,
104                approved,
105                duration_ms,
106                ..
107            } => {
108                let duration = duration_ms
109                    .map(|d| format!(" ({}ms)", d))
110                    .unwrap_or_default();
111                let status = if *approved { "approved" } else { "denied" };
112                format!("TOOL_CALL tool={} status={}{}", tool_name, status, duration)
113            }
114            crate::types::EventKind::LlmRequest {
115                provider,
116                model,
117                streaming,
118                duration_ms,
119                input_tokens,
120                output_tokens,
121                ..
122            } => {
123                let duration = duration_ms
124                    .map(|d| format!(" ({}ms)", d))
125                    .unwrap_or_default();
126                let tokens = match (input_tokens, output_tokens) {
127                    (Some(i), Some(o)) => format!(" tokens={}/{}", i, o),
128                    _ => String::new(),
129                };
130                let stream = if *streaming { " streaming" } else { "" };
131                format!(
132                    "LLM_REQUEST provider={} model={}{}{}{}",
133                    provider, model, stream, tokens, duration
134                )
135            }
136            crate::types::EventKind::LlmResponse {
137                provider,
138                model,
139                finish_reason,
140                tool_calls_count,
141            } => {
142                let reason = finish_reason
143                    .as_ref()
144                    .map(|r| format!(" reason={}", r))
145                    .unwrap_or_default();
146                let tools = if *tool_calls_count > 0 {
147                    format!(" tool_calls={}", tool_calls_count)
148                } else {
149                    String::new()
150                };
151                format!(
152                    "LLM_RESPONSE provider={} model={}{}{}",
153                    provider, model, reason, tools
154                )
155            }
156            crate::types::EventKind::AgentLifecycle { agent_id, action } => {
157                format!("AGENT_LIFECYCLE agent={} action={:?}", agent_id, action)
158            }
159            crate::types::EventKind::ApprovalDecision {
160                tool_name,
161                approved,
162                approver,
163                reason,
164            } => {
165                let status = if *approved { "approved" } else { "denied" };
166                let reason_str = reason
167                    .as_ref()
168                    .map(|r| format!(" reason=\"{}\"", r))
169                    .unwrap_or_default();
170                format!(
171                    "APPROVAL tool={} status={} by={}{}",
172                    tool_name, status, approver, reason_str
173                )
174            }
175            crate::types::EventKind::Error {
176                error_type,
177                message,
178                ..
179            } => {
180                format!("ERROR type={} message=\"{}\"", error_type, message)
181            }
182            crate::types::EventKind::Security {
183                event_type,
184                description,
185            } => {
186                format!(
187                    "SECURITY type={:?} description=\"{}\"",
188                    event_type, description
189                )
190            }
191            crate::types::EventKind::Custom { name, .. } => {
192                format!("CUSTOM name={}", name)
193            }
194        };
195
196        format!("[{}] {} {}{}\n", timestamp, level, event_str, context_str)
197    }
198}
199
200#[async_trait]
201impl AuditLogger for FileLogger {
202    async fn log(&self, event: AuditEvent) -> Result<(), AuditError> {
203        if !self.config.should_log(event.level) {
204            return Ok(());
205        }
206
207        let line = self.format_event(&event);
208        let bytes = line.as_bytes();
209
210        let mut file_guard = self.file.lock().await;
211        if let Some(file) = file_guard.as_mut() {
212            match file.write_all(bytes).await {
213                Ok(_) => {
214                    self.stats.total_events.fetch_add(1, Ordering::Relaxed);
215                    self.stats
216                        .bytes_written
217                        .fetch_add(bytes.len() as u64, Ordering::Relaxed);
218                    Ok(())
219                }
220                Err(e) => {
221                    self.stats.failed_events.fetch_add(1, Ordering::Relaxed);
222                    Err(AuditError::Io(e))
223                }
224            }
225        } else {
226            self.stats.failed_events.fetch_add(1, Ordering::Relaxed);
227            Err(AuditError::NotInitialized)
228        }
229    }
230
231    async fn flush(&self) -> Result<(), AuditError> {
232        let mut file_guard = self.file.lock().await;
233        if let Some(file) = file_guard.as_mut() {
234            file.flush().await?;
235        }
236        Ok(())
237    }
238
239    fn name(&self) -> &str {
240        "file"
241    }
242
243    async fn health_check(&self) -> Result<(), AuditError> {
244        let file_guard = self.file.lock().await;
245        if file_guard.is_some() {
246            Ok(())
247        } else {
248            Err(AuditError::NotInitialized)
249        }
250    }
251
252    async fn stats(&self) -> AuditStats {
253        AuditStats {
254            total_events: self.stats.total_events.load(Ordering::Relaxed),
255            failed_events: self.stats.failed_events.load(Ordering::Relaxed),
256            bytes_written: self.stats.bytes_written.load(Ordering::Relaxed),
257            ..Default::default()
258        }
259    }
260}
261
262impl std::fmt::Debug for FileLogger {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        f.debug_struct("FileLogger")
265            .field("path", &self.path)
266            .field("config", &self.config)
267            .finish()
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::types::{AuditContext, EventKind};
275    use tempfile::tempdir;
276
277    #[tokio::test]
278    async fn test_file_logger_creation() {
279        let dir = tempdir().unwrap();
280        let path = dir.path().join("audit.log");
281
282        let logger = FileLogger::with_path(&path).await.unwrap();
283        assert!(path.exists());
284        assert_eq!(logger.name(), "file");
285    }
286
287    #[tokio::test]
288    async fn test_file_logger_write() {
289        let dir = tempdir().unwrap();
290        let path = dir.path().join("audit.log");
291
292        let logger = FileLogger::with_path(&path).await.unwrap();
293
294        let event = AuditEvent::tool_call("read_file", serde_json::json!({"path": "/tmp"}), true)
295            .with_context(AuditContext::new().with_trace_id("trace-123"));
296
297        logger.log(event).await.unwrap();
298        logger.flush().await.unwrap();
299
300        let content = tokio::fs::read_to_string(&path).await.unwrap();
301        assert!(content.contains("TOOL_CALL"));
302        assert!(content.contains("read_file"));
303        assert!(content.contains("trace=trace-123"));
304    }
305
306    #[tokio::test]
307    async fn test_file_logger_stats() {
308        let dir = tempdir().unwrap();
309        let path = dir.path().join("audit.log");
310
311        let logger = FileLogger::with_path(&path).await.unwrap();
312
313        for i in 0..5 {
314            let event = AuditEvent::tool_call(format!("tool_{}", i), serde_json::json!({}), true);
315            logger.log(event).await.unwrap();
316        }
317
318        let stats = logger.stats().await;
319        assert_eq!(stats.total_events, 5);
320        assert!(stats.bytes_written > 0);
321    }
322
323    #[tokio::test]
324    async fn test_file_logger_level_filtering() {
325        let dir = tempdir().unwrap();
326        let path = dir.path().join("audit.log");
327
328        let config = AuditConfig::new().with_min_level(crate::types::AuditLevel::Warn);
329        let logger = FileLogger::new(&path, config).await.unwrap();
330
331        // Info event should be filtered
332        let info_event = AuditEvent::info(EventKind::Custom {
333            name: "test".to_string(),
334            payload: serde_json::json!({}),
335        });
336        logger.log(info_event).await.unwrap();
337
338        // Warn event should be logged
339        let warn_event = AuditEvent::warn(EventKind::Custom {
340            name: "warning".to_string(),
341            payload: serde_json::json!({}),
342        });
343        logger.log(warn_event).await.unwrap();
344        logger.flush().await.unwrap();
345
346        let content = tokio::fs::read_to_string(&path).await.unwrap();
347        assert!(!content.contains("name=test"));
348        assert!(content.contains("name=warning"));
349    }
350}