From cc694d490735722004a63bf4ac7f359e29a2a8c4 Mon Sep 17 00:00:00 2001 From: Ningyi Xie Date: Tue, 14 Apr 2026 22:48:38 -0700 Subject: [PATCH] Add git workspace metadata to turn analytics Emit the existing per-turn git workspace metadata as a custom analytics fact and attach it to codex_turn_event params when the reducer has enough turn lifecycle state. Wait for the in-flight turn metadata enrichment before normal turn completion so short turns can still report workspace data. This only changes the Codex client-side event payload; backend schema/table ingestion needs the matching field before downstream consumers can query it. Co-authored-by: Codex --- .../analytics/src/analytics_client_tests.rs | 76 +++++++++++++++++++ codex-rs/analytics/src/client.rs | 7 ++ codex-rs/analytics/src/events.rs | 3 + codex-rs/analytics/src/facts.rs | 20 +++++ codex-rs/analytics/src/lib.rs | 2 + codex-rs/analytics/src/reducer.rs | 38 ++++++++++ codex-rs/core/src/tasks/mod.rs | 49 +++++++++++- 7 files changed, 191 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index ed17314630..328dda4222 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,8 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnGitMetadataFact; +use crate::facts::TurnGitWorkspaceMetadata; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRequestError; @@ -105,6 +107,7 @@ use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::BTreeMap; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; @@ -1771,6 +1774,7 @@ fn turn_event_serializes_expected_shape() { subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + git_workspaces: None, input_tokens: None, cached_input_tokens: None, output_tokens: None, @@ -1832,6 +1836,7 @@ fn turn_event_serializes_expected_shape() { "subagent_tool_call_count": null, "web_search_count": null, "image_generation_count": null, + "git_workspaces": null, "input_tokens": null, "cached_input_tokens": null, "output_tokens": null, @@ -2142,6 +2147,77 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn turn_lifecycle_includes_git_metadata_when_recorded() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let mut associated_remote_urls = BTreeMap::new(); + associated_remote_urls.insert( + "origin".to_string(), + "git@github.com:openai/codex.git".to_string(), + ); + let mut git_workspaces = BTreeMap::new(); + git_workspaces.insert( + "/workspace/codex".to_string(), + TurnGitWorkspaceMetadata { + associated_remote_urls: Some(associated_remote_urls), + latest_git_commit_hash: Some("abc123".to_string()), + has_changes: Some(true), + }, + ); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnGitMetadata(Box::new( + TurnGitMetadataFact { + turn_id: "turn-2".to_string(), + thread_id: "thread-2".to_string(), + git_workspaces, + }, + ))), + &mut out, + ) + .await; + assert!(out.is_empty()); + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); + assert_eq!( + payload["event_params"]["git_workspaces"], + json!({ + "/workspace/codex": { + "associated_remote_urls": { + "origin": "git@github.com:openai/codex.git" + }, + "latest_git_commit_hash": "abc123", + "has_changes": true + } + }) + ); +} + #[tokio::test] async fn accepted_steers_increment_turn_steer_count() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e145a00d1d..bd529f9f27 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -18,6 +18,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnGitMetadataFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -231,6 +232,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_git_metadata(&self, fact: TurnGitMetadataFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnGitMetadata( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 98d0e6ff6b..8de29e39b3 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -14,6 +14,7 @@ use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnGitWorkspaceMetadata; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; use crate::facts::TurnSteerResult; @@ -35,6 +36,7 @@ use codex_protocol::protocol::HookSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use serde::Serialize; +use std::collections::BTreeMap; #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] @@ -492,6 +494,7 @@ pub(crate) struct CodexTurnEventParams { pub(crate) subagent_tool_call_count: Option, pub(crate) web_search_count: Option, pub(crate) image_generation_count: Option, + pub(crate) git_workspaces: Option>, pub(crate) input_tokens: Option, pub(crate) cached_input_tokens: Option, pub(crate) output_tokens: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d371acb1c..cb91de0162 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -23,7 +23,9 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; +use serde::Deserialize; use serde::Serialize; +use std::collections::BTreeMap; use std::path::PathBuf; #[derive(Clone)] @@ -89,6 +91,23 @@ pub struct TurnTokenUsageFact { pub token_usage: TokenUsage, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct TurnGitWorkspaceMetadata { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub associated_remote_urls: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest_git_commit_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub has_changes: Option, +} + +#[derive(Clone)] +pub struct TurnGitMetadataFact { + pub turn_id: String, + pub thread_id: String, + pub git_workspaces: BTreeMap, +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -298,6 +317,7 @@ pub(crate) enum CustomAnalyticsFact { GuardianReview(Box), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnGitMetadata(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index ed0f1036ca..0ee672be67 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -34,6 +34,8 @@ pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnGitMetadataFact; +pub use facts::TurnGitWorkspaceMetadata; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSteerRejectionReason; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a6ce3fc831..ee84de93ec 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -41,6 +41,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TurnGitMetadataFact; +use crate::facts::TurnGitWorkspaceMetadata; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -66,6 +68,7 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::TokenUsage; use sha1::Digest; +use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; @@ -149,6 +152,7 @@ struct TurnState { resolved_config: Option, started_at: Option, token_usage: Option, + git_workspaces: Option>, completed: Option, steer_count: usize, } @@ -211,6 +215,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out); } + CustomAnalyticsFact::TurnGitMetadata(input) => { + self.ingest_turn_git_metadata(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -350,6 +357,7 @@ impl AnalyticsReducer { resolved_config: None, started_at: None, token_usage: None, + git_workspaces: None, completed: None, steer_count: 0, }); @@ -372,6 +380,7 @@ impl AnalyticsReducer { resolved_config: None, started_at: None, token_usage: None, + git_workspaces: None, completed: None, steer_count: 0, }); @@ -380,6 +389,31 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out); } + fn ingest_turn_git_metadata( + &mut self, + input: TurnGitMetadataFact, + out: &mut Vec, + ) { + if input.git_workspaces.is_empty() { + return; + } + let turn_id = input.turn_id.clone(); + let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + token_usage: None, + git_workspaces: None, + completed: None, + steer_count: 0, + }); + turn_state.thread_id = Some(input.thread_id); + turn_state.git_workspaces = Some(input.git_workspaces); + self.maybe_emit_turn_event(&turn_id, out); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -533,6 +567,7 @@ impl AnalyticsReducer { resolved_config: None, started_at: None, token_usage: None, + git_workspaces: None, completed: None, steer_count: 0, }); @@ -615,6 +650,7 @@ impl AnalyticsReducer { resolved_config: None, started_at: None, token_usage: None, + git_workspaces: None, completed: None, steer_count: 0, }); @@ -634,6 +670,7 @@ impl AnalyticsReducer { resolved_config: None, started_at: None, token_usage: None, + git_workspaces: None, completed: None, steer_count: 0, }); @@ -933,6 +970,7 @@ fn codex_turn_event_params( subagent_tool_call_count: None, web_search_count: None, image_generation_count: None, + git_workspaces: turn_state.git_workspaces.clone(), input_tokens: token_usage .as_ref() .map(|token_usage| token_usage.input_tokens), diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index fd9aaf2e33..31b539e246 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -5,6 +5,7 @@ mod review; mod undo; mod user_shell; +use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; use std::time::Instant; @@ -31,6 +32,8 @@ use crate::session::turn_context::TurnContext; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnGitMetadataFact; +use codex_analytics::TurnGitWorkspaceMetadata; use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::SharedModelsManager; @@ -151,6 +154,44 @@ fn bool_tag(value: bool) -> &'static str { if value { "true" } else { "false" } } +fn git_workspaces_from_turn_metadata( + metadata: serde_json::Value, +) -> Option> { + let workspaces = metadata.get("workspaces")?.as_object()?; + let git_workspaces = workspaces + .iter() + .filter_map(|(workspace_path, workspace_metadata)| { + serde_json::from_value::(workspace_metadata.clone()) + .ok() + .map(|metadata| (workspace_path.clone(), metadata)) + }) + .collect::>(); + if git_workspaces.is_empty() { + None + } else { + Some(git_workspaces) + } +} + +fn track_turn_git_metadata_analytics(sess: &Session, turn_context: &TurnContext) { + if !sess.enabled(Feature::GeneralAnalytics) { + return; + } + let Some(metadata) = turn_context.turn_metadata_state.current_meta_value() else { + return; + }; + let Some(git_workspaces) = git_workspaces_from_turn_metadata(metadata) else { + return; + }; + sess.services + .analytics_events_client + .track_turn_git_metadata(TurnGitMetadataFact { + turn_id: turn_context.sub_id.clone(), + thread_id: sess.conversation_id.to_string(), + git_workspaces, + }); +} + /// Thin wrapper that exposes the parts of [`Session`] task runners need. #[derive(Clone)] pub(crate) struct SessionTaskContext { @@ -539,10 +580,6 @@ impl Session { turn_context: Arc, last_agent_message: Option, ) { - turn_context - .turn_metadata_state - .cancel_git_enrichment_task(); - let mut pending_input = Vec::::new(); let mut should_clear_active_turn = false; let mut token_usage_at_turn_start = None; @@ -691,6 +728,10 @@ impl Session { { warn!("failed to apply goal runtime turn-finished event: {err}"); } + track_turn_git_metadata_analytics(self, turn_context.as_ref()); + turn_context + .turn_metadata_state + .cancel_git_enrichment_task(); let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message,