llm-request-log 0.1.0

Structured log of LLM API requests with IDs, timing, and token counts
Documentation
/*!
llm-request-log: structured audit log of LLM API requests.

Records each request with a generated ID, model, token counts, latency,
and optional metadata. Useful for cost attribution, debugging, and replay.

```rust
use llm_request_log::RequestLog;

let mut log = RequestLog::new();
log.record("req-1", "claude-opus-4-7", 200, 150, 1200);
assert_eq!(log.len(), 1);
```
*/

use serde_json::Value;

/// A single logged LLM request.
#[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()
    }
}

/// Append-only log of LLM API requests.
#[derive(Debug, Default)]
pub struct RequestLog {
    entries: Vec<LogEntry>,
}

impl RequestLog {
    pub fn new() -> Self {
        Self::default()
    }

    /// Record a successful request.
    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,
        });
    }

    /// Record a request with metadata.
    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,
        });
    }

    /// Record a failed request.
    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());
    }
}