mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
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:
@@ -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!({
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user