diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 37555d6caf..7152339821 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -179,6 +179,7 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::CodexThread; use codex_core::Cursor as RolloutCursor; +use codex_core::ModelProviderInfo; use codex_core::NewThread; use codex_core::RolloutRecorder; use codex_core::SessionMeta; @@ -241,7 +242,6 @@ use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; use codex_protocol::ThreadId; -use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; @@ -317,9 +317,6 @@ use crate::thread_state::ThreadStateManager; const THREAD_LIST_DEFAULT_LIMIT: usize = 25; const THREAD_LIST_MAX_LIMIT: usize = 100; -const APP_SERVER_AUTH_STATUS_EVENT: &str = "codex.app_server_auth_status"; -const APP_SERVER_AUTH_CACHE_EVENT: &str = "codex.app_server_auth_cache"; -const APP_SERVER_AUTH_NOTIFICATION_EVENT: &str = "codex.app_server_auth_notification"; struct ThreadListFilters { model_providers: Option>, @@ -416,174 +413,29 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) log_db: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -struct AppServerAuthStatusObservation { - provider_id: String, +fn app_server_feedback_auth_state( requires_openai_auth: bool, - auth_method: String, - auth_token_present: bool, - include_token_requested: bool, - refresh_token_requested: bool, - decision_state: String, -} + auth: Option<&CodexAuth>, + provider: &ModelProviderInfo, +) -> &'static str { + let has_required_auth = if requires_openai_auth { + auth.is_some() + || provider.api_key().ok().flatten().is_some() + || provider + .experimental_bearer_token + .as_ref() + .is_some_and(|token| !token.trim().is_empty()) + } else { + true + }; -#[derive(Debug, Clone, PartialEq, Eq)] -struct AppServerAuthCacheObservation { - action: String, - reason: String, - auth_method: String, - token_present: bool, - account_id_present: bool, - plan_type: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct AppServerAuthNotificationObservation { - method: String, - followup_auth_check_started: bool, - followup_auth_check_completed: bool, - followup_requires_openai_auth: bool, - followup_auth_method: String, - followup_auth_token_present: bool, -} - -fn normalize_app_server_auth_method(auth_method: Option) -> String { - match auth_method { - Some(AuthMode::ApiKey) => "api_key".to_string(), - Some(AuthMode::Chatgpt) => "chatgpt".to_string(), - Some(AuthMode::ChatgptAuthTokens) => "chatgpt_auth_tokens".to_string(), - None => "none".to_string(), - } -} - -fn normalize_core_auth_method(auth: Option<&CodexAuth>) -> String { - match auth.map(CodexAuth::api_auth_mode) { - Some(AuthMode::ApiKey) => "api_key".to_string(), - Some(AuthMode::Chatgpt) => "chatgpt".to_string(), - Some(AuthMode::ChatgptAuthTokens) => "chatgpt_auth_tokens".to_string(), - None => "none".to_string(), - } -} - -fn normalize_plan_type(auth: Option<&CodexAuth>) -> String { - match auth.and_then(CodexAuth::account_plan_type) { - Some(AccountPlanType::Free) => "free".to_string(), - Some(AccountPlanType::Go) => "go".to_string(), - Some(AccountPlanType::Plus) => "plus".to_string(), - Some(AccountPlanType::Pro) => "pro".to_string(), - Some(AccountPlanType::Team) => "team".to_string(), - Some(AccountPlanType::Business) => "business".to_string(), - Some(AccountPlanType::Enterprise) => "enterprise".to_string(), - Some(AccountPlanType::Edu) => "edu".to_string(), - Some(AccountPlanType::Unknown) => "unknown".to_string(), - None => "absent".to_string(), - } -} - -fn auth_token_present(auth: Option<&CodexAuth>) -> bool { - auth.and_then(|value| value.get_token().ok()) - .is_some_and(|token| !token.is_empty()) -} - -fn build_auth_status_observation( - provider_id: &str, - response: &GetAuthStatusResponse, - include_token_requested: bool, - refresh_token_requested: bool, -) -> AppServerAuthStatusObservation { - let requires_openai_auth = response.requires_openai_auth.unwrap_or(true); - let auth_token_present = response.auth_method.is_some() - || response - .auth_token - .as_ref() - .is_some_and(|token| !token.is_empty()); - let decision_state = if !requires_openai_auth || auth_token_present { + if has_required_auth { "connected" } else { "unauthed" - }; - - AppServerAuthStatusObservation { - provider_id: provider_id.to_string(), - requires_openai_auth, - auth_method: normalize_app_server_auth_method(response.auth_method), - auth_token_present, - include_token_requested, - refresh_token_requested, - decision_state: decision_state.to_string(), } } -fn build_auth_cache_observation( - action: &str, - reason: &str, - auth: Option<&CodexAuth>, -) -> AppServerAuthCacheObservation { - AppServerAuthCacheObservation { - action: action.to_string(), - reason: reason.to_string(), - auth_method: normalize_core_auth_method(auth), - token_present: auth_token_present(auth), - account_id_present: auth.and_then(CodexAuth::get_account_id).is_some(), - plan_type: normalize_plan_type(auth), - } -} - -fn build_auth_notification_observation( - method: &str, - requires_openai_auth: bool, - auth: Option<&CodexAuth>, -) -> AppServerAuthNotificationObservation { - AppServerAuthNotificationObservation { - method: method.to_string(), - followup_auth_check_started: true, - followup_auth_check_completed: true, - followup_requires_openai_auth: requires_openai_auth, - followup_auth_method: normalize_core_auth_method(auth), - followup_auth_token_present: auth_token_present(auth), - } -} - -fn emit_auth_status_observation(observation: &AppServerAuthStatusObservation) { - tracing::info!( - event.name = APP_SERVER_AUTH_STATUS_EVENT, - provider_id = %observation.provider_id, - requires_openai_auth = observation.requires_openai_auth, - auth_method = %observation.auth_method, - auth_token_present = observation.auth_token_present, - include_token_requested = observation.include_token_requested, - refresh_token_requested = observation.refresh_token_requested, - decision_state = %observation.decision_state, - "codex app-server auth status resolved", - ); -} - -fn emit_auth_cache_observation(observation: &AppServerAuthCacheObservation) { - tracing::info!( - event.name = APP_SERVER_AUTH_CACHE_EVENT, - action = %observation.action, - reason = %observation.reason, - auth_method = %observation.auth_method, - token_present = observation.token_present, - account_id_present = observation.account_id_present, - plan_type = %observation.plan_type, - "codex app-server auth cache changed", - ); -} - -fn emit_auth_notification_observation(observation: &AppServerAuthNotificationObservation) { - tracing::info!( - event.name = APP_SERVER_AUTH_NOTIFICATION_EVENT, - method = %observation.method, - followup_auth_check_started = observation.followup_auth_check_started, - followup_auth_check_completed = observation.followup_auth_check_completed, - followup_requires_openai_auth = observation.followup_requires_openai_auth, - followup_auth_method = %observation.followup_auth_method, - followup_auth_token_present = observation.followup_auth_token_present, - "codex app-server auth notification follow-up resolved", - ); -} - impl CodexMessageProcessor { pub(crate) fn clear_plugin_related_caches(&self) { self.thread_manager.plugins_manager().clear_cache(); @@ -1151,7 +1003,6 @@ impl CodexMessageProcessor { ) { match self.login_api_key_common(¶ms).await { Ok(()) => { - let auth = self.auth_manager.auth_cached(); let response = codex_app_server_protocol::LoginAccountResponse::ApiKey {}; self.outgoing.send_response(request_id, response).await; @@ -1165,27 +1016,12 @@ impl CodexMessageProcessor { payload_login_completed, )) .await; - emit_auth_cache_observation(&build_auth_cache_observation( - "set", - "login_completed", - auth.as_ref(), - )); - emit_auth_notification_observation(&build_auth_notification_observation( - "account/login/completed", - self.config.model_provider.requires_openai_auth, - auth.as_ref(), - )); self.outgoing .send_server_notification(ServerNotification::AccountUpdated( self.current_account_updated_notification(), )) .await; - emit_auth_notification_observation(&build_auth_notification_observation( - "account/updated", - self.config.model_provider.requires_openai_auth, - auth.as_ref(), - )); } Err(error) => { self.outgoing.send_error(request_id, error).await; @@ -1249,7 +1085,6 @@ impl CodexMessageProcessor { let chatgpt_base_url = self.config.chatgpt_base_url.clone(); let codex_home = self.config.codex_home.clone(); let cli_overrides = self.cli_overrides.clone(); - let requires_openai_auth = self.config.model_provider.requires_openai_auth; let auth_url = server.auth_url.clone(); tokio::spawn(async move { let (success, error_msg) = match tokio::time::timeout( @@ -1293,18 +1128,6 @@ impl CodexMessageProcessor { // Notify clients with the actual current auth mode. let auth = auth_manager.auth_cached(); - emit_auth_cache_observation(&build_auth_cache_observation( - "set", - "login_completed", - auth.as_ref(), - )); - emit_auth_notification_observation( - &build_auth_notification_observation( - "account/login/completed", - requires_openai_auth, - auth.as_ref(), - ), - ); let payload_v2 = AccountUpdatedNotification { auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), @@ -1314,13 +1137,6 @@ impl CodexMessageProcessor { payload_v2, )) .await; - emit_auth_notification_observation( - &build_auth_notification_observation( - "account/updated", - requires_openai_auth, - auth.as_ref(), - ), - ); } // Clear the active login if it matches this attempt. It may have been replaced or cancelled. @@ -1450,7 +1266,6 @@ impl CodexMessageProcessor { return; } self.auth_manager.reload(); - let auth = self.auth_manager.auth_cached(); replace_cloud_requirements_loader( self.cloud_requirements.as_ref(), self.auth_manager.clone(), @@ -1477,27 +1292,12 @@ impl CodexMessageProcessor { payload_login_completed, )) .await; - emit_auth_cache_observation(&build_auth_cache_observation( - "set", - "login_completed", - auth.as_ref(), - )); - emit_auth_notification_observation(&build_auth_notification_observation( - "account/login/completed", - self.config.model_provider.requires_openai_auth, - auth.as_ref(), - )); self.outgoing .send_server_notification(ServerNotification::AccountUpdated( self.current_account_updated_notification(), )) .await; - emit_auth_notification_observation(&build_auth_notification_observation( - "account/updated", - self.config.model_provider.requires_openai_auth, - auth.as_ref(), - )); } async fn logout_common(&mut self) -> std::result::Result, JSONRPCErrorError> { @@ -1539,11 +1339,6 @@ impl CodexMessageProcessor { self.outgoing .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) .await; - emit_auth_cache_observation(&build_auth_cache_observation( - "clear", - "logout", - self.auth_manager.auth_cached().as_ref(), - )); } Err(error) => { self.outgoing.send_error(request_id, error).await; @@ -1559,11 +1354,6 @@ impl CodexMessageProcessor { if let Err(err) = self.auth_manager.refresh_token().await { tracing::warn!("failed to refresh token while getting account: {err}"); } - emit_auth_cache_observation(&build_auth_cache_observation( - "refresh", - "refresh_requested", - self.auth_manager.auth_cached().as_ref(), - )); } } @@ -1613,12 +1403,6 @@ impl CodexMessageProcessor { } }; - emit_auth_status_observation(&build_auth_status_observation( - self.config.model_provider_id.as_str(), - &response, - include_token, - do_refresh, - )); self.outgoing.send_response(request_id, response).await; } @@ -7139,6 +6923,18 @@ impl CodexMessageProcessor { { tracing::info!(target: "feedback_tags", chatgpt_user_id); } + let auth = self.auth_manager.auth_cached(); + let requires_openai_auth = self.config.model_provider.requires_openai_auth; + tracing::info!( + target: "feedback_tags", + app_server_auth_state = + app_server_feedback_auth_state( + requires_openai_auth, + auth.as_ref(), + &self.config.model_provider, + ), + app_server_requires_openai_auth = requires_openai_auth, + ); let snapshot = self.feedback.snapshot(conversation_id); let thread_id = snapshot.thread_id.clone(); let sqlite_feedback_logs = if include_logs { @@ -8376,7 +8172,6 @@ mod tests { use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingMessage; use anyhow::Result; - use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_protocol::protocol::SessionSource; @@ -8768,87 +8563,6 @@ mod tests { Ok(()) } - #[test] - fn build_auth_status_observation_marks_unauthed_when_auth_is_required_but_missing() { - let observation = build_auth_status_observation( - "openai", - &GetAuthStatusResponse { - auth_method: None, - auth_token: None, - requires_openai_auth: Some(true), - }, - false, - false, - ); - - assert_eq!( - observation, - AppServerAuthStatusObservation { - provider_id: "openai".to_string(), - requires_openai_auth: true, - auth_method: "none".to_string(), - auth_token_present: false, - include_token_requested: false, - refresh_token_requested: false, - decision_state: "unauthed".to_string(), - } - ); - } - - #[test] - fn build_auth_status_observation_marks_connected_when_openai_auth_is_not_required() { - let observation = build_auth_status_observation( - "mock_provider", - &GetAuthStatusResponse { - auth_method: None, - auth_token: None, - requires_openai_auth: Some(false), - }, - false, - false, - ); - - assert_eq!(observation.decision_state, "connected"); - assert_eq!(observation.auth_method, "none"); - assert_eq!(observation.requires_openai_auth, false); - } - - #[test] - fn build_auth_cache_observation_normalizes_present_chatgpt_auth() { - let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let observation = build_auth_cache_observation("refresh", "refresh_requested", Some(&auth)); - - assert_eq!( - observation, - AppServerAuthCacheObservation { - action: "refresh".to_string(), - reason: "refresh_requested".to_string(), - auth_method: "chatgpt".to_string(), - token_present: true, - account_id_present: true, - plan_type: "unknown".to_string(), - } - ); - } - - #[test] - fn build_auth_notification_observation_uses_current_auth_snapshot() { - let auth = CodexAuth::from_api_key("sk-test-key"); - let observation = build_auth_notification_observation("account/updated", true, Some(&auth)); - - assert_eq!( - observation, - AppServerAuthNotificationObservation { - method: "account/updated".to_string(), - followup_auth_check_started: true, - followup_auth_check_completed: true, - followup_requires_openai_auth: true, - followup_auth_method: "api_key".to_string(), - followup_auth_token_present: true, - } - ); - } - #[tokio::test] async fn removing_thread_state_clears_listener_and_active_turn_history() -> Result<()> { let manager = ThreadStateManager::new(); diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index beb005cbf7..90f0dcfdaf 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -1244,10 +1244,6 @@ impl AuthManager { .unwrap_or(false) } - pub(crate) fn codex_api_key_env_enabled(&self) -> bool { - self.enable_codex_api_key_env - } - pub fn is_external_auth_active(&self) -> bool { self.auth_cached() .as_ref() diff --git a/codex-rs/core/src/auth_env_telemetry.rs b/codex-rs/core/src/auth_env_telemetry.rs index 157e0efbf6..4b522ffb49 100644 --- a/codex-rs/core/src/auth_env_telemetry.rs +++ b/codex-rs/core/src/auth_env_telemetry.rs @@ -26,12 +26,6 @@ impl AuthEnvTelemetry { refresh_token_url_override_present: self.refresh_token_url_override_present, } } - - pub(crate) fn has_api_key_override(&self) -> bool { - self.openai_api_key_env_present - || (self.codex_api_key_env_enabled && self.codex_api_key_env_present) - || self.provider_env_key_present == Some(true) - } } pub(crate) fn collect_auth_env_telemetry( provider: &ModelProviderInfo, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 99ad51bac5..0c08a7c085 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -95,7 +95,6 @@ use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; use crate::config::Config; use crate::default_client::build_reqwest_client; -use crate::default_client::residency_header_telemetry_for_provider_headers; use crate::endpoint_config_telemetry::EndpointConfigTelemetry; use crate::endpoint_config_telemetry::EndpointConfigTelemetrySource; use crate::error::CodexErr; @@ -121,6 +120,10 @@ const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=20 const RESPONSES_ENDPOINT: &str = "/responses"; const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact"; const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize"; +#[cfg(test)] +pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration = + Duration::from_millis(crate::model_provider_info::DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS); + pub fn ws_version_from_features(config: &Config) -> bool { config .features @@ -166,18 +169,11 @@ struct CurrentClientSetup { #[derive(Clone)] struct RequestRouteTelemetry { endpoint: &'static str, - residency_header_attached: bool, - residency_header_value: Option, } impl RequestRouteTelemetry { - fn for_endpoint(endpoint: &'static str, provider_headers: &ApiHeaderMap) -> Self { - let residency = residency_header_telemetry_for_provider_headers(provider_headers); - Self { - endpoint, - residency_header_attached: residency.attached, - residency_header_value: residency.value, - } + fn for_endpoint(endpoint: &'static str, _provider_headers: &ApiHeaderMap) -> Self { + Self { endpoint } } } @@ -295,35 +291,6 @@ impl ModelClient { ) } - #[allow(clippy::too_many_arguments)] - pub fn new_with_provider_id( - auth_manager: Option>, - conversation_id: ThreadId, - provider_id: &str, - provider: ModelProviderInfo, - session_source: SessionSource, - model_verbosity: Option, - responses_websockets_enabled_by_feature: bool, - enable_request_compression: bool, - include_timing_metrics: bool, - beta_features_header: Option, - ) -> Self { - let endpoint_telemetry_source = - EndpointConfigTelemetrySource::for_provider(provider_id, &provider); - Self::new_with_endpoint_telemetry_source( - auth_manager, - conversation_id, - provider, - endpoint_telemetry_source, - session_source, - model_verbosity, - responses_websockets_enabled_by_feature, - enable_request_compression, - include_timing_metrics, - beta_features_header, - ) - } - #[allow(clippy::too_many_arguments)] pub(crate) fn new_with_endpoint_telemetry_source( auth_manager: Option>, @@ -646,15 +613,22 @@ impl ModelClient { provider_header_names.map(str::to_owned), request_route_telemetry.clone(), ); + let websocket_connect_timeout = self.state.provider.websocket_connect_timeout(); let start = Instant::now(); - let result = ApiWebSocketResponsesClient::new(api_provider, api_auth) - .connect( + let result = match tokio::time::timeout( + websocket_connect_timeout, + ApiWebSocketResponsesClient::new(api_provider, api_auth).connect( headers, crate::default_client::default_headers(), turn_state, Some(websocket_telemetry), - ) - .await; + ), + ) + .await + { + Ok(result) => result, + Err(_) => Err(ApiError::Transport(TransportError::Timeout)), + }; let error_message = result.as_ref().err().map(telemetry_api_error_message); let response_debug = result .as_ref() @@ -662,7 +636,7 @@ impl ModelClient { .map(extract_response_debug_context_from_api_error) .unwrap_or_default(); let status = result.as_ref().err().and_then(api_error_http_status); - session_telemetry.record_websocket_connect_with_endpoint_details( + session_telemetry.record_websocket_connect( start.elapsed(), status, error_message.as_deref(), @@ -672,8 +646,6 @@ impl ModelClient { auth_context.recovery_mode, auth_context.recovery_phase, request_route_telemetry.endpoint, - request_route_telemetry.residency_header_attached, - request_route_telemetry.residency_header_value.as_deref(), provider_header_names, endpoint_telemetry.base_url_origin, endpoint_telemetry.host_class, @@ -723,20 +695,19 @@ impl ModelClient { auth_recovery_mode: auth_context.recovery_mode, auth_recovery_phase: auth_context.recovery_phase, auth_connection_reused: Some(false), + app_server_auth_state: None, + app_server_requires_openai_auth: None, provider_header_names, base_url_origin: endpoint_telemetry.base_url_origin, host_class: endpoint_telemetry.host_class, base_url_source: endpoint_telemetry.base_url_source, base_url_is_default: endpoint_telemetry.base_url_is_default, - residency_header_attached: Some(request_route_telemetry.residency_header_attached), - residency_header_value: request_route_telemetry.residency_header_value.as_deref(), auth_request_id: response_debug.request_id.as_deref(), auth_cf_ray: response_debug.cf_ray.as_deref(), auth_error: response_debug.auth_error.as_deref(), auth_error_code: response_debug.auth_error_code.as_deref(), error_body_class: response_debug.error_body_class, safe_error_message: response_debug.safe_error_message, - geo_denial_detected: Some(response_debug.geo_denial_detected), auth_recovery_followup_success: auth_context .retry_after_unauthorized .then_some(result.is_ok()), @@ -745,23 +716,6 @@ impl ModelClient { .then_some(status) .flatten(), }); - if status == Some(StatusCode::UNAUTHORIZED.as_u16()) && response_debug.geo_denial_detected { - session_telemetry.record_geo_denial( - request_route_telemetry.endpoint, - auth_context.auth_header_attached, - auth_context.auth_header_name, - request_route_telemetry.residency_header_attached, - request_route_telemetry.residency_header_value.as_deref(), - provider_header_names, - status, - response_debug.request_id.as_deref(), - response_debug.cf_ray.as_deref(), - response_debug.auth_error.as_deref(), - response_debug.auth_error_code.as_deref(), - response_debug.error_body_class.unwrap_or_default(), - response_debug.safe_error_message, - ); - } result } @@ -1825,40 +1779,35 @@ impl RequestTelemetry for ApiTelemetry { let debug = error .map(extract_response_debug_context) .unwrap_or_default(); - self.session_telemetry - .record_api_request_with_endpoint_details( - attempt, - status, - error_message.as_deref(), - duration, - self.auth_context.auth_header_attached, - self.auth_context.auth_header_name, - self.auth_context.retry_after_unauthorized, - self.auth_context.recovery_mode, - self.auth_context.recovery_phase, - self.request_route_telemetry.endpoint, - self.request_route_telemetry.residency_header_attached, - self.request_route_telemetry - .residency_header_value - .as_deref(), - self.provider_header_names.as_deref(), - self.endpoint_telemetry.base_url_origin, - self.endpoint_telemetry.host_class, - self.endpoint_telemetry.base_url_source, - self.endpoint_telemetry.base_url_is_default, - self.auth_env_telemetry.openai_api_key_env_present, - self.auth_env_telemetry.codex_api_key_env_present, - self.auth_env_telemetry.codex_api_key_env_enabled, - self.auth_env_telemetry.provider_env_key_name.as_deref(), - self.auth_env_telemetry.provider_env_key_present, - self.auth_env_telemetry.refresh_token_url_override_present, - debug.request_id.as_deref(), - debug.cf_ray.as_deref(), - debug.auth_error.as_deref(), - debug.auth_error_code.as_deref(), - debug.error_body_class, - debug.safe_error_message, - ); + self.session_telemetry.record_api_request( + attempt, + status, + error_message.as_deref(), + duration, + self.auth_context.auth_header_attached, + self.auth_context.auth_header_name, + self.auth_context.retry_after_unauthorized, + self.auth_context.recovery_mode, + self.auth_context.recovery_phase, + self.request_route_telemetry.endpoint, + self.provider_header_names.as_deref(), + self.endpoint_telemetry.base_url_origin, + self.endpoint_telemetry.host_class, + self.endpoint_telemetry.base_url_source, + self.endpoint_telemetry.base_url_is_default, + self.auth_env_telemetry.openai_api_key_env_present, + self.auth_env_telemetry.codex_api_key_env_present, + self.auth_env_telemetry.codex_api_key_env_enabled, + self.auth_env_telemetry.provider_env_key_name.as_deref(), + self.auth_env_telemetry.provider_env_key_present, + self.auth_env_telemetry.refresh_token_url_override_present, + debug.request_id.as_deref(), + debug.cf_ray.as_deref(), + debug.auth_error.as_deref(), + debug.auth_error_code.as_deref(), + debug.error_body_class, + debug.safe_error_message, + ); emit_feedback_request_tags(&FeedbackRequestTags { endpoint: self.request_route_telemetry.endpoint, auth_header_attached: self.auth_context.auth_header_attached, @@ -1876,23 +1825,19 @@ impl RequestTelemetry for ApiTelemetry { auth_recovery_mode: self.auth_context.recovery_mode, auth_recovery_phase: self.auth_context.recovery_phase, auth_connection_reused: None, + app_server_auth_state: None, + app_server_requires_openai_auth: None, provider_header_names: self.provider_header_names.as_deref(), base_url_origin: self.endpoint_telemetry.base_url_origin, host_class: self.endpoint_telemetry.host_class, base_url_source: self.endpoint_telemetry.base_url_source, base_url_is_default: self.endpoint_telemetry.base_url_is_default, - residency_header_attached: Some(self.request_route_telemetry.residency_header_attached), - residency_header_value: self - .request_route_telemetry - .residency_header_value - .as_deref(), auth_request_id: debug.request_id.as_deref(), auth_cf_ray: debug.cf_ray.as_deref(), auth_error: debug.auth_error.as_deref(), auth_error_code: debug.auth_error_code.as_deref(), error_body_class: debug.error_body_class, safe_error_message: debug.safe_error_message, - geo_denial_detected: Some(debug.geo_denial_detected), auth_recovery_followup_success: self .auth_context .retry_after_unauthorized @@ -1903,25 +1848,6 @@ impl RequestTelemetry for ApiTelemetry { .then_some(status) .flatten(), }); - if status == Some(StatusCode::UNAUTHORIZED.as_u16()) && debug.geo_denial_detected { - self.session_telemetry.record_geo_denial( - self.request_route_telemetry.endpoint, - self.auth_context.auth_header_attached, - self.auth_context.auth_header_name, - self.request_route_telemetry.residency_header_attached, - self.request_route_telemetry - .residency_header_value - .as_deref(), - self.provider_header_names.as_deref(), - status, - debug.request_id.as_deref(), - debug.cf_ray.as_deref(), - debug.auth_error.as_deref(), - debug.auth_error_code.as_deref(), - debug.error_body_class.unwrap_or_default(), - debug.safe_error_message, - ); - } } } @@ -1967,23 +1893,19 @@ impl WebsocketTelemetry for ApiTelemetry { auth_recovery_mode: self.auth_context.recovery_mode, auth_recovery_phase: self.auth_context.recovery_phase, auth_connection_reused: Some(connection_reused), + app_server_auth_state: None, + app_server_requires_openai_auth: None, provider_header_names: self.provider_header_names.as_deref(), base_url_origin: self.endpoint_telemetry.base_url_origin, host_class: self.endpoint_telemetry.host_class, base_url_source: self.endpoint_telemetry.base_url_source, base_url_is_default: self.endpoint_telemetry.base_url_is_default, - residency_header_attached: Some(self.request_route_telemetry.residency_header_attached), - residency_header_value: self - .request_route_telemetry - .residency_header_value - .as_deref(), auth_request_id: debug.request_id.as_deref(), auth_cf_ray: debug.cf_ray.as_deref(), auth_error: debug.auth_error.as_deref(), auth_error_code: debug.auth_error_code.as_deref(), error_body_class: debug.error_body_class, safe_error_message: debug.safe_error_message, - geo_denial_detected: Some(debug.geo_denial_detected), auth_recovery_followup_success: self .auth_context .retry_after_unauthorized diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 48266c29df..d367479187 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -26,7 +26,6 @@ use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::config::ManagedFeatures; use crate::connectors; -use crate::endpoint_config_telemetry::resolve_endpoint_config_telemetry_source; use crate::exec_policy::ExecPolicyManager; use crate::features::FEATURES; use crate::features::Feature; @@ -154,7 +153,6 @@ use tracing::warn; use uuid::Uuid; use crate::ModelProviderInfo; -use crate::auth_env_telemetry::collect_auth_env_telemetry; use crate::client::ModelClient; use crate::client::ModelClientSession; use crate::client_common::Prompt; @@ -1613,33 +1611,8 @@ impl Session { )], ); - let auth_env_telemetry = collect_auth_env_telemetry( - &config.model_provider, - auth_manager.codex_api_key_env_enabled(), - ); - let conversation_start_base_url = config - .model_provider - .to_api_provider(auth.map(CodexAuth::auth_mode)) - .map(|provider| provider.base_url) - .unwrap_or_default(); - let endpoint_telemetry = resolve_endpoint_config_telemetry_source( - &config, - session_configuration.session_source.clone(), - ) - .classify(&conversation_start_base_url); - - session_telemetry.conversation_starts_with_endpoint_details( + session_telemetry.conversation_starts( config.model_provider.name.as_str(), - endpoint_telemetry.base_url_origin, - endpoint_telemetry.host_class, - endpoint_telemetry.base_url_source, - endpoint_telemetry.base_url_is_default, - auth_env_telemetry.openai_api_key_env_present, - auth_env_telemetry.codex_api_key_env_present, - auth_env_telemetry.codex_api_key_env_enabled, - auth_env_telemetry.provider_env_key_name.as_deref(), - auth_env_telemetry.provider_env_key_present, - auth_env_telemetry.refresh_token_url_override_present, session_configuration.collaboration_mode.reasoning_effort(), config .model_reasoning_summary @@ -1823,14 +1796,10 @@ impl Session { network_proxy, network_approval: Arc::clone(&network_approval), state_db: state_db_ctx.clone(), - model_client: ModelClient::new_with_endpoint_telemetry_source( + model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), conversation_id, session_configuration.provider.clone(), - resolve_endpoint_config_telemetry_source( - &config, - session_configuration.session_source.clone(), - ), session_configuration.session_source.clone(), config.model_verbosity, ws_version_from_features(config.as_ref()), diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e354441690..c6372de258 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4136,7 +4136,7 @@ model_verbosity = "high" supports_websockets: false, }; let model_provider_map = { - let mut model_provider_map = built_in_model_providers(None); + let mut model_provider_map = built_in_model_providers(/* openai_base_url */ None); model_provider_map.insert("openai-custom".to_string(), openai_custom_provider.clone()); model_provider_map }; diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index f9ea384a14..60995a96ec 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -30,12 +30,6 @@ pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; pub const RESIDENCY_HEADER_NAME: &str = "x-openai-internal-codex-residency"; -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ResidencyHeaderTelemetry { - pub attached: bool, - pub value: Option, -} - #[derive(Debug, Clone)] pub struct Originator { pub value: String, @@ -95,33 +89,6 @@ pub fn set_default_client_residency_requirement(enforce_residency: Option ResidencyHeaderTelemetry { - let Ok(guard) = REQUIREMENTS_RESIDENCY.read() else { - tracing::warn!("Failed to acquire requirements residency lock"); - return ResidencyHeaderTelemetry::default(); - }; - let Some(requirement) = guard.as_ref() else { - return ResidencyHeaderTelemetry::default(); - }; - ResidencyHeaderTelemetry { - attached: true, - value: Some(residency_header_value(*requirement).to_string()), - } -} - -pub fn residency_header_telemetry_for_provider_headers( - provider_headers: &HeaderMap, -) -> ResidencyHeaderTelemetry { - if let Some(value) = provider_headers.get(RESIDENCY_HEADER_NAME) { - return ResidencyHeaderTelemetry { - attached: true, - value: value.to_str().ok().map(str::to_owned), - }; - } - - current_residency_header_telemetry() -} - pub fn originator() -> Originator { if let Ok(guard) = ORIGINATOR.read() && let Some(originator) = guard.as_ref() @@ -153,6 +120,18 @@ pub fn is_first_party_chat_originator(originator_value: &str) -> bool { originator_value == "codex_atlas" || originator_value == "codex_chatgpt_desktop" } +pub fn client_origin_class(originator_value: &str) -> &'static str { + if is_first_party_chat_originator(originator_value) { + "first_party_chat" + } else if originator_value == DEFAULT_ORIGINATOR { + "codex_cli" + } else if is_first_party_originator(originator_value) { + "first_party_ide" + } else { + "custom" + } +} + pub fn get_codex_user_agent() -> String { let build_version = env!("CARGO_PKG_VERSION"); let os_info = os_info::get(); diff --git a/codex-rs/core/src/endpoint_config_telemetry.rs b/codex-rs/core/src/endpoint_config_telemetry.rs index 79cff8c6ad..b937ae748b 100644 --- a/codex-rs/core/src/endpoint_config_telemetry.rs +++ b/codex-rs/core/src/endpoint_config_telemetry.rs @@ -204,133 +204,3 @@ fn classify_base_url(base_url: &str) -> (&'static str, &'static str) { fn is_chatgpt_codex_path(path: &str) -> bool { path == "/backend-api/codex" || path.starts_with("/backend-api/codex/") } - -#[cfg(test)] -mod tests { - use super::EndpointConfigTelemetry; - use super::EndpointConfigTelemetrySource; - use super::endpoint_source_from_layer; - use super::endpoint_source_from_provider_defaults; - use crate::model_provider_info::WireApi; - use crate::model_provider_info::create_oss_provider_with_base_url; - use codex_app_server_protocol::ConfigLayerSource; - use codex_protocol::protocol::SessionSource; - use codex_utils_absolute_path::AbsolutePathBuf; - use pretty_assertions::assert_eq; - - fn provider(base_url: Option<&str>) -> crate::ModelProviderInfo { - crate::ModelProviderInfo { - name: "test-provider".to_string(), - base_url: base_url.map(str::to_string), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - wire_api: crate::WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: None, - stream_max_retries: None, - stream_idle_timeout_ms: None, - requires_openai_auth: true, - supports_websockets: true, - } - } - - #[test] - fn endpoint_config_telemetry_classifies_known_hosts_without_logging_custom_values() { - let source = EndpointConfigTelemetrySource::new("config_toml", false); - - assert_eq!( - source.classify("https://chatgpt.com/backend-api/codex"), - EndpointConfigTelemetry { - base_url_origin: "chatgpt.com", - host_class: "openai_chatgpt", - base_url_source: "config_toml", - base_url_is_default: false, - } - ); - assert_eq!( - source.classify("https://api.openai.com/v1"), - EndpointConfigTelemetry { - base_url_origin: "api.openai.com", - host_class: "openai_api", - base_url_source: "config_toml", - base_url_is_default: false, - } - ); - assert_eq!( - source.classify("https://openrouter.ai/api/v1"), - EndpointConfigTelemetry { - base_url_origin: "openrouter.ai", - host_class: "known_third_party", - base_url_source: "config_toml", - base_url_is_default: false, - } - ); - assert_eq!( - source.classify("https://private.example.internal/v1?token=secret"), - EndpointConfigTelemetry { - base_url_origin: "custom", - host_class: "custom_unknown", - base_url_source: "config_toml", - base_url_is_default: false, - } - ); - assert_eq!( - source.classify("https://chatgpt.com/api/codex"), - EndpointConfigTelemetry { - base_url_origin: "chatgpt.com", - host_class: "custom_unknown", - base_url_source: "config_toml", - base_url_is_default: false, - } - ); - } - - #[test] - fn endpoint_config_telemetry_source_maps_layers_and_defaults() { - assert_eq!( - endpoint_source_from_layer(&ConfigLayerSource::SessionFlags, SessionSource::VSCode), - EndpointConfigTelemetrySource::new("ide_settings", false) - ); - assert_eq!( - endpoint_source_from_layer( - &ConfigLayerSource::Project { - dot_codex_folder: AbsolutePathBuf::try_from(std::path::PathBuf::from( - "/tmp/project/.codex", - )) - .expect("absolute path"), - }, - SessionSource::Cli, - ), - EndpointConfigTelemetrySource::new("config_toml", false) - ); - assert_eq!( - endpoint_source_from_provider_defaults("openai", &provider(None)), - EndpointConfigTelemetrySource::new("default", true) - ); - assert_eq!( - endpoint_source_from_provider_defaults( - "custom", - &provider(Some("https://example.com/v1")) - ), - EndpointConfigTelemetrySource::new("config_toml", false) - ); - } - - #[test] - fn endpoint_config_telemetry_source_requires_explicit_provider_id_for_builtin_oss_defaults() { - let provider = - create_oss_provider_with_base_url("http://localhost:1234/v1", WireApi::Responses); - - assert_eq!( - EndpointConfigTelemetrySource::for_provider("lmstudio", &provider), - EndpointConfigTelemetrySource::new("default", true) - ); - assert_eq!( - EndpointConfigTelemetrySource::for_provider_without_id(&provider), - EndpointConfigTelemetrySource::new("config_toml", false) - ); - } -} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 2dc601ac44..c097af9019 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -110,7 +110,6 @@ pub type CodexConversation = CodexThread; pub use analytics_client::AnalyticsEventsClient; pub use auth::AuthManager; pub use auth::CodexAuth; -mod auth_env_telemetry; pub mod default_client; pub mod project_doc; mod rollout; diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 1f9fcece19..5197dbc0c6 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -15,7 +15,6 @@ use http::header::HeaderValue; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; -use std::collections::BTreeSet; use std::collections::HashMap; use std::fmt; use std::time::Duration; @@ -23,6 +22,7 @@ use std::time::Duration; const DEFAULT_STREAM_IDLE_TIMEOUT_MS: u64 = 300_000; const DEFAULT_STREAM_MAX_RETRIES: u64 = 5; const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4; +pub(crate) const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS: u64 = 15_000; /// Hard cap for user-configured `stream_max_retries`. const MAX_STREAM_MAX_RETRIES: u64 = 100; /// Hard cap for user-configured `request_max_retries`. @@ -113,6 +113,10 @@ pub struct ModelProviderInfo { /// the connection as lost. pub stream_idle_timeout_ms: Option, + /// Maximum time (in milliseconds) to wait for a websocket connection attempt before treating + /// it as failed. + pub websocket_connect_timeout_ms: Option, + /// Does this provider require an OpenAI API Key or ChatGPT login token? If true, /// user is presented with login screen on first run, and login preference and token/key /// are stored in auth.json. If false (which is the default), login screen is skipped, @@ -127,34 +131,17 @@ pub struct ModelProviderInfo { impl ModelProviderInfo { pub(crate) fn telemetry_header_names(&self) -> Option { - let mut names = BTreeSet::new(); - - if let Some(headers) = &self.http_headers { - for (name, value) in headers { - if let (Ok(name), Ok(_value)) = - (HeaderName::try_from(name), HeaderValue::try_from(value)) - { - names.insert(name.as_str().to_string()); - } - } - } - - if let Some(env_headers) = &self.env_http_headers { - for (header, env_var) in env_headers { - if let Ok(value) = std::env::var(env_var) - && !value.trim().is_empty() - && let (Ok(name), Ok(_value)) = - (HeaderName::try_from(header), HeaderValue::try_from(value)) - { - names.insert(name.as_str().to_string()); - } - } - } - - if names.is_empty() { + let headers = self.build_header_map().ok()?; + if headers.is_empty() { None } else { - Some(names.into_iter().collect::>().join(",")) + Some( + headers + .keys() + .map(HeaderName::as_str) + .collect::>() + .join(","), + ) } } @@ -259,6 +246,13 @@ impl ModelProviderInfo { .map(Duration::from_millis) .unwrap_or(Duration::from_millis(DEFAULT_STREAM_IDLE_TIMEOUT_MS)) } + + /// Effective timeout for websocket connect attempts. + pub fn websocket_connect_timeout(&self) -> Duration { + self.websocket_connect_timeout_ms + .map(Duration::from_millis) + .unwrap_or(Duration::from_millis(DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS)) + } pub fn create_openai_provider(base_url: Option) -> ModelProviderInfo { ModelProviderInfo { name: OPENAI_PROVIDER_NAME.into(), @@ -288,6 +282,7 @@ impl ModelProviderInfo { request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, requires_openai_auth: true, supports_websockets: true, } @@ -363,6 +358,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, } diff --git a/codex-rs/core/src/model_provider_info_tests.rs b/codex-rs/core/src/model_provider_info_tests.rs index 8034672493..a5309117ae 100644 --- a/codex-rs/core/src/model_provider_info_tests.rs +++ b/codex-rs/core/src/model_provider_info_tests.rs @@ -1,15 +1,6 @@ use super::*; use pretty_assertions::assert_eq; -#[test] -fn openai_auth_provider_defaults_to_api_before_login() { - let provider = ModelProviderInfo::create_openai_provider(None); - - let api_provider = provider.to_api_provider(None).unwrap(); - - assert_eq!(api_provider.base_url, "https://api.openai.com/v1"); -} - #[test] fn test_deserialize_ollama_model_provider_toml() { let azure_provider_toml = r#" diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 5c554dbe25..18bd3565c5 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -3,11 +3,11 @@ use crate::api_bridge::auth_provider_from_auth; use crate::api_bridge::map_api_error; use crate::auth::AuthManager; use crate::auth::AuthMode; +use crate::auth::CodexAuth; use crate::auth_env_telemetry::AuthEnvTelemetry; use crate::auth_env_telemetry::collect_auth_env_telemetry; use crate::config::Config; use crate::default_client::build_reqwest_client; -use crate::default_client::residency_header_telemetry_for_provider_headers; use crate::endpoint_config_telemetry::EndpointConfigTelemetrySource; use crate::error::CodexErr; use crate::error::Result as CoreResult; @@ -50,8 +50,6 @@ struct ModelsRequestTelemetry { auth_header_attached: bool, auth_header_name: Option<&'static str>, auth_env_telemetry: AuthEnvTelemetry, - residency_header_attached: bool, - residency_header_value: Option, provider_header_names: Option, base_url_origin: &'static str, host_class: &'static str, @@ -92,8 +90,6 @@ impl RequestTelemetry for ModelsRequestTelemetry { auth.env_provider_key_name = self.auth_env_telemetry.provider_env_key_name.as_deref(), auth.env_provider_key_present = self.auth_env_telemetry.provider_env_key_present, auth.env_refresh_token_url_override_present = self.auth_env_telemetry.refresh_token_url_override_present, - residency_header_attached = self.residency_header_attached, - residency_header_value = self.residency_header_value.as_deref(), provider_header_names = self.provider_header_names.as_deref(), base_url_origin = self.base_url_origin, host_class = self.host_class, @@ -125,8 +121,6 @@ impl RequestTelemetry for ModelsRequestTelemetry { auth.env_provider_key_name = self.auth_env_telemetry.provider_env_key_name.as_deref(), auth.env_provider_key_present = self.auth_env_telemetry.provider_env_key_present, auth.env_refresh_token_url_override_present = self.auth_env_telemetry.refresh_token_url_override_present, - residency_header_attached = self.residency_header_attached, - residency_header_value = self.residency_header_value.as_deref(), provider_header_names = self.provider_header_names.as_deref(), base_url_origin = self.base_url_origin, host_class = self.host_class, @@ -157,88 +151,22 @@ impl RequestTelemetry for ModelsRequestTelemetry { auth_recovery_mode: None, auth_recovery_phase: None, auth_connection_reused: None, + app_server_auth_state: None, + app_server_requires_openai_auth: None, provider_header_names: self.provider_header_names.as_deref(), base_url_origin: self.base_url_origin, host_class: self.host_class, base_url_source: self.base_url_source, base_url_is_default: self.base_url_is_default, - residency_header_attached: Some(self.residency_header_attached), - residency_header_value: self.residency_header_value.as_deref(), auth_request_id: response_debug.request_id.as_deref(), auth_cf_ray: response_debug.cf_ray.as_deref(), auth_error: response_debug.auth_error.as_deref(), auth_error_code: response_debug.auth_error_code.as_deref(), error_body_class: response_debug.error_body_class, safe_error_message: response_debug.safe_error_message, - geo_denial_detected: Some(response_debug.geo_denial_detected), auth_recovery_followup_success: None, auth_recovery_followup_status: None, }); - - if status == Some(http::StatusCode::UNAUTHORIZED.as_u16()) - && response_debug.geo_denial_detected - { - tracing::event!( - target: "codex_otel.log_only", - tracing::Level::INFO, - event.name = "codex.geo_denial", - geo_denial_detected = true, - request_id = response_debug.request_id.as_deref(), - cf_ray = response_debug.cf_ray.as_deref(), - endpoint = MODELS_ENDPOINT, - auth.header_attached = self.auth_header_attached, - auth.header_name = self.auth_header_name, - auth.mode = self.auth_mode.as_deref(), - auth.env_openai_api_key_present = self.auth_env_telemetry.openai_api_key_env_present, - auth.env_codex_api_key_present = self.auth_env_telemetry.codex_api_key_env_present, - auth.env_codex_api_key_enabled = self.auth_env_telemetry.codex_api_key_env_enabled, - auth.env_provider_key_name = self.auth_env_telemetry.provider_env_key_name.as_deref(), - auth.env_provider_key_present = self.auth_env_telemetry.provider_env_key_present, - auth.env_refresh_token_url_override_present = self.auth_env_telemetry.refresh_token_url_override_present, - residency_header_attached = self.residency_header_attached, - residency_header_value = self.residency_header_value.as_deref(), - provider_header_names = self.provider_header_names.as_deref(), - base_url_origin = self.base_url_origin, - host_class = self.host_class, - base_url_source = self.base_url_source, - base_url_is_default = self.base_url_is_default, - http_status = status, - auth.error = response_debug.auth_error.as_deref(), - auth.error_code = response_debug.auth_error_code.as_deref(), - error_body_class = response_debug.error_body_class.unwrap_or_default(), - safe_error_message = response_debug.safe_error_message, - ); - tracing::event!( - target: "codex_otel.trace_safe", - tracing::Level::INFO, - event.name = "codex.geo_denial", - geo_denial_detected = true, - request_id = response_debug.request_id.as_deref(), - cf_ray = response_debug.cf_ray.as_deref(), - endpoint = MODELS_ENDPOINT, - auth.header_attached = self.auth_header_attached, - auth.header_name = self.auth_header_name, - auth.mode = self.auth_mode.as_deref(), - auth.env_openai_api_key_present = self.auth_env_telemetry.openai_api_key_env_present, - auth.env_codex_api_key_present = self.auth_env_telemetry.codex_api_key_env_present, - auth.env_codex_api_key_enabled = self.auth_env_telemetry.codex_api_key_env_enabled, - auth.env_provider_key_name = self.auth_env_telemetry.provider_env_key_name.as_deref(), - auth.env_provider_key_present = self.auth_env_telemetry.provider_env_key_present, - auth.env_refresh_token_url_override_present = self.auth_env_telemetry.refresh_token_url_override_present, - residency_header_attached = self.residency_header_attached, - residency_header_value = self.residency_header_value.as_deref(), - provider_header_names = self.provider_header_names.as_deref(), - base_url_origin = self.base_url_origin, - host_class = self.host_class, - base_url_source = self.base_url_source, - base_url_is_default = self.base_url_is_default, - http_status = status, - auth.error = response_debug.auth_error.as_deref(), - auth.error_code = response_debug.auth_error_code.as_deref(), - error_body_class = response_debug.error_body_class.unwrap_or_default(), - safe_error_message = response_debug.safe_error_message, - ); - } } } @@ -304,24 +232,6 @@ impl ModelsManager { model_catalog: Option, collaboration_modes_config: CollaborationModesConfig, provider: ModelProviderInfo, - ) -> Self { - Self::new_with_provider_and_endpoint_telemetry_source( - codex_home, - auth_manager, - model_catalog, - collaboration_modes_config, - provider.clone(), - EndpointConfigTelemetrySource::for_provider(OPENAI_PROVIDER_ID, &provider), - ) - } - - pub(crate) fn new_with_provider_and_endpoint_telemetry_source( - codex_home: PathBuf, - auth_manager: Arc, - model_catalog: Option, - collaboration_modes_config: CollaborationModesConfig, - provider: ModelProviderInfo, - endpoint_telemetry_source: EndpointConfigTelemetrySource, ) -> Self { let auth_env_telemetry = collect_auth_env_telemetry(&provider, auth_manager.codex_api_key_env_enabled()); @@ -338,6 +248,8 @@ impl ModelsManager { Self::load_remote_models_from_file() .unwrap_or_else(|err| panic!("failed to load bundled models.json: {err}")) }); + let endpoint_telemetry_source = + EndpointConfigTelemetrySource::for_provider_without_id(&provider); Self { remote_models: RwLock::new(remote_models), catalog_mode, @@ -533,21 +445,18 @@ impl ModelsManager { let _timer = codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]); let auth = self.auth_manager.auth().await; - let auth_mode = self.auth_manager.auth_mode(); + let auth_mode = auth.as_ref().map(CodexAuth::auth_mode); let api_provider = self.provider.to_api_provider(auth_mode)?; let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?; let transport = ReqwestTransport::new(build_reqwest_client()); let endpoint_telemetry = self .endpoint_telemetry_source .classify(api_provider.base_url.as_str()); - let residency = residency_header_telemetry_for_provider_headers(&api_provider.headers); let request_telemetry: Arc = Arc::new(ModelsRequestTelemetry { auth_mode: auth_mode.map(|mode| TelemetryAuthMode::from(mode).to_string()), auth_header_attached: api_auth.auth_header_attached(), auth_header_name: api_auth.auth_header_name(), auth_env_telemetry: self.auth_env_telemetry.clone(), - residency_header_attached: residency.attached, - residency_header_value: residency.value, provider_header_names: self.provider.telemetry_header_names(), base_url_origin: endpoint_telemetry.base_url_origin, host_class: endpoint_telemetry.host_class, diff --git a/codex-rs/core/src/models_manager/manager_tests.rs b/codex-rs/core/src/models_manager/manager_tests.rs index 07cf4dc39d..46e064fbbd 100644 --- a/codex-rs/core/src/models_manager/manager_tests.rs +++ b/codex-rs/core/src/models_manager/manager_tests.rs @@ -597,7 +597,7 @@ fn models_request_telemetry_emits_auth_env_feedback_tags_on_failure() { auth_mode: Some(TelemetryAuthMode::Chatgpt.to_string()), auth_header_attached: true, auth_header_name: Some("authorization"), - auth_env: crate::auth_env_telemetry::AuthEnvTelemetry { + auth_env_telemetry: crate::auth_env_telemetry::AuthEnvTelemetry { openai_api_key_env_present: false, codex_api_key_env_present: false, codex_api_key_env_enabled: false, @@ -605,6 +605,11 @@ fn models_request_telemetry_emits_auth_env_feedback_tags_on_failure() { provider_env_key_present: Some(false), refresh_token_url_override_present: false, }, + provider_header_names: None, + base_url_origin: "chatgpt.com", + host_class: "openai_chatgpt", + base_url_source: "default", + base_url_is_default: true, }; let mut headers = HeaderMap::new(); headers.insert("x-request-id", "req-models-401".parse().unwrap()); diff --git a/codex-rs/core/src/response_debug_context.rs b/codex-rs/core/src/response_debug_context.rs index 1fd57ef66d..970f69d0a2 100644 --- a/codex-rs/core/src/response_debug_context.rs +++ b/codex-rs/core/src/response_debug_context.rs @@ -21,7 +21,6 @@ pub(crate) struct ResponseDebugContext { pub(crate) auth_error_code: Option, pub(crate) safe_error_message: Option<&'static str>, pub(crate) error_body_class: Option<&'static str>, - pub(crate) geo_denial_detected: bool, } pub(crate) fn extract_response_debug_context(transport: &TransportError) -> ResponseDebugContext { @@ -59,9 +58,6 @@ pub(crate) fn extract_response_debug_context(transport: &TransportError) -> Resp .as_deref() .and_then(allowlisted_error_body_message); context.error_body_class = error_body.as_deref().and_then(classify_error_body_message); - context.geo_denial_detected = context.error_body_class - == Some(WORKSPACE_NOT_AUTHORIZED_IN_REGION_CLASS) - || context.auth_error_code.as_deref() == Some(WORKSPACE_NOT_AUTHORIZED_IN_REGION_CLASS); context } @@ -164,119 +160,3 @@ fn truncate_with_ellipsis(input: &str, max_bytes: usize) -> String { truncated.push_str(ellipsis); truncated } - -#[cfg(test)] -mod tests { - use super::ResponseDebugContext; - use super::WORKSPACE_NOT_AUTHORIZED_IN_REGION_CLASS; - use super::extract_response_debug_context; - use super::telemetry_api_error_message; - use super::telemetry_transport_error_message; - use codex_api::TransportError; - use codex_api::error::ApiError; - use http::HeaderMap; - use http::HeaderValue; - use http::StatusCode; - use pretty_assertions::assert_eq; - - #[test] - fn extract_response_debug_context_decodes_geo_denial_details() { - let mut headers = HeaderMap::new(); - headers.insert("x-oai-request-id", HeaderValue::from_static("req-geo")); - headers.insert("cf-ray", HeaderValue::from_static("ray-geo")); - headers.insert( - "x-error-json", - HeaderValue::from_static( - "eyJlcnJvciI6eyJjb2RlIjoid29ya3NwYWNlX25vdF9hdXRob3JpemVkX2luX3JlZ2lvbiJ9fQ==", - ), - ); - - let context = extract_response_debug_context(&TransportError::Http { - status: StatusCode::UNAUTHORIZED, - url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), - headers: Some(headers), - body: Some( - r#"{"error":{"message":"Workspace is not authorized in this region."},"status":401}"# - .to_string(), - ), - }); - - assert_eq!( - context, - ResponseDebugContext { - request_id: Some("req-geo".to_string()), - cf_ray: Some("ray-geo".to_string()), - auth_error: None, - auth_error_code: Some("workspace_not_authorized_in_region".to_string()), - safe_error_message: Some("Workspace is not authorized in this region."), - error_body_class: Some(WORKSPACE_NOT_AUTHORIZED_IN_REGION_CLASS), - geo_denial_detected: true, - } - ); - } - - #[test] - fn extract_response_debug_context_detects_geo_denial_from_error_code_without_body_message() { - let mut headers = HeaderMap::new(); - headers.insert("x-oai-request-id", HeaderValue::from_static("req-geo-code")); - headers.insert( - "x-error-json", - HeaderValue::from_static( - "eyJlcnJvciI6eyJjb2RlIjoid29ya3NwYWNlX25vdF9hdXRob3JpemVkX2luX3JlZ2lvbiJ9fQ==", - ), - ); - - let context = extract_response_debug_context(&TransportError::Http { - status: StatusCode::UNAUTHORIZED, - url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), - headers: Some(headers), - body: Some(String::new()), - }); - - assert_eq!( - context, - ResponseDebugContext { - request_id: Some("req-geo-code".to_string()), - cf_ray: None, - auth_error: None, - auth_error_code: Some("workspace_not_authorized_in_region".to_string()), - safe_error_message: None, - error_body_class: None, - geo_denial_detected: true, - } - ); - } - - #[test] - fn telemetry_error_messages_omit_http_bodies() { - let transport = TransportError::Http { - status: StatusCode::UNAUTHORIZED, - url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), - headers: None, - body: Some(r#"{"error":{"message":"secret token leaked"}}"#.to_string()), - }; - - assert_eq!(telemetry_transport_error_message(&transport), "http 401"); - assert_eq!( - telemetry_api_error_message(&ApiError::Transport(transport)), - "http 401" - ); - } - - #[test] - fn telemetry_error_messages_preserve_non_http_details() { - let network = TransportError::Network("dns lookup failed".to_string()); - let build = TransportError::Build("invalid header value".to_string()); - let stream = ApiError::Stream("socket closed".to_string()); - - assert_eq!( - telemetry_transport_error_message(&network), - "dns lookup failed" - ); - assert_eq!( - telemetry_transport_error_message(&build), - "invalid header value" - ); - assert_eq!(telemetry_api_error_message(&stream), "socket closed"); - } -} diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f725a04084..58b3e30c0d 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1,6 +1,7 @@ use crate::AuthManager; use crate::CodexAuth; use crate::ModelProviderInfo; +use crate::OPENAI_PROVIDER_ID; use crate::agent::AgentControl; use crate::codex::Codex; use crate::codex::CodexSpawnArgs; @@ -8,7 +9,6 @@ use crate::codex::CodexSpawnOk; use crate::codex::INITIAL_SUBMIT_ID; use crate::codex_thread::CodexThread; use crate::config::Config; -use crate::endpoint_config_telemetry::resolve_endpoint_config_telemetry_source_for_provider; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::file_watcher::FileWatcher; @@ -47,8 +47,6 @@ use tokio::sync::RwLock; use tokio::sync::broadcast; use tracing::warn; -use crate::model_provider_info::OPENAI_PROVIDER_ID; - const THREAD_CREATED_CHANNEL_CAPACITY: usize = 1024; /// Test-only override for enabling thread-manager behaviors used by integration /// tests. @@ -175,14 +173,7 @@ impl ThreadManager { .model_providers .get(OPENAI_PROVIDER_ID) .cloned() - .unwrap_or_else(|| ModelProviderInfo::create_openai_provider(None)); - let openai_endpoint_telemetry_source = - resolve_endpoint_config_telemetry_source_for_provider( - config, - OPENAI_PROVIDER_ID, - &openai_models_provider, - session_source.clone(), - ); + .unwrap_or_else(|| ModelProviderInfo::create_openai_provider(/*base_url*/ None)); let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY); let plugins_manager = Arc::new(PluginsManager::new(codex_home.clone())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); @@ -196,16 +187,13 @@ impl ThreadManager { state: Arc::new(ThreadManagerState { threads: Arc::new(RwLock::new(HashMap::new())), thread_created_tx, - models_manager: Arc::new( - ModelsManager::new_with_provider_and_endpoint_telemetry_source( - codex_home, - auth_manager.clone(), - config.model_catalog.clone(), - collaboration_modes_config, - openai_models_provider, - openai_endpoint_telemetry_source, - ), - ), + models_manager: Arc::new(ModelsManager::new_with_provider( + codex_home, + auth_manager.clone(), + config.model_catalog.clone(), + collaboration_modes_config, + openai_models_provider, + )), skills_manager, plugins_manager, mcp_manager, @@ -225,7 +213,7 @@ impl ThreadManager { auth: CodexAuth, provider: ModelProviderInfo, ) -> Self { - set_thread_manager_test_mode_for_tests(true); + set_thread_manager_test_mode_for_tests(/*enabled*/ true); let codex_home = std::env::temp_dir().join(format!( "codex-thread-manager-test-{}", uuid::Uuid::new_v4() @@ -245,7 +233,7 @@ impl ThreadManager { provider: ModelProviderInfo, codex_home: PathBuf, ) -> Self { - set_thread_manager_test_mode_for_tests(true); + set_thread_manager_test_mode_for_tests(/*enabled*/ true); let auth_manager = AuthManager::from_auth_for_testing(auth); let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY); let plugins_manager = Arc::new(PluginsManager::new(codex_home.clone())); @@ -253,7 +241,7 @@ impl ThreadManager { let skills_manager = Arc::new(SkillsManager::new( codex_home.clone(), Arc::clone(&plugins_manager), - true, + /*bundled_skills_enabled*/ true, )); let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager)); Self { @@ -352,7 +340,12 @@ impl ThreadManager { pub async fn start_thread(&self, config: Config) -> CodexResult { // Box delegated thread-spawn futures so these convenience wrappers do // not inline the full spawn path into every caller's async state. - Box::pin(self.start_thread_with_tools(config, Vec::new(), false)).await + Box::pin(self.start_thread_with_tools( + config, + Vec::new(), + /*persist_extended_history*/ false, + )) + .await } pub async fn start_thread_with_tools( @@ -365,8 +358,8 @@ impl ThreadManager { config, dynamic_tools, persist_extended_history, - None, - None, + /*metrics_service_name*/ None, + /*parent_trace*/ None, )) .await } @@ -388,6 +381,7 @@ impl ThreadManager { persist_extended_history, metrics_service_name, parent_trace, + /*user_shell_override*/ None, )) .await } @@ -404,7 +398,7 @@ impl ThreadManager { config, initial_history, auth_manager, - false, + /*persist_extended_history*/ false, parent_trace, )) .await @@ -425,8 +419,50 @@ impl ThreadManager { self.agent_control(), Vec::new(), persist_extended_history, - None, + /*metrics_service_name*/ None, parent_trace, + /*user_shell_override*/ None, + )) + .await + } + + pub(crate) async fn start_thread_with_user_shell_override_for_tests( + &self, + config: Config, + user_shell_override: crate::shell::Shell, + ) -> CodexResult { + Box::pin(self.state.spawn_thread( + config, + InitialHistory::New, + Arc::clone(&self.state.auth_manager), + self.agent_control(), + Vec::new(), + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + /*parent_trace*/ None, + /*user_shell_override*/ Some(user_shell_override), + )) + .await + } + + pub(crate) async fn resume_thread_from_rollout_with_user_shell_override_for_tests( + &self, + config: Config, + rollout_path: PathBuf, + auth_manager: Arc, + user_shell_override: crate::shell::Shell, + ) -> CodexResult { + let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?; + Box::pin(self.state.spawn_thread( + config, + initial_history, + auth_manager, + self.agent_control(), + Vec::new(), + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + /*parent_trace*/ None, + /*user_shell_override*/ Some(user_shell_override), )) .await } @@ -510,8 +546,9 @@ impl ThreadManager { self.agent_control(), Vec::new(), persist_extended_history, - None, + /*metrics_service_name*/ None, parent_trace, + /*user_shell_override*/ None, )) .await } @@ -570,9 +607,9 @@ impl ThreadManagerState { config, agent_control, self.session_source.clone(), - false, - None, - None, + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + /*inherited_shell_snapshot*/ None, )) .await } @@ -596,7 +633,8 @@ impl ThreadManagerState { persist_extended_history, metrics_service_name, inherited_shell_snapshot, - None, + /*parent_trace*/ None, + /*user_shell_override*/ None, )) .await } @@ -617,10 +655,11 @@ impl ThreadManagerState { agent_control, session_source, Vec::new(), - false, - None, + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, inherited_shell_snapshot, - None, + /*parent_trace*/ None, + /*user_shell_override*/ None, )) .await } @@ -642,9 +681,10 @@ impl ThreadManagerState { session_source, Vec::new(), persist_extended_history, - None, + /*metrics_service_name*/ None, inherited_shell_snapshot, - None, + /*parent_trace*/ None, + /*user_shell_override*/ None, )) .await } @@ -661,6 +701,7 @@ impl ThreadManagerState { persist_extended_history: bool, metrics_service_name: Option, parent_trace: Option, + user_shell_override: Option, ) -> CodexResult { Box::pin(self.spawn_thread_with_source( config, @@ -671,8 +712,9 @@ impl ThreadManagerState { dynamic_tools, persist_extended_history, metrics_service_name, - None, + /*inherited_shell_snapshot*/ None, parent_trace, + user_shell_override, )) .await } @@ -690,6 +732,7 @@ impl ThreadManagerState { metrics_service_name: Option, inherited_shell_snapshot: Option>, parent_trace: Option, + user_shell_override: Option, ) -> CodexResult { let watch_registration = self .file_watcher @@ -711,6 +754,7 @@ impl ThreadManagerState { persist_extended_history, metrics_service_name, inherited_shell_snapshot, + user_shell_override, parent_trace, }) .await?; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e4596febf1..99afe8ac25 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -59,7 +59,7 @@ fn function_payload(args: serde_json::Value) -> ToolPayload { fn thread_manager() -> ThreadManager { ThreadManager::with_models_provider_for_tests( CodexAuth::from_api_key("dummy"), - built_in_model_providers(None)["openai"].clone(), + built_in_model_providers(/* openai_base_url */ None)["openai"].clone(), ) } @@ -164,7 +164,7 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() { let manager = thread_manager(); session.services.agent_control = manager.agent_control(); let mut config = (*turn.config).clone(); - let provider = built_in_model_providers(None)["ollama"].clone(); + let provider = built_in_model_providers(/* openai_base_url */ None)["ollama"].clone(); config.model_provider_id = "ollama".to_string(); config.model_provider = provider.clone(); config diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index c7dac9ee78..416c4cd794 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -7,6 +7,8 @@ use rand::Rng; use tracing::debug; use tracing::error; +use crate::default_client::client_origin_class; +use crate::default_client::originator; use crate::parse_command::shlex_join; const INITIAL_DELAY_MS: u64 = 200; @@ -52,20 +54,19 @@ pub(crate) struct FeedbackRequestTags<'a> { pub auth_recovery_mode: Option<&'a str>, pub auth_recovery_phase: Option<&'a str>, pub auth_connection_reused: Option, + pub app_server_auth_state: Option<&'a str>, + pub app_server_requires_openai_auth: Option, pub provider_header_names: Option<&'a str>, pub base_url_origin: &'a str, pub host_class: &'a str, pub base_url_source: &'a str, pub base_url_is_default: bool, - pub residency_header_attached: Option, - pub residency_header_value: Option<&'a str>, pub auth_request_id: Option<&'a str>, pub auth_cf_ray: Option<&'a str>, pub auth_error: Option<&'a str>, pub auth_error_code: Option<&'a str>, pub error_body_class: Option<&'a str>, pub safe_error_message: Option<&'a str>, - pub geo_denial_detected: Option, pub auth_recovery_followup_success: Option, pub auth_recovery_followup_status: Option, } @@ -108,28 +109,27 @@ pub(crate) fn emit_feedback_request_tags(tags: &FeedbackRequestTags<'_>) { let auth_connection_reused = tags .auth_connection_reused .map_or_else(String::new, |value| value.to_string()); - let provider_header_names = tags.provider_header_names.unwrap_or(""); - let residency_header_attached = tags - .residency_header_attached + let app_server_auth_state = tags.app_server_auth_state.unwrap_or(""); + let app_server_requires_openai_auth = tags + .app_server_requires_openai_auth .map_or_else(String::new, |value| value.to_string()); - let residency_header_value = tags.residency_header_value.unwrap_or(""); + let provider_header_names = tags.provider_header_names.unwrap_or(""); let auth_request_id = tags.auth_request_id.unwrap_or(""); let auth_cf_ray = tags.auth_cf_ray.unwrap_or(""); let auth_error = tags.auth_error.unwrap_or(""); let auth_error_code = tags.auth_error_code.unwrap_or(""); let error_body_class = tags.error_body_class.unwrap_or(""); let safe_error_message = tags.safe_error_message.unwrap_or(""); - let geo_denial_detected = tags - .geo_denial_detected - .map_or_else(String::new, |value| value.to_string()); let auth_recovery_followup_success = tags .auth_recovery_followup_success .map_or_else(String::new, |value| value.to_string()); let auth_recovery_followup_status = tags .auth_recovery_followup_status .map_or_else(String::new, |value| value.to_string()); + let originator = originator().value; feedback_tags!( endpoint = tags.endpoint, + client_origin = client_origin_class(originator.as_str()), auth_header_attached = tags.auth_header_attached, auth_header_name = auth_header_name, auth_mode = auth_mode, @@ -144,20 +144,19 @@ pub(crate) fn emit_feedback_request_tags(tags: &FeedbackRequestTags<'_>) { auth_recovery_mode = auth_recovery_mode, auth_recovery_phase = auth_recovery_phase, auth_connection_reused = auth_connection_reused, + app_server_auth_state = app_server_auth_state, + app_server_requires_openai_auth = app_server_requires_openai_auth, provider_header_names = provider_header_names, base_url_origin = tags.base_url_origin, host_class = tags.host_class, base_url_source = tags.base_url_source, base_url_is_default = tags.base_url_is_default, - residency_header_attached = residency_header_attached, - residency_header_value = residency_header_value, auth_request_id = auth_request_id, auth_cf_ray = auth_cf_ray, auth_error = auth_error, auth_error_code = auth_error_code, error_body_class = error_body_class, safe_error_message = safe_error_message, - geo_denial_detected = geo_denial_detected, auth_recovery_followup_success = auth_recovery_followup_success, auth_recovery_followup_status = auth_recovery_followup_status ); diff --git a/codex-rs/core/src/util_tests.rs b/codex-rs/core/src/util_tests.rs index 6dfdb2f499..0e9979309f 100644 --- a/codex-rs/core/src/util_tests.rs +++ b/codex-rs/core/src/util_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::auth_env_telemetry::AuthEnvTelemetry; use std::collections::BTreeMap; use std::sync::Arc; use std::sync::Mutex; @@ -68,6 +69,7 @@ impl Visit for TagCollectorVisitor { #[derive(Clone)] struct TagCollectorLayer { tags: Arc>>, + event_count: Arc>, } impl Layer for TagCollectorLayer @@ -81,48 +83,49 @@ where let mut visitor = TagCollectorVisitor::default(); event.record(&mut visitor); self.tags.lock().unwrap().extend(visitor.tags); + *self.event_count.lock().unwrap() += 1; } } #[test] fn emit_feedback_request_tags_records_sentry_feedback_fields() { let tags = Arc::new(Mutex::new(BTreeMap::new())); + let event_count = Arc::new(Mutex::new(0)); let _guard = tracing_subscriber::registry() - .with(TagCollectorLayer { tags: tags.clone() }) + .with(TagCollectorLayer { + tags: tags.clone(), + event_count: event_count.clone(), + }) .set_default(); - emit_feedback_request_tags(&FeedbackRequestTags { - endpoint: "/responses", - auth_header_attached: true, - auth_header_name: Some("authorization"), - auth_mode: Some("chatgpt"), - auth_env_openai_api_key_present: true, - auth_env_codex_api_key_present: false, - auth_env_codex_api_key_enabled: true, - auth_env_provider_key_name: Some("OPENAI_API_KEY"), - auth_env_provider_key_present: Some(true), - auth_env_refresh_token_url_override_present: true, - auth_retry_after_unauthorized: Some(false), - auth_recovery_mode: Some("managed"), - auth_recovery_phase: Some("refresh_token"), - auth_connection_reused: Some(true), - provider_header_names: Some("openai-project"), - base_url_origin: "chatgpt.com", - host_class: "openai_chatgpt", - base_url_source: "default", - base_url_is_default: true, - residency_header_attached: Some(true), - residency_header_value: Some("us"), - auth_request_id: Some("req-123"), - auth_cf_ray: Some("ray-123"), - auth_error: Some("missing_authorization_header"), - auth_error_code: Some("token_expired"), - error_body_class: Some("workspace_not_authorized_in_region"), - safe_error_message: Some("Workspace is not authorized in this region."), - geo_denial_detected: Some(true), - auth_recovery_followup_success: Some(true), - auth_recovery_followup_status: Some(200), - }); + let auth_env = AuthEnvTelemetry { + openai_api_key_env_present: true, + codex_api_key_env_present: false, + codex_api_key_env_enabled: true, + provider_env_key_name: Some("configured".to_string()), + provider_env_key_present: Some(true), + refresh_token_url_override_present: true, + }; + + emit_feedback_request_tags_with_auth_env( + &FeedbackRequestTags { + endpoint: "/responses", + auth_header_attached: true, + auth_header_name: Some("authorization"), + auth_mode: Some("chatgpt"), + auth_retry_after_unauthorized: Some(false), + auth_recovery_mode: Some("managed"), + auth_recovery_phase: Some("refresh_token"), + auth_connection_reused: Some(true), + auth_request_id: Some("req-123"), + auth_cf_ray: Some("ray-123"), + auth_error: Some("missing_authorization_header"), + auth_error_code: Some("token_expired"), + auth_recovery_followup_success: Some(true), + auth_recovery_followup_status: Some(200), + }, + &auth_env, + ); let tags = tags.lock().unwrap().clone(); assert_eq!( @@ -138,12 +141,23 @@ fn emit_feedback_request_tags_records_sentry_feedback_fields() { Some("\"authorization\"") ); assert_eq!( - tags.get("auth_request_id").map(String::as_str), - Some("\"req-123\"") + tags.get("auth_env_openai_api_key_present") + .map(String::as_str), + Some("true") + ); + assert_eq!( + tags.get("auth_env_codex_api_key_present") + .map(String::as_str), + Some("false") + ); + assert_eq!( + tags.get("auth_env_codex_api_key_enabled") + .map(String::as_str), + Some("true") ); assert_eq!( tags.get("auth_env_provider_key_name").map(String::as_str), - Some("\"OPENAI_API_KEY\"") + Some("\"configured\"") ); assert_eq!( tags.get("auth_env_provider_key_present") @@ -151,12 +165,17 @@ fn emit_feedback_request_tags_records_sentry_feedback_fields() { Some("\"true\"") ); assert_eq!( - tags.get("auth_error_code").map(String::as_str), - Some("\"token_expired\"") + tags.get("auth_env_refresh_token_url_override_present") + .map(String::as_str), + Some("true") ); assert_eq!( - tags.get("geo_denial_detected").map(String::as_str), - Some("\"true\"") + tags.get("auth_request_id").map(String::as_str), + Some("\"req-123\"") + ); + assert_eq!( + tags.get("auth_error_code").map(String::as_str), + Some("\"token_expired\"") ); assert_eq!( tags.get("auth_recovery_followup_success") @@ -168,13 +187,18 @@ fn emit_feedback_request_tags_records_sentry_feedback_fields() { .map(String::as_str), Some("\"200\"") ); + assert_eq!(*event_count.lock().unwrap(), 1); } #[test] fn emit_feedback_auth_recovery_tags_preserves_401_specific_fields() { let tags = Arc::new(Mutex::new(BTreeMap::new())); + let event_count = Arc::new(Mutex::new(0)); let _guard = tracing_subscriber::registry() - .with(TagCollectorLayer { tags: tags.clone() }) + .with(TagCollectorLayer { + tags: tags.clone(), + event_count: event_count.clone(), + }) .set_default(); emit_feedback_auth_recovery_tags( @@ -204,13 +228,18 @@ fn emit_feedback_auth_recovery_tags_preserves_401_specific_fields() { tags.get("auth_401_error_code").map(String::as_str), Some("\"token_expired\"") ); + assert_eq!(*event_count.lock().unwrap(), 1); } #[test] fn emit_feedback_auth_recovery_tags_clears_stale_401_fields() { let tags = Arc::new(Mutex::new(BTreeMap::new())); + let event_count = Arc::new(Mutex::new(0)); let _guard = tracing_subscriber::registry() - .with(TagCollectorLayer { tags: tags.clone() }) + .with(TagCollectorLayer { + tags: tags.clone(), + event_count: event_count.clone(), + }) .set_default(); emit_feedback_auth_recovery_tags( @@ -246,13 +275,18 @@ fn emit_feedback_auth_recovery_tags_clears_stale_401_fields() { tags.get("auth_401_error_code").map(String::as_str), Some("\"\"") ); + assert_eq!(*event_count.lock().unwrap(), 2); } #[test] -fn emit_feedback_request_tags_clears_stale_latest_auth_fields() { +fn emit_feedback_request_tags_preserves_latest_auth_fields_after_unauthorized() { let tags = Arc::new(Mutex::new(BTreeMap::new())); + let event_count = Arc::new(Mutex::new(0)); let _guard = tracing_subscriber::registry() - .with(TagCollectorLayer { tags: tags.clone() }) + .with(TagCollectorLayer { + tags: tags.clone(), + event_count: event_count.clone(), + }) .set_default(); emit_feedback_request_tags(&FeedbackRequestTags { @@ -260,62 +294,95 @@ fn emit_feedback_request_tags_clears_stale_latest_auth_fields() { auth_header_attached: true, auth_header_name: Some("authorization"), auth_mode: Some("chatgpt"), - auth_env_openai_api_key_present: true, - auth_env_codex_api_key_present: true, - auth_env_codex_api_key_enabled: true, - auth_env_provider_key_name: Some("OPENAI_API_KEY"), - auth_env_provider_key_present: Some(true), - auth_env_refresh_token_url_override_present: true, auth_retry_after_unauthorized: Some(true), auth_recovery_mode: Some("managed"), auth_recovery_phase: Some("refresh_token"), - auth_connection_reused: Some(true), - provider_header_names: Some("openai-project"), - base_url_origin: "chatgpt.com", - host_class: "openai_chatgpt", - base_url_source: "default", - base_url_is_default: true, - residency_header_attached: Some(true), - residency_header_value: Some("us"), + auth_connection_reused: None, auth_request_id: Some("req-123"), auth_cf_ray: Some("ray-123"), auth_error: Some("missing_authorization_header"), auth_error_code: Some("token_expired"), - error_body_class: Some("workspace_not_authorized_in_region"), - safe_error_message: Some("Workspace is not authorized in this region."), - geo_denial_detected: Some(true), - auth_recovery_followup_success: Some(true), - auth_recovery_followup_status: Some(200), + auth_recovery_followup_success: Some(false), + auth_recovery_followup_status: Some(401), }); + + let tags = tags.lock().unwrap().clone(); + assert_eq!( + tags.get("auth_request_id").map(String::as_str), + Some("\"req-123\"") + ); + assert_eq!( + tags.get("auth_cf_ray").map(String::as_str), + Some("\"ray-123\"") + ); + assert_eq!( + tags.get("auth_error").map(String::as_str), + Some("\"missing_authorization_header\"") + ); + assert_eq!( + tags.get("auth_error_code").map(String::as_str), + Some("\"token_expired\"") + ); + assert_eq!( + tags.get("auth_recovery_followup_success") + .map(String::as_str), + Some("\"false\"") + ); + assert_eq!(*event_count.lock().unwrap(), 1); +} + +#[test] +fn emit_feedback_request_tags_preserves_auth_env_fields_for_legacy_emitters() { + let tags = Arc::new(Mutex::new(BTreeMap::new())); + let event_count = Arc::new(Mutex::new(0)); + let _guard = tracing_subscriber::registry() + .with(TagCollectorLayer { + tags: tags.clone(), + event_count: event_count.clone(), + }) + .set_default(); + + let auth_env = AuthEnvTelemetry { + openai_api_key_env_present: true, + codex_api_key_env_present: true, + codex_api_key_env_enabled: true, + provider_env_key_name: Some("configured".to_string()), + provider_env_key_present: Some(true), + refresh_token_url_override_present: true, + }; + + emit_feedback_request_tags_with_auth_env( + &FeedbackRequestTags { + endpoint: "/responses", + auth_header_attached: true, + auth_header_name: Some("authorization"), + auth_mode: Some("chatgpt"), + auth_retry_after_unauthorized: Some(false), + auth_recovery_mode: Some("managed"), + auth_recovery_phase: Some("refresh_token"), + auth_connection_reused: Some(true), + auth_request_id: Some("req-123"), + auth_cf_ray: Some("ray-123"), + auth_error: Some("missing_authorization_header"), + auth_error_code: Some("token_expired"), + auth_recovery_followup_success: Some(true), + auth_recovery_followup_status: Some(200), + }, + &auth_env, + ); emit_feedback_request_tags(&FeedbackRequestTags { endpoint: "/responses", auth_header_attached: true, auth_header_name: None, auth_mode: None, - auth_env_openai_api_key_present: false, - auth_env_codex_api_key_present: false, - auth_env_codex_api_key_enabled: false, - auth_env_provider_key_name: None, - auth_env_provider_key_present: None, - auth_env_refresh_token_url_override_present: false, auth_retry_after_unauthorized: None, auth_recovery_mode: None, auth_recovery_phase: None, auth_connection_reused: None, - provider_header_names: None, - base_url_origin: "chatgpt.com", - host_class: "openai_chatgpt", - base_url_source: "default", - base_url_is_default: true, - residency_header_attached: None, - residency_header_value: None, auth_request_id: None, auth_cf_ray: None, auth_error: None, auth_error_code: None, - error_body_class: None, - safe_error_message: None, - geo_denial_detected: None, auth_recovery_followup_success: None, auth_recovery_followup_status: None, }); @@ -326,27 +393,6 @@ fn emit_feedback_request_tags_clears_stale_latest_auth_fields() { Some("\"\"") ); assert_eq!(tags.get("auth_mode").map(String::as_str), Some("\"\"")); - assert_eq!( - tags.get("provider_header_names").map(String::as_str), - Some("\"\"") - ); - assert_eq!( - tags.get("auth_env_provider_key_name").map(String::as_str), - Some("\"\"") - ); - assert_eq!( - tags.get("auth_env_provider_key_present") - .map(String::as_str), - Some("\"\"") - ); - assert_eq!( - tags.get("residency_header_attached").map(String::as_str), - Some("\"\"") - ); - assert_eq!( - tags.get("residency_header_value").map(String::as_str), - Some("\"\"") - ); assert_eq!( tags.get("auth_request_id").map(String::as_str), Some("\"\"") @@ -358,16 +404,33 @@ fn emit_feedback_request_tags_clears_stale_latest_auth_fields() { Some("\"\"") ); assert_eq!( - tags.get("error_body_class").map(String::as_str), - Some("\"\"") + tags.get("auth_env_openai_api_key_present") + .map(String::as_str), + Some("true") ); assert_eq!( - tags.get("safe_error_message").map(String::as_str), - Some("\"\"") + tags.get("auth_env_codex_api_key_present") + .map(String::as_str), + Some("true") ); assert_eq!( - tags.get("geo_denial_detected").map(String::as_str), - Some("\"\"") + tags.get("auth_env_codex_api_key_enabled") + .map(String::as_str), + Some("true") + ); + assert_eq!( + tags.get("auth_env_provider_key_name").map(String::as_str), + Some("\"configured\"") + ); + assert_eq!( + tags.get("auth_env_provider_key_present") + .map(String::as_str), + Some("\"true\"") + ); + assert_eq!( + tags.get("auth_env_refresh_token_url_override_present") + .map(String::as_str), + Some("true") ); assert_eq!( tags.get("auth_recovery_followup_success") @@ -379,6 +442,7 @@ fn emit_feedback_request_tags_clears_stale_latest_auth_fields() { .map(String::as_str), Some("\"\"") ); + assert_eq!(*event_count.lock().unwrap(), 2); } #[test] diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a78cd66bc9..f66855a6a3 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -13,6 +13,8 @@ use codex_core::built_in_model_providers; use codex_core::config::Config; use codex_core::features::Feature; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; +use codex_core::shell::Shell; +use codex_core::shell::get_shell_by_model_provided_path; use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; @@ -64,6 +66,7 @@ pub struct TestCodexBuilder { auth: CodexAuth, pre_build_hooks: Vec>, home: Option>, + user_shell_override: Option, } impl TestCodexBuilder { @@ -100,12 +103,25 @@ impl TestCodexBuilder { self } + pub fn with_user_shell(mut self, user_shell: Shell) -> Self { + self.user_shell_override = Some(user_shell); + self + } + + pub fn with_windows_cmd_shell(self) -> Self { + if cfg!(windows) { + self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) + } else { + self + } + } + pub async fn build(&mut self, server: &wiremock::MockServer) -> anyhow::Result { let home = match self.home.clone() { Some(home) => home, None => Arc::new(TempDir::new()?), }; - Box::pin(self.build_with_home(server, home, None)).await + Box::pin(self.build_with_home(server, home, /*resume_from*/ None)).await } pub async fn build_with_streaming_server( @@ -117,7 +133,12 @@ impl TestCodexBuilder { Some(home) => home, None => Arc::new(TempDir::new()?), }; - Box::pin(self.build_with_home_and_base_url(format!("{base_url}/v1"), home, None)).await + Box::pin(self.build_with_home_and_base_url( + format!("{base_url}/v1"), + home, + /*resume_from*/ None, + )) + .await } pub async fn build_with_websocket_server( @@ -138,7 +159,7 @@ impl TestCodexBuilder { .enable(Feature::ResponsesWebsockets) .expect("test config should allow feature update"); })); - Box::pin(self.build_with_home_and_base_url(base_url, home, None)).await + Box::pin(self.build_with_home_and_base_url(base_url, home, /*resume_from*/ None)).await } pub async fn resume( @@ -194,19 +215,43 @@ impl TestCodexBuilder { ) }; let thread_manager = Arc::new(thread_manager); + let user_shell_override = self.user_shell_override.clone(); - let new_conversation = match resume_from { - Some(path) => { + let new_conversation = match (resume_from, user_shell_override) { + (Some(path), Some(user_shell_override)) => { + let auth_manager = codex_core::test_support::auth_manager_from_auth(auth); + Box::pin( + codex_core::test_support::resume_thread_from_rollout_with_user_shell_override( + thread_manager.as_ref(), + config.clone(), + path, + auth_manager, + user_shell_override, + ), + ) + .await? + } + (Some(path), None) => { let auth_manager = codex_core::test_support::auth_manager_from_auth(auth); Box::pin(thread_manager.resume_thread_from_rollout( config.clone(), path, auth_manager, - None, + /*parent_trace*/ None, )) .await? } - None => Box::pin(thread_manager.start_thread(config.clone())).await?, + (None, Some(user_shell_override)) => { + Box::pin( + codex_core::test_support::start_thread_with_user_shell_override( + thread_manager.as_ref(), + config.clone(), + user_shell_override, + ), + ) + .await? + } + (None, None) => Box::pin(thread_manager.start_thread(config.clone())).await?, }; Ok(TestCodex { @@ -226,7 +271,7 @@ impl TestCodexBuilder { ) -> anyhow::Result<(Config, Arc)> { let model_provider = ModelProviderInfo { base_url: Some(base_url), - ..built_in_model_providers(None)["openai"].clone() + ..built_in_model_providers(/*openai_base_url*/ None)["openai"].clone() }; let cwd = Arc::new(TempDir::new()?); let mut config = load_default_config_for_test(home).await; @@ -362,8 +407,13 @@ impl TestCodex { approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, ) -> Result<()> { - self.submit_turn_with_context(prompt, approval_policy, sandbox_policy, None) - .await + self.submit_turn_with_context( + prompt, + approval_policy, + sandbox_policy, + /*service_tier*/ None, + ) + .await } async fn submit_turn_with_context( @@ -552,6 +602,7 @@ pub fn test_codex() -> TestCodexBuilder { auth: CodexAuth::from_api_key("dummy"), pre_build_hooks: vec![], home: None, + user_shell_override: None, } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index f6ab8e6a3a..7d785694dc 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -715,7 +715,7 @@ async fn chatgpt_auth_sends_correct_request() { ) .await; - let mut model_provider = built_in_model_providers(None)["openai"].clone(); + let mut model_provider = built_in_model_providers(/* openai_base_url */ None)["openai"].clone(); model_provider.base_url = Some(format!("{}/api/codex", server.uri())); let mut builder = test_codex() .with_auth(create_dummy_codex_auth()) @@ -791,7 +791,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers(None)["openai"].clone() + ..built_in_model_providers(/* openai_base_url */ None)["openai"].clone() }; // Init session @@ -1966,7 +1966,7 @@ async fn token_count_includes_rate_limits_snapshot() { .mount(&server) .await; - let mut provider = built_in_model_providers(None)["openai"].clone(); + let mut provider = built_in_model_providers(/* openai_base_url */ None)["openai"].clone(); provider.base_url = Some(format!("{}/v1", server.uri())); let mut builder = test_codex() diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index b2f06d50d5..6cf1c275a7 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -93,7 +93,7 @@ fn json_fragment(text: &str) -> String { } fn non_openai_model_provider(server: &MockServer) -> ModelProviderInfo { - let mut provider = built_in_model_providers(None)["openai"].clone(); + let mut provider = built_in_model_providers(/* openai_base_url */ None)["openai"].clone(); provider.name = "OpenAI (test)".into(); provider.base_url = Some(format!("{}/v1", server.uri())); provider diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index f43b94c2f2..589c6b9d5d 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -95,7 +95,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers(None)["openai"].clone() + ..built_in_model_providers(/* openai_base_url */ None)["openai"].clone() }; let manager = codex_core::test_support::models_manager_with_provider( codex_home.path().to_path_buf(), @@ -654,7 +654,7 @@ async fn remote_models_do_not_append_removed_builtin_presets() -> Result<()> { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers(None)["openai"].clone() + ..built_in_model_providers(/* openai_base_url */ None)["openai"].clone() }; let manager = codex_core::test_support::models_manager_with_provider( codex_home.path().to_path_buf(), @@ -709,7 +709,7 @@ async fn remote_models_merge_adds_new_high_priority_first() -> Result<()> { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers(None)["openai"].clone() + ..built_in_model_providers(/* openai_base_url */ None)["openai"].clone() }; let manager = codex_core::test_support::models_manager_with_provider( codex_home.path().to_path_buf(), @@ -756,7 +756,7 @@ async fn remote_models_merge_replaces_overlapping_model() -> Result<()> { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers(None)["openai"].clone() + ..built_in_model_providers(/* openai_base_url */ None)["openai"].clone() }; let manager = codex_core::test_support::models_manager_with_provider( codex_home.path().to_path_buf(), @@ -800,7 +800,7 @@ async fn remote_models_merge_preserves_bundled_models_on_empty_response() -> Res let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers(None)["openai"].clone() + ..built_in_model_providers(/* openai_base_url */ None)["openai"].clone() }; let manager = codex_core::test_support::models_manager_with_provider( codex_home.path().to_path_buf(), @@ -841,7 +841,7 @@ async fn remote_models_request_times_out_after_5s() -> Result<()> { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers(None)["openai"].clone() + ..built_in_model_providers(/* openai_base_url */ None)["openai"].clone() }; let manager = codex_core::test_support::models_manager_with_provider( codex_home.path().to_path_buf(), @@ -907,7 +907,7 @@ async fn remote_models_hide_picker_only_models() -> Result<()> { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers(None)["openai"].clone() + ..built_in_model_providers(/* openai_base_url */ None)["openai"].clone() }; let manager = codex_core::test_support::models_manager_with_provider( codex_home.path().to_path_buf(), diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 6075fd2158..74032320fd 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -62,10 +62,33 @@ const RESPONSES_API_ENGINE_SERVICE_TTFT_FIELD: &str = "engine_service_ttft_total const RESPONSES_API_ENGINE_IAPI_TBT_FIELD: &str = "engine_iapi_tbt_across_engine_calls_ms"; const RESPONSES_API_ENGINE_SERVICE_TBT_FIELD: &str = "engine_service_tbt_across_engine_calls_ms"; +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AuthEnvTelemetryMetadata { + pub openai_api_key_env_present: bool, + pub codex_api_key_env_present: bool, + pub codex_api_key_env_enabled: bool, + pub provider_env_key_name: Option, + pub provider_env_key_present: Option, + pub refresh_token_url_override_present: bool, +} + +fn client_origin_class(originator: &str) -> &'static str { + if matches!(originator, "codex_atlas" | "codex_chatgpt_desktop") { + "first_party_chat" + } else if originator == "codex_cli_rs" { + "codex_cli" + } else if originator == "codex_vscode" || originator.starts_with("Codex ") { + "first_party_ide" + } else { + "custom" + } +} + #[derive(Debug, Clone)] pub struct SessionTelemetryMetadata { pub(crate) conversation_id: ThreadId, pub(crate) auth_mode: Option, + pub(crate) auth_env: AuthEnvTelemetryMetadata, pub(crate) account_id: Option, pub(crate) account_email: Option, pub(crate) originator: String, @@ -86,6 +109,11 @@ pub struct SessionTelemetry { } impl SessionTelemetry { + pub fn with_auth_env(mut self, auth_env: AuthEnvTelemetryMetadata) -> Self { + self.metadata.auth_env = auth_env; + self + } + pub fn with_model(mut self, model: &str, slug: &str) -> Self { self.metadata.model = model.to_owned(); self.metadata.slug = slug.to_owned(); @@ -255,6 +283,7 @@ impl SessionTelemetry { metadata: SessionTelemetryMetadata { conversation_id, auth_mode: auth_mode.map(|m| m.to_string()), + auth_env: AuthEnvTelemetryMetadata::default(), account_id, account_email, originator: sanitize_metric_tag_value(originator.as_str()), @@ -291,7 +320,6 @@ impl SessionTelemetry { } } - #[allow(clippy::too_many_arguments)] pub fn conversation_starts( &self, provider_name: &str, @@ -303,68 +331,12 @@ impl SessionTelemetry { sandbox_policy: SandboxPolicy, mcp_servers: Vec<&str>, active_profile: Option, - ) { - self.conversation_starts_with_endpoint_details( - provider_name, - "unknown", - "unknown", - "unknown", - false, - false, - false, - false, - None, - None, - false, - reasoning_effort, - reasoning_summary, - context_window, - auto_compact_token_limit, - approval_policy, - sandbox_policy, - mcp_servers, - active_profile, - ); - } - - #[allow(clippy::too_many_arguments)] - pub fn conversation_starts_with_endpoint_details( - &self, - provider_name: &str, - base_url_origin: &str, - host_class: &str, - base_url_source: &str, - base_url_is_default: bool, - auth_env_openai_api_key_present: bool, - auth_env_codex_api_key_present: bool, - auth_env_codex_api_key_enabled: bool, - auth_env_provider_key_name: Option<&str>, - auth_env_provider_key_present: Option, - auth_env_refresh_token_url_override_present: bool, - reasoning_effort: Option, - reasoning_summary: ReasoningSummary, - context_window: Option, - auto_compact_token_limit: Option, - approval_policy: AskForApproval, - sandbox_policy: SandboxPolicy, - mcp_servers: Vec<&str>, - active_profile: Option, ) { log_and_trace_event!( self, common: { event.name = "codex.conversation_starts", provider_name = %provider_name, - base_url_origin = base_url_origin, - host_class = host_class, - base_url_source = base_url_source, - base_url_is_default = base_url_is_default, - auth.env_openai_api_key_present = auth_env_openai_api_key_present, - auth.env_codex_api_key_present = auth_env_codex_api_key_present, - auth.env_codex_api_key_enabled = auth_env_codex_api_key_enabled, - auth.env_provider_key_name = auth_env_provider_key_name, - auth.env_provider_key_present = auth_env_provider_key_present, - auth.env_refresh_token_url_override_present = auth_env_refresh_token_url_override_present, reasoning_effort = reasoning_effort.map(|e| e.to_string()), reasoning_summary = %reasoning_summary, context_window = context_window, @@ -396,7 +368,7 @@ impl SessionTelemetry { Ok(response) => (Some(response.status().as_u16()), None), Err(error) => (error.status().map(|s| s.as_u16()), Some(error.to_string())), }; - self.record_api_request_with_endpoint_details( + self.record_api_request( attempt, status, error.as_deref(), @@ -407,8 +379,6 @@ impl SessionTelemetry { None, None, "unknown", - false, - None, None, "custom", "custom_unknown", @@ -431,7 +401,6 @@ impl SessionTelemetry { response } - #[allow(clippy::too_many_arguments)] pub fn record_api_request( &self, attempt: u64, @@ -444,59 +413,6 @@ impl SessionTelemetry { recovery_mode: Option<&str>, recovery_phase: Option<&str>, endpoint: &str, - request_id: Option<&str>, - cf_ray: Option<&str>, - auth_error: Option<&str>, - auth_error_code: Option<&str>, - ) { - self.record_api_request_with_endpoint_details( - attempt, - status, - error, - duration, - auth_header_attached, - auth_header_name, - retry_after_unauthorized, - recovery_mode, - recovery_phase, - endpoint, - false, - None, - None, - "unknown", - "unknown", - "unknown", - false, - false, - false, - false, - None, - None, - false, - request_id, - cf_ray, - auth_error, - auth_error_code, - None, - None, - ); - } - - #[allow(clippy::too_many_arguments)] - pub fn record_api_request_with_endpoint_details( - &self, - attempt: u64, - status: Option, - error: Option<&str>, - duration: Duration, - auth_header_attached: bool, - auth_header_name: Option<&str>, - retry_after_unauthorized: bool, - recovery_mode: Option<&str>, - recovery_phase: Option<&str>, - endpoint: &str, - residency_header_attached: bool, - residency_header_value: Option<&str>, provider_header_names: Option<&str>, base_url_origin: &str, host_class: &str, @@ -544,8 +460,7 @@ impl SessionTelemetry { auth.recovery_mode = recovery_mode, auth.recovery_phase = recovery_phase, endpoint = endpoint, - residency_header_attached = residency_header_attached, - residency_header_value = residency_header_value, + client_origin = client_origin_class(self.metadata.originator.as_str()), provider_header_names = provider_header_names, base_url_origin = base_url_origin, host_class = host_class, @@ -569,7 +484,6 @@ impl SessionTelemetry { ); } - #[allow(clippy::too_many_arguments)] pub fn record_websocket_connect( &self, duration: Duration, @@ -581,59 +495,6 @@ impl SessionTelemetry { recovery_mode: Option<&str>, recovery_phase: Option<&str>, endpoint: &str, - connection_reused: bool, - request_id: Option<&str>, - cf_ray: Option<&str>, - auth_error: Option<&str>, - auth_error_code: Option<&str>, - ) { - self.record_websocket_connect_with_endpoint_details( - duration, - status, - error, - auth_header_attached, - auth_header_name, - retry_after_unauthorized, - recovery_mode, - recovery_phase, - endpoint, - false, - None, - None, - "unknown", - "unknown", - "unknown", - false, - false, - false, - false, - None, - None, - false, - connection_reused, - request_id, - cf_ray, - auth_error, - auth_error_code, - None, - None, - ); - } - - #[allow(clippy::too_many_arguments)] - pub fn record_websocket_connect_with_endpoint_details( - &self, - duration: Duration, - status: Option, - error: Option<&str>, - auth_header_attached: bool, - auth_header_name: Option<&str>, - retry_after_unauthorized: bool, - recovery_mode: Option<&str>, - recovery_phase: Option<&str>, - endpoint: &str, - residency_header_attached: bool, - residency_header_value: Option<&str>, provider_header_names: Option<&str>, base_url_origin: &str, host_class: &str, @@ -672,8 +533,7 @@ impl SessionTelemetry { auth.recovery_mode = recovery_mode, auth.recovery_phase = recovery_phase, endpoint = endpoint, - residency_header_attached = residency_header_attached, - residency_header_value = residency_header_value, + client_origin = client_origin_class(self.metadata.originator.as_str()), provider_header_names = provider_header_names, base_url_origin = base_url_origin, host_class = host_class, @@ -698,47 +558,6 @@ impl SessionTelemetry { ); } - #[allow(clippy::too_many_arguments)] - pub fn record_geo_denial( - &self, - endpoint: &str, - auth_header_attached: bool, - auth_header_name: Option<&str>, - residency_header_attached: bool, - residency_header_value: Option<&str>, - provider_header_names: Option<&str>, - http_status: Option, - request_id: Option<&str>, - cf_ray: Option<&str>, - auth_error: Option<&str>, - auth_error_code: Option<&str>, - error_body_class: &str, - safe_error_message: Option<&str>, - ) { - log_and_trace_event!( - self, - common: { - event.name = "codex.geo_denial", - geo_denial_detected = true, - request_id = request_id, - cf_ray = cf_ray, - endpoint = endpoint, - auth.header_attached = auth_header_attached, - auth.header_name = auth_header_name, - residency_header_attached = residency_header_attached, - residency_header_value = residency_header_value, - provider_header_names = provider_header_names, - http_status = http_status, - auth.error = auth_error, - auth.error_code = auth_error_code, - error_body_class = error_body_class, - safe_error_message = safe_error_message, - }, - log: {}, - trace: {}, - ); - } - pub fn record_websocket_request( &self, duration: Duration, diff --git a/codex-rs/otel/tests/suite/otel_export_routing_policy.rs b/codex-rs/otel/tests/suite/otel_export_routing_policy.rs index b924ef31cf..b7fce86145 100644 --- a/codex-rs/otel/tests/suite/otel_export_routing_policy.rs +++ b/codex-rs/otel/tests/suite/otel_export_routing_policy.rs @@ -1,3 +1,4 @@ +use codex_otel::AuthEnvTelemetryMetadata; use codex_otel::OtelProvider; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; @@ -18,6 +19,9 @@ use tracing_subscriber::filter::filter_fn; use tracing_subscriber::layer::SubscriberExt; use codex_protocol::ThreadId; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; @@ -76,6 +80,17 @@ fn find_span_event_by_name_attr<'a>( .unwrap_or_else(|| panic!("missing span event: {event_name}")) } +fn auth_env_metadata() -> AuthEnvTelemetryMetadata { + AuthEnvTelemetryMetadata { + openai_api_key_env_present: true, + codex_api_key_env_present: false, + codex_api_key_env_enabled: true, + provider_env_key_name: Some("configured".to_string()), + provider_env_key_present: Some(true), + refresh_token_url_override_present: true, + } +} + #[test] fn otel_export_routing_policy_routes_user_prompt_log_and_trace_events() { let log_exporter = InMemoryLogExporter::default(); @@ -482,10 +497,22 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() { true, "tty".to_string(), SessionSource::Cli, - ); + ) + .with_auth_env(auth_env_metadata()); let root_span = tracing::info_span!("root"); let _root_guard = root_span.enter(); - manager.record_api_request_with_endpoint_details( + manager.conversation_starts( + "openai", + None, + ReasoningSummary::Auto, + None, + None, + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + Vec::new(), + None, + ); + manager.record_api_request( 1, Some(401), Some("http 401"), @@ -496,25 +523,10 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() { Some("managed"), Some("refresh_token"), "/responses", - true, - Some("us"), - Some("openai-project,version"), - "chatgpt.com", - "openai_chatgpt", - "ide_settings", - false, - true, - false, - true, - Some("OPENAI_API_KEY"), - Some(true), - true, Some("req-401"), Some("ray-401"), Some("missing_authorization_header"), Some("token_expired"), - Some("workspace_not_authorized_in_region"), - Some("Workspace is not authorized in this region."), ); }); @@ -522,6 +534,20 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() { tracer_provider.force_flush().expect("flush traces"); let logs = log_exporter.get_emitted_logs().expect("log export"); + let conversation_log = find_log_by_event_name(&logs, "codex.conversation_starts"); + let conversation_log_attrs = log_attributes(&conversation_log.record); + assert_eq!( + conversation_log_attrs + .get("auth.env_openai_api_key_present") + .map(String::as_str), + Some("true") + ); + assert_eq!( + conversation_log_attrs + .get("auth.env_provider_key_name") + .map(String::as_str), + Some("configured") + ); let request_log = find_log_by_event_name(&logs, "codex.api_request"); let request_log_attrs = log_attributes(&request_log.record); assert_eq!( @@ -558,73 +584,33 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() { request_log_attrs.get("endpoint").map(String::as_str), Some("/responses") ); - assert_eq!( - request_log_attrs - .get("residency_header_attached") - .map(String::as_str), - Some("true") - ); - assert_eq!( - request_log_attrs - .get("residency_header_value") - .map(String::as_str), - Some("us") - ); - assert_eq!( - request_log_attrs.get("base_url_origin").map(String::as_str), - Some("chatgpt.com") - ); - assert_eq!( - request_log_attrs.get("host_class").map(String::as_str), - Some("openai_chatgpt") - ); - assert_eq!( - request_log_attrs.get("base_url_source").map(String::as_str), - Some("ide_settings") - ); - assert_eq!( - request_log_attrs - .get("auth.env_openai_api_key_present") - .map(String::as_str), - Some("true") - ); - assert_eq!( - request_log_attrs - .get("auth.env_provider_key_name") - .map(String::as_str), - Some("OPENAI_API_KEY") - ); - assert_eq!( - request_log_attrs - .get("provider_header_names") - .map(String::as_str), - Some("openai-project,version") - ); - assert_eq!( - request_log_attrs - .get("base_url_is_default") - .map(String::as_str), - Some("false") - ); assert_eq!( request_log_attrs.get("auth.error").map(String::as_str), Some("missing_authorization_header") ); assert_eq!( request_log_attrs - .get("error_body_class") + .get("auth.env_codex_api_key_enabled") .map(String::as_str), - Some("workspace_not_authorized_in_region") + Some("true") ); assert_eq!( request_log_attrs - .get("safe_error_message") + .get("auth.env_refresh_token_url_override_present") .map(String::as_str), - Some("Workspace is not authorized in this region.") + Some("true") ); - assert!(!request_log_attrs.contains_key("error_body")); let spans = span_exporter.get_finished_spans().expect("span export"); + let conversation_trace_event = + find_span_event_by_name_attr(&spans[0].events.events, "codex.conversation_starts"); + let conversation_trace_attrs = span_event_attributes(conversation_trace_event); + assert_eq!( + conversation_trace_attrs + .get("auth.env_provider_key_present") + .map(String::as_str), + Some("true") + ); let request_trace_event = find_span_event_by_name_attr(&spans[0].events.events, "codex.api_request"); let request_trace_attrs = span_event_attributes(request_trace_event); @@ -646,21 +632,15 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() { .map(String::as_str), Some("true") ); - assert_eq!( - request_trace_attrs - .get("base_url_origin") - .map(String::as_str), - Some("chatgpt.com") - ); assert_eq!( request_trace_attrs.get("endpoint").map(String::as_str), Some("/responses") ); assert_eq!( request_trace_attrs - .get("safe_error_message") + .get("auth.env_openai_api_key_present") .map(String::as_str), - Some("Workspace is not authorized in this region.") + Some("true") ); } @@ -702,10 +682,11 @@ fn otel_export_routing_policy_routes_websocket_connect_auth_observability() { true, "tty".to_string(), SessionSource::Cli, - ); + ) + .with_auth_env(auth_env_metadata()); let root_span = tracing::info_span!("root"); let _root_guard = root_span.enter(); - manager.record_websocket_connect_with_endpoint_details( + manager.record_websocket_connect( std::time::Duration::from_millis(17), Some(401), Some("http 401"), @@ -715,26 +696,11 @@ fn otel_export_routing_policy_routes_websocket_connect_auth_observability() { Some("managed"), Some("reload"), "/responses", - true, - Some("us"), - Some("x-api-key"), - "openrouter.ai", - "known_third_party", - "config_toml", - false, - false, - true, - false, - Some("OPENROUTER_API_KEY"), - Some(true), - false, false, Some("req-ws-401"), Some("ray-ws-401"), Some("missing_authorization_header"), Some("token_expired"), - Some("workspace_not_authorized_in_region"), - Some("Workspace is not authorized in this region."), ); }); @@ -750,12 +716,6 @@ fn otel_export_routing_policy_routes_websocket_connect_auth_observability() { .map(String::as_str), Some("true") ); - assert_eq!( - connect_log_attrs - .get("auth.env_provider_key_name") - .map(String::as_str), - Some("OPENROUTER_API_KEY") - ); assert_eq!( connect_log_attrs .get("auth.header_name") @@ -766,42 +726,22 @@ fn otel_export_routing_policy_routes_websocket_connect_auth_observability() { connect_log_attrs.get("auth.error").map(String::as_str), Some("missing_authorization_header") ); - assert_eq!( - connect_log_attrs - .get("provider_header_names") - .map(String::as_str), - Some("x-api-key") - ); - assert_eq!( - connect_log_attrs.get("base_url_origin").map(String::as_str), - Some("openrouter.ai") - ); - assert_eq!( - connect_log_attrs.get("host_class").map(String::as_str), - Some("known_third_party") - ); assert_eq!( connect_log_attrs.get("endpoint").map(String::as_str), Some("/responses") ); - assert_eq!( - connect_log_attrs - .get("residency_header_value") - .map(String::as_str), - Some("us") - ); - assert_eq!( - connect_log_attrs - .get("safe_error_message") - .map(String::as_str), - Some("Workspace is not authorized in this region.") - ); assert_eq!( connect_log_attrs .get("auth.connection_reused") .map(String::as_str), Some("false") ); + assert_eq!( + connect_log_attrs + .get("auth.env_provider_key_name") + .map(String::as_str), + Some("configured") + ); let spans = span_exporter.get_finished_spans().expect("span export"); let connect_trace_event = @@ -815,21 +755,9 @@ fn otel_export_routing_policy_routes_websocket_connect_auth_observability() { ); assert_eq!( connect_trace_attrs - .get("base_url_source") + .get("auth.env_refresh_token_url_override_present") .map(String::as_str), - Some("config_toml") - ); - assert_eq!( - connect_trace_attrs - .get("error_body_class") - .map(String::as_str), - Some("workspace_not_authorized_in_region") - ); - assert_eq!( - connect_trace_attrs - .get("safe_error_message") - .map(String::as_str), - Some("Workspace is not authorized in this region.") + Some("true") ); } @@ -871,7 +799,8 @@ fn otel_export_routing_policy_routes_websocket_request_transport_observability() true, "tty".to_string(), SessionSource::Cli, - ); + ) + .with_auth_env(auth_env_metadata()); let root_span = tracing::info_span!("root"); let _root_guard = root_span.enter(); manager.record_websocket_request( @@ -897,6 +826,12 @@ fn otel_export_routing_policy_routes_websocket_request_transport_observability() request_log_attrs.get("error.message").map(String::as_str), Some("stream error") ); + assert_eq!( + request_log_attrs + .get("auth.env_openai_api_key_present") + .map(String::as_str), + Some("true") + ); let spans = span_exporter.get_finished_spans().expect("span export"); let request_trace_event = @@ -908,246 +843,10 @@ fn otel_export_routing_policy_routes_websocket_request_transport_observability() .map(String::as_str), Some("true") ); -} - -#[test] -fn otel_export_routing_policy_routes_geo_denial_log_and_trace_events() { - let log_exporter = InMemoryLogExporter::default(); - let logger_provider = SdkLoggerProvider::builder() - .with_simple_exporter(log_exporter.clone()) - .build(); - let span_exporter = InMemorySpanExporter::default(); - let tracer_provider = SdkTracerProvider::builder() - .with_simple_exporter(span_exporter.clone()) - .build(); - let tracer = tracer_provider.tracer("sink-split-test"); - - let subscriber = tracing_subscriber::registry() - .with( - opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new( - &logger_provider, - ) - .with_filter(filter_fn(OtelProvider::log_export_filter)), - ) - .with( - tracing_opentelemetry::layer() - .with_tracer(tracer) - .with_filter(filter_fn(OtelProvider::trace_export_filter)), - ); - - tracing::subscriber::with_default(subscriber, || { - tracing::callsite::rebuild_interest_cache(); - let manager = SessionTelemetry::new( - ThreadId::new(), - "gpt-5.1", - "gpt-5.1", - Some("account-id".to_string()), - Some("engineer@example.com".to_string()), - Some(TelemetryAuthMode::Chatgpt), - "codex_exec".to_string(), - true, - "tty".to_string(), - SessionSource::Cli, - ); - let root_span = tracing::info_span!("root"); - let _root_guard = root_span.enter(); - manager.record_geo_denial( - "/responses", - true, - Some("authorization"), - true, - Some("us"), - Some("x-api-key"), - Some(401), - Some("req-geo"), - Some("ray-geo"), - Some("missing_authorization_header"), - Some("workspace_not_authorized_in_region"), - "workspace_not_authorized_in_region", - Some("Workspace is not authorized in this region."), - ); - }); - - logger_provider.force_flush().expect("flush logs"); - tracer_provider.force_flush().expect("flush traces"); - - let logs = log_exporter.get_emitted_logs().expect("log export"); - let geo_log = find_log_by_event_name(&logs, "codex.geo_denial"); - let geo_log_attrs = log_attributes(&geo_log.record); assert_eq!( - geo_log_attrs.get("geo_denial_detected").map(String::as_str), - Some("true") - ); - assert_eq!( - geo_log_attrs.get("request_id").map(String::as_str), - Some("req-geo") - ); - assert_eq!( - geo_log_attrs - .get("auth.header_attached") + request_trace_attrs + .get("auth.env_provider_key_present") .map(String::as_str), Some("true") ); - assert_eq!( - geo_log_attrs.get("auth.header_name").map(String::as_str), - Some("authorization") - ); - assert_eq!( - geo_log_attrs.get("endpoint").map(String::as_str), - Some("/responses") - ); - assert_eq!( - geo_log_attrs - .get("provider_header_names") - .map(String::as_str), - Some("x-api-key") - ); - assert_eq!( - geo_log_attrs.get("auth.error_code").map(String::as_str), - Some("workspace_not_authorized_in_region") - ); - assert_eq!( - geo_log_attrs - .get("residency_header_value") - .map(String::as_str), - Some("us") - ); - assert_eq!( - geo_log_attrs.get("error_body_class").map(String::as_str), - Some("workspace_not_authorized_in_region") - ); - assert_eq!( - geo_log_attrs.get("safe_error_message").map(String::as_str), - Some("Workspace is not authorized in this region.") - ); - assert!(!geo_log_attrs.contains_key("error_body")); - - let spans = span_exporter.get_finished_spans().expect("span export"); - let geo_trace_event = find_span_event_by_name_attr(&spans[0].events.events, "codex.geo_denial"); - let geo_trace_attrs = span_event_attributes(geo_trace_event); - assert_eq!( - geo_trace_attrs - .get("auth.header_attached") - .map(String::as_str), - Some("true") - ); - assert_eq!( - geo_trace_attrs.get("cf_ray").map(String::as_str), - Some("ray-geo") - ); - assert_eq!( - geo_trace_attrs.get("auth.error").map(String::as_str), - Some("missing_authorization_header") - ); - assert_eq!( - geo_trace_attrs.get("http_status").map(String::as_str), - Some("401") - ); - assert_eq!( - geo_trace_attrs - .get("safe_error_message") - .map(String::as_str), - Some("Workspace is not authorized in this region.") - ); -} - -#[test] -fn otel_export_routing_policy_routes_conversation_start_endpoint_config() { - let log_exporter = InMemoryLogExporter::default(); - let logger_provider = SdkLoggerProvider::builder() - .with_simple_exporter(log_exporter.clone()) - .build(); - let span_exporter = InMemorySpanExporter::default(); - let tracer_provider = SdkTracerProvider::builder() - .with_simple_exporter(span_exporter.clone()) - .build(); - let tracer = tracer_provider.tracer("sink-split-test"); - - let subscriber = tracing_subscriber::registry() - .with( - opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new( - &logger_provider, - ) - .with_filter(filter_fn(OtelProvider::log_export_filter)), - ) - .with( - tracing_opentelemetry::layer() - .with_tracer(tracer) - .with_filter(filter_fn(OtelProvider::trace_export_filter)), - ); - - tracing::subscriber::with_default(subscriber, || { - tracing::callsite::rebuild_interest_cache(); - let manager = SessionTelemetry::new( - ThreadId::new(), - "gpt-5.1", - "gpt-5.1", - Some("account-id".to_string()), - Some("engineer@example.com".to_string()), - Some(TelemetryAuthMode::Chatgpt), - "codex_exec".to_string(), - true, - "tty".to_string(), - SessionSource::Cli, - ); - let root_span = tracing::info_span!("root"); - let _root_guard = root_span.enter(); - manager.conversation_starts_with_endpoint_details( - "OpenAI", - "custom", - "custom_unknown", - "env", - false, - true, - false, - true, - Some("OPENAI_API_KEY"), - Some(true), - true, - None, - codex_protocol::config_types::ReasoningSummary::Auto, - None, - None, - codex_protocol::protocol::AskForApproval::OnRequest, - codex_protocol::protocol::SandboxPolicy::new_read_only_policy(), - Vec::new(), - None, - ); - }); - - logger_provider.force_flush().expect("flush logs"); - tracer_provider.force_flush().expect("flush traces"); - - let logs = log_exporter.get_emitted_logs().expect("log export"); - let start_log = find_log_by_event_name(&logs, "codex.conversation_starts"); - let start_log_attrs = log_attributes(&start_log.record); - assert_eq!( - start_log_attrs.get("base_url_origin").map(String::as_str), - Some("custom") - ); - assert_eq!( - start_log_attrs.get("host_class").map(String::as_str), - Some("custom_unknown") - ); - assert_eq!( - start_log_attrs.get("base_url_source").map(String::as_str), - Some("env") - ); - assert_eq!( - start_log_attrs - .get("auth.env_provider_key_name") - .map(String::as_str), - Some("OPENAI_API_KEY") - ); - - let spans = span_exporter.get_finished_spans().expect("span export"); - let start_trace_event = - find_span_event_by_name_attr(&spans[0].events.events, "codex.conversation_starts"); - let start_trace_attrs = span_event_attributes(start_trace_event); - assert_eq!( - start_trace_attrs - .get("base_url_is_default") - .map(String::as_str), - Some("false") - ); }