From 89db4b7ea41feab6b2121f345d815d535d76185d Mon Sep 17 00:00:00 2001 From: Benjamin Klieger Date: Fri, 3 Jul 2026 12:24:04 -0700 Subject: [PATCH] fix: emit OTEL for hosted web search --- codex-rs/core/src/client.rs | 27 ++++++ codex-rs/core/src/client_tests.rs | 91 ++++++++++++++++++- codex-rs/otel/src/events/session_telemetry.rs | 17 ++++ 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 43a758b0e851..51a1d47f832a 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1919,6 +1919,7 @@ where let mut tx_last_response = Some(tx_last_response); let mut items_added: Vec = Vec::new(); let (request_start, mut ttft_ms) = (Instant::now(), None); + let mut hosted_web_search_started_at: HashMap = HashMap::new(); let mut api_stream = api_stream; let upstream_request_id = upstream_request_id.as_deref(); if let Some(upstream_request_id) = upstream_request_id { @@ -1941,6 +1942,22 @@ where }; match event { Ok(ResponseEvent::OutputItemDone(item)) => { + if let ResponseItem::WebSearchCall { + id: Some(call_id), + status, + .. + } = &item + { + let duration = hosted_web_search_started_at + .remove(call_id) + .map(|start| start.elapsed()) + .unwrap_or(Duration::ZERO); + session_telemetry.hosted_web_search_tool_result( + call_id, + duration, + status.as_deref().unwrap_or("completed") == "completed", + ); + } items_added.push(item.clone()); if tx_event .send(Ok(ResponseEvent::OutputItemDone(item))) @@ -1996,6 +2013,16 @@ where } } Ok(event) => { + // Hosted web search is executed by the Responses API instead of the local + // tool registry. Track the streamed item lifetime here so its completion can + // still produce a `codex.tool_result` row without retaining query content. + if let ResponseEvent::OutputItemAdded(ResponseItem::WebSearchCall { + id: Some(call_id), + .. + }) = &event + { + hosted_web_search_started_at.insert(call_id.clone(), Instant::now()); + } if matches!(&event, ResponseEvent::OutputItemAdded(_)) && ttft_ms.is_none() { ttft_ms = Some( i64::try_from(request_start.elapsed().as_millis()).unwrap_or(i64::MAX), diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 82c464625d5d..e0d3056fb460 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -37,6 +37,7 @@ use codex_protocol::auth::AuthMode; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::models::WebSearchAction; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::InternalSessionSource; @@ -354,6 +355,7 @@ impl Visit for TagCollectorVisitor { #[derive(Clone)] struct TagCollectorLayer { + target: &'static str, tags: Arc>>, } @@ -362,7 +364,7 @@ where S: Subscriber + for<'a> LookupSpan<'a>, { fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) { - if event.metadata().target() != "feedback_tags" { + if event.metadata().target() != self.target { return; } let mut visitor = TagCollectorVisitor::default(); @@ -618,7 +620,10 @@ async fn dropped_response_stream_traces_cancelled_partial_output() -> anyhow::Re async fn response_stream_records_last_model_feedback_ids() { let tags = Arc::new(Mutex::new(BTreeMap::new())); let _guard = tracing_subscriber::registry() - .with(TagCollectorLayer { tags: tags.clone() }) + .with(TagCollectorLayer { + target: "feedback_tags", + tags: tags.clone(), + }) .set_default(); let api_stream = futures::stream::iter([ @@ -650,6 +655,88 @@ async fn response_stream_records_last_model_feedback_ids() { ); } +#[tokio::test] +async fn response_stream_logs_hosted_web_search_tool_result_without_query_content() { + let otel_fields = Arc::new(Mutex::new(BTreeMap::new())); + let _guard = tracing_subscriber::registry() + .with(TagCollectorLayer { + target: "codex_otel.log_only", + tags: Arc::clone(&otel_fields), + }) + .set_default(); + + let item = ResponseItem::WebSearchCall { + id: Some("ws-1".to_string()), + status: Some("completed".to_string()), + action: Some(WebSearchAction::Search { + query: Some("sensitive query".to_string()), + queries: Some(vec!["sensitive query".to_string()]), + }), + internal_chat_message_metadata_passthrough: None, + }; + let api_stream = futures::stream::iter([ + Ok(ResponseEvent::OutputItemAdded(item.clone())), + Ok(ResponseEvent::OutputItemDone(item)), + Ok(ResponseEvent::Completed { + response_id: "resp-123".to_string(), + token_usage: None, + end_turn: Some(true), + }), + ]); + let (mut stream, _) = super::map_response_events( + Some("req-123".to_string()), + api_stream, + test_session_telemetry(), + InferenceTraceAttempt::disabled(), + test_model_provider(), + ); + + while stream.next().await.is_some() {} + + let tool_result = otel_fields.lock().unwrap(); + + assert_eq!( + tool_result + .get("event.name") + .map(|value| value.trim_matches('"')), + Some("codex.tool_result") + ); + assert_eq!( + tool_result + .get("tool_name") + .map(|value| value.trim_matches('"')), + Some("web_search") + ); + assert_eq!( + tool_result + .get("call_id") + .map(|value| value.trim_matches('"')), + Some("ws-1") + ); + assert_eq!( + tool_result + .get("success") + .map(|value| value.trim_matches('"')), + Some("true") + ); + assert_eq!( + tool_result + .get("arguments") + .map(|value| value.trim_matches('"')), + Some("") + ); + assert_eq!( + tool_result + .get("output") + .map(|value| value.trim_matches('"')), + Some("") + ); + assert!( + !format!("{tool_result:?}").contains("sensitive query"), + "web-search query content must not be logged in OTEL" + ); +} + #[tokio::test] async fn bedrock_unauthorized_error_uses_provider_mapping() { let provider = create_model_provider( diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 440f8f04a56b..0aa0f70d8b4f 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1084,6 +1084,23 @@ impl SessionTelemetry { ); } + /// Emits a completed hosted web-search tool result to OTEL. + /// + /// Use case: hosted Responses API web search does not pass through the local tool registry, + /// so the response stream records the completed hosted tool item directly. + pub fn hosted_web_search_tool_result(&self, call_id: &str, duration: Duration, success: bool) { + self.tool_result_with_tags( + "web_search", + call_id, + "", + duration, + success, + "", + &[("source", "hosted")], + /*extra_trace_fields*/ &[], + ); + } + #[allow(clippy::too_many_arguments)] pub fn tool_result_with_tags( &self,