use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct ProviderAuditEntry {
pub timestamp: String,
pub plugin_id: String,
pub provider_id: String,
pub model_id: String,
pub tools_exposed: bool,
#[serde(default)]
pub tools_requested: u32,
#[serde(default)]
pub streamed: bool,
pub outcome: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_class: Option<String>,
}
pub fn audit_file_path() -> PathBuf {
audit_file_path_for(&crate::config::base_dir())
}
pub(crate) fn audit_file_path_for(base: &Path) -> PathBuf {
base.join("extensions").join("audit.jsonl")
}
#[allow(clippy::too_many_arguments)]
pub fn new_audit_entry(
plugin_id: impl Into<String>,
provider_id: impl Into<String>,
model_id: impl Into<String>,
tools_exposed: bool,
tools_requested: u32,
streamed: bool,
outcome: impl Into<String>,
error_class: Option<String>,
) -> ProviderAuditEntry {
ProviderAuditEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
plugin_id: plugin_id.into(),
provider_id: provider_id.into(),
model_id: model_id.into(),
tools_exposed,
tools_requested,
streamed,
outcome: outcome.into(),
error_class,
}
}
pub fn append_audit_entry(entry: &ProviderAuditEntry) -> Result<(), String> {
append_audit_entry_to(&crate::config::base_dir(), entry)
}
pub(crate) fn append_audit_entry_to(
base: &Path,
entry: &ProviderAuditEntry,
) -> Result<(), String> {
let path = audit_file_path_for(base);
let parent = path
.parent()
.ok_or_else(|| format!("audit.jsonl path has no parent: {}", path.display()))?;
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create dir {}: {}", parent.display(), e))?;
let mut line = serde_json::to_string(entry)
.map_err(|e| format!("failed to serialize audit entry: {}", e))?;
line.push('\n');
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("failed to open {}: {}", path.display(), e))?;
file.write_all(line.as_bytes())
.map_err(|e| format!("failed to append to {}: {}", path.display(), e))?;
Ok(())
}
pub fn read_audit_entries() -> Result<Vec<ProviderAuditEntry>, String> {
read_audit_entries_from(&crate::config::base_dir())
}
pub(crate) fn read_audit_entries_from(
base: &Path,
) -> Result<Vec<ProviderAuditEntry>, String> {
let path = audit_file_path_for(base);
let contents = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(format!(
"failed to read audit.jsonl at {}: {}",
path.display(),
e
));
}
};
let mut entries = Vec::new();
for (idx, raw) in contents.lines().enumerate() {
let line = raw.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<ProviderAuditEntry>(line) {
Ok(entry) => entries.push(entry),
Err(e) => {
tracing::warn!(
target: "synaps::extensions::audit",
"skipping malformed audit.jsonl line {} at {}: {}",
idx + 1,
path.display(),
e
);
}
}
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample(plugin: &str, outcome: &str) -> ProviderAuditEntry {
ProviderAuditEntry {
timestamp: "2025-01-01T00:00:00Z".to_string(),
plugin_id: plugin.to_string(),
provider_id: "p".to_string(),
model_id: "m".to_string(),
tools_exposed: false,
tools_requested: 0,
streamed: false,
outcome: outcome.to_string(),
error_class: None,
}
}
#[test]
fn audit_file_path_is_under_extensions_dir() {
let dir = TempDir::new().unwrap();
let p = audit_file_path_for(dir.path());
assert_eq!(p, dir.path().join("extensions").join("audit.jsonl"));
}
#[test]
fn append_two_entries_then_read_returns_them_in_order() {
let dir = TempDir::new().unwrap();
let a = sample("plug-a", "ok");
let b = sample("plug-b", "blocked");
append_audit_entry_to(dir.path(), &a).unwrap();
append_audit_entry_to(dir.path(), &b).unwrap();
let entries = read_audit_entries_from(dir.path()).unwrap();
assert_eq!(entries, vec![a, b]);
}
#[test]
fn read_missing_file_returns_empty() {
let dir = TempDir::new().unwrap();
let entries = read_audit_entries_from(dir.path()).unwrap();
assert!(entries.is_empty());
}
#[test]
fn malformed_line_in_middle_is_skipped() {
let dir = TempDir::new().unwrap();
let a = sample("plug-a", "ok");
let c = sample("plug-c", "error");
append_audit_entry_to(dir.path(), &a).unwrap();
let path = audit_file_path_for(dir.path());
let mut f = OpenOptions::new().append(true).open(&path).unwrap();
f.write_all(b"{ this is not valid json\n").unwrap();
drop(f);
append_audit_entry_to(dir.path(), &c).unwrap();
let entries = read_audit_entries_from(dir.path()).unwrap();
assert_eq!(entries, vec![a, c]);
}
#[test]
fn concurrent_appenders_produce_full_record_count() {
let dir = TempDir::new().unwrap();
let base = dir.path().to_path_buf();
let mut handles = Vec::new();
for t in 0..4u32 {
let base = base.clone();
handles.push(std::thread::spawn(move || {
for i in 0..10u32 {
let mut e = sample(&format!("plug-{t}"), "ok");
e.tools_requested = i;
append_audit_entry_to(&base, &e).expect("append");
}
}));
}
for h in handles {
h.join().unwrap();
}
let entries = read_audit_entries_from(&base).unwrap();
assert_eq!(entries.len(), 40);
}
#[test]
fn new_audit_entry_produces_rfc3339_timestamp() {
let e = new_audit_entry(
"plug",
"prov",
"model",
true,
0,
false,
"ok",
None,
);
let ts = &e.timestamp;
assert!(ts.len() >= 20, "timestamp too short: {ts}");
assert!(
ts.chars().take(4).all(|c| c.is_ascii_digit()),
"expected 4-digit year: {ts}"
);
assert!(ts.contains('T'), "expected 'T' separator: {ts}");
assert!(
ts.ends_with('Z') || ts.contains('+') || ts[10..].contains('-'),
"expected timezone suffix: {ts}"
);
chrono::DateTime::parse_from_rfc3339(ts)
.unwrap_or_else(|err| panic!("parse_from_rfc3339({ts}) failed: {err}"));
}
#[test]
fn round_trip_with_error_class_omitted_when_none() {
let dir = TempDir::new().unwrap();
let mut e = sample("plug", "ok");
e.error_class = None;
append_audit_entry_to(dir.path(), &e).unwrap();
let raw = std::fs::read_to_string(audit_file_path_for(dir.path())).unwrap();
assert!(
!raw.contains("error_class"),
"error_class should be skipped when None: {raw}"
);
let loaded = read_audit_entries_from(dir.path()).unwrap();
assert_eq!(loaded, vec![e]);
}
}