Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions codex-rs/core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1919,6 +1919,7 @@ where
let mut tx_last_response = Some(tx_last_response);
let mut items_added: Vec<ResponseItem> = Vec::new();
let (request_start, mut ttft_ms) = (Instant::now(), None);
let mut hosted_web_search_started_at: HashMap<String, Instant> = 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 {
Expand All @@ -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)))
Expand Down Expand Up @@ -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),
Expand Down
91 changes: 89 additions & 2 deletions codex-rs/core/src/client_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -354,6 +355,7 @@ impl Visit for TagCollectorVisitor {

#[derive(Clone)]
struct TagCollectorLayer {
target: &'static str,
tags: Arc<Mutex<BTreeMap<String, String>>>,
}

Expand All @@ -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();
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions codex-rs/otel/src/events/session_telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading