Report turn trigger and source in turn analytics (#42003)

## What changed

- Add `turn_trigger` and `codex_turn_source` to `codex_turn_event` analytics.
- Read the source from effective Responses metadata, preserving configured-over-client precedence and reflecting accepted steer metadata.
- Omit trigger and source values from analytics when they exceed the existing Responses metadata value limit.

## Testing

- Cover event serialization, turn lifecycle emission, configured and client source precedence, size limits, and source updates after steering.

GitOrigin-RevId: bbd45af4c6af52956d2394b722ea78e65e5b9bd7
This commit is contained in:
Eddie Chen
2026-09-01 07:32:54 +00:00
committed by copyberry
parent e017e93ace
commit 2b7c279735
9 changed files with 172 additions and 8 deletions
@@ -209,6 +209,14 @@ impl TurnAnalyticsMetadata for TestTurnMetadata {
fn root_turn_id(&self) -> Option<String> {
self.root_turn_id.lock().expect("root turn ID").clone()
}
fn turn_trigger(&self) -> Option<String> {
None
}
fn codex_turn_source(&self) -> Option<String> {
None
}
}
fn test_turn_metadata(root_turn_id: Option<&str>) -> Arc<TestTurnMetadata> {
@@ -4648,6 +4656,8 @@ fn turn_event_serializes_expected_shape() {
session_id: "session-thread-2".to_string(),
turn_id: "turn-2".to_string(),
root_turn_id: Some("turn-2".to_string()),
turn_trigger: Some("user".to_string()),
codex_turn_source: Some("composer".to_string()),
app_server_client: sample_app_server_client_metadata(),
runtime: sample_runtime_metadata(),
submission_type: None,
@@ -4722,6 +4732,8 @@ fn turn_event_serializes_expected_shape() {
"session_id": "session-thread-2",
"turn_id": "turn-2",
"root_turn_id": "turn-2",
"turn_trigger": "user",
"codex_turn_source": "composer",
"submission_type": null,
"app_server_client": {
"product_client_id": "codex_cli_rs",
@@ -5072,6 +5084,16 @@ async fn turn_lifecycle_emits_turn_event() {
json!("session-thread-2")
);
assert_eq!(payload["event_params"]["turn_id"], json!("turn-2"));
assert_eq!(
(
payload["event_params"].get("turn_trigger"),
payload["event_params"].get("codex_turn_source"),
),
(
Some(&serde_json::Value::Null),
Some(&serde_json::Value::Null)
)
);
assert_eq!(
payload["event_params"]["app_server_client"],
json!({
+2
View File
@@ -1012,6 +1012,8 @@ pub(crate) struct CodexTurnEventParams {
pub(crate) session_id: String,
pub(crate) turn_id: String,
pub(crate) root_turn_id: Option<String>,
pub(crate) turn_trigger: Option<String>,
pub(crate) codex_turn_source: Option<String>,
// TODO(rhan-oai): Populate once queued/default submission type is plumbed from
// the turn/start callsites instead of always being reported as None.
pub(crate) submission_type: Option<TurnSubmissionType>,
+5 -1
View File
@@ -206,13 +206,17 @@ pub struct TurnResolvedConfigFact {
pub is_first_turn: bool,
}
/// A live, read-only view of a turn's trusted analytics provenance.
/// A live, read-only view of a turn's analytics metadata.
///
/// Implementations must return `None` for unknown or ambiguous roots. The reducer
/// reads this when constructing each event because steering can invalidate a root
/// after the turn's configuration has been resolved.
pub trait TurnAnalyticsMetadata: Send + Sync {
fn root_turn_id(&self) -> Option<String>;
/// The caller-provided trigger recorded when this turn started.
fn turn_trigger(&self) -> Option<String>;
/// The effective Responses source at event emission, including accepted steers.
fn codex_turn_source(&self) -> Option<String>;
}
#[derive(Clone, Copy, Debug, Serialize)]
+2
View File
@@ -3426,6 +3426,8 @@ fn codex_turn_event_params(
session_id: thread_metadata.session_id.clone(),
turn_id,
root_turn_id: turn_metadata.root_turn_id(),
turn_trigger: turn_metadata.turn_trigger(),
codex_turn_source: turn_metadata.codex_turn_source(),
app_server_client,
runtime,
submission_type,
@@ -453,8 +453,10 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2()
responses::mount_response_sequence(&server, vec![first_response, second_response]).await;
MockResponsesConfig::new(&server.uri())
.with_root_config(&format!("chatgpt_base_url = \"{}\"", server.uri()))
.with_provider_config("supports_websockets = false")
.write(codex_home.path())?;
mount_analytics_capture(&server, codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
@@ -467,8 +469,10 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2()
let ThreadStartResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(thread_req)).await??;
let start_metadata =
HashMap::from([("fiber_run_id".to_string(), "fiber-start-123".to_string())]);
let start_metadata = HashMap::from([
("fiber_run_id".to_string(), "fiber-start-123".to_string()),
("source".to_string(), "initial-source".to_string()),
]);
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
@@ -496,6 +500,7 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2()
let steer_metadata = HashMap::from([
("fiber_run_id".to_string(), "fiber-steer-456".to_string()),
("origin".to_string(), "gaas".to_string()),
("source".to_string(), "steer-source".to_string()),
]);
let steer_req = mcp
.send_turn_steer_request(TurnSteerParams {
@@ -532,6 +537,7 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2()
);
assert_eq!(first_metadata["turn_id"].as_str(), Some(turn_id.as_str()));
assert_eq!(first_metadata["turn_trigger"].as_str(), Some("user"));
assert_eq!(first_metadata["source"].as_str(), Some("initial-source"));
let second_metadata = requests[1]
.header("x-codex-turn-metadata")
@@ -545,6 +551,16 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2()
assert_eq!(second_metadata["origin"].as_str(), Some("gaas"));
assert_eq!(second_metadata["turn_id"].as_str(), Some(turn_id.as_str()));
assert_eq!(second_metadata["turn_trigger"].as_str(), Some("user"));
assert_eq!(second_metadata["source"].as_str(), Some("steer-source"));
let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?;
assert_eq!(
(
event["event_params"]["turn_trigger"].as_str(),
event["event_params"]["codex_turn_source"].as_str(),
),
(Some("user"), Some("steer-source"))
);
Ok(())
}
@@ -1159,10 +1159,11 @@ async fn turn_start_tracks_thread_originator_in_analytics() -> Result<()> {
url: TINY_PNG_DATA_URL.to_string(),
detail: None,
}],
responsesapi_client_metadata: Some(HashMap::from([(
"workspace_kind".to_string(),
"projectless".to_string(),
)])),
turn_trigger: Some("user".to_string()),
responsesapi_client_metadata: Some(HashMap::from([
("workspace_kind".to_string(), "projectless".to_string()),
("source".to_string(), "composer".to_string()),
])),
..Default::default()
},
})
@@ -1179,6 +1180,26 @@ async fn turn_start_tracks_thread_originator_in_analytics() -> Result<()> {
assert_eq!(event["event_params"]["session_id"], thread.session_id);
assert_eq!(event["event_params"]["turn_id"], turn.id);
assert_eq!(event["event_params"]["root_turn_id"], turn.id);
let request = response_mock.requests()[0].body_json();
let request_metadata: Value = serde_json::from_str(
request["client_metadata"]["x-codex-turn-metadata"]
.as_str()
.context("expected turn metadata")?,
)?;
assert_eq!(
json!({
"eventTrigger": event["event_params"]["turn_trigger"],
"eventSource": event["event_params"]["codex_turn_source"],
"requestTrigger": request_metadata["turn_trigger"],
"requestSource": request_metadata["source"],
}),
json!({
"eventTrigger": "user",
"eventSource": "composer",
"requestTrigger": "user",
"requestSource": "composer",
})
);
assert_eq!(
event["event_params"]["app_server_client"]["product_client_id"],
"codex_work_desktop"
+1 -1
View File
@@ -98,7 +98,7 @@ const BACKWARD_COMPATIBLE_RESERVED_METADATA_KEYS: &[&str] =
&[WINDOW_NUMBER_KEY, FORKED_FROM_ORDINAL_EXCLUSIVE_KEY];
const MAX_EXTRA_METADATA_ENTRIES: usize = 16;
const MAX_EXTRA_METADATA_KEY_BYTES: usize = 64;
const MAX_EXTRA_METADATA_VALUE_BYTES: usize = 128;
pub(crate) const MAX_EXTRA_METADATA_VALUE_BYTES: usize = 128;
/// Metadata attached to model requests whose purpose is conversation compaction.
///
+25
View File
@@ -16,6 +16,7 @@ use tokio::task::JoinHandle;
use crate::responses_metadata::AGENT_NAME_KEY;
use crate::responses_metadata::CodexResponsesMetadata;
use crate::responses_metadata::CodexResponsesRequestKind;
use crate::responses_metadata::MAX_EXTRA_METADATA_VALUE_BYTES;
use crate::responses_metadata::PARENT_TURN_ID_KEY;
use crate::responses_metadata::ROOT_TURN_ID_KEY;
use crate::responses_metadata::TurnMetadataWorkspace;
@@ -136,6 +137,30 @@ impl codex_analytics::TurnAnalyticsMetadata for TurnMetadataState {
fn root_turn_id(&self) -> Option<String> {
TurnMetadataState::root_turn_id(self)
}
fn turn_trigger(&self) -> Option<String> {
self.turn_trigger
.get()
.filter(|trigger| trigger.len() <= MAX_EXTRA_METADATA_VALUE_BYTES)
.cloned()
}
fn codex_turn_source(&self) -> Option<String> {
// Match Responses metadata precedence without copying oversized client values to analytics.
let configured_metadata = self
.responses_api_metadata
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let client_metadata = self
.responsesapi_client_metadata
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
configured_metadata
.get("source")
.or_else(|| client_metadata.get("source"))
.filter(|source| source.len() <= MAX_EXTRA_METADATA_VALUE_BYTES)
.cloned()
}
}
impl TurnMetadataState {
+72
View File
@@ -24,6 +24,7 @@ use codex_analytics::CompactionImplementation;
use codex_analytics::CompactionPhase;
use codex_analytics::CompactionReason;
use codex_analytics::CompactionTrigger;
use codex_analytics::TurnAnalyticsMetadata;
use codex_models_manager::model_info::model_info_from_slug;
use codex_protocol::AgentPath;
use codex_protocol::models::PermissionProfile;
@@ -688,6 +689,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
);
state.set_responses_api_metadata(BTreeMap::from([
("codex_security_surface".to_string(), "sdk".to_string()),
("source".to_string(), " Configured_Source ".to_string()),
(
WINDOW_NUMBER_KEY.to_string(),
"configured-value".to_string(),
@@ -708,6 +710,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
("fiber_run_id".to_string(), "fiber-123".to_string()),
("origin".to_string(), "東京".to_string()),
("workspace_kind".to_string(), "projectless".to_string()),
("source".to_string(), "client-source".to_string()),
("model".to_string(), "client-supplied".to_string()),
(
"reasoning_effort".to_string(),
@@ -910,6 +913,75 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
assert!(meta.get(WINDOW_ID_KEY).is_none());
assert!(meta.get("codex_security_surface").is_none());
assert_eq!(state.workspace_kind().as_deref(), Some("projectless"));
assert_eq!(
(state.turn_trigger(), state.codex_turn_source()),
(
Some("goal".to_string()),
Some(" Configured_Source ".to_string())
)
);
assert_eq!(model_request_json["source"], " Configured_Source ");
for (configured, client, expected) in [
(None, None, None),
(
None,
Some(" New_Source ".to_string()),
Some(" New_Source ".to_string()),
),
(None, Some(String::new()), Some(String::new())),
(None, Some("é".repeat(/*n*/ 64)), Some("é".repeat(/*n*/ 64))),
(None, Some("é".repeat(/*n*/ 65)), None),
(
Some("x".repeat(/*n*/ 129)),
Some("client-source".to_string()),
None,
),
] {
state.set_responses_api_metadata(
configured
.map(|source| ("source".to_string(), source))
.into_iter()
.collect(),
);
state.set_responsesapi_client_metadata(
client
.map(|source| ("source".to_string(), source))
.into_iter()
.collect(),
);
assert_eq!(state.codex_turn_source(), expected);
}
}
#[test]
fn turn_metadata_state_bounds_trigger_only_for_analytics() {
let temp_dir = TempDir::new().expect("temp dir");
for (characters, included) in [(64, true), (65, false)] {
let state = TurnMetadataState::new(
"session-a".to_string(),
"thread-a".to_string(),
/*forked_from_thread_id*/ None,
/*parent_thread_id*/ None,
&SessionSource::Exec,
/*thread_source*/ None,
"turn-a".to_string(),
temp_dir.path().abs(),
&PermissionProfile::read_only(),
WindowsSandboxLevel::Disabled,
/*enforce_managed_network*/ false,
/*auto_review_enabled*/ false,
&model_info_from_slug("gpt-5.4"),
);
let trigger = "é".repeat(characters);
state.set_turn_trigger(trigger.clone());
let metadata: Value =
serde_json::from_str(&test_turn_metadata_header(&state)).expect("json");
assert_eq!(
(state.turn_trigger(), metadata[TURN_TRIGGER_KEY].as_str()),
(included.then(|| trigger.clone()), Some(trigger.as_str()))
);
}
}
#[test]