Propagate turn triggers to usage analytics events (#60018)

## What changed

Include `turn_trigger` from turn metadata in tool, artifact, skill, app, plugin usage, and hook analytics events. Omit the field when no trigger is available.

## Testing

Extend app serialization and reducer tests to check trigger propagation, and verify that MCP tool events retain the trigger after turn completion.

GitOrigin-RevId: 975780983d0761eb34fecf5e8b4abe98a432d0c3
This commit is contained in:
Chris Dong
2026-11-07 01:25:00 +00:00
committed by copyberry
parent 2f742f5885
commit 5ff78c5e66
6 changed files with 85 additions and 13 deletions
+3
View File
@@ -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,
+23
View File
@@ -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<String>,
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<String>,
pub(crate) product_client_id: Option<String>,
pub(crate) skill_scope: Option<String>,
pub(crate) plugin_id: Option<String>,
@@ -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<String>,
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<String>,
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<String>,
pub(crate) connector_id: Option<String>,
pub(crate) thread_id: Option<String>,
pub(crate) turn_id: Option<String>,
@@ -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<String>,
pub(crate) thread_id: Option<String>,
pub(crate) turn_id: Option<String>,
pub(crate) product_client_id: Option<String>,
@@ -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<String>,
#[serde(flatten)]
pub(crate) plugin: CodexPluginMetadata,
pub(crate) mcp_server_names: Option<Vec<String>>,
@@ -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()),
+19 -10
View File
@@ -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<String>,
turn_metadata: Option<&dyn TurnAnalyticsMetadata>,
mut event: TrackEventRequest,
emission: ToolEventEmission,
out: &mut Vec<TrackEventRequest>,
@@ -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(&notification.thread_id),
);
let root_turn_id = self
let turn_metadata = self
.turns
.get(&notification.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(&notification.turn_id)
.and_then(|turn| turn.voice_session_id.clone());
}
let root_turn_id = self
let turn_metadata = self
.turns
.get(&notification.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(
&notification.thread_id,
&notification.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,
@@ -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)
@@ -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")
+3 -1
View File
@@ -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<Option<String>>,
pub(super) turn_trigger: Option<String>,
}
impl TurnAnalyticsMetadata for TestTurnMetadata {
@@ -72,7 +73,7 @@ impl TurnAnalyticsMetadata for TestTurnMetadata {
}
fn turn_trigger(&self) -> Option<String> {
None
self.turn_trigger.clone()
}
fn codex_turn_source(&self) -> Option<String> {
@@ -83,6 +84,7 @@ impl TurnAnalyticsMetadata for TestTurnMetadata {
pub(super) fn test_turn_metadata(root_turn_id: Option<&str>) -> Arc<TestTurnMetadata> {
Arc::new(TestTurnMetadata {
root_turn_id: Mutex::new(root_turn_id.map(str::to_string)),
turn_trigger: None,
})
}