diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5736abe3e6..4f3f4504d1 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -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 { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 613d1eb0ab..e2b13d60b9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -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, pub(crate) thread_source: Option, pub(crate) initialization_mode: ThreadInitializationMode, pub(crate) subagent_source: Option, @@ -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)), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 27558c5560..5dfa55f690 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2102,6 +2102,11 @@ impl AnalyticsReducer { out: &mut Vec, ) { 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(), diff --git a/codex-rs/app-server/tests/suite/v2/otel.rs b/codex-rs/app-server/tests/suite/v2/otel.rs index 70690a3b04..bbaa3692be 100644 --- a/codex-rs/app-server/tests/suite/v2/otel.rs +++ b/codex-rs/app-server/tests/suite/v2/otel.rs @@ -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(()) } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index a7206faa6d..c43e21de2e 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -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 =