1use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9
10#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum TurnOutcome {
16 Completed,
18 ToolCalls,
20 Error,
22 MaxTurns,
24 Cancelled,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum SessionOutcome {
32 Completed,
34 Failed,
36 Cancelled,
38 MaxTurns,
40}
41
42#[derive(Clone, Debug, Serialize, Deserialize)]
46pub struct TurnMetrics {
47 pub turn_number: u32,
49 pub started_at: String, pub duration_ms: u64,
51
52 pub time_to_first_token_ms: u64,
54 pub llm_duration_ms: u64,
55 pub tool_duration_ms: u64,
56
57 pub llm_calls: u32,
59 pub input_tokens: u64,
60 pub output_tokens: u64,
61 pub model: String,
62
63 pub tool_call_count: u32,
65 pub tools_used: Vec<String>,
66 pub tool_success: u32,
67 pub tool_failed: u32,
68
69 pub outcome: TurnOutcome,
71 pub text_length: u64,
72 pub error_message: Option<String>,
73 pub has_thinking: bool,
74
75 #[serde(default)]
77 pub plan_updates: u32,
78 #[serde(default)]
79 pub approval_count: u32,
80
81 pub user_input: String,
83
84 #[serde(default)]
86 pub custom: Value,
87}
88
89impl TurnMetrics {
90 pub fn new(
92 turn_number: u32,
93 started_at: String,
94 duration_ms: u64,
95 model: String,
96 user_input: String,
97 outcome: TurnOutcome,
98 ) -> Self {
99 Self {
100 turn_number,
101 started_at,
102 duration_ms,
103 time_to_first_token_ms: 0,
104 llm_duration_ms: 0,
105 tool_duration_ms: 0,
106 llm_calls: 1,
107 input_tokens: 0,
108 output_tokens: 0,
109 model,
110 tool_call_count: 0,
111 tools_used: Vec::new(),
112 tool_success: 0,
113 tool_failed: 0,
114 outcome,
115 text_length: 0,
116 error_message: None,
117 has_thinking: false,
118 plan_updates: 0,
119 approval_count: 0,
120 user_input: truncate_str(&user_input, 80),
121 custom: Value::Object(serde_json::Map::new()),
122 }
123 }
124}
125
126#[derive(Clone, Debug, Serialize, Deserialize)]
131pub struct SessionMetrics {
132 pub session_id: String,
134 #[serde(default)]
135 pub node_id: String,
136 pub created_at: String,
137
138 pub model: String,
140 pub total_input_tokens: u64,
141 pub total_output_tokens: u64,
142 pub estimated_cost: f64,
143
144 #[serde(default)]
146 pub total_chars: u64,
147
148 pub total_tool_calls: u32,
150 pub tool_breakdown: HashMap<String, u32>,
151 pub tool_fail_rate: f64,
152 #[serde(default)]
154 pub total_failed: u32,
155
156 pub total_duration_ms: u64,
158 pub total_llm_ms: u64,
159 pub total_tool_ms: u64,
160 pub total_turns: u32,
161 pub avg_turn_ms: u64,
162 pub p50_turn_ms: u64,
163 pub p95_turn_ms: u64,
164 pub p99_turn_ms: u64,
165
166 pub outcome: SessionOutcome,
168 pub error_count: u32,
169
170 #[serde(default)]
172 pub total_plan_updates: u32,
173 #[serde(default)]
174 pub total_approvals: u32,
175
176 #[serde(default)]
178 pub custom: Value,
179
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub parent_session_id: Option<String>,
183 #[serde(default = "default_session_type")]
184 pub session_type: String,
185
186 pub turns: Vec<TurnMetrics>,
188}
189
190fn default_session_type() -> String {
191 "default".to_string()
192}
193
194impl SessionMetrics {
195 pub fn new(session_id: String, node_id: String, model: String) -> Self {
197 Self {
198 session_id,
199 node_id,
200 created_at: chrono::Utc::now().to_rfc3339(),
201 model,
202 total_input_tokens: 0,
203 total_output_tokens: 0,
204 estimated_cost: 0.0,
205 total_chars: 0,
206 total_tool_calls: 0,
207 tool_breakdown: HashMap::new(),
208 tool_fail_rate: 0.0,
209 total_failed: 0,
210 total_duration_ms: 0,
211 total_llm_ms: 0,
212 total_tool_ms: 0,
213 total_turns: 0,
214 avg_turn_ms: 0,
215 p50_turn_ms: 0,
216 p95_turn_ms: 0,
217 p99_turn_ms: 0,
218 outcome: SessionOutcome::Completed,
219 error_count: 0,
220 total_plan_updates: 0,
221 total_approvals: 0,
222 custom: Value::Object(serde_json::Map::new()),
223 parent_session_id: None,
224 session_type: "default".to_string(),
225 turns: Vec::new(),
226 }
227 }
228
229 pub fn append_turn(&mut self, turn: TurnMetrics) {
231 self.total_turns += 1;
232 self.total_input_tokens += turn.input_tokens;
233 self.total_output_tokens += turn.output_tokens;
234 self.total_chars += turn.text_length;
235 self.total_duration_ms += turn.duration_ms;
236 self.total_llm_ms += turn.llm_duration_ms;
237 self.total_tool_ms += turn.tool_duration_ms;
238 self.total_tool_calls += turn.tool_call_count;
239
240 for tool_name in &turn.tools_used {
242 *self.tool_breakdown.entry(tool_name.clone()).or_insert(0) += 1;
243 }
244
245 self.total_failed += turn.tool_failed;
247 if self.total_tool_calls > 0 {
248 self.tool_fail_rate = self.total_failed as f64 / self.total_tool_calls as f64;
249 }
250
251 if matches!(turn.outcome, TurnOutcome::Error) {
253 self.error_count += 1;
254 }
255
256 self.total_plan_updates += turn.plan_updates;
258 self.total_approvals += turn.approval_count;
259
260 self.avg_turn_ms = self.total_duration_ms / self.total_turns as u64;
262
263 {
265 let mut model_counts: HashMap<String, u32> = HashMap::new();
266 for t in &self.turns {
267 if !t.model.is_empty() {
268 *model_counts.entry(t.model.clone()).or_insert(0) += 1;
269 }
270 }
271 if !turn.model.is_empty() {
272 *model_counts.entry(turn.model.clone()).or_insert(0) += 1;
273 }
274 if let Some(top_model) = model_counts
275 .into_iter()
276 .max_by_key(|(_, count)| *count)
277 .map(|(m, _)| m)
278 {
279 self.model = top_model;
280 }
281 }
282
283 self.turns.push(turn);
285 self.recompute_percentiles();
286 }
287
288 fn recompute_percentiles(&mut self) {
290 if self.turns.is_empty() {
291 self.p50_turn_ms = 0;
292 self.p95_turn_ms = 0;
293 self.p99_turn_ms = 0;
294 return;
295 }
296
297 let mut durations: Vec<u64> = self.turns.iter().map(|t| t.duration_ms).collect();
298 durations.sort_unstable();
299
300 self.p50_turn_ms = percentile_from_sorted(&durations, 50.0);
301 self.p95_turn_ms = percentile_from_sorted(&durations, 95.0);
302 self.p99_turn_ms = percentile_from_sorted(&durations, 99.0);
303 }
304
305 pub fn finalize(&mut self, outcome: SessionOutcome) {
307 self.outcome = outcome;
308 self.recompute_percentiles();
309 }
310}
311
312#[derive(Clone, Debug, Serialize, Deserialize)]
316pub struct SessionSummary {
317 pub session_id: String,
318 pub node_id: String,
319 pub created_at: String,
320 pub model: String,
321 pub total_turns: u32,
322 pub total_chars: u64,
323 pub outcome: SessionOutcome,
324 pub product: Option<String>,
326}
327
328pub(crate) fn percentile_from_sorted(sorted: &[u64], p: f64) -> u64 {
332 if sorted.is_empty() {
333 return 0;
334 }
335 let n = sorted.len() as f64;
336 let idx = ((p / 100.0) * (n - 1.0)).round() as usize;
337 sorted[idx.min(sorted.len() - 1)]
338}
339
340pub(crate) fn truncate_str(s: &str, max_chars: usize) -> String {
342 if s.chars().count() > max_chars {
343 let truncated: String = s.chars().take(max_chars).collect();
344 format!("{}...", truncated)
345 } else {
346 s.to_string()
347 }
348}
349
350pub fn run_outcome_to_turn_outcome(
355 outcome: &agent_base::RunOutcome,
356 tools_used: &[String],
357) -> TurnOutcome {
358 match outcome {
359 agent_base::RunOutcome::Completed => {
360 if tools_used.is_empty() {
361 TurnOutcome::Completed
362 } else {
363 TurnOutcome::ToolCalls
364 }
365 }
366 agent_base::RunOutcome::Continuing => TurnOutcome::Completed,
369 agent_base::RunOutcome::Failed { .. } => TurnOutcome::Error,
370 agent_base::RunOutcome::Cancelled => TurnOutcome::Cancelled,
371 agent_base::RunOutcome::MaxTurnsExceeded { .. } => TurnOutcome::MaxTurns,
372 }
373}
374
375pub fn run_outcome_to_session_outcome(outcome: &agent_base::RunOutcome) -> SessionOutcome {
377 match outcome {
378 agent_base::RunOutcome::Completed => SessionOutcome::Completed,
379 agent_base::RunOutcome::Continuing => SessionOutcome::Completed,
380 agent_base::RunOutcome::Failed { .. } => SessionOutcome::Failed,
381 agent_base::RunOutcome::Cancelled => SessionOutcome::Cancelled,
382 agent_base::RunOutcome::MaxTurnsExceeded { .. } => SessionOutcome::MaxTurns,
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn test_truncate_str() {
392 assert_eq!(truncate_str("hello", 10), "hello");
393 assert_eq!(
394 truncate_str("hello world this is long", 10),
395 "hello worl..."
396 );
397 assert_eq!(truncate_str("你好世界测试文本", 4), "你好世界...");
398 }
399
400 #[test]
401 fn test_percentile_from_sorted() {
402 let data = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
403 assert_eq!(percentile_from_sorted(&data, 50.0), 60);
404 assert_eq!(percentile_from_sorted(&data, 95.0), 100);
405 assert_eq!(percentile_from_sorted(&data, 0.0), 10);
406 assert_eq!(percentile_from_sorted(&data, 100.0), 100);
407 }
408
409 #[test]
410 fn test_percentile_empty() {
411 assert_eq!(percentile_from_sorted(&[], 50.0), 0);
412 }
413
414 #[test]
415 fn test_session_metrics_new() {
416 let m = SessionMetrics::new(
417 "20260729_test".to_string(),
418 "".to_string(),
419 "claude-sonnet".to_string(),
420 );
421 assert_eq!(m.session_id, "20260729_test");
422 assert_eq!(m.node_id, "");
423 assert_eq!(m.model, "claude-sonnet");
424 assert_eq!(m.total_turns, 0);
425 assert_eq!(m.parent_session_id, None);
426 assert_eq!(m.session_type, "default");
427 assert!(m.turns.is_empty());
428 }
429
430 #[test]
431 fn test_session_metrics_append_turn() {
432 let mut m = SessionMetrics::new(
433 "test".to_string(),
434 "".to_string(),
435 "claude-sonnet".to_string(),
436 );
437 let turn = TurnMetrics {
438 turn_number: 1,
439 started_at: "2026-07-29T00:00:00Z".to_string(),
440 duration_ms: 1000,
441 time_to_first_token_ms: 200,
442 llm_duration_ms: 800,
443 tool_duration_ms: 100,
444 llm_calls: 1,
445 input_tokens: 500,
446 output_tokens: 300,
447 model: "claude-sonnet".to_string(),
448 tool_call_count: 1,
449 tools_used: vec!["shell".to_string()],
450 tool_success: 1,
451 tool_failed: 0,
452 outcome: TurnOutcome::ToolCalls,
453 text_length: 200,
454 error_message: None,
455 has_thinking: true,
456 plan_updates: 0,
457 approval_count: 0,
458 user_input: "test input".to_string(),
459 custom: Value::Object(serde_json::Map::new()),
460 };
461 m.append_turn(turn);
462 assert_eq!(m.total_turns, 1);
463 assert_eq!(m.total_input_tokens, 500);
464 assert_eq!(m.total_output_tokens, 300);
465 assert_eq!(m.total_duration_ms, 1000);
466 assert_eq!(m.total_tool_calls, 1);
467 assert_eq!(m.tool_breakdown.get("shell"), Some(&1));
468 assert_eq!(m.tool_fail_rate, 0.0);
469 assert_eq!(m.avg_turn_ms, 1000);
470 assert_eq!(m.p50_turn_ms, 1000);
471 }
472
473 #[test]
474 fn test_session_metrics_append_multiple_turns() {
475 let mut m = SessionMetrics::new(
476 "test".to_string(),
477 "".to_string(),
478 "claude-sonnet".to_string(),
479 );
480 for i in 1..=5 {
481 let turn = TurnMetrics {
482 turn_number: i,
483 started_at: "2026-07-29T00:00:00Z".to_string(),
484 duration_ms: i as u64 * 1000,
485 time_to_first_token_ms: 200,
486 llm_duration_ms: 800,
487 tool_duration_ms: 100,
488 llm_calls: 1,
489 input_tokens: 500,
490 output_tokens: 300,
491 model: "claude-sonnet".to_string(),
492 tool_call_count: 1,
493 tools_used: vec!["shell".to_string()],
494 tool_success: 1,
495 tool_failed: 0,
496 outcome: TurnOutcome::ToolCalls,
497 text_length: 200,
498 error_message: None,
499 has_thinking: true,
500 plan_updates: 0,
501 approval_count: 0,
502 user_input: "test".to_string(),
503 custom: Value::Object(serde_json::Map::new()),
504 };
505 m.append_turn(turn);
506 }
507 assert_eq!(m.total_turns, 5);
508 assert_eq!(m.total_duration_ms, 15000);
509 assert_eq!(m.avg_turn_ms, 3000);
510 assert_eq!(m.p50_turn_ms, 3000);
511 }
512
513 #[test]
514 fn test_session_metrics_finalize() {
515 let mut m = SessionMetrics::new(
516 "test".to_string(),
517 "".to_string(),
518 "claude-sonnet".to_string(),
519 );
520 m.finalize(SessionOutcome::Completed);
521 assert_eq!(m.outcome, SessionOutcome::Completed);
522 }
523
524 #[test]
525 fn test_session_metrics_json_roundtrip() {
526 let m = SessionMetrics::new(
527 "20260729_test".to_string(),
528 "node-1".to_string(),
529 "claude-sonnet".to_string(),
530 );
531 let json = serde_json::to_string(&m).unwrap();
532 let m2: SessionMetrics = serde_json::from_str(&json).unwrap();
533 assert_eq!(m2.session_id, "20260729_test");
534 assert_eq!(m2.node_id, "node-1");
535 assert_eq!(m2.parent_session_id, None);
536 assert_eq!(m2.session_type, "default");
537 }
538
539 #[test]
540 fn test_session_metrics_backward_compat() {
541 let old_json = r#"{
542 "session_id": "test",
543 "node_id": "",
544 "created_at": "2026-07-29T00:00:00Z",
545 "model": "claude-sonnet",
546 "total_input_tokens": 0,
547 "total_output_tokens": 0,
548 "estimated_cost": 0.0,
549 "total_chars": 0,
550 "total_tool_calls": 0,
551 "tool_breakdown": {},
552 "tool_fail_rate": 0.0,
553 "total_duration_ms": 0,
554 "total_llm_ms": 0,
555 "total_tool_ms": 0,
556 "total_turns": 0,
557 "avg_turn_ms": 0,
558 "p50_turn_ms": 0,
559 "p95_turn_ms": 0,
560 "p99_turn_ms": 0,
561 "outcome": "completed",
562 "error_count": 0,
563 "custom": {},
564 "turns": []
565 }"#;
566 let m: SessionMetrics = serde_json::from_str(old_json).unwrap();
567 assert_eq!(m.parent_session_id, None);
568 assert_eq!(m.session_type, "default");
569 }
570
571 #[test]
572 fn test_turn_metrics_custom_default() {
573 let turn = TurnMetrics::new(
574 1,
575 "2026-07-29T00:00:00Z".to_string(),
576 1000,
577 "claude-sonnet".to_string(),
578 "hello world".to_string(),
579 TurnOutcome::Completed,
580 );
581 assert_eq!(turn.custom, Value::Object(serde_json::Map::new()));
582 assert_eq!(turn.user_input, "hello world");
583 let turn_long = TurnMetrics::new(
584 1,
585 "2026-07-29T00:00:00Z".to_string(),
586 1000,
587 "claude-sonnet".to_string(),
588 "this is a very long user input that should definitely be truncated at eighty characters because that's the max we allow for storage".to_string(),
589 TurnOutcome::Completed,
590 );
591 assert!(turn_long.user_input.chars().count() <= 83);
592 assert!(turn_long.user_input.ends_with("..."));
593 }
594
595 #[test]
596 fn test_run_outcome_conversion() {
597 use agent_base::RunOutcome;
598
599 assert_eq!(
601 run_outcome_to_turn_outcome(&RunOutcome::Completed, &[]),
602 TurnOutcome::Completed
603 );
604
605 assert_eq!(
607 run_outcome_to_turn_outcome(&RunOutcome::Completed, &["shell".to_string()]),
608 TurnOutcome::ToolCalls
609 );
610
611 assert_eq!(
613 run_outcome_to_turn_outcome(
614 &RunOutcome::Failed {
615 error: "oops".to_string()
616 },
617 &[]
618 ),
619 TurnOutcome::Error
620 );
621
622 assert_eq!(
624 run_outcome_to_turn_outcome(&RunOutcome::Cancelled, &[]),
625 TurnOutcome::Cancelled
626 );
627 }
628}