//! Request statistics tracking for the dashboard and `GET /metrics`. //! //! Everything is held in memory (aggregates + per-minute buckets for the retention //! window + a ring buffer of recent requests) and optionally appended to a JSONL //! file that is replayed on startup — so statistics survive a restart with no //! database dependency. use crate::config::StatsCfg; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, VecDeque}; use std::io::Write; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; fn now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } /// A single record for a completed request (one JSONL line and one "recent" ring entry). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RequestRecord { pub ts: u64, #[serde(default)] pub account: String, #[serde(default)] pub protocol: String, #[serde(default)] pub directive: String, // auto | pinned #[serde(default)] pub task_label: String, #[serde(default)] pub tier: String, #[serde(default)] pub score: f32, #[serde(default)] pub model_id: String, #[serde(default)] pub route_source: String, #[serde(default)] pub prompt_tokens: u32, #[serde(default)] pub completion_tokens: u32, #[serde(default)] pub cost_usd: f64, #[serde(default)] pub latency_ms: u64, #[serde(default)] pub outcome: String, // ok | error #[serde(default)] pub failover: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, } #[derive(Clone, Debug, Default, Serialize)] pub struct Totals { pub requests: u64, pub ok: u64, pub errors: u64, pub failovers: u64, pub prompt_tokens: u64, pub completion_tokens: u64, pub cost_usd: f64, pub latency_ms_sum: u64, } impl Totals { fn apply(&mut self, r: &RequestRecord) { self.requests += 1; if r.outcome == "ok" { self.ok += 1; } else { self.errors += 1; } if r.failover { self.failovers += 1; } self.prompt_tokens += r.prompt_tokens as u64; self.completion_tokens += r.completion_tokens as u64; self.cost_usd += r.cost_usd; self.latency_ms_sum += r.latency_ms; } } /// Per-minute bucket for time-series charts. #[derive(Clone, Debug, Default, Serialize)] pub struct Bucket { pub minute: u64, // unix seconds, rounded down to the minute pub requests: u64, pub errors: u64, pub prompt_tokens: u64, pub completion_tokens: u64, pub cost_usd: f64, pub latency_ms_sum: u64, } #[derive(Default)] struct Inner { total: Totals, by_model: HashMap, by_account: HashMap, by_tier: HashMap, by_label: HashMap, buckets: VecDeque, recent: VecDeque, } pub struct Stats { enabled: bool, file: Option, ring_size: usize, retention_secs: u64, inner: Mutex, } impl Stats { pub fn new(cfg: &StatsCfg) -> std::sync::Arc { let file = if cfg.file.trim().is_empty() { None } else { Some(cfg.file.clone()) }; let s = Stats { enabled: cfg.enabled, file, ring_size: cfg.ring_size.max(1), retention_secs: parse_duration_secs(&cfg.retention).unwrap_or(7 * 86_400), inner: Mutex::new(Inner::default()), }; s.replay(); std::sync::Arc::new(s) } /// Replay the JSONL file into aggregates (within the retention window only). fn replay(&self) { let Some(path) = &self.file else { return }; let Ok(content) = std::fs::read_to_string(path) else { return; }; let cutoff = now_secs().saturating_sub(self.retention_secs); let mut inner = self.inner.lock().unwrap(); for line in content.lines() { let line = line.trim(); if line.is_empty() { continue; } if let Ok(rec) = serde_json::from_str::(line) { // push everything to the "recent" ring (it self-limits by size), // but apply to aggregates/series only entries within the window. Self::push_recent(&mut inner, &rec, self.ring_size); if rec.ts >= cutoff { Self::apply_aggregates(&mut inner, &rec, self.retention_secs); } } } } fn push_recent(inner: &mut Inner, rec: &RequestRecord, ring_size: usize) { inner.recent.push_back(rec.clone()); while inner.recent.len() > ring_size { inner.recent.pop_front(); } } fn apply_aggregates(inner: &mut Inner, rec: &RequestRecord, retention_secs: u64) { inner.total.apply(rec); inner .by_model .entry(rec.model_id.clone()) .or_default() .apply(rec); inner .by_account .entry(if rec.account.is_empty() { "anonymous".to_string() } else { rec.account.clone() }) .or_default() .apply(rec); inner .by_tier .entry(rec.tier.clone()) .or_default() .apply(rec); inner .by_label .entry(rec.task_label.clone()) .or_default() .apply(rec); let minute = rec.ts - (rec.ts % 60); match inner.buckets.back_mut() { Some(b) if b.minute == minute => fill_bucket(b, rec), Some(b) if b.minute > minute => { /* arrived older than the tail — ignore for series */ } _ => { let mut b = Bucket { minute, ..Default::default() }; fill_bucket(&mut b, rec); inner.buckets.push_back(b); } } // trim old buckets outside the retention window let cutoff = now_secs().saturating_sub(retention_secs); while let Some(front) = inner.buckets.front() { if front.minute < cutoff { inner.buckets.pop_front(); } else { break; } } } /// Record a new request event. pub fn record(&self, rec: RequestRecord) { if !self.enabled { return; } { let mut inner = self.inner.lock().unwrap(); Self::apply_aggregates(&mut inner, &rec, self.retention_secs); Self::push_recent(&mut inner, &rec, self.ring_size); } if let Some(path) = &self.file { if let Ok(line) = serde_json::to_string(&rec) { if let Ok(mut f) = std::fs::OpenOptions::new() .create(true) .append(true) .open(path) { let _ = writeln!(f, "{line}"); } } } } /// Snapshot for the admin API: aggregates + time series for `range_secs` + breakdown by `groupby`. pub fn snapshot(&self, range_secs: u64, groupby: &str) -> serde_json::Value { let inner = self.inner.lock().unwrap(); let cutoff = now_secs().saturating_sub(range_secs); let series: Vec<&Bucket> = inner .buckets .iter() .filter(|b| b.minute >= cutoff) .collect(); let breakdown_src = match groupby { "account" => &inner.by_account, "tier" => &inner.by_tier, "label" => &inner.by_label, _ => &inner.by_model, }; let mut breakdown: Vec = breakdown_src .iter() .map(|(k, t)| { serde_json::json!({ "key": k, "requests": t.requests, "ok": t.ok, "errors": t.errors, "prompt_tokens": t.prompt_tokens, "completion_tokens": t.completion_tokens, "cost_usd": t.cost_usd, "avg_latency_ms": avg(t.latency_ms_sum, t.requests), }) }) .collect(); breakdown.sort_by(|a, b| { b["requests"] .as_u64() .unwrap_or(0) .cmp(&a["requests"].as_u64().unwrap_or(0)) }); serde_json::json!({ "totals": { "requests": inner.total.requests, "ok": inner.total.ok, "errors": inner.total.errors, "failovers": inner.total.failovers, "prompt_tokens": inner.total.prompt_tokens, "completion_tokens": inner.total.completion_tokens, "total_tokens": inner.total.prompt_tokens + inner.total.completion_tokens, "cost_usd": inner.total.cost_usd, "avg_latency_ms": avg(inner.total.latency_ms_sum, inner.total.requests), "success_rate": if inner.total.requests > 0 { inner.total.ok as f64 / inner.total.requests as f64 } else { 0.0 }, }, "groupby": groupby, "breakdown": breakdown, "series": series, }) } /// Recent requests (newest first), with pagination. pub fn recent(&self, limit: usize, offset: usize) -> Vec { let inner = self.inner.lock().unwrap(); inner .recent .iter() .rev() .skip(offset) .take(limit) .cloned() .collect() } /// Metrics text in Prometheus format. pub fn prometheus(&self) -> String { let inner = self.inner.lock().unwrap(); let mut out = String::new(); let t = &inner.total; out.push_str("# HELP gw_requests_total Total gateway requests.\n"); out.push_str("# TYPE gw_requests_total counter\n"); out.push_str(&format!("gw_requests_total {}\n", t.requests)); out.push_str("# HELP gw_failovers_total Total provider failovers.\n"); out.push_str("# TYPE gw_failovers_total counter\n"); out.push_str(&format!("gw_failovers_total {}\n", t.failovers)); out.push_str("# HELP gw_tokens_total Total tokens by direction.\n"); out.push_str("# TYPE gw_tokens_total counter\n"); out.push_str(&format!( "gw_tokens_total{{direction=\"in\"}} {}\n", t.prompt_tokens )); out.push_str(&format!( "gw_tokens_total{{direction=\"out\"}} {}\n", t.completion_tokens )); out.push_str("# HELP gw_cost_usd_total Total estimated cost in USD.\n"); out.push_str("# TYPE gw_cost_usd_total counter\n"); out.push_str(&format!("gw_cost_usd_total {:.6}\n", t.cost_usd)); out.push_str("# HELP gw_provider_calls_total Calls by model and outcome.\n"); out.push_str("# TYPE gw_provider_calls_total counter\n"); for (model, mt) in &inner.by_model { let model = escape_label(model); out.push_str(&format!( "gw_provider_calls_total{{model_id=\"{model}\",outcome=\"ok\"}} {}\n", mt.ok )); out.push_str(&format!( "gw_provider_calls_total{{model_id=\"{model}\",outcome=\"error\"}} {}\n", mt.errors )); } out } } fn fill_bucket(b: &mut Bucket, r: &RequestRecord) { b.requests += 1; if r.outcome != "ok" { b.errors += 1; } b.prompt_tokens += r.prompt_tokens as u64; b.completion_tokens += r.completion_tokens as u64; b.cost_usd += r.cost_usd; b.latency_ms_sum += r.latency_ms; } fn avg(sum: u64, n: u64) -> u64 { sum.checked_div(n).unwrap_or(0) } fn escape_label(s: &str) -> String { s.replace('\\', "\\\\") .replace('"', "\\\"") .replace('\n', " ") } /// Parse a duration string of the form `7d` / `24h` / `90m` / `3600s` into seconds. pub fn parse_duration_secs(s: &str) -> Option { let s = s.trim(); if s.is_empty() { return None; } let (num, mult) = if let Some(n) = s.strip_suffix('d') { (n, 86_400) } else if let Some(n) = s.strip_suffix('h') { (n, 3_600) } else if let Some(n) = s.strip_suffix('m') { (n, 60) } else if let Some(n) = s.strip_suffix('s') { (n, 1) } else { (s, 1) }; num.trim().parse::().ok().map(|v| v * mult) }