Skip to main content

phi_telemetry/
types.rs

1//! Structured metrics types for Agent observability.
2//!
3//! These types were moved from agent-base to phi-telemetry so that agent-base
4//! remains a pure runtime kernel with no knowledge of metrics.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9
10// ── Outcome enums ──
11
12/// Outcome of a single turn (one LLM interaction).
13#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum TurnOutcome {
16    /// Turn completed successfully (text response, no tool calls).
17    Completed,
18    /// Turn ended with tool calls (agent will loop back for another turn).
19    ToolCalls,
20    /// Turn ended with an error.
21    Error,
22    /// Turn hit the max-turns safety limit.
23    MaxTurns,
24    /// User cancelled this turn.
25    Cancelled,
26}
27
28/// Outcome of a session (the entire conversation).
29#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum SessionOutcome {
32    /// Session completed normally.
33    Completed,
34    /// Session ended with an unrecoverable error.
35    Failed,
36    /// User cancelled the session.
37    Cancelled,
38    /// Max turns exceeded (safety limit).
39    MaxTurns,
40}
41
42// ── TurnMetrics ──
43
44/// Per-turn metrics — one per LLM interaction.
45#[derive(Clone, Debug, Serialize, Deserialize)]
46pub struct TurnMetrics {
47    // Timing
48    pub turn_number: u32,
49    pub started_at: String, // ISO8601
50    pub duration_ms: u64,
51
52    // Latency breakdown
53    pub time_to_first_token_ms: u64,
54    pub llm_duration_ms: u64,
55    pub tool_duration_ms: u64,
56
57    // LLM
58    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    // Tool
66    pub tool_call_count: u32,
67    pub tools_used: Vec<String>,
68    pub tool_success: u32,
69    pub tool_failed: u32,
70
71    // Result
72    pub outcome: TurnOutcome,
73    pub text_length: u64,
74    pub error_message: Option<String>,
75    pub has_thinking: bool,
76
77    // Meta events
78    #[serde(default)]
79    pub plan_updates: u32,
80    #[serde(default)]
81    pub approval_count: u32,
82
83    // User input (truncated to 80 chars)
84    pub user_input: String,
85
86    // Business extension
87    #[serde(default)]
88    pub custom: Value,
89}
90
91impl TurnMetrics {
92    /// Create a new TurnMetrics with defaults (custom = {}).
93    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// ── SessionMetrics ──
130
131/// Accumulated session metrics. Written incrementally to `session_metrics.json`
132/// at the end of each turn.
133#[derive(Clone, Debug, Serialize, Deserialize)]
134pub struct SessionMetrics {
135    // Identity
136    pub session_id: String,
137    #[serde(default)]
138    pub node_id: String,
139    pub created_at: String,
140
141    // LLM summary
142    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    // Characters (always available, even without API token support)
150    #[serde(default)]
151    pub total_chars: u64,
152
153    // Tool summary
154    pub total_tool_calls: u32,
155    pub tool_breakdown: HashMap<String, u32>,
156    pub tool_fail_rate: f64,
157    /// Running total of failed tool calls.
158    #[serde(default)]
159    pub total_failed: u32,
160
161    // Timing
162    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    // Outcome
172    pub outcome: SessionOutcome,
173    pub error_count: u32,
174
175    // Meta events
176    #[serde(default)]
177    pub total_plan_updates: u32,
178    #[serde(default)]
179    pub total_approvals: u32,
180
181    // Business extension
182    #[serde(default)]
183    pub custom: Value,
184
185    // Multi-agent reservation (Phase 1: always None / "default")
186    #[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    // Per-turn details
192    pub turns: Vec<TurnMetrics>,
193}
194
195fn default_session_type() -> String {
196    "default".to_string()
197}
198
199impl SessionMetrics {
200    /// Create a new session metrics accumulator (empty turns).
201    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    /// Append a turn and recompute session-level aggregates.
236    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        // Tool breakdown
248        for tool_name in &turn.tools_used {
249            *self.tool_breakdown.entry(tool_name.clone()).or_insert(0) += 1;
250        }
251
252        // Tool fail rate (incremental — O(1) per turn)
253        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        // Error count
259        if matches!(turn.outcome, TurnOutcome::Error) {
260            self.error_count += 1;
261        }
262
263        // Meta events
264        self.total_plan_updates += turn.plan_updates;
265        self.total_approvals += turn.approval_count;
266
267        // Average turn duration
268        self.avg_turn_ms = self.total_duration_ms / self.total_turns as u64;
269
270        // Update model to the most frequently used one
271        {
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        // Percentiles
291        self.turns.push(turn);
292        self.recompute_percentiles();
293    }
294
295    /// Recompute P50/P95/P99 from stored turn durations.
296    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    /// Finalize the session — recompute percentiles and set outcome.
313    pub fn finalize(&mut self, outcome: SessionOutcome) {
314        self.outcome = outcome;
315        self.recompute_percentiles();
316    }
317}
318
319// ── Summary for CLI listing ──
320
321/// Lightweight summary returned by `list_all()`.
322#[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    /// Product name from custom field, if set (e.g. "phi-bard").
332    pub product: Option<String>,
333}
334
335// ── Helpers ──
336
337/// Compute the `p`-th percentile from an already-sorted slice of values.
338pub(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
347/// Truncate a string to `max_chars` characters, appending "..." if truncated.
348pub(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
357// ── Conversion from agent-base ──
358
359/// Convert an agent-base `RunOutcome` to a turn-level `TurnOutcome`.
360/// Checks `tools_used` to distinguish `ToolCalls` from `Completed`.
361pub 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        // Continuing means the guard nudged — treat as a completed turn
374        // (the run will loop, but this turn is done).
375        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
382/// Convert agent-base `RunOutcome` to `SessionOutcome`.
383pub 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        // Completed with no tools → TurnOutcome::Completed
609        assert_eq!(
610            run_outcome_to_turn_outcome(&RunOutcome::Completed, &[]),
611            TurnOutcome::Completed
612        );
613
614        // Completed with tools → TurnOutcome::ToolCalls
615        assert_eq!(
616            run_outcome_to_turn_outcome(&RunOutcome::Completed, &["shell".to_string()]),
617            TurnOutcome::ToolCalls
618        );
619
620        // Failed → Error
621        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        // Cancelled → Cancelled
632        assert_eq!(
633            run_outcome_to_turn_outcome(&RunOutcome::Cancelled, &[]),
634            TurnOutcome::Cancelled
635        );
636    }
637}