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