Skip to main content

mcpr_integrations/store/query/
stats.rs

1//! Query: `mcpr proxy stats <proxy>` — per-tool aggregated metrics.
2
3use rusqlite::params;
4use serde::Serialize;
5
6use super::QueryEngine;
7
8/// Raw aggregate row from SQL: (label, calls, avg_us, min_us, max_us, error_pct, bytes_in, bytes_out).
9type AggRow = (String, i64, f64, i64, i64, f64, i64, i64);
10
11/// Filter parameters for the stats query.
12pub struct StatsParams {
13    /// Proxy name to filter by (None = all proxies).
14    pub proxy: Option<String>,
15    /// Only rows newer than this unix ms timestamp.
16    pub since_ts: i64,
17}
18
19/// Aggregated stats for one tool (or method).
20#[derive(Debug, Clone, Serialize)]
21pub struct ToolStats {
22    /// Tool name, or `<method>` for non-tool-call methods.
23    pub label: String,
24    /// Total number of calls.
25    pub calls: i64,
26    /// Average latency in microseconds.
27    pub avg_us: f64,
28    /// Minimum latency in microseconds.
29    pub min_us: i64,
30    /// Maximum latency in microseconds.
31    pub max_us: i64,
32    /// 95th percentile latency in microseconds (approximate).
33    pub p95_us: i64,
34    /// Error percentage (0.0 to 100.0).
35    pub error_pct: f64,
36    /// Total request bytes.
37    pub total_bytes_in: i64,
38    /// Total response bytes.
39    pub total_bytes_out: i64,
40}
41
42/// Aggregated result for the stats command.
43#[derive(Debug, Serialize)]
44pub struct StatsResult {
45    /// Per-tool/method breakdown, sorted by call count descending.
46    pub tools: Vec<ToolStats>,
47    /// Total calls across all tools.
48    pub total_calls: i64,
49    /// Overall error percentage.
50    pub error_pct: f64,
51}
52
53impl QueryEngine {
54    /// Compute per-tool aggregated stats for a proxy within a time window.
55    ///
56    /// Percentiles are computed in Rust (load latency values into a Vec and sort)
57    /// because SQLite has no native percentile function. This is fine for the
58    /// expected data volumes (<1M rows per proxy).
59    pub fn stats(&self, params: &StatsParams) -> Result<StatsResult, rusqlite::Error> {
60        // Step 1: Get basic aggregates per tool/method group.
61        let agg_sql = "
62            SELECT
63                COALESCE(tool, '<' || method || '>') AS label,
64                COUNT(*) AS calls,
65                AVG(latency_us) AS avg_us,
66                MIN(latency_us) AS min_us,
67                MAX(latency_us) AS max_us,
68                SUM(CASE WHEN status != 'ok' THEN 1 ELSE 0 END) * 100.0
69                    / COUNT(*) AS error_pct,
70                COALESCE(SUM(bytes_in), 0) AS total_bytes_in,
71                COALESCE(SUM(bytes_out), 0) AS total_bytes_out
72            FROM requests
73            WHERE (?1 IS NULL OR proxy = ?1) AND ts >= ?2
74            GROUP BY COALESCE(tool, '<' || method || '>')
75            ORDER BY calls DESC
76        ";
77
78        let mut stmt = self.conn().prepare(agg_sql)?;
79        let groups: Vec<AggRow> = stmt
80            .query_map(params![params.proxy, params.since_ts], |row| {
81                Ok((
82                    row.get(0)?,
83                    row.get(1)?,
84                    row.get(2)?,
85                    row.get(3)?,
86                    row.get(4)?,
87                    row.get(5)?,
88                    row.get(6)?,
89                    row.get(7)?,
90                ))
91            })?
92            .collect::<Result<Vec<_>, _>>()?;
93
94        // Step 2: Compute p95 per group by loading latency values into Rust.
95        let p95_sql = "
96            SELECT latency_us
97            FROM requests
98            WHERE (?1 IS NULL OR proxy = ?1) AND ts >= ?2
99              AND COALESCE(tool, '<' || method || '>') = ?3
100            ORDER BY latency_us
101        ";
102
103        let mut total_calls: i64 = 0;
104        let mut total_errors: f64 = 0.0;
105        let mut tools = Vec::with_capacity(groups.len());
106
107        for (label, calls, avg_us, min_us, max_us, error_pct, bytes_in, bytes_out) in &groups {
108            // Load all latency values for this group to compute p95.
109            let mut p95_stmt = self.conn().prepare(p95_sql)?;
110            let latencies: Vec<i64> = p95_stmt
111                .query_map(params![params.proxy, params.since_ts, label], |row| {
112                    row.get(0)
113                })?
114                .collect::<Result<Vec<_>, _>>()?;
115
116            let p95 = percentile(&latencies, 95);
117
118            total_calls += calls;
119            total_errors += (*calls as f64) * error_pct / 100.0;
120
121            tools.push(ToolStats {
122                label: label.clone(),
123                calls: *calls,
124                avg_us: *avg_us,
125                min_us: *min_us,
126                max_us: *max_us,
127                p95_us: p95,
128                error_pct: *error_pct,
129                total_bytes_in: *bytes_in,
130                total_bytes_out: *bytes_out,
131            });
132        }
133
134        let overall_error_pct = if total_calls > 0 {
135            total_errors / total_calls as f64 * 100.0
136        } else {
137            0.0
138        };
139
140        Ok(StatsResult {
141            tools,
142            total_calls,
143            error_pct: overall_error_pct,
144        })
145    }
146}
147
148/// Compute the Nth percentile from a sorted (ascending) list of values.
149///
150/// Uses nearest-rank method. Returns 0 for empty input.
151fn percentile(sorted_values: &[i64], pct: u8) -> i64 {
152    if sorted_values.is_empty() {
153        return 0;
154    }
155    let idx = ((pct as f64 / 100.0) * sorted_values.len() as f64).ceil() as usize;
156    let idx = idx.min(sorted_values.len()) - 1;
157    sorted_values[idx]
158}
159
160#[cfg(test)]
161#[allow(non_snake_case)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn percentile__basic() {
167        let values = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
168        assert_eq!(percentile(&values, 50), 5);
169        assert_eq!(percentile(&values, 95), 10);
170        assert_eq!(percentile(&values, 100), 10);
171    }
172
173    #[test]
174    fn percentile__empty() {
175        assert_eq!(percentile(&[], 95), 0);
176    }
177
178    #[test]
179    fn percentile__single() {
180        assert_eq!(percentile(&[42], 95), 42);
181    }
182}