[codex-analytics] emit forked thread id on initialization (#26248)

## Why
- Thread initialization analytics do not identify the source thread for
forked threads.
- The session viewer needs this lineage to construct thread trees.
- Depends on openai/openai#987854. Do not release this change before
that backend schema change is deployed.

## What Changed
- Adds optional `forked_from_thread_id` to `codex_thread_initialized`.
- Populates it from the existing thread fork lineage for app-server and
in-process subagent initialization paths.
- Keeps it null for non-forked threads.

## Verification
- `just fmt`
- `just test -p codex-analytics`
- `just test -p codex-app-server
thread_fork_tracks_thread_initialized_analytics`
This commit is contained in:
kbazzi
2026-06-04 11:24:12 -07:00
committed by GitHub
Unverified
parent c8fdc74b42
commit 9e41f8ddbe
10 changed files with 119 additions and 1 deletions
@@ -1347,6 +1347,7 @@ fn thread_initialized_event_serializes_expected_shape() {
initialization_mode: ThreadInitializationMode::New,
subagent_source: None,
parent_thread_id: None,
forked_from_thread_id: None,
created_at: 1,
},
});
@@ -1379,6 +1380,7 @@ fn thread_initialized_event_serializes_expected_shape() {
"initialization_mode": "new",
"subagent_source": null,
"parent_thread_id": null,
"forked_from_thread_id": null,
"created_at": 1
}
})
@@ -2429,6 +2431,7 @@ fn subagent_thread_started_review_serializes_expected_shape() {
session_id: "session-root".to_string(),
thread_id: "thread-review".to_string(),
parent_thread_id: None,
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2461,18 +2464,26 @@ fn subagent_thread_started_review_serializes_expected_shape() {
assert_eq!(payload["event_params"]["initialization_mode"], "new");
assert_eq!(payload["event_params"]["subagent_source"], "review");
assert_eq!(payload["event_params"]["parent_thread_id"], json!(null));
assert_eq!(
payload["event_params"]["forked_from_thread_id"],
json!(null)
);
}
#[test]
fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() {
fn subagent_thread_started_thread_spawn_serializes_thread_lineage() {
let parent_thread_id =
codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111")
.expect("valid thread id");
let forked_from_thread_id =
codex_protocol::ThreadId::from_string("22222222-2222-4222-8222-222222222222")
.expect("valid thread id");
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
SubAgentThreadStartedInput {
session_id: "session-root".to_string(),
thread_id: "thread-spawn".to_string(),
parent_thread_id: Some(parent_thread_id.to_string()),
forked_from_thread_id: Some(forked_from_thread_id.to_string()),
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2497,6 +2508,10 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() {
payload["event_params"]["parent_thread_id"],
"11111111-1111-1111-1111-111111111111"
);
assert_eq!(
payload["event_params"]["forked_from_thread_id"],
"22222222-2222-4222-8222-222222222222"
);
assert_eq!(payload["event_params"]["session_id"], "session-root");
}
@@ -2507,6 +2522,7 @@ fn subagent_thread_started_memory_consolidation_serializes_expected_shape() {
session_id: "session-root".to_string(),
thread_id: "thread-memory".to_string(),
parent_thread_id: None,
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2533,6 +2549,7 @@ fn subagent_thread_started_other_serializes_expected_shape() {
session_id: "session-root".to_string(),
thread_id: "thread-guardian".to_string(),
parent_thread_id: None,
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2558,6 +2575,7 @@ fn subagent_thread_started_other_serializes_explicit_parent_thread_id() {
session_id: "session-root".to_string(),
thread_id: "thread-guardian".to_string(),
parent_thread_id: Some(parent_thread_id.to_string()),
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2588,6 +2606,7 @@ async fn subagent_thread_started_publishes_without_initialize() {
session_id: "session-root".to_string(),
thread_id: "thread-review".to_string(),
parent_thread_id: None,
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2662,6 +2681,7 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
session_id: "session-root".to_string(),
thread_id: "thread-review".to_string(),
parent_thread_id: Some(parent_thread_id.to_string()),
forked_from_thread_id: None,
product_client_id: "parent-client".to_string(),
client_name: "parent-client".to_string(),
client_version: "1.0.0".to_string(),
@@ -2732,6 +2752,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() {
session_id: "session-root".to_string(),
thread_id: "thread-subagent".to_string(),
parent_thread_id: Some("thread-1".to_string()),
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
+2
View File
@@ -157,6 +157,7 @@ pub(crate) struct ThreadInitializedEventParams {
pub(crate) initialization_mode: ThreadInitializationMode,
pub(crate) subagent_source: Option<String>,
pub(crate) parent_thread_id: Option<String>,
pub(crate) forked_from_thread_id: Option<String>,
pub(crate) created_at: u64,
}
@@ -1060,6 +1061,7 @@ pub(crate) fn subagent_thread_started_event_request(
initialization_mode: ThreadInitializationMode::New,
subagent_source: Some(subagent_source_name(&input.subagent_source)),
parent_thread_id: input.parent_thread_id,
forked_from_thread_id: input.forked_from_thread_id,
created_at: input.created_at,
};
ThreadInitializedEvent {
+1
View File
@@ -348,6 +348,7 @@ pub struct SubAgentThreadStartedInput {
pub session_id: String,
pub thread_id: String,
pub parent_thread_id: Option<String>,
pub forked_from_thread_id: Option<String>,
pub product_client_id: String,
pub client_name: String,
pub client_version: String,
+2
View File
@@ -1268,6 +1268,7 @@ impl AnalyticsReducer {
let session_id = thread.session_id;
let thread_id = thread.id;
let parent_thread_id = thread.parent_thread_id;
let forked_from_thread_id = thread.forked_from_id;
let Some(connection_state) = self.connections.get(&connection_id) else {
return;
};
@@ -1299,6 +1300,7 @@ impl AnalyticsReducer {
initialization_mode,
subagent_source: thread_metadata.subagent_source.clone(),
parent_thread_id: thread_metadata.parent_thread_id,
forked_from_thread_id,
created_at: u64::try_from(thread.created_at).unwrap_or_default(),
},
},
@@ -701,6 +701,7 @@ mod thread_processor_behavior_tests {
},
},
session_source: SessionSource::Cli,
forked_from_thread_id: None,
parent_thread_id: None,
thread_source: None,
};
@@ -490,6 +490,13 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> {
"forked",
"user",
);
assert_eq!(
event["event_params"]["forked_from_thread_id"],
thread
.forked_from_id
.as_deref()
.expect("forked thread has a source thread")
);
Ok(())
}
+1
View File
@@ -70,6 +70,7 @@ pub struct ThreadConfigSnapshot {
pub personality: Option<Personality>,
pub collaboration_mode: CollaborationMode,
pub session_source: SessionSource,
pub forked_from_thread_id: Option<ThreadId>,
pub parent_thread_id: Option<ThreadId>,
pub thread_source: Option<ThreadSource>,
}
+3
View File
@@ -3364,6 +3364,9 @@ pub(crate) fn emit_subagent_session_started(
session_id: session_id.to_string(),
thread_id: thread_id.to_string(),
parent_thread_id: parent_thread_id.map(|thread_id| thread_id.to_string()),
forked_from_thread_id: thread_config
.forked_from_thread_id
.map(|thread_id| thread_id.to_string()),
product_client_id: client_name.clone(),
client_name,
client_version,
+1
View File
@@ -185,6 +185,7 @@ impl SessionConfiguration {
personality: self.personality,
collaboration_mode: self.collaboration_mode.clone(),
session_source: self.session_source.clone(),
forked_from_thread_id: self.forked_from_thread_id,
parent_thread_id: self.parent_thread_id,
thread_source: self.thread_source,
}
+79
View File
@@ -3831,6 +3831,85 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
}
}
#[tokio::test]
async fn emit_subagent_session_started_includes_fork_lineage_from_session_configuration() {
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/analytics-events/events"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let analytics_events_client = AnalyticsEventsClient::new(
auth_manager,
server.uri(),
/*analytics_enabled*/ Some(true),
);
let parent_thread_id = ThreadId::new();
let forked_from_thread_id = ThreadId::new();
let child_thread_id = ThreadId::new();
let mut session_configuration = make_session_configuration_for_tests().await;
session_configuration.forked_from_thread_id = Some(forked_from_thread_id);
emit_subagent_session_started(
&analytics_events_client,
AppServerClientMetadata {
client_name: Some("codex-tui".to_string()),
client_version: Some("1.0.0".to_string()),
},
SessionId::from(child_thread_id),
child_thread_id,
Some(parent_thread_id),
session_configuration.thread_config_snapshot(),
SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
},
);
let event = timeout(Duration::from_secs(1), async {
'wait_for_event: loop {
if let Some(requests) = server.received_requests().await {
for request in requests {
let payload: serde_json::Value =
serde_json::from_slice(&request.body).expect("valid analytics payload");
if let Some(event) = payload["events"].as_array().and_then(|events| {
events
.iter()
.find(|event| event["event_type"] == "codex_thread_initialized")
}) {
break 'wait_for_event event.clone();
}
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("subagent initialization analytics should be emitted");
assert_eq!(
event["event_params"]["parent_thread_id"],
parent_thread_id.to_string()
);
assert_eq!(
event["event_params"]["forked_from_thread_id"],
forked_from_thread_id.to_string()
);
}
fn turn_environments_for_tests(
environment: &Arc<codex_exec_server::Environment>,
cwd: &codex_utils_absolute_path::AbsolutePathBuf,