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    pub model: String,
62
63    // Tool
64    pub tool_call_count: u32,
65    pub tools_used: Vec<String>,
66    pub tool_success: u32,
67    pub tool_failed: u32,
68
69    // Result
70    pub outcome: TurnOutcome,
71    pub text_length: u64,
72    pub error_message: Option<String>,
73    pub has_thinking: bool,
74
75    // Meta events
76    #[serde(default)]
77    pub plan_updates: u32,
78    #[serde(default)]
79    pub approval_count: u32,
80
81    // User input (truncated to 80 chars)
82    pub user_input: String,
83
84    // Business extension
85    #[serde(default)]
86    pub custom: Value,
87}
88
89impl TurnMetrics {
90    /// Create a new TurnMetrics with defaults (custom = {}).
91    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// ── SessionMetrics ──
127
128/// Accumulated session metrics. Written incrementally to `session_metrics.json`
129/// at the end of each turn.
130#[derive(Clone, Debug, Serialize, Deserialize)]
131pub struct SessionMetrics {
132    // Identity
133    pub session_id: String,
134    #[serde(default)]
135    pub node_id: String,
136    pub created_at: String,
137
138    // LLM summary
139    pub model: String,
140    pub total_input_tokens: u64,
141    pub total_output_tokens: u64,
142    pub estimated_cost: f64,
143
144    // Characters (always available, even without API token support)
145    #[serde(default)]
146    pub total_chars: u64,
147
148    // Tool summary
149    pub total_tool_calls: u32,
150    pub tool_breakdown: HashMap<String, u32>,
151    pub tool_fail_rate: f64,
152    /// Running total of failed tool calls.
153    #[serde(default)]
154    pub total_failed: u32,
155
156    // Timing
157    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    // Outcome
167    pub outcome: SessionOutcome,
168    pub error_count: u32,
169
170    // Meta events
171    #[serde(default)]
172    pub total_plan_updates: u32,
173    #[serde(default)]
174    pub total_approvals: u32,
175
176    // Business extension
177    #[serde(default)]
178    pub custom: Value,
179
180    // Multi-agent reservation (Phase 1: always None / "default")
181    #[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    // Per-turn details
187    pub turns: Vec<TurnMetrics>,
188}
189
190fn default_session_type() -> String {
191    "default".to_string()
192}
193
194impl SessionMetrics {
195    /// Create a new session metrics accumulator (empty turns).
196    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    /// Append a turn and recompute session-level aggregates.
230    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        // Tool breakdown
241        for tool_name in &turn.tools_used {
242            *self.tool_breakdown.entry(tool_name.clone()).or_insert(0) += 1;
243        }
244
245        // Tool fail rate (incremental — O(1) per turn)
246        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        // Error count
252        if matches!(turn.outcome, TurnOutcome::Error) {
253            self.error_count += 1;
254        }
255
256        // Meta events
257        self.total_plan_updates += turn.plan_updates;
258        self.total_approvals += turn.approval_count;
259
260        // Average turn duration
261        self.avg_turn_ms = self.total_duration_ms / self.total_turns as u64;
262
263        // Update model to the most frequently used one
264        {
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        // Percentiles
284        self.turns.push(turn);
285        self.recompute_percentiles();
286    }
287
288    /// Recompute P50/P95/P99 from stored turn durations.
289    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    /// Finalize the session — recompute percentiles and set outcome.
306    pub fn finalize(&mut self, outcome: SessionOutcome) {
307        self.outcome = outcome;
308        self.recompute_percentiles();
309    }
310}
311
312// ── Summary for CLI listing ──
313
314/// Lightweight summary returned by `list_all()`.
315#[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    /// Product name from custom field, if set (e.g. "phi-bard").
325    pub product: Option<String>,
326}
327
328// ── Helpers ──
329
330/// Compute the `p`-th percentile from an already-sorted slice of values.
331pub(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
340/// Truncate a string to `max_chars` characters, appending "..." if truncated.
341pub(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
350// ── Conversion from agent-base ──
351
352/// Convert an agent-base `RunOutcome` to a turn-level `TurnOutcome`.
353/// Checks `tools_used` to distinguish `ToolCalls` from `Completed`.
354pub 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        // Continuing means the guard nudged — treat as a completed turn
367        // (the run will loop, but this turn is done).
368        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
375/// Convert agent-base `RunOutcome` to `SessionOutcome`.
376pub 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        // Completed with no tools → TurnOutcome::Completed
600        assert_eq!(
601            run_outcome_to_turn_outcome(&RunOutcome::Completed, &[]),
602            TurnOutcome::Completed
603        );
604
605        // Completed with tools → TurnOutcome::ToolCalls
606        assert_eq!(
607            run_outcome_to_turn_outcome(&RunOutcome::Completed, &["shell".to_string()]),
608            TurnOutcome::ToolCalls
609        );
610
611        // Failed → Error
612        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        // Cancelled → Cancelled
623        assert_eq!(
624            run_outcome_to_turn_outcome(&RunOutcome::Cancelled, &[]),
625            TurnOutcome::Cancelled
626        );
627    }
628}