Skip to main content

phi_telemetry/
collector.rs

1//! Metrics collector — subscribes to turn-end hooks and accumulates session metrics.
2//!
3//! Runs in an independent tokio task via channel isolation so that observer
4//! panics never affect the agent runtime.
5
6use agent_base::TurnContext;
7use serde_json::Value;
8use std::sync::{Arc, Mutex};
9use tokio::sync::RwLock;
10use tokio::sync::mpsc;
11use tokio::task::JoinHandle;
12use tracing;
13
14use crate::types::{SessionMetrics, TurnMetrics, run_outcome_to_turn_outcome};
15
16/// Message sent from the hook callback to the observer task.
17#[derive(Clone, Debug)]
18pub(crate) enum ObserverMsg {
19    /// A turn has completed — build TurnMetrics and accumulate.
20    /// Boxed: TurnContext is large, and this enum crosses the channel on
21    /// every turn (clippy::large_enum_variant).
22    TurnEnd(Box<TurnContext>),
23    /// Set session-level custom data.
24    SetSessionCustom(Value),
25    /// Shut down the observer and finalize metrics.
26    Shutdown,
27}
28
29/// Handle to the background observer task.
30///
31/// Call [`shutdown`](Self::shutdown) to gracefully stop the observer and wait
32/// for all pending metrics to be processed. Dropping without calling `shutdown`
33/// detaches the task — metrics from the final turns may be lost.
34pub struct ObserverHandle {
35    tx: mpsc::UnboundedSender<ObserverMsg>,
36    /// Shared session metrics, readable for external use (e.g. CLI display).
37    pub session: Arc<RwLock<SessionMetrics>>,
38    /// Handle to the observer task. None after `shutdown()` has been called.
39    task: Option<JoinHandle<()>>,
40    /// Pending turn-level custom data. Set by the consumer (e.g. phi-bard)
41    /// via `on_turn_end`, consumed by the observer task when building TurnMetrics.
42    pending_turn_custom: Arc<Mutex<Option<Value>>>,
43}
44
45impl ObserverHandle {
46    /// Send session-level custom data to the observer.
47    pub fn set_session_custom(&self, custom: Value) {
48        let _ = self.tx.send(ObserverMsg::SetSessionCustom(custom));
49    }
50
51    /// Set turn-level custom data for the NEXT turn that the observer processes.
52    ///
53    /// Call this from an `on_turn_end` callback. The custom value will be
54    /// merged into that turn's `custom` field in `TurnMetrics`.
55    ///
56    /// ```ignore
57    /// agent.runtime().on_turn_end(move |_ctx| {
58    ///     handle.set_turn_custom(json!({"check_quality_passed": true}));
59    /// });
60    /// ```
61    pub fn set_turn_custom(&self, custom: Value) {
62        if let Ok(mut pending) = self.pending_turn_custom.lock() {
63            *pending = Some(custom);
64        }
65    }
66
67    /// Shut down the observer gracefully and wait for all pending metrics
68    /// to be processed. Returns once the observer task has exited.
69    ///
70    /// After this call, [`session`](Self::session) contains the final
71    /// accumulated metrics (minus any `finalize()` call, which the caller
72    /// should perform separately).
73    pub async fn shutdown(&mut self) {
74        let _ = self.tx.send(ObserverMsg::Shutdown);
75        if let Some(task) = self.task.take() {
76            // Don't propagate panic — observer panics must not crash the caller.
77            let _ = task.await;
78        }
79    }
80}
81
82/// Initialise telemetry for an agent runtime.
83///
84/// Registers an `on_turn_end` hook that sends `TurnContext` data through a
85/// channel to an independent observer task. The observer task builds
86/// `TurnMetrics` and accumulates `SessionMetrics` — it never blocks the
87/// agent runtime hot path.
88///
89/// Returns an [`ObserverHandle`] that can be used to inject custom data or
90/// shut down the observer.
91pub fn init_telemetry(
92    runtime: &agent_base::AgentRuntime,
93    session_id: String,
94    node_id: String,
95    model: String,
96) -> ObserverHandle {
97    let (tx, mut rx) = mpsc::unbounded_channel::<ObserverMsg>();
98
99    // Hook: only sends a message — no heavy work on the hot path.
100    let hook_tx = tx.clone();
101    runtime.on_turn_end(move |ctx: &TurnContext| {
102        let _ = hook_tx.send(ObserverMsg::TurnEnd(Box::new(ctx.clone())));
103    });
104
105    let session = Arc::new(RwLock::new(SessionMetrics::new(session_id, node_id, model)));
106
107    let observer_session = session.clone();
108    let pending_turn_custom = Arc::new(Mutex::new(None::<Value>));
109    let pending = pending_turn_custom.clone();
110
111    // Independent task: build metrics from TurnContext
112    let task = tokio::spawn(async move {
113        let accumulator = observer_session;
114        while let Some(msg) = rx.recv().await {
115            match msg {
116                ObserverMsg::TurnEnd(ctx) => {
117                    let turn = {
118                        let mut turn = build_turn_metrics(&ctx);
119                        // Apply any pending turn-level custom data
120                        if let Ok(mut pending) = pending.lock()
121                            && let Some(custom) = pending.take()
122                            && let Value::Object(ref mut map) = turn.custom
123                            && let Value::Object(custom_map) = custom
124                        {
125                            for (k, v) in custom_map {
126                                map.insert(k, v);
127                            }
128                        }
129                        turn
130                    };
131                    let mut session = accumulator.write().await;
132                    session.append_turn(turn);
133                    tracing::debug!(turn = session.total_turns, "metrics: turn accumulated");
134                }
135                ObserverMsg::SetSessionCustom(custom) => {
136                    let mut session = accumulator.write().await;
137                    if let Value::Object(ref mut map) = session.custom
138                        && let Value::Object(custom_map) = custom
139                    {
140                        for (k, v) in custom_map {
141                            map.insert(k, v);
142                        }
143                    }
144                }
145                ObserverMsg::Shutdown => {
146                    tracing::debug!("metrics: observer shutting down");
147                    break;
148                }
149            }
150        }
151    });
152
153    ObserverHandle {
154        tx,
155        session,
156        task: Some(task),
157        pending_turn_custom,
158    }
159}
160
161/// Build a TurnMetrics from the raw TurnContext.
162pub(crate) fn build_turn_metrics(ctx: &TurnContext) -> TurnMetrics {
163    let input_tokens = ctx
164        .usage
165        .as_ref()
166        .and_then(|u| u.prompt_tokens)
167        .unwrap_or(0) as u64;
168    let output_tokens = ctx
169        .usage
170        .as_ref()
171        .and_then(|u| u.completion_tokens)
172        .unwrap_or(0) as u64;
173    let thinking_tokens = ctx
174        .usage
175        .as_ref()
176        .and_then(|u| u.reasoning_tokens)
177        .unwrap_or(0) as u64;
178
179    let duration_ms = ctx.duration_ms;
180
181    let turn_outcome = run_outcome_to_turn_outcome(&ctx.outcome, &ctx.tools_used);
182
183    let mut turn = TurnMetrics::new(
184        ctx.turn_number,
185        chrono::Utc::now().to_rfc3339(),
186        duration_ms,
187        ctx.model.clone(),
188        ctx.user_input.clone(),
189        turn_outcome,
190    );
191
192    turn.time_to_first_token_ms = ctx.ttft_ms;
193    turn.llm_duration_ms = ctx.llm_duration_ms;
194    turn.tool_duration_ms = ctx.tool_duration_ms;
195    turn.input_tokens = input_tokens;
196    turn.output_tokens = output_tokens;
197    turn.thinking_tokens = thinking_tokens;
198    turn.thinking_bytes = ctx.thinking_bytes;
199    turn.tool_call_count = ctx.tool_call_count;
200    turn.tools_used = ctx.tools_used.clone();
201    turn.tool_success = ctx.tool_success;
202    turn.tool_failed = ctx.tool_failed;
203    turn.text_length = ctx.full_text_len;
204    turn.has_thinking = ctx.has_thinking;
205    turn.plan_updates = ctx.plan_updates;
206    turn.approval_count = ctx.approval_count;
207    turn.llm_calls = ctx.llm_calls;
208    if let Some(ref msg) = ctx.error_message {
209        turn.error_message = Some(msg.clone());
210    }
211
212    turn
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::types::TurnOutcome;
219    use agent_base::llm::{Capabilities, UsageInfo};
220    use agent_base::llm_trait::{LlmError, ProviderInfo};
221    use agent_base::{AgentBuilder, RunOutcome};
222    use async_trait::async_trait;
223    use serde_json::json;
224    use std::sync::Arc;
225
226    // ── Stub LlmProvider for creating AgentRuntime ──
227
228    struct StubProvider;
229
230    #[async_trait]
231    impl agent_base::llm::LlmProvider for StubProvider {
232        async fn stream(
233            &self,
234            _request: agent_base::llm::ChatRequest,
235        ) -> Result<agent_base::llm::ChatStream, LlmError> {
236            Ok(agent_base::llm::ChatStream::new(Box::pin(
237                futures_util::stream::empty(),
238            )))
239        }
240
241        async fn chat(
242            &self,
243            _request: agent_base::llm::ChatRequest,
244        ) -> Result<agent_base::llm::ChatResponse, LlmError> {
245            Err(LlmError::llm("stub"))
246        }
247
248        fn capabilities(&self) -> Capabilities {
249            Capabilities::default()
250        }
251
252        fn info(&self) -> ProviderInfo {
253            ProviderInfo {
254                name: "stub".to_string(),
255                model: "stub-model".to_string(),
256                version: None,
257            }
258        }
259    }
260
261    /// Helper: create an AgentRuntime for testing.
262    fn test_runtime() -> agent_base::AgentRuntime {
263        AgentBuilder::new(Arc::new(StubProvider)).build().unwrap()
264    }
265
266    /// Helper: build a TurnContext with sensible defaults.
267    fn make_ctx(turn_number: u32, outcome: RunOutcome, tools_used: Vec<String>) -> TurnContext {
268        TurnContext {
269            session_id: 1,
270            turn_number,
271            ttft_ms: 150,
272            llm_duration_ms: 800,
273            duration_ms: 1200,
274            tool_duration_ms: 200,
275            usage: Some(UsageInfo {
276                prompt_tokens: Some(500),
277                completion_tokens: Some(300),
278                total_tokens: Some(800),
279                reasoning_tokens: None,
280            }),
281            full_text_len: 1024,
282            has_thinking: false,
283            thinking_bytes: 0,
284            tools_used,
285            tool_call_count: 1,
286            tool_success: 1,
287            tool_failed: 0,
288            outcome,
289            error_message: None,
290            user_input: "test input".to_string(),
291            model: "gpt-4o".to_string(),
292            plan_updates: 0,
293            approval_count: 0,
294            llm_calls: 1,
295        }
296    }
297
298    // ── build_turn_metrics ──
299
300    #[test]
301    fn build_turn_metrics_basic() {
302        let ctx = make_ctx(1, RunOutcome::Completed, vec![]);
303        let turn = build_turn_metrics(&ctx);
304
305        assert_eq!(turn.turn_number, 1);
306        assert_eq!(turn.duration_ms, 1200);
307        assert_eq!(turn.time_to_first_token_ms, 150);
308        assert_eq!(turn.llm_duration_ms, 800);
309        assert_eq!(turn.tool_duration_ms, 200);
310        assert_eq!(turn.input_tokens, 500);
311        assert_eq!(turn.output_tokens, 300);
312        assert_eq!(turn.model, "gpt-4o");
313        assert_eq!(turn.tool_call_count, 1);
314        assert_eq!(turn.tool_success, 1);
315        assert_eq!(turn.tool_failed, 0);
316        assert_eq!(turn.text_length, 1024);
317        assert_eq!(turn.outcome, TurnOutcome::Completed);
318        assert_eq!(turn.user_input, "test input");
319        assert_eq!(turn.llm_calls, 1);
320        assert!(turn.error_message.is_none());
321        assert!(!turn.has_thinking);
322        assert_eq!(turn.plan_updates, 0);
323        assert_eq!(turn.approval_count, 0);
324    }
325
326    #[test]
327    fn build_turn_metrics_with_tools() {
328        let ctx = make_ctx(
329            2,
330            RunOutcome::Completed,
331            vec!["shell".to_string(), "read_file".to_string()],
332        );
333        let turn = build_turn_metrics(&ctx);
334
335        assert_eq!(turn.outcome, TurnOutcome::ToolCalls);
336        assert_eq!(turn.tools_used, vec!["shell", "read_file"]);
337    }
338
339    #[test]
340    fn build_turn_metrics_error_outcome() {
341        let ctx = make_ctx(
342            1,
343            RunOutcome::Failed {
344                error: "oops".to_string(),
345            },
346            vec![],
347        );
348        let turn = build_turn_metrics(&ctx);
349        assert_eq!(turn.outcome, TurnOutcome::Error);
350    }
351
352    #[test]
353    fn build_turn_metrics_cancelled() {
354        let ctx = make_ctx(1, RunOutcome::Cancelled, vec![]);
355        let turn = build_turn_metrics(&ctx);
356        assert_eq!(turn.outcome, TurnOutcome::Cancelled);
357    }
358
359    #[test]
360    fn build_turn_metrics_max_turns() {
361        let ctx = make_ctx(1, RunOutcome::MaxTurnsExceeded { turns: 10 }, vec![]);
362        let turn = build_turn_metrics(&ctx);
363        assert_eq!(turn.outcome, TurnOutcome::MaxTurns);
364    }
365
366    #[test]
367    fn build_turn_metrics_continuing() {
368        let ctx = make_ctx(1, RunOutcome::Continuing, vec![]);
369        let turn = build_turn_metrics(&ctx);
370        assert_eq!(turn.outcome, TurnOutcome::Completed);
371    }
372
373    #[test]
374    fn build_turn_metrics_no_usage() {
375        let mut ctx = make_ctx(1, RunOutcome::Completed, vec![]);
376        ctx.usage = None;
377        let turn = build_turn_metrics(&ctx);
378        assert_eq!(turn.input_tokens, 0);
379        assert_eq!(turn.output_tokens, 0);
380    }
381
382    #[test]
383    fn build_turn_metrics_partial_usage() {
384        let mut ctx = make_ctx(1, RunOutcome::Completed, vec![]);
385        ctx.usage = Some(UsageInfo {
386            prompt_tokens: Some(100),
387            completion_tokens: None,
388            total_tokens: None,
389            reasoning_tokens: None,
390        });
391        let turn = build_turn_metrics(&ctx);
392        assert_eq!(turn.input_tokens, 100);
393        assert_eq!(turn.output_tokens, 0);
394    }
395
396    #[test]
397    fn build_turn_metrics_with_error_message() {
398        let mut ctx = make_ctx(1, RunOutcome::Completed, vec![]);
399        ctx.error_message = Some("connection timeout".to_string());
400        let turn = build_turn_metrics(&ctx);
401        assert_eq!(turn.error_message, Some("connection timeout".to_string()));
402    }
403
404    #[test]
405    fn build_turn_metrics_with_thinking() {
406        let mut ctx = make_ctx(1, RunOutcome::Completed, vec![]);
407        ctx.has_thinking = true;
408        let turn = build_turn_metrics(&ctx);
409        assert!(turn.has_thinking);
410    }
411
412    #[test]
413    fn build_turn_metrics_with_thinking_bytes() {
414        let mut ctx = make_ctx(1, RunOutcome::Completed, vec![]);
415        ctx.has_thinking = true;
416        ctx.thinking_bytes = 43;
417        let turn = build_turn_metrics(&ctx);
418        assert!(turn.has_thinking);
419        assert_eq!(turn.thinking_bytes, 43);
420    }
421
422    #[test]
423    fn build_turn_metrics_thinking_bytes_default_zero() {
424        let ctx = make_ctx(1, RunOutcome::Completed, vec![]);
425        let turn = build_turn_metrics(&ctx);
426        assert_eq!(turn.thinking_bytes, 0);
427    }
428
429    #[test]
430    fn build_turn_metrics_with_meta_events() {
431        let mut ctx = make_ctx(1, RunOutcome::Completed, vec![]);
432        ctx.plan_updates = 3;
433        ctx.approval_count = 2;
434        let turn = build_turn_metrics(&ctx);
435        assert_eq!(turn.plan_updates, 3);
436        assert_eq!(turn.approval_count, 2);
437    }
438
439    #[test]
440    fn build_turn_metrics_user_input_truncated() {
441        let mut ctx = make_ctx(1, RunOutcome::Completed, vec![]);
442        ctx.user_input = "a".repeat(200);
443        let turn = build_turn_metrics(&ctx);
444        // TurnMetrics::new truncates to 80 chars + "..."
445        assert!(turn.user_input.len() <= 83);
446        assert!(turn.user_input.ends_with("..."));
447    }
448
449    // ── ObserverHandle ──
450
451    #[tokio::test]
452    async fn observer_handle_set_session_custom() {
453        let runtime = test_runtime();
454        let mut handle = init_telemetry(
455            &runtime,
456            "test".to_string(),
457            "".to_string(),
458            "gpt-4o".to_string(),
459        );
460
461        handle.set_session_custom(json!({"product": "phi-bard"}));
462
463        // Give observer time to process
464        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
465
466        {
467            let session = handle.session.read().await;
468            assert_eq!(
469                session.custom.get("product").and_then(|v| v.as_str()),
470                Some("phi-bard")
471            );
472        }
473
474        handle.shutdown().await;
475    }
476
477    #[tokio::test]
478    async fn observer_handle_shutdown() {
479        let runtime = test_runtime();
480        let mut handle = init_telemetry(
481            &runtime,
482            "test".to_string(),
483            "".to_string(),
484            "gpt-4o".to_string(),
485        );
486
487        // Shutdown should complete without hanging
488        handle.shutdown().await;
489
490        // After shutdown, session should still be accessible
491        let session = handle.session.read().await;
492        assert_eq!(session.session_id, "test");
493        assert_eq!(session.model, "gpt-4o");
494    }
495
496    #[tokio::test]
497    async fn observer_shutdown_idempotent() {
498        let runtime = test_runtime();
499        let mut handle = init_telemetry(
500            &runtime,
501            "test".to_string(),
502            "".to_string(),
503            "gpt-4o".to_string(),
504        );
505
506        handle.shutdown().await;
507        // Second shutdown should not panic
508        handle.shutdown().await;
509    }
510
511    // ── init_telemetry ──
512
513    #[tokio::test]
514    async fn init_telemetry_returns_handle() {
515        let runtime = test_runtime();
516        let handle = init_telemetry(
517            &runtime,
518            "s1".to_string(),
519            "n1".to_string(),
520            "gpt-4o".to_string(),
521        );
522
523        // Session should be initialized with provided values
524        let session = handle.session.read().await;
525        assert_eq!(session.session_id, "s1");
526        assert_eq!(session.node_id, "n1");
527        assert_eq!(session.model, "gpt-4o");
528        assert_eq!(session.total_turns, 0);
529        assert!(session.turns.is_empty());
530    }
531
532    #[tokio::test]
533    async fn observer_processes_turn_end_via_hook() {
534        let runtime = test_runtime();
535        let mut handle = init_telemetry(
536            &runtime,
537            "test".to_string(),
538            "".to_string(),
539            "gpt-4o".to_string(),
540        );
541
542        // Use set_session_custom to verify the observer task is running
543        handle.set_session_custom(json!({"test": true}));
544        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
545
546        {
547            let session = handle.session.read().await;
548            assert_eq!(session.custom.get("test"), Some(&json!(true)));
549        }
550
551        handle.shutdown().await;
552    }
553
554    #[tokio::test]
555    async fn observer_set_turn_custom_merges_into_next_turn() {
556        let runtime = test_runtime();
557        let mut handle = init_telemetry(
558            &runtime,
559            "test".to_string(),
560            "".to_string(),
561            "gpt-4o".to_string(),
562        );
563
564        // Set turn custom data — it should be stored in pending_turn_custom
565        handle.set_turn_custom(json!({"quality": 0.95}));
566
567        // Verify it's stored
568        {
569            let pending = handle.pending_turn_custom.lock().unwrap();
570            assert!(pending.is_some());
571            assert_eq!(
572                pending
573                    .as_ref()
574                    .unwrap()
575                    .get("quality")
576                    .and_then(|v| v.as_f64()),
577                Some(0.95)
578            );
579        }
580
581        handle.shutdown().await;
582    }
583
584    #[tokio::test]
585    async fn observer_turn_custom_overwrites_previous() {
586        let runtime = test_runtime();
587        let mut handle = init_telemetry(
588            &runtime,
589            "test".to_string(),
590            "".to_string(),
591            "gpt-4o".to_string(),
592        );
593
594        handle.set_turn_custom(json!({"first": true}));
595        handle.set_turn_custom(json!({"second": true}));
596
597        {
598            let pending = handle.pending_turn_custom.lock().unwrap();
599            assert_eq!(
600                pending
601                    .as_ref()
602                    .unwrap()
603                    .get("second")
604                    .and_then(|v| v.as_bool()),
605                Some(true)
606            );
607            // First value was overwritten
608            assert!(pending.as_ref().unwrap().get("first").is_none());
609        }
610
611        handle.shutdown().await;
612    }
613}