1use 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#[derive(Clone, Debug)]
18pub(crate) enum ObserverMsg {
19 TurnEnd(Box<TurnContext>),
23 SetSessionCustom(Value),
25 Shutdown,
27}
28
29pub struct ObserverHandle {
35 tx: mpsc::UnboundedSender<ObserverMsg>,
36 pub session: Arc<RwLock<SessionMetrics>>,
38 task: Option<JoinHandle<()>>,
40 pending_turn_custom: Arc<Mutex<Option<Value>>>,
43}
44
45impl ObserverHandle {
46 pub fn set_session_custom(&self, custom: Value) {
48 let _ = self.tx.send(ObserverMsg::SetSessionCustom(custom));
49 }
50
51 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 pub async fn shutdown(&mut self) {
74 let _ = self.tx.send(ObserverMsg::Shutdown);
75 if let Some(task) = self.task.take() {
76 let _ = task.await;
78 }
79 }
80}
81
82pub 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 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 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 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
161pub(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 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 fn test_runtime() -> agent_base::AgentRuntime {
263 AgentBuilder::new(Arc::new(StubProvider)).build().unwrap()
264 }
265
266 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 #[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 assert!(turn.user_input.len() <= 83);
446 assert!(turn.user_input.ends_with("..."));
447 }
448
449 #[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 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 handle.shutdown().await;
489
490 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 handle.shutdown().await;
509 }
510
511 #[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 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 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 handle.set_turn_custom(json!({"quality": 0.95}));
566
567 {
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 assert!(pending.as_ref().unwrap().get("first").is_none());
609 }
610
611 handle.shutdown().await;
612 }
613}