use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use serde::Serialize;
use tokio::sync::Mutex;
#[derive(Debug, Clone, Default, Serialize)]
pub struct TraceUsage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub cached_tokens: Option<u64>,
pub reasoning_tokens: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TraceEvent {
LlmRequest {
round: usize,
model: String,
messages_count: usize,
tools_count: usize,
fingerprint: String,
},
LlmResponse {
round: usize,
finish_reason: String,
ttft_ms: Option<u32>,
total_ms: u64,
usage: Option<TraceUsage>,
reasoning_chars: usize,
content_chars: usize,
tool_calls_count: usize,
},
ToolCall {
round: usize,
name: String,
args_preview: String,
args_hash: String,
},
ToolResult {
round: usize,
ok: bool,
duration_ms: u64,
output_chars: usize,
error_kind: Option<String>,
},
Error {
round: usize,
kind: String,
message: String,
},
}
#[async_trait]
pub trait TraceSink: Send + Sync {
async fn emit(&self, event: TraceEvent);
}
pub struct FileTraceSink {
file: Arc<Mutex<std::fs::File>>,
}
impl FileTraceSink {
pub fn create(dir: &Path, test_name: &str) -> std::io::Result<Self> {
std::fs::create_dir_all(dir)?;
let path = dir.join(format!("{test_name}_trace.jsonl"));
let file = std::fs::File::create(&path)?;
Ok(Self {
file: Arc::new(Mutex::new(file)),
})
}
}
#[async_trait]
impl TraceSink for FileTraceSink {
async fn emit(&self, event: TraceEvent) {
let Ok(line) = serde_json::to_string(&event) else {
return;
};
let mut f = self.file.lock().await;
use std::io::Write;
let _ = writeln!(f, "{line}");
}
}
pub struct NullTraceSink;
#[async_trait]
impl TraceSink for NullTraceSink {
async fn emit(&self, _event: TraceEvent) {}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
#[tokio::test]
async fn file_trace_sink_writes_jsonl_lines() {
let tmp = tempfile::tempdir().unwrap();
let sink = FileTraceSink::create(tmp.path(), "unit").unwrap();
sink.emit(TraceEvent::LlmRequest {
round: 0,
model: "test-model".into(),
messages_count: 2,
tools_count: 0,
fingerprint: "abc123".into(),
})
.await;
sink.emit(TraceEvent::LlmResponse {
round: 0,
finish_reason: "stop".into(),
ttft_ms: Some(120),
total_ms: 800,
usage: Some(TraceUsage {
prompt_tokens: 10,
completion_tokens: 5,
cached_tokens: Some(2),
reasoning_tokens: None,
}),
reasoning_chars: 0,
content_chars: 42,
tool_calls_count: 0,
})
.await;
{
let mut f = sink.file.lock().await;
let _ = f.flush();
}
let mut content = String::new();
let mut file = std::fs::File::open(tmp.path().join("unit_trace.jsonl")).unwrap();
file.read_to_string(&mut content).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2, "应有 2 行 JSONL");
assert!(lines[0].contains("\"type\":\"llm_request\""));
assert!(lines[0].contains("\"round\":0"));
assert!(lines[1].contains("\"type\":\"llm_response\""));
assert!(lines[1].contains("\"finish_reason\":\"stop\""));
assert!(lines[1].contains("\"prompt_tokens\":10"));
}
#[tokio::test]
async fn null_trace_sink_silent() {
let sink = NullTraceSink;
sink.emit(TraceEvent::Error {
round: 0,
kind: "test".into(),
message: "should be dropped".into(),
})
.await;
}
}