1use 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
15pub 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 pub async fn new(path: impl Into<PathBuf>, config: AuditConfig) -> Result<Self, AuditError> {
34 let path = path.into();
35
36 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 pub async fn with_path(path: impl Into<PathBuf>) -> Result<Self, AuditError> {
61 Self::new(path, AuditConfig::default()).await
62 }
63
64 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 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 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}