use serde_json::Value;
#[derive(Debug, Clone)]
pub struct LogEntry {
pub request_id: String,
pub model: String,
pub input_tokens: u64,
pub output_tokens: u64,
pub latency_ms: u64,
pub metadata: Option<Value>,
pub error: Option<String>,
}
impl LogEntry {
pub fn total_tokens(&self) -> u64 {
self.input_tokens + self.output_tokens
}
pub fn is_error(&self) -> bool {
self.error.is_some()
}
}
#[derive(Debug, Default)]
pub struct RequestLog {
entries: Vec<LogEntry>,
}
impl RequestLog {
pub fn new() -> Self {
Self::default()
}
pub fn record(
&mut self,
request_id: impl Into<String>,
model: impl Into<String>,
input_tokens: u64,
output_tokens: u64,
latency_ms: u64,
) {
self.entries.push(LogEntry {
request_id: request_id.into(),
model: model.into(),
input_tokens,
output_tokens,
latency_ms,
metadata: None,
error: None,
});
}
pub fn record_with_meta(
&mut self,
request_id: impl Into<String>,
model: impl Into<String>,
input_tokens: u64,
output_tokens: u64,
latency_ms: u64,
metadata: Value,
) {
self.entries.push(LogEntry {
request_id: request_id.into(),
model: model.into(),
input_tokens,
output_tokens,
latency_ms,
metadata: Some(metadata),
error: None,
});
}
pub fn record_error(
&mut self,
request_id: impl Into<String>,
model: impl Into<String>,
error: impl Into<String>,
latency_ms: u64,
) {
self.entries.push(LogEntry {
request_id: request_id.into(),
model: model.into(),
input_tokens: 0,
output_tokens: 0,
latency_ms,
metadata: None,
error: Some(error.into()),
});
}
pub fn len(&self) -> usize { self.entries.len() }
pub fn is_empty(&self) -> bool { self.entries.is_empty() }
pub fn entries(&self) -> &[LogEntry] { &self.entries }
pub fn get(&self, request_id: &str) -> Option<&LogEntry> {
self.entries.iter().find(|e| e.request_id == request_id)
}
pub fn by_model(&self, model: &str) -> Vec<&LogEntry> {
self.entries.iter().filter(|e| e.model == model).collect()
}
pub fn errors(&self) -> Vec<&LogEntry> {
self.entries.iter().filter(|e| e.is_error()).collect()
}
pub fn total_input_tokens(&self) -> u64 {
self.entries.iter().map(|e| e.input_tokens).sum()
}
pub fn total_output_tokens(&self) -> u64 {
self.entries.iter().map(|e| e.output_tokens).sum()
}
pub fn avg_latency_ms(&self) -> f64 {
if self.entries.is_empty() { return 0.0; }
let sum: u64 = self.entries.iter().map(|e| e.latency_ms).sum();
sum as f64 / self.entries.len() as f64
}
pub fn slowest(&self) -> Option<&LogEntry> {
self.entries.iter().max_by_key(|e| e.latency_ms)
}
pub fn clear(&mut self) { self.entries.clear(); }
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn empty_log() {
let log = RequestLog::new();
assert!(log.is_empty());
assert_eq!(log.len(), 0);
}
#[test]
fn record_single_entry() {
let mut log = RequestLog::new();
log.record("r1", "claude-opus-4-7", 100, 50, 500);
assert_eq!(log.len(), 1);
}
#[test]
fn get_by_id() {
let mut log = RequestLog::new();
log.record("abc", "gpt-5.4", 200, 100, 300);
let e = log.get("abc").unwrap();
assert_eq!(e.model, "gpt-5.4");
}
#[test]
fn get_missing_returns_none() {
let log = RequestLog::new();
assert!(log.get("nope").is_none());
}
#[test]
fn total_tokens() {
let mut e = LogEntry {
request_id: "x".into(), model: "m".into(),
input_tokens: 100, output_tokens: 50, latency_ms: 0,
metadata: None, error: None,
};
assert_eq!(e.total_tokens(), 150);
}
#[test]
fn record_error_entry() {
let mut log = RequestLog::new();
log.record_error("e1", "claude-opus-4-7", "rate limit", 50);
assert_eq!(log.errors().len(), 1);
assert!(log.get("e1").unwrap().is_error());
}
#[test]
fn by_model_filter() {
let mut log = RequestLog::new();
log.record("r1", "model-a", 10, 5, 100);
log.record("r2", "model-b", 10, 5, 100);
log.record("r3", "model-a", 10, 5, 100);
assert_eq!(log.by_model("model-a").len(), 2);
assert_eq!(log.by_model("model-b").len(), 1);
}
#[test]
fn total_input_tokens() {
let mut log = RequestLog::new();
log.record("r1", "m", 100, 0, 0);
log.record("r2", "m", 200, 0, 0);
assert_eq!(log.total_input_tokens(), 300);
}
#[test]
fn total_output_tokens() {
let mut log = RequestLog::new();
log.record("r1", "m", 0, 50, 0);
log.record("r2", "m", 0, 75, 0);
assert_eq!(log.total_output_tokens(), 125);
}
#[test]
fn avg_latency() {
let mut log = RequestLog::new();
log.record("r1", "m", 0, 0, 100);
log.record("r2", "m", 0, 0, 200);
assert!((log.avg_latency_ms() - 150.0).abs() < 0.01);
}
#[test]
fn avg_latency_empty() {
assert_eq!(RequestLog::new().avg_latency_ms(), 0.0);
}
#[test]
fn slowest_entry() {
let mut log = RequestLog::new();
log.record("r1", "m", 0, 0, 100);
log.record("r2", "m", 0, 0, 999);
assert_eq!(log.slowest().unwrap().request_id, "r2");
}
#[test]
fn record_with_metadata() {
let mut log = RequestLog::new();
log.record_with_meta("r1", "m", 10, 5, 100, json!({"tag": "test"}));
let e = log.get("r1").unwrap();
assert!(e.metadata.is_some());
}
#[test]
fn clear_resets_log() {
let mut log = RequestLog::new();
log.record("r1", "m", 10, 5, 100);
log.clear();
assert!(log.is_empty());
}
}