Add rejected turn/start analytics

This commit is contained in:
Roy Han
2026-05-25 15:16:45 -07:00
parent 9f42c89c01
commit a79522c16f
5 changed files with 294 additions and 5 deletions
@@ -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<TrackEventRequest>,
error: JSONRPCErrorError,
error_type: Option<AnalyticsJsonRpcError>,
) -> 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<TrackEventRequest>) {
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::<serde_json::Value>(
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.
+21
View File
@@ -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<CodexCompactionEventRequest>),
TurnEvent(Box<CodexTurnEventRequest>),
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<ThreadSource>,
pub(crate) subagent_source: Option<String>,
pub(crate) parent_thread_id: Option<String>,
pub(crate) num_input_images: usize,
pub(crate) rejection_reason: Option<TurnStartRejectionReason>,
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,
+13
View File
@@ -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<TurnStartRejectionReason>,
pub created_at: u64,
}
#[derive(Clone, Copy, Debug)]
pub enum AnalyticsJsonRpcError {
TurnSteer(TurnSteerRequestError),
+2
View File
@@ -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;
+90 -3
View File
@@ -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(&params.input),
created_at: now_unix_seconds(),
}),
);
}
@@ -1037,7 +1053,14 @@ impl AnalyticsReducer {
out: &mut Vec<TrackEventRequest>,
) {
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<AnalyticsJsonRpcError>,
out: &mut Vec<TrackEventRequest>,
) {
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<TurnStartRejectionReason>,
out: &mut Vec<TrackEventRequest>,
) {
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<AnalyticsJsonRpcError>,
) -> Option<TurnStartRejectionReason> {
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<AnalyticsJsonRpcError>,
) -> Option<TurnSteerRejectionReason> {
match error_type? {