Skip to main content

opensession_core/
stats.rs

1use crate::{Event, EventType, Session, Stats};
2use chrono::Utc;
3
4/// Aggregate statistics computed from a collection of sessions.
5///
6/// All fields are `u64` for in-memory computation; convert to `i64` when
7/// mapping to SQL-based API types via the `From` impls in `api-types`.
8#[derive(Debug, Clone, Default, PartialEq, Eq)]
9pub struct SessionAggregate {
10    pub session_count: u64,
11    pub message_count: u64,
12    pub event_count: u64,
13    pub tool_call_count: u64,
14    pub task_count: u64,
15    pub duration_seconds: u64,
16    pub total_input_tokens: u64,
17    pub total_output_tokens: u64,
18    pub user_message_count: u64,
19    pub files_changed: u64,
20    pub lines_added: u64,
21    pub lines_removed: u64,
22}
23
24impl SessionAggregate {
25    fn add_session_stats(&mut self, stats: &Stats) {
26        self.session_count += 1;
27        self.message_count += stats.message_count;
28        self.event_count += stats.event_count;
29        self.tool_call_count += stats.tool_call_count;
30        self.task_count += stats.task_count;
31        self.duration_seconds += stats.duration_seconds;
32        self.total_input_tokens += stats.total_input_tokens;
33        self.total_output_tokens += stats.total_output_tokens;
34        self.user_message_count += stats.user_message_count;
35        self.files_changed += stats.files_changed;
36        self.lines_added += stats.lines_added;
37        self.lines_removed += stats.lines_removed;
38    }
39}
40
41/// Aggregate pre-computed `stats` from every session in the slice.
42pub fn aggregate(sessions: &[Session]) -> SessionAggregate {
43    let mut agg = SessionAggregate::default();
44    for s in sessions {
45        agg.add_session_stats(&s.stats);
46    }
47    agg
48}
49
50/// Group sessions by `agent.tool` and aggregate each group.
51pub fn aggregate_by_tool(sessions: &[Session]) -> Vec<(String, SessionAggregate)> {
52    aggregate_by(sessions, |s| s.agent.tool.clone())
53}
54
55/// Group sessions by `agent.model` and aggregate each group.
56pub fn aggregate_by_model(sessions: &[Session]) -> Vec<(String, SessionAggregate)> {
57    aggregate_by(sessions, |s| s.agent.model.clone())
58}
59
60/// Generic group-by aggregation. Results sorted by session_count descending.
61fn aggregate_by(
62    sessions: &[Session],
63    key_fn: impl Fn(&Session) -> String,
64) -> Vec<(String, SessionAggregate)> {
65    let mut map = std::collections::HashMap::<String, SessionAggregate>::new();
66    for s in sessions {
67        map.entry(key_fn(s))
68            .or_default()
69            .add_session_stats(&s.stats);
70    }
71    let mut result: Vec<_> = map.into_iter().collect();
72    result.sort_by(|a, b| b.1.session_count.cmp(&a.1.session_count));
73    result
74}
75
76/// Filter sessions by a time-range string relative to now.
77///
78/// Supported values: `"24h"`, `"7d"`, `"30d"`, `"all"` (or anything else → no filter).
79pub fn filter_by_time_range<'a>(sessions: &'a [Session], range: &str) -> Vec<&'a Session> {
80    let cutoff = match range {
81        "24h" => Some(Utc::now() - chrono::Duration::days(1)),
82        "7d" => Some(Utc::now() - chrono::Duration::days(7)),
83        "30d" => Some(Utc::now() - chrono::Duration::days(30)),
84        _ => None,
85    };
86    match cutoff {
87        Some(c) => sessions
88            .iter()
89            .filter(|s| s.context.created_at >= c)
90            .collect(),
91        None => sessions.iter().collect(),
92    }
93}
94
95/// Extract tool name from an event, if applicable.
96fn extract_tool_name(event: &Event) -> Option<String> {
97    match &event.event_type {
98        EventType::ToolCall { name } => Some(name.clone()),
99        EventType::FileRead { .. } => Some("FileRead".to_string()),
100        EventType::CodeSearch { .. } => Some("CodeSearch".to_string()),
101        EventType::FileSearch { .. } => Some("FileSearch".to_string()),
102        EventType::FileEdit { .. } => Some("FileEdit".to_string()),
103        EventType::FileCreate { .. } => Some("FileCreate".to_string()),
104        EventType::FileDelete { .. } => Some("FileDelete".to_string()),
105        EventType::ShellCommand { .. } => Some("ShellCommand".to_string()),
106        EventType::WebSearch { .. } => Some("WebSearch".to_string()),
107        EventType::WebFetch { .. } => Some("WebFetch".to_string()),
108        _ => None,
109    }
110}
111
112/// Count tool calls per tool name across all sessions, returning `(tool_name, count)` sorted descending.
113pub fn count_tool_calls(sessions: &[Session]) -> Vec<(String, u64)> {
114    let mut result: Vec<_> = sessions
115        .iter()
116        .flat_map(|s| &s.events)
117        .filter_map(extract_tool_name)
118        .fold(
119            std::collections::HashMap::<String, u64>::new(),
120            |mut m, n| {
121                *m.entry(n).or_default() += 1;
122                m
123            },
124        )
125        .into_iter()
126        .collect();
127    result.sort_by(|a, b| b.1.cmp(&a.1));
128    result
129}
130
131// ---------------------------------------------------------------------------
132// SQL helpers — shared query strings for SQLite-backed servers
133// ---------------------------------------------------------------------------
134
135pub mod sql {
136    /// Convert a time-range string to a SQL WHERE clause fragment.
137    ///
138    /// Returns an empty string for `"all"` or unknown values.
139    pub fn time_range_filter(range: &str) -> &'static str {
140        match range {
141            "24h" => " AND s.created_at >= datetime('now', '-1 day')",
142            "7d" => " AND s.created_at >= datetime('now', '-7 days')",
143            "30d" => " AND s.created_at >= datetime('now', '-30 days')",
144            _ => "",
145        }
146    }
147
148    /// Build a totals query for sessions matching `team_id = ?1`.
149    pub fn totals_query(time_filter: &str) -> String {
150        format!(
151            "SELECT \
152                COUNT(*) as session_count, \
153                COALESCE(SUM(s.message_count), 0) as message_count, \
154                COALESCE(SUM(s.event_count), 0) as event_count, \
155                COALESCE(SUM(s.duration_seconds), 0) as duration_seconds, \
156                COALESCE(SUM(s.total_input_tokens), 0) as total_input_tokens, \
157                COALESCE(SUM(s.total_output_tokens), 0) as total_output_tokens \
158             FROM sessions s \
159             WHERE s.team_id = ?1{time_filter}"
160        )
161    }
162
163    /// Build a by-user grouped query (requires JOIN with `users`).
164    pub fn by_user_query(time_filter: &str) -> String {
165        format!(
166            "SELECT \
167                s.user_id as user_id, \
168                COALESCE(u.nickname, 'unknown') as nickname, \
169                COUNT(*) as session_count, \
170                COALESCE(SUM(s.message_count), 0) as message_count, \
171                COALESCE(SUM(s.event_count), 0) as event_count, \
172                COALESCE(SUM(s.duration_seconds), 0) as duration_seconds, \
173                COALESCE(SUM(s.total_input_tokens), 0) as total_input_tokens, \
174                COALESCE(SUM(s.total_output_tokens), 0) as total_output_tokens \
175             FROM sessions s \
176             LEFT JOIN users u ON u.id = s.user_id \
177             WHERE s.team_id = ?1{time_filter} \
178             GROUP BY s.user_id \
179             ORDER BY session_count DESC"
180        )
181    }
182
183    /// Build a by-tool grouped query.
184    pub fn by_tool_query(time_filter: &str) -> String {
185        format!(
186            "SELECT \
187                s.tool as tool, \
188                COUNT(*) as session_count, \
189                COALESCE(SUM(s.message_count), 0) as message_count, \
190                COALESCE(SUM(s.event_count), 0) as event_count, \
191                COALESCE(SUM(s.duration_seconds), 0) as duration_seconds, \
192                COALESCE(SUM(s.total_input_tokens), 0) as total_input_tokens, \
193                COALESCE(SUM(s.total_output_tokens), 0) as total_output_tokens \
194             FROM sessions s \
195             WHERE s.team_id = ?1{time_filter} \
196             GROUP BY s.tool \
197             ORDER BY session_count DESC"
198        )
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::testing;
206    use crate::{Content, Event, Session, Stats};
207    use chrono::{Duration, Utc};
208    use std::collections::HashMap;
209
210    fn make_session_with_stats(tool: &str, model: &str, stats: Stats) -> Session {
211        let mut s = Session::new("s1".to_string(), testing::agent_with(tool, model));
212        s.stats = stats;
213        s
214    }
215
216    fn sample_stats(msg: u64, events: u64, tools: u64, dur: u64) -> Stats {
217        Stats {
218            event_count: events,
219            message_count: msg,
220            tool_call_count: tools,
221            task_count: 1,
222            duration_seconds: dur,
223            total_input_tokens: 100,
224            total_output_tokens: 200,
225            ..Default::default()
226        }
227    }
228
229    #[test]
230    fn test_aggregate_empty() {
231        let agg = aggregate(&[]);
232        assert_eq!(agg, SessionAggregate::default());
233    }
234
235    #[test]
236    fn test_aggregate_single() {
237        let sessions = vec![make_session_with_stats(
238            "claude-code",
239            "opus",
240            sample_stats(5, 10, 3, 60),
241        )];
242        let agg = aggregate(&sessions);
243        assert_eq!(agg.session_count, 1);
244        assert_eq!(agg.message_count, 5);
245        assert_eq!(agg.event_count, 10);
246        assert_eq!(agg.tool_call_count, 3);
247        assert_eq!(agg.duration_seconds, 60);
248        assert_eq!(agg.total_input_tokens, 100);
249        assert_eq!(agg.total_output_tokens, 200);
250    }
251
252    #[test]
253    fn test_aggregate_multiple() {
254        let sessions = vec![
255            make_session_with_stats("claude-code", "opus", sample_stats(5, 10, 3, 60)),
256            make_session_with_stats("cursor", "gpt-4o", sample_stats(3, 6, 2, 30)),
257        ];
258        let agg = aggregate(&sessions);
259        assert_eq!(agg.session_count, 2);
260        assert_eq!(agg.message_count, 8);
261        assert_eq!(agg.event_count, 16);
262        assert_eq!(agg.tool_call_count, 5);
263        assert_eq!(agg.duration_seconds, 90);
264        assert_eq!(agg.total_input_tokens, 200);
265        assert_eq!(agg.total_output_tokens, 400);
266    }
267
268    #[test]
269    fn test_aggregate_by_tool() {
270        let sessions = vec![
271            make_session_with_stats("claude-code", "opus", sample_stats(5, 10, 3, 60)),
272            make_session_with_stats("claude-code", "sonnet", sample_stats(3, 6, 2, 30)),
273            make_session_with_stats("cursor", "gpt-4o", sample_stats(1, 2, 1, 10)),
274        ];
275        let by_tool = aggregate_by_tool(&sessions);
276        assert_eq!(by_tool.len(), 2);
277        // claude-code has 2 sessions → should be first
278        assert_eq!(by_tool[0].0, "claude-code");
279        assert_eq!(by_tool[0].1.session_count, 2);
280        assert_eq!(by_tool[1].0, "cursor");
281        assert_eq!(by_tool[1].1.session_count, 1);
282    }
283
284    #[test]
285    fn test_aggregate_by_model() {
286        let sessions = vec![
287            make_session_with_stats("claude-code", "opus", sample_stats(5, 10, 3, 60)),
288            make_session_with_stats("cursor", "opus", sample_stats(3, 6, 2, 30)),
289            make_session_with_stats("cursor", "gpt-4o", sample_stats(1, 2, 1, 10)),
290        ];
291        let by_model = aggregate_by_model(&sessions);
292        assert_eq!(by_model.len(), 2);
293        assert_eq!(by_model[0].0, "opus");
294        assert_eq!(by_model[0].1.session_count, 2);
295    }
296
297    #[test]
298    fn test_filter_by_time_range_all() {
299        let sessions = vec![make_session_with_stats(
300            "cc",
301            "opus",
302            sample_stats(1, 1, 0, 10),
303        )];
304        let filtered = filter_by_time_range(&sessions, "all");
305        assert_eq!(filtered.len(), 1);
306    }
307
308    #[test]
309    fn test_filter_by_time_range_24h() {
310        let mut recent = make_session_with_stats("cc", "opus", sample_stats(1, 1, 0, 10));
311        recent.context.created_at = Utc::now();
312
313        let mut old = make_session_with_stats("cc", "opus", sample_stats(1, 1, 0, 10));
314        old.context.created_at = Utc::now() - Duration::days(2);
315
316        let sessions = vec![recent, old];
317        let filtered = filter_by_time_range(&sessions, "24h");
318        assert_eq!(filtered.len(), 1);
319    }
320
321    #[test]
322    fn test_count_tool_calls() {
323        let mut session = Session::new("s1".to_string(), testing::agent_with("cc", "opus"));
324        session.events.push(Event {
325            event_id: "e1".to_string(),
326            timestamp: Utc::now(),
327            event_type: EventType::ToolCall {
328                name: "Read".to_string(),
329            },
330            task_id: None,
331            content: Content::empty(),
332            duration_ms: None,
333            attributes: HashMap::new(),
334        });
335        session.events.push(Event {
336            event_id: "e2".to_string(),
337            timestamp: Utc::now(),
338            event_type: EventType::FileRead {
339                path: "/tmp/a.rs".to_string(),
340            },
341            task_id: None,
342            content: Content::empty(),
343            duration_ms: None,
344            attributes: HashMap::new(),
345        });
346        session.events.push(Event {
347            event_id: "e3".to_string(),
348            timestamp: Utc::now(),
349            event_type: EventType::UserMessage,
350            task_id: None,
351            content: Content::text("hello"),
352            duration_ms: None,
353            attributes: HashMap::new(),
354        });
355
356        let counts = count_tool_calls(&[session]);
357        assert_eq!(counts.len(), 2);
358        // Both Read and FileRead should appear
359        let names: Vec<&str> = counts.iter().map(|(n, _)| n.as_str()).collect();
360        assert!(names.contains(&"Read"));
361        assert!(names.contains(&"FileRead"));
362    }
363
364    // --- SQL helper tests ---
365
366    #[test]
367    fn test_sql_time_range_filter() {
368        assert_eq!(
369            sql::time_range_filter("24h"),
370            " AND s.created_at >= datetime('now', '-1 day')"
371        );
372        assert_eq!(
373            sql::time_range_filter("7d"),
374            " AND s.created_at >= datetime('now', '-7 days')"
375        );
376        assert_eq!(
377            sql::time_range_filter("30d"),
378            " AND s.created_at >= datetime('now', '-30 days')"
379        );
380        assert_eq!(sql::time_range_filter("all"), "");
381        assert_eq!(sql::time_range_filter("unknown"), "");
382    }
383
384    #[test]
385    fn test_sql_totals_query_contains_expected_fragments() {
386        let q = sql::totals_query("");
387        assert!(q.contains("COUNT(*) as session_count"));
388        assert!(q.contains("SUM(s.message_count)"));
389        assert!(q.contains("SUM(s.total_input_tokens)"));
390        assert!(q.contains("WHERE s.team_id = ?1"));
391    }
392
393    #[test]
394    fn test_sql_totals_query_with_time_filter() {
395        let tf = sql::time_range_filter("24h");
396        let q = sql::totals_query(tf);
397        assert!(q.contains("datetime('now', '-1 day')"));
398    }
399
400    #[test]
401    fn test_sql_by_user_query() {
402        let q = sql::by_user_query("");
403        assert!(q.contains("LEFT JOIN users u"));
404        assert!(q.contains("GROUP BY s.user_id"));
405        assert!(q.contains("ORDER BY session_count DESC"));
406    }
407
408    #[test]
409    fn test_sql_by_tool_query() {
410        let q = sql::by_tool_query("");
411        assert!(q.contains("GROUP BY s.tool"));
412        assert!(q.contains("ORDER BY session_count DESC"));
413    }
414}