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(TurnContext),
21 SetSessionCustom(Value),
23 Shutdown,
25}
26
27pub struct ObserverHandle {
33 tx: mpsc::UnboundedSender<ObserverMsg>,
34 pub session: Arc<RwLock<SessionMetrics>>,
36 task: Option<JoinHandle<()>>,
38 pending_turn_custom: Arc<Mutex<Option<Value>>>,
41}
42
43impl ObserverHandle {
44 pub fn set_session_custom(&self, custom: Value) {
46 let _ = self.tx.send(ObserverMsg::SetSessionCustom(custom));
47 }
48
49 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 pub async fn shutdown(&mut self) {
72 let _ = self.tx.send(ObserverMsg::Shutdown);
73 if let Some(task) = self.task.take() {
74 let _ = task.await;
76 }
77 }
78}
79
80pub 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 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 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 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
159pub(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 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 fn test_runtime() -> agent_base::AgentRuntime {
260 AgentBuilder::new(Arc::new(StubProvider)).build().unwrap()
261 }
262
263 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 #[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 assert!(turn.user_input.len() <= 83);
425 assert!(turn.user_input.ends_with("..."));
426 }
427
428 #[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 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 handle.shutdown().await;
468
469 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 handle.shutdown().await;
488 }
489
490 #[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 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 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 handle.set_turn_custom(json!({"quality": 0.95}));
545
546 {
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 assert!(pending.as_ref().unwrap().get("first").is_none());
588 }
589
590 handle.shutdown().await;
591 }
592}