1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
//! Session-level analytics
//!
//! Calculates metrics and statistics for a single session
use crate::parser::models::ContentBlock;
use crate::parser::Session;
use std::collections::HashMap;
/// Analytics for a single session
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SessionAnalytics {
/// Total number of nodes
pub total_nodes: usize,
/// Node counts by type
pub node_counts: HashMap<String, usize>,
/// Tool usage breakdown (tool name -> count)
pub tool_usage: Vec<(String, usize)>,
/// Session duration in seconds (None if timestamps missing)
pub duration_seconds: Option<i64>,
/// Number of errors
pub error_count: usize,
/// Whether session uses subagents
pub has_subagents: bool,
/// Number of thinking blocks
pub thinking_count: usize,
/// Detected model (date suffix stripped)
pub model: Option<String>,
/// Total number of tool calls (ToolUse blocks in assistant messages)
pub tool_call_count: usize,
/// Number of tool calls that returned an error (tool_result with is_error=true)
pub tool_result_error_count: usize,
}
impl SessionAnalytics {
/// Calculate analytics from a session
pub fn from_session(session: &Session) -> Self {
let total_nodes = session.nodes.len();
// Pre-allocate with capacity hints to reduce reallocations
let mut node_counts: HashMap<String, usize> = HashMap::with_capacity(10);
let mut tool_counts: HashMap<String, usize> = HashMap::with_capacity(20);
let mut timestamps = Vec::with_capacity(total_nodes);
let mut error_count = 0;
let mut has_subagents = false;
let mut thinking_count = 0;
let mut tool_call_count = 0;
let mut tool_result_error_count = 0;
for node in &session.nodes {
// Count by node type
*node_counts.entry(node.node_type.clone()).or_insert(0) += 1;
// Check for subagents
if let Some(extra) = node.extra.as_ref().and_then(|e| e.get("isSidechain")) {
if let Some(is_sidechain) = extra.as_bool() {
if is_sidechain {
has_subagents = true;
}
}
}
// Count thinking blocks using typed ContentBlock matching
let has_thinking = node.thinking.is_some()
|| node.node_type == "thinking"
|| node
.message
.as_ref()
.map(|m| {
m.content_blocks()
.iter()
.any(|b| matches!(b, ContentBlock::Thinking { .. }))
})
.unwrap_or(false);
if has_thinking {
thinking_count += 1;
}
// Count tool usage — top-level tool_use field
if let Some(ref tool_use) = node.tool_use {
*tool_counts.entry(tool_use.name.clone()).or_insert(0) += 1;
}
// Count tool usage — ToolUse blocks inside assistant message content
for block in node
.message
.as_ref()
.map(|m| m.content_blocks())
.unwrap_or(&[])
{
if let ContentBlock::ToolUse { name, .. } = block {
*tool_counts.entry(name.clone()).or_insert(0) += 1;
tool_call_count += 1;
}
}
// Count errors — aligned with Session::new comprehensive detection
if node.node_type == "error" {
error_count += 1;
}
{
let tr = node.tool_result.as_ref();
let tool_result_error = tr.and_then(|r| r.is_error).unwrap_or(false);
let content_tag_error = tr
.and_then(|r| r.content.as_deref())
.map(|c| c.contains("<tool_use_error>"))
.unwrap_or(false);
let tool_use_result_error = node
.tool_use_result
.as_ref()
.and_then(|v| {
serde_json::from_value::<crate::parser::models::ToolResult>(v.clone())
.ok()
.and_then(|r| r.is_error)
})
.unwrap_or(false);
let block_error = node
.message
.as_ref()
.map(|m| {
m.content_blocks().iter().any(|b| match b {
ContentBlock::ToolResult {
content, is_error, ..
} => {
is_error.unwrap_or(false)
|| content
.as_ref()
.and_then(|v| v.as_str())
.map(|s| s.contains("<tool_use_error>"))
.unwrap_or(false)
}
_ => false,
})
})
.unwrap_or(false);
if tool_result_error || content_tag_error || tool_use_result_error || block_error {
error_count += 1;
if tool_result_error {
tool_result_error_count += 1;
}
}
}
// Collect timestamps
if let Some(ts) = node.timestamp {
timestamps.push(ts);
}
}
// Model detection: first assistant message with a model field
let model = session
.nodes
.iter()
.filter_map(|n| n.message.as_ref())
.filter_map(|m| m.model_short())
.next()
.map(str::to_string);
// Calculate duration
let duration_seconds = if timestamps.len() >= 2 {
timestamps.sort_unstable();
let first = timestamps.first().unwrap();
let last = timestamps.last().unwrap();
Some((last - first) / 1000) // Convert ms to seconds
} else {
None
};
// Sort tools by usage (unstable sort is faster, order of equal elements doesn't matter)
let mut tool_usage: Vec<_> = tool_counts.into_iter().collect();
tool_usage.sort_unstable_by(|a, b| b.1.cmp(&a.1));
SessionAnalytics {
total_nodes,
node_counts,
tool_usage,
duration_seconds,
error_count,
has_subagents,
thinking_count,
model,
tool_call_count,
tool_result_error_count,
}
}
/// Format duration as human-readable string
pub fn duration_string(&self) -> String {
match self.duration_seconds {
Some(secs) => {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}
None => "unknown".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_session() {
let session = Session::new("test-session".to_string(), None, vec![]);
let analytics = SessionAnalytics::from_session(&session);
assert_eq!(analytics.total_nodes, 0);
assert_eq!(analytics.thinking_count, 0);
assert_eq!(analytics.error_count, 0);
}
#[test]
fn test_duration_formatting() {
let session = Session::new("test-session".to_string(), None, vec![]);
let mut analytics = SessionAnalytics::from_session(&session);
// Test various durations
analytics.duration_seconds = Some(45);
assert_eq!(analytics.duration_string(), "45s");
analytics.duration_seconds = Some(90);
assert_eq!(analytics.duration_string(), "1m 30s");
analytics.duration_seconds = Some(3661);
assert_eq!(analytics.duration_string(), "1h 1m");
analytics.duration_seconds = None;
assert_eq!(analytics.duration_string(), "unknown");
}
}