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