diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index ddc9efafea..7a5fa64afa 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -3,6 +3,7 @@ use indexmap::IndexMap; use std::collections::HashMap; use std::sync::Arc; +use std::time::Instant; use tokio::sync::Mutex; use tokio::sync::Notify; use tokio_util::sync::CancellationToken; @@ -19,14 +20,12 @@ use crate::tasks::SessionTask; pub(crate) struct ActiveTurn { pub(crate) tasks: IndexMap, pub(crate) turn_state: Arc>, + pub(crate) started_at: Instant, } impl Default for ActiveTurn { fn default() -> Self { - Self { - tasks: IndexMap::new(), - turn_state: Arc::new(Mutex::new(TurnState::default())), - } + Self::new() } } @@ -48,6 +47,14 @@ pub(crate) struct RunningTask { } impl ActiveTurn { + pub(crate) fn new() -> Self { + Self { + tasks: IndexMap::new(), + turn_state: Arc::new(Mutex::new(TurnState::default())), + started_at: Instant::now(), + } + } + pub(crate) fn add_task(&mut self, task: RunningTask) { let sub_id = task.turn_context.sub_id.clone(); self.tasks.insert(sub_id, task); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index d6754c23c8..f589093540 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -168,9 +168,11 @@ impl Session { last_agent_message: Option, ) { let mut active = self.active_turn.lock().await; + let mut completed_turn = None; let should_close_processes = if let Some(at) = active.as_mut() && at.remove_task(&turn_context.sub_id) { + completed_turn = Some(at.started_at); *active = None; true } else { @@ -180,13 +182,20 @@ impl Session { if should_close_processes { self.close_unified_exec_processes().await; } + if let Some(started_at) = completed_turn { + self.services.otel_manager.record_duration( + "codex.turn.end_to_end_duration", + started_at.elapsed(), + &[], + ); + } let event = EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message }); self.send_event(turn_context.as_ref(), event).await; } async fn register_new_active_task(&self, task: RunningTask) { let mut active = self.active_turn.lock().await; - let mut turn = ActiveTurn::default(); + let mut turn = ActiveTurn::new(); turn.add_task(task); *active = Some(turn); } diff --git a/codex-rs/otel/tests/suite/manager_metrics.rs b/codex-rs/otel/tests/suite/manager_metrics.rs index 1497a5f84c..501cebeefc 100644 --- a/codex-rs/otel/tests/suite/manager_metrics.rs +++ b/codex-rs/otel/tests/suite/manager_metrics.rs @@ -1,6 +1,7 @@ use crate::harness::attributes_to_map; use crate::harness::build_metrics_with_defaults; use crate::harness::find_metric; +use crate::harness::histogram_data; use crate::harness::latest_metrics; use codex_app_server_protocol::AuthMode; use codex_otel::OtelManager; @@ -11,6 +12,7 @@ use opentelemetry_sdk::metrics::data::AggregatedMetrics; use opentelemetry_sdk::metrics::data::MetricData; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +use std::time::Duration; // Ensures OtelManager attaches metadata tags when forwarding metrics. #[test] @@ -102,3 +104,62 @@ fn manager_allows_disabling_metadata_tags() -> Result<()> { Ok(()) } + +// Ensures duration metrics from OtelManager emit histograms with metadata tags. +#[test] +fn manager_records_turn_end_to_end_duration_histogram() -> Result<()> { + let (metrics, exporter) = build_metrics_with_defaults(&[("service", "codex-cli")])?; + let manager = OtelManager::new( + ThreadId::new(), + "gpt-5.1", + "gpt-5.1", + Some("account-id".to_string()), + None, + Some(AuthMode::ApiKey), + true, + "tty".to_string(), + SessionSource::Cli, + ) + .with_metrics(metrics); + + manager.record_duration( + "codex.turn.end_to_end_duration", + Duration::from_millis(120), + &[], + ); + manager.shutdown_metrics()?; + + let resource_metrics = latest_metrics(&exporter); + let (_bounds, bucket_counts, sum, count) = + histogram_data(&resource_metrics, "codex.turn.end_to_end_duration"); + assert_eq!(bucket_counts.iter().sum::(), 1); + assert_eq!(sum, 120.0); + assert_eq!(count, 1); + + let metric = find_metric(&resource_metrics, "codex.turn.end_to_end_duration") + .expect("duration metric missing"); + let attrs = match metric.data() { + AggregatedMetrics::F64(data) => match data { + MetricData::Histogram(histogram) => { + let points: Vec<_> = histogram.data_points().collect(); + assert_eq!(points.len(), 1); + attributes_to_map(points[0].attributes()) + } + _ => panic!("unexpected histogram aggregation"), + }, + _ => panic!("unexpected metric data type"), + }; + + let expected = BTreeMap::from([ + ( + "app.version".to_string(), + env!("CARGO_PKG_VERSION").to_string(), + ), + ("auth_mode".to_string(), AuthMode::ApiKey.to_string()), + ("model".to_string(), "gpt-5.1".to_string()), + ("service".to_string(), "codex-cli".to_string()), + ]); + assert_eq!(attrs, expected); + + Ok(()) +}