diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index cabea80886..4a093ee8fe 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -15,7 +15,10 @@ use crate::events::CodexReviewEventParams; use crate::events::CodexReviewEventRequest; use crate::events::CodexRuntimeMetadata; use crate::events::CodexToolItemEventBase; +use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnStartRejectedEventParams; +use crate::events::CodexTurnStartRejectedEventRequest; use crate::events::FinalApprovalOutcome; use crate::events::GuardianApprovalRequestSource; use crate::events::GuardianReviewDecision; @@ -63,6 +66,7 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStartRejectionReason; use crate::facts::TurnStatus; use crate::facts::TurnSteerRequestError; use crate::facts::TurnTokenUsageFact; @@ -531,6 +535,52 @@ async fn ingest_rejected_turn_steer( serde_json::to_value(&out[0]).expect("serialize turn steer event") } +async fn ingest_rejected_turn_start( + reducer: &mut AnalyticsReducer, + out: &mut Vec, + error: JSONRPCErrorError, + error_type: Option, +) -> serde_json::Value { + ingest_initialize(reducer, out).await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(1), + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + out, + ) + .await; + out.clear(); + reducer + .ingest( + AnalyticsFact::ClientRequest { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(sample_turn_start_request("thread-2", /*request_id*/ 3)), + }, + out, + ) + .await; + reducer + .ingest( + AnalyticsFact::ErrorResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + error, + error_type, + }, + out, + ) + .await; + + assert_eq!(out.len(), 1); + serde_json::to_value(&out[0]).expect("serialize rejected turn start event") +} + async fn ingest_initialize(reducer: &mut AnalyticsReducer, out: &mut Vec) { reducer .ingest( @@ -3188,7 +3238,7 @@ async fn reducer_ingests_plugin_state_changed_fact() { fn turn_event_serializes_expected_shape() { let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { event_type: "codex_turn_event", - event_params: crate::events::CodexTurnEventParams { + event_params: CodexTurnEventParams { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), app_server_client: sample_app_server_client_metadata(), @@ -3300,6 +3350,56 @@ fn turn_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[test] +fn rejected_turn_start_event_serializes_expected_shape() { + let event = TrackEventRequest::TurnStartRejected(CodexTurnStartRejectedEventRequest { + event_type: "codex_turn_start_rejected_event", + event_params: CodexTurnStartRejectedEventParams { + thread_id: "thread-2".to_string(), + app_server_client: sample_app_server_client_metadata(), + runtime: sample_runtime_metadata(), + thread_source: Some(ThreadSource::User), + subagent_source: None, + parent_thread_id: None, + num_input_images: 2, + rejection_reason: Some(TurnStartRejectionReason::InputTooLarge), + created_at: 456, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize rejected turn start event"); + let expected = serde_json::from_str::( + r#"{ + "event_type": "codex_turn_start_rejected_event", + "event_params": { + "thread_id": "thread-2", + "app_server_client": { + "product_client_id": "codex_cli_rs", + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "thread_source": "user", + "subagent_source": null, + "parent_thread_id": null, + "num_input_images": 2, + "rejection_reason": "input_too_large", + "created_at": 456 + } + }"#, + ) + .expect("parse expected rejected turn start event"); + + assert_eq!(payload, expected); +} + #[tokio::test] async fn accepted_turn_steer_emits_expected_event() { let mut reducer = AnalyticsReducer::default(); @@ -3463,12 +3563,66 @@ async fn turn_steer_does_not_emit_without_pending_request() { assert!(out.is_empty()); } +#[tokio::test] +async fn rejected_turn_start_uses_request_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + let payload = ingest_rejected_turn_start( + &mut reducer, + &mut out, + input_too_large_steer_error(), + Some(input_too_large_error_type()), + ) + .await; + + assert_eq!( + payload["event_type"], + json!("codex_turn_start_rejected_event") + ); + assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!(payload["event_params"]["num_input_images"], json!(1)); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + json!("codex-tui") + ); + assert_eq!( + payload["event_params"]["runtime"]["codex_rs_version"], + json!("0.1.0") + ); + assert_eq!(payload["event_params"]["thread_source"], json!("user")); + assert_eq!(payload["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!( + payload["event_params"]["rejection_reason"], + json!("input_too_large") + ); + assert!( + payload["event_params"]["created_at"] + .as_u64() + .expect("created_at") + > 0 + ); +} + #[tokio::test] async fn turn_start_error_response_discards_pending_start_request() { let mut reducer = AnalyticsReducer::default(); let mut out = Vec::new(); ingest_initialize(&mut reducer, &mut out).await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(1), + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut out, + ) + .await; + out.clear(); reducer .ingest( AnalyticsFact::ClientRequest { @@ -3484,12 +3638,24 @@ async fn turn_start_error_response_discards_pending_start_request() { AnalyticsFact::ErrorResponse { connection_id: 7, request_id: RequestId::Integer(3), - error: no_active_turn_steer_error(), + error: JSONRPCErrorError { + code: -32600, + message: "turn start failed".to_string(), + data: None, + }, error_type: None, }, &mut out, ) .await; + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize rejected turn start event"); + assert_eq!( + payload["event_type"], + json!("codex_turn_start_rejected_event") + ); + assert_eq!(payload["event_params"]["rejection_reason"], json!(null)); + out.clear(); // A late/synthetic response for the same request id must not resurrect the // failed turn/start request and attach request-scoped connection metadata. diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 87cb165ac2..f4deeb265b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -15,6 +15,7 @@ use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnStartRejectionReason; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; use crate::facts::TurnSteerResult; @@ -63,6 +64,7 @@ pub(crate) enum TrackEventRequest { HookRun(CodexHookRunEventRequest), Compaction(Box), TurnEvent(Box), + TurnStartRejected(CodexTurnStartRejectedEventRequest), TurnSteer(CodexTurnSteerEventRequest), CommandExecution(CodexCommandExecutionEventRequest), FileChange(CodexFileChangeEventRequest), @@ -818,6 +820,25 @@ pub(crate) struct CodexTurnEventRequest { pub(crate) event_params: CodexTurnEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexTurnStartRejectedEventParams { + pub(crate) thread_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) num_input_images: usize, + pub(crate) rejection_reason: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexTurnStartRejectedEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexTurnStartRejectedEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexTurnSteerEventParams { pub(crate) thread_id: String, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 56bd0a5d2c..25e3969927 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -107,6 +107,12 @@ pub enum TurnStatus { Interrupted, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStartRejectionReason { + InputTooLarge, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnSteerResult { @@ -135,6 +141,13 @@ pub struct CodexTurnSteerEvent { pub created_at: u64, } +#[derive(Clone)] +pub struct CodexTurnStartRejectedEvent { + pub num_input_images: usize, + pub rejection_reason: Option, + pub created_at: u64, +} + #[derive(Clone, Copy, Debug)] pub enum AnalyticsJsonRpcError { TurnSteer(TurnSteerRequestError), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index a33ca7b9e3..977274d1bf 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -24,6 +24,7 @@ pub use facts::AcceptedLineFingerprint; pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexTurnStartRejectedEvent; pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; @@ -39,6 +40,7 @@ pub use facts::SubAgentThreadStartedInput; pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnResolvedConfigFact; +pub use facts::TurnStartRejectionReason; pub use facts::TurnStatus; pub use facts::TurnSteerRejectionReason; pub use facts::TurnSteerRequestError; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d072720d10..ef471e64be 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -28,6 +28,8 @@ use crate::events::CodexRuntimeMetadata; use crate::events::CodexToolItemEventBase; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; +use crate::events::CodexTurnStartRejectedEventParams; +use crate::events::CodexTurnStartRejectedEventRequest; use crate::events::CodexTurnSteerEventParams; use crate::events::CodexTurnSteerEventRequest; use crate::events::CodexWebSearchEventParams; @@ -65,6 +67,7 @@ use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::HookRunInput; +use crate::facts::InputError; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; @@ -72,6 +75,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnResolvedConfigFact; +use crate::facts::TurnStartRejectionReason; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; use crate::facts::TurnSteerResult; @@ -212,6 +216,16 @@ impl<'a> AnalyticsDropSite<'a> { } } + fn turn_start(thread_id: &'a str) -> Self { + Self { + event_name: "turn start rejected", + thread_id, + turn_id: None, + review_id: None, + item_id: None, + } + } + fn turn(thread_id: &'a str, turn_id: &'a str) -> Self { Self { event_name: "turn", @@ -297,6 +311,7 @@ enum RequestState { struct PendingTurnStartState { thread_id: String, num_input_images: usize, + created_at: u64, } struct PendingTurnSteerState { @@ -573,6 +588,7 @@ impl AnalyticsReducer { RequestState::TurnStart(PendingTurnStartState { thread_id: params.thread_id, num_input_images: num_input_images(¶ms.input), + created_at: now_unix_seconds(), }), ); } @@ -1037,7 +1053,14 @@ impl AnalyticsReducer { out: &mut Vec, ) { match request { - RequestState::TurnStart(_) => {} + RequestState::TurnStart(pending_request) => { + self.ingest_turn_start_error_response( + connection_id, + pending_request, + error_type, + out, + ); + } RequestState::TurnSteer(pending_request) => { self.ingest_turn_steer_error_response( connection_id, @@ -1049,6 +1072,21 @@ impl AnalyticsReducer { } } + fn ingest_turn_start_error_response( + &mut self, + connection_id: u64, + pending_request: PendingTurnStartState, + error_type: Option, + out: &mut Vec, + ) { + self.emit_turn_start_rejected_event( + connection_id, + pending_request, + turn_start_rejection_reason_from_error_type(error_type), + out, + ); + } + fn ingest_turn_steer_error_response( &mut self, connection_id: u64, @@ -1061,7 +1099,7 @@ impl AnalyticsReducer { pending_request, /*accepted_turn_id*/ None, TurnSteerResult::Rejected, - rejection_reason_from_error_type(error_type), + turn_steer_rejection_reason_from_error_type(error_type), out, ); } @@ -1354,6 +1392,43 @@ impl AnalyticsReducer { ); } + fn emit_turn_start_rejected_event( + &mut self, + connection_id: u64, + pending_request: PendingTurnStartState, + rejection_reason: Option, + out: &mut Vec, + ) { + let Some(connection_state) = self.connections.get(&connection_id) else { + return; + }; + let drop_site = AnalyticsDropSite::turn_start(&pending_request.thread_id); + let Some(thread_metadata) = self + .threads + .get(drop_site.thread_id) + .and_then(|thread| thread.metadata.as_ref()) + else { + warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata); + return; + }; + out.push(TrackEventRequest::TurnStartRejected( + CodexTurnStartRejectedEventRequest { + event_type: "codex_turn_start_rejected_event", + event_params: CodexTurnStartRejectedEventParams { + thread_id: pending_request.thread_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + thread_source: thread_metadata.thread_source, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + num_input_images: pending_request.num_input_images, + rejection_reason, + created_at: pending_request.created_at, + }, + }, + )); + } + fn emit_turn_steer_event( &mut self, connection_id: u64, @@ -2567,7 +2642,19 @@ fn num_input_images(input: &[UserInput]) -> usize { .count() } -fn rejection_reason_from_error_type( +fn turn_start_rejection_reason_from_error_type( + error_type: Option, +) -> Option { + match error_type? { + AnalyticsJsonRpcError::TurnSteer(_) => None, + AnalyticsJsonRpcError::Input(InputError::Empty) => None, + AnalyticsJsonRpcError::Input(InputError::TooLarge) => { + Some(TurnStartRejectionReason::InputTooLarge) + } + } +} + +fn turn_steer_rejection_reason_from_error_type( error_type: Option, ) -> Option { match error_type? {