diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 6819823d9e..534886e0bf 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -155,6 +155,7 @@ fn sample_skill_track_event(thread_id: &str, plugin_id: Option<&str>) -> TrackEv skill_id: format!("skill-{thread_id}"), skill_name: "doc".to_string(), event_params: SkillInvocationEventParams { + turn_trigger: None, product_client_id: None, skill_scope: None, plugin_id: plugin_id.map(str::to_string), @@ -205,6 +206,7 @@ fn sample_mcp_tool_call_event(thread_id: &str, plugin_id: Option<&str>) -> Track event_type: "codex_mcp_tool_call_event", event_params: CodexMcpToolCallEventParams { base: CodexToolItemEventBase { + turn_trigger: None, thread_id: thread_id.to_string(), session_id: format!("session-{thread_id}"), turn_id: "turn-1".to_string(), @@ -261,6 +263,7 @@ fn sample_plugin_used_track_event(thread_id: &str, plugin_id: Option<&str>) -> T TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest { event_type: "codex_plugin_used", event_params: CodexPluginUsedMetadata { + turn_trigger: None, plugin: CodexPluginMetadata { plugin_id: plugin_id.map(str::to_string), remote_plugin_id: None, diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 0cd0f66ad5..bb0a74d505 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -23,6 +23,7 @@ use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnAnalyticsMetadata; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; use crate::facts::TurnSteerResult; @@ -108,6 +109,8 @@ pub(crate) enum TrackEventRequest { #[derive(Serialize)] pub(crate) struct CodexArtifactOperationEventParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) turn_trigger: Option, pub(crate) thread_id: String, pub(crate) turn_id: String, pub(crate) item_id: String, @@ -136,9 +139,11 @@ pub(crate) fn codex_artifact_operation_event_request( tracking: TrackEventsContext, operation: ArtifactOperation, ) -> CodexArtifactOperationEventRequest { + let turn = tracking.turn_metadata.as_deref(); CodexArtifactOperationEventRequest { event_type: "codex_artifact_operation", event_params: CodexArtifactOperationEventParams { + turn_trigger: turn.and_then(TurnAnalyticsMetadata::turn_trigger), thread_id: tracking.thread_id, turn_id: tracking.turn_id, item_id: operation.item_id, @@ -209,6 +214,8 @@ pub(crate) struct SkillInvocationEventRequest { #[derive(Serialize)] pub(crate) struct SkillInvocationEventParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) turn_trigger: Option, pub(crate) product_client_id: Option, pub(crate) skill_scope: Option, pub(crate) plugin_id: Option, @@ -682,6 +689,8 @@ pub(crate) enum ToolEventType { #[derive(Serialize)] pub(crate) struct CodexToolItemEventBase { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) turn_trigger: Option, pub(crate) thread_id: String, pub(crate) session_id: String, pub(crate) turn_id: String, @@ -719,6 +728,8 @@ pub(crate) struct CodexToolItemEventBase { #[derive(Serialize)] pub(crate) struct CodexCuaToolCallStartedEventParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) turn_trigger: Option, pub(crate) thread_id: String, pub(crate) session_id: String, pub(crate) turn_id: String, @@ -1000,6 +1011,8 @@ pub(crate) struct CodexImageGenerationEventRequest { #[derive(Serialize)] pub(crate) struct CodexAppMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) turn_trigger: Option, pub(crate) connector_id: Option, pub(crate) thread_id: Option, pub(crate) turn_id: Option, @@ -1031,6 +1044,8 @@ pub(crate) struct CodexAppUsedEventRequest { #[derive(Serialize)] pub(crate) struct CodexHookRunMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) turn_trigger: Option, pub(crate) thread_id: Option, pub(crate) turn_id: Option, pub(crate) product_client_id: Option, @@ -1230,6 +1245,8 @@ pub(crate) struct CodexPluginMetadata { #[derive(Serialize)] pub(crate) struct CodexPluginUsedMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) turn_trigger: Option, #[serde(flatten)] pub(crate) plugin: CodexPluginMetadata, pub(crate) mcp_server_names: Option>, @@ -1340,7 +1357,9 @@ pub(crate) fn codex_app_metadata( tracking: &TrackEventsContext, app: AppInvocation, ) -> CodexAppMetadata { + let turn = tracking.turn_metadata.as_deref(); CodexAppMetadata { + turn_trigger: turn.and_then(TurnAnalyticsMetadata::turn_trigger), connector_id: app.connector_id, thread_id: Some(tracking.thread_id.clone()), turn_id: Some(tracking.turn_id.clone()), @@ -1487,7 +1506,9 @@ pub(crate) fn codex_plugin_used_metadata( .capability_summary .as_ref() .map(|summary| summary.mcp_server_names.clone()); + let turn = tracking.turn_metadata.as_deref(); CodexPluginUsedMetadata { + turn_trigger: turn.and_then(TurnAnalyticsMetadata::turn_trigger), plugin: codex_plugin_metadata_with_product_client_id( plugin, tracking.product_client_id.clone(), @@ -1503,7 +1524,9 @@ pub(crate) fn codex_hook_run_metadata( tracking: &TrackEventsContext, hook: HookRunFact, ) -> CodexHookRunMetadata { + let turn = tracking.turn_metadata.as_deref(); CodexHookRunMetadata { + turn_trigger: turn.and_then(TurnAnalyticsMetadata::turn_trigger), thread_id: Some(tracking.thread_id.clone()), turn_id: Some(tracking.turn_id.clone()), product_client_id: Some(tracking.product_client_id.clone()), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 26cd458bd3..e9a59cac51 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -110,6 +110,7 @@ use crate::facts::SkillInvocationLocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TurnAnalyticsMetadata; use crate::facts::TurnCodexError; use crate::facts::TurnCodexErrorFact; use crate::facts::TurnProfile; @@ -182,6 +183,7 @@ use std::collections::HashSet; use std::collections::VecDeque; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; const MAX_TOOL_RESPONSE_ENTRIES: usize = 256; const MAX_RECENT_VOICE_SESSION_WINDOWS: usize = 16; @@ -1056,7 +1058,7 @@ impl AnalyticsReducer { self.record_tool_event( &thread_id, &turn_id, - turn_metadata.root_turn_id(), + Some(turn_metadata.as_ref()), event, ToolEventEmission::AwaitResponse, out, @@ -1133,7 +1135,7 @@ impl AnalyticsReducer { self.record_tool_event( &thread_id, &turn_id, - turn_metadata.root_turn_id(), + Some(turn_metadata.as_ref()), event, ToolEventEmission::AwaitResponse, out, @@ -1192,7 +1194,7 @@ impl AnalyticsReducer { &mut self, thread_id: &str, turn_id: &str, - root_turn_id: Option, + turn_metadata: Option<&dyn TurnAnalyticsMetadata>, mut event: TrackEventRequest, emission: ToolEventEmission, out: &mut Vec, @@ -1201,7 +1203,8 @@ impl AnalyticsReducer { out.push(event); return; }; - base.root_turn_id = root_turn_id; + base.root_turn_id = turn_metadata.and_then(TurnAnalyticsMetadata::root_turn_id); + base.turn_trigger = turn_metadata.and_then(TurnAnalyticsMetadata::turn_trigger); let state = self .tool_response_states .entry((thread_id.to_string(), turn_id.to_string())) @@ -1489,12 +1492,14 @@ impl AnalyticsReducer { (skill_id, skill_scope) } }; + let turn = tracking.turn_metadata.as_deref(); out.push(TrackEventRequest::SkillInvocation( SkillInvocationEventRequest { event_type: "skill_invocation", skill_id, skill_name: invocation.skill_name.clone(), event_params: SkillInvocationEventParams { + turn_trigger: turn.and_then(TurnAnalyticsMetadata::turn_trigger), thread_id: Some(tracking.thread_id.clone()), turn_id: Some(tracking.turn_id.clone()), voice_session_id: self @@ -2120,11 +2125,11 @@ impl AnalyticsReducer { .get(&(notification.thread_id.clone(), notification.turn_id.clone())), self.code_mode_cells.get(¬ification.thread_id), ); - let root_turn_id = self + let turn_metadata = self .turns .get(¬ification.turn_id) .and_then(|turn| turn.resolved_config.as_ref()) - .and_then(|config| config.turn_metadata.root_turn_id()); + .map(|config| config.turn_metadata.as_ref()); out.push(TrackEventRequest::CuaToolCallStarted( CodexCuaToolCallStartedEventRequest { event_type: "codex_cua_tool_call_started", @@ -2132,7 +2137,10 @@ impl AnalyticsReducer { thread_id: notification.thread_id, session_id: thread_metadata.session_id.clone(), turn_id: notification.turn_id, - root_turn_id, + root_turn_id: turn_metadata + .and_then(TurnAnalyticsMetadata::root_turn_id), + turn_trigger: turn_metadata + .and_then(TurnAnalyticsMetadata::turn_trigger), item_id: item_id.to_string(), cell_id: attribution.cell_id, parent_call_id: attribution.parent_call_id, @@ -2248,11 +2256,11 @@ impl AnalyticsReducer { .get(¬ification.turn_id) .and_then(|turn| turn.voice_session_id.clone()); } - let root_turn_id = self + let turn_metadata = self .turns .get(¬ification.turn_id) .and_then(|turn| turn.resolved_config.as_ref()) - .and_then(|config| config.turn_metadata.root_turn_id()); + .map(|config| Arc::clone(&config.turn_metadata)); // Fast collaborator tools can complete before their sampling response. // Keep the event until that response can supply its originating ID. let emission = if matches!( @@ -2266,7 +2274,7 @@ impl AnalyticsReducer { self.record_tool_event( ¬ification.thread_id, ¬ification.turn_id, - root_turn_id, + turn_metadata.as_deref(), event, emission, out, @@ -3469,6 +3477,7 @@ fn tool_item_base( thread_id: thread_id.to_string(), session_id: thread_metadata.session_id.clone(), turn_id: turn_id.to_string(), + turn_trigger: None, root_turn_id: None, item_id, cell_id: None, diff --git a/codex-rs/analytics/src/tests/suite/apps_tests.rs b/codex-rs/analytics/src/tests/suite/apps_tests.rs index bc31c36107..1060e12a32 100644 --- a/codex-rs/analytics/src/tests/suite/apps_tests.rs +++ b/codex-rs/analytics/src/tests/suite/apps_tests.rs @@ -15,6 +15,7 @@ use crate::facts::InvocationType; use crate::reducer::AnalyticsReducer; use crate::tests::support::TEST_PRODUCT_CLIENT_ID; use crate::tests::support::test_tracking_context; +use crate::tests::support::test_turn_metadata; use pretty_assertions::assert_eq; use serde_json::json; @@ -54,7 +55,12 @@ fn app_mentioned_event_serializes_expected_shape() { #[test] fn app_used_event_serializes_expected_shape() { - let tracking = test_tracking_context("thread-2", "turn-2"); + let mut tracking = test_tracking_context("thread-2", "turn-2"); + let mut metadata = test_turn_metadata(/*root_turn_id*/ None); + std::sync::Arc::get_mut(&mut metadata) + .expect("unique metadata") + .turn_trigger = Some("appServer-goal".to_string()); + tracking.turn_metadata = Some(metadata); let event = TrackEventRequest::AppUsed(CodexAppUsedEventRequest { event_type: "codex_app_used", event_params: CodexAppUsedMetadata { @@ -78,6 +84,7 @@ fn app_used_event_serializes_expected_shape() { json!({ "event_type": "codex_app_used", "event_params": { + "turn_trigger": "appServer-goal", "connector_id": "drive", "thread_id": "thread-2", "turn_id": "turn-2", @@ -97,6 +104,14 @@ async fn reducer_ingests_app_facts() { let mut reducer = AnalyticsReducer::default(); let mut events = Vec::new(); let tracking = test_tracking_context("thread-1", "turn-1"); + let mut metadata = test_turn_metadata(/*root_turn_id*/ None); + std::sync::Arc::get_mut(&mut metadata) + .expect("unique metadata") + .turn_trigger = Some("automation_cron_run_now".to_string()); + let tracking = crate::facts::TrackEventsContext { + turn_metadata: Some(metadata), + ..tracking + }; reducer .ingest( @@ -149,6 +164,16 @@ async fn reducer_ingests_app_facts() { ("codex_app_used", Some(TEST_PRODUCT_CLIENT_ID)), ] ); + assert_eq!( + ( + mention.event_params.turn_trigger.as_deref(), + usage.event_params.app.turn_trigger.as_deref() + ), + ( + Some("automation_cron_run_now"), + Some("automation_cron_run_now") + ) + ); assert_eq!( usage.event_params.elicitation_type, Some(ElicitationType::AuthOrLink) diff --git a/codex-rs/analytics/src/tests/suite/tools_tests.rs b/codex-rs/analytics/src/tests/suite/tools_tests.rs index 9ca6b21d45..d84996e7f3 100644 --- a/codex-rs/analytics/src/tests/suite/tools_tests.rs +++ b/codex-rs/analytics/src/tests/suite/tools_tests.rs @@ -105,6 +105,7 @@ fn command_execution_event_serializes_expected_shape() { model_slug: None, reasoning_effort: None, base: CodexToolItemEventBase { + turn_trigger: None, thread_id: "thread-1".to_string(), session_id: "session-thread-1".to_string(), turn_id: "turn-1".to_string(), @@ -771,7 +772,10 @@ async fn mcp_elicitation_classification_survives_turn_completion_and_preserves_c ) .await; - let turn_metadata = test_turn_metadata(/*root_turn_id*/ None); + let mut turn_metadata = test_turn_metadata(/*root_turn_id*/ None); + std::sync::Arc::get_mut(&mut turn_metadata) + .expect("unique metadata") + .turn_trigger = Some("automation_cron_scheduled".to_string()); reducer .ingest( AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new( @@ -870,6 +874,12 @@ async fn mcp_elicitation_classification_survives_turn_completion_and_preserves_c } let payload = serde_json::to_value(&events).expect("serialize analytics events"); + for event in payload.as_array().expect("analytics events array") { + assert_eq!( + event["event_params"]["turn_trigger"], + "automation_cron_scheduled" + ); + } let classifications = payload .as_array() .expect("analytics events array") diff --git a/codex-rs/analytics/src/tests/support.rs b/codex-rs/analytics/src/tests/support.rs index ba1df28286..b1cc0dd4a9 100644 --- a/codex-rs/analytics/src/tests/support.rs +++ b/codex-rs/analytics/src/tests/support.rs @@ -64,6 +64,7 @@ pub(super) const TEST_PRODUCT_CLIENT_ID: &str = "codex_work_desktop"; pub(super) struct TestTurnMetadata { pub(super) root_turn_id: Mutex>, + pub(super) turn_trigger: Option, } impl TurnAnalyticsMetadata for TestTurnMetadata { @@ -72,7 +73,7 @@ impl TurnAnalyticsMetadata for TestTurnMetadata { } fn turn_trigger(&self) -> Option { - None + self.turn_trigger.clone() } fn codex_turn_source(&self) -> Option { @@ -83,6 +84,7 @@ impl TurnAnalyticsMetadata for TestTurnMetadata { pub(super) fn test_turn_metadata(root_turn_id: Option<&str>) -> Arc { Arc::new(TestTurnMetadata { root_turn_id: Mutex::new(root_turn_id.map(str::to_string)), + turn_trigger: None, }) }