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