Add worktree classification to thread telemetry (#43621)

## What changed

Add `is_worktree` to thread initialization analytics and the `codex.thread.started` metric using validated Git repository metadata. Classify linked worktrees as `true` and primary checkouts as `false`; report an unavailable classification as `null` in analytics and `unknown` in metrics. Subagent initialization analytics leave the field unset (`null`).

## Testing

Add analytics coverage for primary checkouts, linked worktrees, unknown repositories, and event serialization. Extend the account-switch telemetry test to check that the `is_worktree` tag reaches the next account's collector.

GitOrigin-RevId: bc9bcd3c0212c0a123e4bcfed0c7020eef17dda6
This commit is contained in:
Benjamin Carlsson
2026-09-07 23:38:35 +00:00
committed by copyberry
parent c977cc0c19
commit 4b0f44d304
5 changed files with 90 additions and 8 deletions
@@ -1674,6 +1674,7 @@ fn thread_initialized_event_serializes_expected_shape() {
},
model: "gpt-5".to_string(),
ephemeral: true,
is_worktree: Some(true),
thread_source: Some(ThreadSource::Feature("automation".to_string())),
initialization_mode: ThreadInitializationMode::New,
subagent_source: None,
@@ -1707,6 +1708,7 @@ fn thread_initialized_event_serializes_expected_shape() {
},
"model": "gpt-5",
"ephemeral": true,
"is_worktree": true,
"thread_source": "automation",
"initialization_mode": "new",
"subagent_source": null,
@@ -1718,6 +1720,65 @@ fn thread_initialized_event_serializes_expected_shape() {
);
}
#[tokio::test]
async fn thread_initialized_classifies_validated_linked_worktrees() {
let root = std::env::temp_dir().join(format!(
"codex-analytics-worktree-{}",
codex_protocol::ThreadId::new()
));
let primary = root.join("primary");
let linked = root.join("linked");
let admin = primary.join(".git/worktrees/linked");
std::fs::create_dir_all(&admin).expect("worktree administrative directory");
std::fs::create_dir_all(&linked).expect("linked checkout");
std::fs::write(primary.join(".git/HEAD"), "ref: refs/heads/main\n")
.expect("primary repository HEAD");
std::fs::write(admin.join("commondir"), "../..\n").expect("common directory");
std::fs::write(
admin.join("gitdir"),
format!("{}\n", linked.join(".git").display()),
)
.expect("linked checkout backlink");
std::fs::write(
linked.join(".git"),
format!("gitdir: {}\n", admin.display()),
)
.expect("linked checkout git file");
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
ingest_initialize(&mut reducer, &mut events).await;
for (cwd, expected) in [
(primary.as_path(), json!(false)),
(linked.as_path(), json!(true)),
(root.as_path(), serde_json::Value::Null),
] {
events.clear();
let mut response =
sample_thread_start_response("thread-1", /*ephemeral*/ false, "gpt-5");
let ClientResponsePayload::ThreadStart(start) = &mut response else {
panic!("expected thread/start response");
};
start.thread.cwd = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(cwd)
.expect("absolute checkout path");
reducer
.ingest(
AnalyticsFact::ClientResponse {
connection_id: 7,
request_id: RequestId::Integer(1),
response: Box::new(response),
thread_originator: None,
},
&mut events,
)
.await;
let payload = serde_json::to_value(&events).expect("serialize thread event");
assert_eq!(payload[0]["event_params"]["is_worktree"], expected);
}
std::fs::remove_dir_all(root).expect("remove test checkout");
}
#[test]
fn command_execution_event_serializes_expected_shape() {
let event = TrackEventRequest::CommandExecution(CodexCommandExecutionEventRequest {
+3
View File
@@ -241,6 +241,8 @@ pub(crate) struct ThreadInitializedEventParams {
pub(crate) runtime: CodexRuntimeMetadata,
pub(crate) model: String,
pub(crate) ephemeral: bool,
/// Whether the thread's checkout is a validated linked Git worktree, if known.
pub(crate) is_worktree: Option<bool>,
pub(crate) thread_source: Option<ThreadSource>,
pub(crate) initialization_mode: ThreadInitializationMode,
pub(crate) subagent_source: Option<String>,
@@ -1465,6 +1467,7 @@ pub(crate) fn subagent_thread_started_event_request(
runtime: current_runtime_metadata(),
model: input.model,
ephemeral: input.ephemeral,
is_worktree: None,
thread_source: input.thread_source,
initialization_mode: ThreadInitializationMode::New,
subagent_source: Some(subagent_source_name(&input.subagent_source)),
+6
View File
@@ -2102,6 +2102,11 @@ impl AnalyticsReducer {
out: &mut Vec<TrackEventRequest>,
) {
let session_source: SessionSource = thread.source.into();
let is_worktree =
codex_git_utils::repository_identity(thread.cwd.as_path()).and_then(|_| {
codex_git_utils::get_git_repo_root(thread.cwd.canonicalize().ok()?.as_path())
.map(|root| root.join(".git").is_file())
});
let session_id = thread.session_id;
let thread_id = thread.id;
let parent_thread_id = thread.parent_thread_id;
@@ -2133,6 +2138,7 @@ impl AnalyticsReducer {
runtime: connection_state.runtime.clone(),
model,
ephemeral: thread.ephemeral,
is_worktree,
thread_source: thread_metadata.thread_source,
initialization_mode,
subagent_source: thread_metadata.subagent_source.clone(),
@@ -129,6 +129,10 @@ async fn account_switch_reloads_telemetry_collectors_and_preserves_trace_context
next_metrics.contains("codex.thread.started"),
"the next account's metrics did not reach its collector: {next_metrics}"
);
assert!(
next_metrics.contains("is_worktree"),
"the thread-start worktree tag did not reach its collector: {next_metrics}"
);
Ok(())
}
+16 -8
View File
@@ -1130,17 +1130,25 @@ impl Session {
slug: Some(session_model),
};
config.features.emit_metrics(&session_telemetry);
let is_worktree = session_configuration.cwd().canonicalize().ok().and_then(|cwd| {
codex_git_utils::repository_identity(&cwd).and_then(|_| {
get_git_repo_root(&cwd).map(|root| root.join(".git").is_file())
})
});
let is_worktree_tag = match is_worktree {
Some(true) => "true",
Some(false) => "false",
None => "unknown",
};
let is_git_tag = if get_git_repo_root(session_configuration.cwd()).is_some() {
"true"
} else {
"false"
};
session_telemetry.counter(
THREAD_STARTED_METRIC,
/*inc*/ 1,
&[(
"is_git",
if get_git_repo_root(session_configuration.cwd()).is_some() {
"true"
} else {
"false"
},
)],
&[("is_git", is_git_tag), ("is_worktree", is_worktree_tag)],
);
let mcp_server_names =