From 8c3b21f23d762d9b3335fa729ffa9a24369d4ad8 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 30 May 2026 01:25:28 +0000 Subject: [PATCH 1/6] Track app server startup analytics by transport --- codex-rs/analytics/src/client.rs | 16 +++ codex-rs/analytics/src/events.rs | 19 +++ codex-rs/analytics/src/facts.rs | 11 ++ codex-rs/analytics/src/reducer.rs | 24 ++++ codex-rs/app-server/src/in_process.rs | 4 + codex-rs/app-server/src/lib.rs | 6 +- .../app-server/tests/suite/v2/analytics.rs | 120 ++++++++++++++++-- .../tests/suite/v2/plugin_install.rs | 13 +- .../tests/suite/v2/plugin_uninstall.rs | 13 +- .../tests/suite/v2/remote_control.rs | 8 ++ .../app-server/tests/suite/v2/thread_fork.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 4 +- 13 files changed, 225 insertions(+), 21 deletions(-) diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index fbcfa32dc5e..133e3542a2f 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -8,6 +8,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; +use crate::facts::AppServerStartedInput; use crate::facts::AppUsedInput; use crate::facts::CustomAnalyticsFact; use crate::facts::HookRunFact; @@ -169,6 +170,21 @@ impl AnalyticsEventsClient { )); } + pub fn track_app_server_started( + &self, + rpc_transport: AppServerRpcTransport, + duration: Duration, + ) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::AppServerStarted(AppServerStartedInput { + runtime: current_runtime_metadata(), + rpc_transport, + duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX), + created_at: crate::now_unix_seconds(), + }), + )); + } + pub fn track_guardian_review( &self, tracking: &GuardianReviewTrackContext, diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index fd52fefc1e3..16d096b9396 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -56,6 +56,7 @@ pub(crate) struct TrackEventsRequest { #[serde(untagged)] pub(crate) enum TrackEventRequest { SkillInvocation(SkillInvocationEventRequest), + AppServerStarted(CodexAppServerStartedEventRequest), ThreadInitialized(ThreadInitializedEvent), GuardianReview(Box), AppMentioned(CodexAppMentionedEventRequest), @@ -144,6 +145,24 @@ pub(crate) struct CodexRuntimeMetadata { pub(crate) runtime_arch: String, } +/// Analytics parameters emitted when an app-server runtime starts. +#[derive(Serialize)] +pub(crate) struct CodexAppServerStartedEventParams { + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) rpc_transport: AppServerRpcTransport, + /// Elapsed measured startup duration, in milliseconds from a monotonic clock. + pub(crate) duration_ms: u64, + /// Time at which the event was recorded, in seconds since the Unix epoch. + pub(crate) created_at: u64, +} + +/// Analytics track-event envelope for an app-server startup event. +#[derive(Serialize)] +pub(crate) struct CodexAppServerStartedEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexAppServerStartedEventParams, +} + #[derive(Serialize)] pub(crate) struct ThreadInitializedEventParams { pub(crate) thread_id: String, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 9a852767124..eef74a9c938 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -324,6 +324,7 @@ pub(crate) enum AnalyticsFact { } pub(crate) enum CustomAnalyticsFact { + AppServerStarted(AppServerStartedInput), SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), GuardianReview(Box), @@ -337,6 +338,16 @@ pub(crate) enum CustomAnalyticsFact { PluginStateChanged(PluginStateChangedInput), } +/// Analytics input captured when an app-server runtime starts. +pub(crate) struct AppServerStartedInput { + pub runtime: CodexRuntimeMetadata, + pub rpc_transport: AppServerRpcTransport, + /// Elapsed measured startup duration, in milliseconds from a monotonic clock. + pub duration_ms: u64, + /// Time at which the event was recorded, in seconds since the Unix epoch. + pub created_at: u64, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f2063934761..fde6c19644d 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,6 +5,8 @@ use crate::accepted_lines::accepted_line_repo_hash_for_cwd; use crate::events::AppServerRpcTransport; use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; +use crate::events::CodexAppServerStartedEventParams; +use crate::events::CodexAppServerStartedEventRequest; use crate::events::CodexAppUsedEventRequest; use crate::events::CodexCollabAgentToolCallEventParams; use crate::events::CodexCollabAgentToolCallEventRequest; @@ -60,6 +62,7 @@ use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; +use crate::facts::AppServerStartedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; use crate::facts::CustomAnalyticsFact; @@ -446,6 +449,9 @@ impl AnalyticsReducer { self.ingest_server_request_aborted(completed_at_ms, request_id, out); } AnalyticsFact::Custom(input) => match input { + CustomAnalyticsFact::AppServerStarted(input) => { + self.ingest_app_server_started(input, out); + } CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); } @@ -483,6 +489,24 @@ impl AnalyticsReducer { } } + fn ingest_app_server_started( + &mut self, + input: AppServerStartedInput, + out: &mut Vec, + ) { + out.push(TrackEventRequest::AppServerStarted( + CodexAppServerStartedEventRequest { + event_type: "codex_app_server_started", + event_params: CodexAppServerStartedEventParams { + runtime: input.runtime, + rpc_transport: input.rpc_transport, + duration_ms: input.duration_ms, + created_at: input.created_at, + }, + }, + )); + } + fn ingest_initialize( &mut self, connection_id: u64, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index f7072d0fa17..91372e2bbbe 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -49,6 +49,7 @@ use std::sync::RwLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; +use std::time::Instant; use crate::analytics_utils::analytics_events_client_from_config; use crate::config_manager::ConfigManager; @@ -370,6 +371,7 @@ pub async fn start(args: InProcessStartArgs) -> IoResult } async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { + let startup_started = Instant::now(); let channel_capacity = args.channel_capacity.max(1); let installation_id = resolve_installation_id(&args.config.codex_home).await?; let (client_tx, mut client_rx) = mpsc::channel::(channel_capacity); @@ -420,6 +422,8 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult(channel_capacity); + analytics_events_client + .track_app_server_started(AppServerRpcTransport::InProcess, startup_started.elapsed()); let mut processor_handle = tokio::spawn(async move { let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { outgoing: Arc::clone(&processor_outgoing), diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 80305d2d9fc..8b95468c670 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -17,6 +17,7 @@ use std::io::Result as IoResult; use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; +use std::time::Instant; use crate::analytics_utils::analytics_events_client_from_config; use crate::config_manager::ConfigManager; @@ -428,6 +429,7 @@ pub async fn run_main_with_transport_options( auth: AppServerWebsocketAuthSettings, runtime_options: AppServerRuntimeOptions, ) -> IoResult<()> { + let startup_started = Instant::now(); let (transport_event_tx, mut transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(CHANNEL_CAPACITY); @@ -789,12 +791,14 @@ pub async fn run_main_with_transport_options( let auth_manager = Arc::clone(&auth_manager); let analytics_events_client = analytics_events_client_from_config(Arc::clone(&auth_manager), &config); + let rpc_transport = analytics_rpc_transport(&transport); let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( outgoing_tx, analytics_events_client.clone(), )); let initialize_notification_sender = outgoing_message_sender.clone(); let outbound_control_tx = outbound_control_tx; + analytics_events_client.track_app_server_started(rpc_transport, startup_started.elapsed()); let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { outgoing: outgoing_message_sender, analytics_events_client, @@ -809,7 +813,7 @@ pub async fn run_main_with_transport_options( session_source, auth_manager, installation_id, - rpc_transport: analytics_rpc_transport(&transport), + rpc_transport, remote_control_handle: Some(remote_control_handle.clone()), plugin_startup_tasks: runtime_options.plugin_startup_tasks, })); diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8e8f2a7557d..8aa15834b1f 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -1,15 +1,28 @@ use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::DEFAULT_CLIENT_NAME; +use app_test_support::McpProcess; use app_test_support::write_chatgpt_auth; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; +use codex_app_server::in_process; +use codex_app_server::in_process::InProcessStartArgs; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeParams; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; use codex_config::types::AuthCredentialsStoreMode; use codex_config::types::OtelExporterKind; use codex_config::types::OtelHttpProtocol; use codex_core::config::ConfigBuilder; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; +use codex_protocol::protocol::SessionSource; use pretty_assertions::assert_eq; use serde_json::Value; use std::collections::HashMap; use std::path::Path; +use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; @@ -79,6 +92,91 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { Ok(()) } +#[tokio::test] +async fn standalone_app_server_startup_tracks_analytics_event() -> Result<()> { + let server = MockServer::start().await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let _mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + let event = + wait_for_analytics_event(&server, Duration::from_secs(10), "codex_app_server_started") + .await?; + + assert_app_server_started_event(&event, "stdio"); + Ok(()) +} + +#[tokio::test] +async fn embedded_app_server_startup_tracks_analytics_event() -> Result<()> { + let server = MockServer::start().await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(loader_overrides.clone()) + .build() + .await?; + let client = in_process::start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides, + strict_config: false, + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: None, + }, + channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + + let event = + wait_for_analytics_event(&server, Duration::from_secs(10), "codex_app_server_started") + .await?; + + assert_app_server_started_event(&event, "in_process"); + client.shutdown().await?; + Ok(()) +} + +fn assert_app_server_started_event(event: &Value, rpc_transport: &str) { + assert_eq!(event["event_params"]["rpc_transport"], rpc_transport); + assert!(event["event_params"]["duration_ms"].as_u64().is_some()); + assert!(event["event_params"]["created_at"].as_u64().is_some()); + assert!( + event["event_params"]["runtime"]["codex_rs_version"] + .as_str() + .is_some() + ); +} + pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) @@ -98,26 +196,32 @@ pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Pa Ok(()) } -pub(crate) async fn wait_for_analytics_payload( +pub(crate) async fn wait_for_thread_initialized_payload( server: &MockServer, read_timeout: Duration, ) -> Result { - let body = timeout(read_timeout, async { + timeout(read_timeout, async { loop { let Some(requests) = server.received_requests().await else { tokio::time::sleep(Duration::from_millis(25)).await; continue; }; - if let Some(request) = requests.iter().find(|request| { - request.method == "POST" && request.url.path() == "/codex/analytics-events/events" - }) { - break request.body.clone(); + for request in &requests { + if request.method != "POST" + || request.url.path() != "/codex/analytics-events/events" + { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + if thread_initialized_event(&payload).is_ok() { + return Ok::(payload); + } } tokio::time::sleep(Duration::from_millis(25)).await; } }) - .await?; - serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) + .await? } pub(crate) async fn wait_for_analytics_event( diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 61582438af7..2ccf98fa7f0 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -1352,15 +1352,22 @@ async fn wait_for_plugin_analytics_payload(server: &MockServer) -> Result(payload); + } } tokio::time::sleep(Duration::from_millis(25)).await; } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index 4feede934da..31894d940b9 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -122,16 +122,23 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { tokio::time::sleep(Duration::from_millis(25)).await; continue; }; - if let Some(request) = requests.iter().find(|request| { + for request in requests.iter().filter(|request| { request.method == "POST" && request.url.path() == "/codex/analytics-events/events" }) { - break request.body.clone(); + let payload: serde_json::Value = + serde_json::from_slice(&request.body).expect("analytics payload"); + if payload["events"].as_array().is_some_and(|events| { + events + .iter() + .any(|event| event["event_type"] == "codex_plugin_uninstalled") + }) { + return payload; + } } tokio::time::sleep(Duration::from_millis(25)).await; } }) .await?; - let payload: serde_json::Value = serde_json::from_slice(&payload).expect("analytics payload"); assert_eq!( payload, json!({ diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index d71f05bf584..45b324f36fa 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -139,6 +139,14 @@ impl BlockingRemoteControlBackend { &remote_control_url, &remote_control_url, )?; + // This fixture implements remote-control enrollment only; prevent the + // startup event from becoming the first request to its blocking server. + let config_path = codex_home.join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!("{config}\n[analytics]\nenabled = false\n"), + )?; write_chatgpt_auth( codex_home, ChatGptAuthFixture::new("chatgpt-token") diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 5c4c637f4b6..1ad3b3e9eb2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -44,7 +44,7 @@ use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; -use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_thread_initialized_payload; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -403,7 +403,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { .await??; let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; - let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; + let payload = wait_for_thread_initialized_payload(&server, DEFAULT_READ_TIMEOUT).await?; let event = thread_initialized_event(&payload)?; assert_basic_thread_initialized_event( event, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index e0263d77f3e..747942c4067 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -98,7 +98,7 @@ use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; -use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_thread_initialized_payload; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); @@ -424,7 +424,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { ); assert_eq!(thread.thread_source, Some(ThreadSource::User)); - let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; + let payload = wait_for_thread_initialized_payload(&server, DEFAULT_READ_TIMEOUT).await?; let event = thread_initialized_event(&payload)?; assert_basic_thread_initialized_event( event, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 6be6d838ba0..131a1ada77f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -47,7 +47,7 @@ use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; -use super::analytics::wait_for_analytics_payload; +use super::analytics::wait_for_thread_initialized_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; @@ -436,7 +436,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(resp)?; - let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; + let payload = wait_for_thread_initialized_payload(&server, DEFAULT_READ_TIMEOUT).await?; assert_eq!(payload["events"].as_array().expect("events array").len(), 1); let event = thread_initialized_event(&payload)?; assert_basic_thread_initialized_event( From 7784179e985e771166b84145900c0a24ec63ce29 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 30 May 2026 01:45:12 +0000 Subject: [PATCH 2/6] Infer app-server startup analytics event type --- codex-rs/analytics/src/events.rs | 13 ++++++++----- codex-rs/analytics/src/reducer.rs | 5 ++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 16d096b9396..8624505a900 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -56,7 +56,7 @@ pub(crate) struct TrackEventsRequest { #[serde(untagged)] pub(crate) enum TrackEventRequest { SkillInvocation(SkillInvocationEventRequest), - AppServerStarted(CodexAppServerStartedEventRequest), + AppServerStarted(CodexAppServerEventRequest), ThreadInitialized(ThreadInitializedEvent), GuardianReview(Box), AppMentioned(CodexAppMentionedEventRequest), @@ -156,11 +156,14 @@ pub(crate) struct CodexAppServerStartedEventParams { pub(crate) created_at: u64, } -/// Analytics track-event envelope for an app-server startup event. +/// Analytics events emitted for app-server lifecycle changes. #[derive(Serialize)] -pub(crate) struct CodexAppServerStartedEventRequest { - pub(crate) event_type: &'static str, - pub(crate) event_params: CodexAppServerStartedEventParams, +#[serde(tag = "event_type")] +pub(crate) enum CodexAppServerEventRequest { + #[serde(rename = "codex_app_server_started")] + Started { + event_params: CodexAppServerStartedEventParams, + }, } #[derive(Serialize)] diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index fde6c19644d..f837b6fe194 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -5,8 +5,8 @@ use crate::accepted_lines::accepted_line_repo_hash_for_cwd; use crate::events::AppServerRpcTransport; use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; +use crate::events::CodexAppServerEventRequest; use crate::events::CodexAppServerStartedEventParams; -use crate::events::CodexAppServerStartedEventRequest; use crate::events::CodexAppUsedEventRequest; use crate::events::CodexCollabAgentToolCallEventParams; use crate::events::CodexCollabAgentToolCallEventRequest; @@ -495,8 +495,7 @@ impl AnalyticsReducer { out: &mut Vec, ) { out.push(TrackEventRequest::AppServerStarted( - CodexAppServerStartedEventRequest { - event_type: "codex_app_server_started", + CodexAppServerEventRequest::Started { event_params: CodexAppServerStartedEventParams { runtime: input.runtime, rpc_transport: input.rpc_transport, From 9441ce2b71ef9eed7eff6b25dd9cb3e03ec7b8b3 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 30 May 2026 02:10:09 +0000 Subject: [PATCH 3/6] codex: fix CI failure on PR #25193 --- codex-rs/app-server/tests/suite/v2/plugin_share.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index 69f93f0b7e4..83a1472a76e 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -336,12 +336,17 @@ async fn plugin_share_save_rejects_when_plugin_sharing_disabled() -> Result<()> let plugin_root = TempDir::new()?; let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; let server = MockServer::start().await; + // This test verifies sharing makes no backend request; startup analytics + // would otherwise be an unrelated request to the same mock server. std::fs::write( codex_home.path().join("config.toml"), format!( r#" chatgpt_base_url = "{}/backend-api" +[analytics] +enabled = false + [features] plugins = true remote_plugin = true From babf24a55914e0a2fc30184c6e6a9c4dbe2f0ac5 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Mon, 1 Jun 2026 19:17:39 +0000 Subject: [PATCH 4/6] codex: address app-server analytics review feedback (#25193) --- codex-rs/analytics/src/events.rs | 1 + codex-rs/app-server/src/analytics_utils.rs | 7 +- codex-rs/app-server/src/in_process.rs | 21 +++- codex-rs/app-server/src/lib.rs | 30 ++++- .../src/message_processor_tracing_tests.rs | 7 +- .../app-server/tests/suite/v2/analytics.rs | 114 ++++++++++++++++-- .../tests/suite/v2/plugin_install.rs | 12 +- .../tests/suite/v2/plugin_uninstall.rs | 2 +- 8 files changed, 167 insertions(+), 27 deletions(-) diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 8624505a900..642c4bd772b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -43,6 +43,7 @@ use serde::Serialize; #[serde(rename_all = "snake_case")] pub enum AppServerRpcTransport { Stdio, + UnixSocket, Websocket, InProcess, } diff --git a/codex-rs/app-server/src/analytics_utils.rs b/codex-rs/app-server/src/analytics_utils.rs index 24ed12d2ad3..2da606d3d56 100644 --- a/codex-rs/app-server/src/analytics_utils.rs +++ b/codex-rs/app-server/src/analytics_utils.rs @@ -7,10 +7,15 @@ use codex_login::AuthManager; pub(crate) fn analytics_events_client_from_config( auth_manager: Arc, config: &Config, + default_analytics_enabled: bool, ) -> AnalyticsEventsClient { AnalyticsEventsClient::new( auth_manager, config.chatgpt_base_url.trim_end_matches('/').to_string(), - config.analytics_enabled, + Some( + config + .analytics_enabled + .unwrap_or(default_analytics_enabled), + ), ) } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 91372e2bbbe..26bbac25ca6 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -382,8 +382,11 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult IoResult(channel_capacity); - analytics_events_client - .track_app_server_started(AppServerRpcTransport::InProcess, startup_started.elapsed()); let mut processor_handle = tokio::spawn(async move { + let app_server_started_analytics_events_client = analytics_events_client.clone(); let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { outgoing: Arc::clone(&processor_outgoing), analytics_events_client, @@ -446,6 +448,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult IoResult { + let initialized = + matches!(notification, ClientNotification::Initialized); processor.process_client_notification(notification).await; + if initialized && !app_server_started_tracked { + app_server_started_analytics_events_client + .track_app_server_started( + AppServerRpcTransport::InProcess, + startup_started.elapsed(), + ); + app_server_started_tracked = true; + } } None => { break; diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 8b95468c670..de274d0df75 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -789,8 +789,11 @@ pub async fn run_main_with_transport_options( let processor_handle = tokio::spawn({ let auth_manager = Arc::clone(&auth_manager); - let analytics_events_client = - analytics_events_client_from_config(Arc::clone(&auth_manager), &config); + let analytics_events_client = analytics_events_client_from_config( + Arc::clone(&auth_manager), + &config, + default_analytics_enabled, + ); let rpc_transport = analytics_rpc_transport(&transport); let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( outgoing_tx, @@ -1090,15 +1093,19 @@ pub async fn run_main_with_transport_options( fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransport { match transport { AppServerTransport::Stdio => AppServerRpcTransport::Stdio, - AppServerTransport::UnixSocket { .. } - | AppServerTransport::WebSocket { .. } - | AppServerTransport::Off => AppServerRpcTransport::Websocket, + AppServerTransport::UnixSocket { .. } => AppServerRpcTransport::UnixSocket, + AppServerTransport::WebSocket { .. } | AppServerTransport::Off => { + AppServerRpcTransport::Websocket + } } } #[cfg(test)] mod tests { use super::LogFormat; + use super::analytics_rpc_transport; + use crate::transport::AppServerTransport; + use codex_analytics::AppServerRpcTransport; use pretty_assertions::assert_eq; #[test] @@ -1118,4 +1125,17 @@ mod tests { assert_eq!(LogFormat::from_env_value(Some("text")), LogFormat::Default); assert_eq!(LogFormat::from_env_value(Some("jsonl")), LogFormat::Default); } + + #[cfg(unix)] + #[test] + fn analytics_rpc_transport_preserves_unix_socket() { + let transport = "unix:///tmp/codex-app-server.sock" + .parse::() + .expect("unix socket transport should parse"); + + assert!(matches!( + analytics_rpc_transport(&transport), + AppServerRpcTransport::UnixSocket + )); + } } diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 94b5a1af27d..1a3ce2cb6e1 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -244,8 +244,11 @@ async fn build_test_processor( Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), ); - let analytics_events_client = - analytics_events_client_from_config(Arc::clone(&auth_manager), config.as_ref()); + let analytics_events_client = analytics_events_client_from_config( + Arc::clone(&auth_manager), + config.as_ref(), + /*default_analytics_enabled*/ true, + ); let outgoing = Arc::new(OutgoingMessageSender::new( outgoing_tx, analytics_events_client.clone(), diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 8aa15834b1f..18c116e438c 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -112,6 +112,24 @@ async fn standalone_app_server_startup_tracks_analytics_event() -> Result<()> { Ok(()) } +#[tokio::test] +async fn standalone_app_server_startup_respects_default_disabled_analytics() -> Result<()> { + let server = MockServer::start().await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + mount_analytics_endpoint(&server).await; + write_analytics_auth(codex_home.path())?; + + let _mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + + assert_analytics_event_not_received(&server, Duration::from_secs(1), "codex_app_server_started") + .await +} + #[tokio::test] async fn embedded_app_server_startup_tracks_analytics_event() -> Result<()> { let server = MockServer::start().await; @@ -123,14 +141,53 @@ async fn embedded_app_server_startup_tracks_analytics_event() -> Result<()> { )?; mount_analytics_capture(&server, codex_home.path()).await?; + let client = + in_process::start(in_process_start_args(codex_home.path(), "codex-tui").await?).await?; + + let event = + wait_for_analytics_event(&server, Duration::from_secs(10), "codex_app_server_started") + .await?; + + assert_app_server_started_event(&event, "in_process"); + client.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn embedded_app_server_failed_initialize_does_not_track_startup_event() -> Result<()> { + let server = MockServer::start().await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let result = + in_process::start(in_process_start_args(codex_home.path(), "bad\rname").await?).await; + let Err(error) = result else { + anyhow::bail!("in-process start should reject an invalid client name"); + }; + assert!( + error + .to_string() + .contains("in-process initialize failed: Invalid clientInfo.name") + ); + + assert_analytics_event_not_received(&server, Duration::from_secs(1), "codex_app_server_started") + .await +} + +async fn in_process_start_args(codex_home: &Path, client_name: &str) -> Result { let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) + .codex_home(codex_home.to_path_buf()) + .fallback_cwd(Some(codex_home.to_path_buf())) .loader_overrides(loader_overrides.clone()) .build() .await?; - let client = in_process::start(InProcessStartArgs { + Ok(InProcessStartArgs { arg0_paths: Arg0DispatchPaths::default(), config: Arc::new(config), cli_overrides: Vec::new(), @@ -147,7 +204,7 @@ async fn embedded_app_server_startup_tracks_analytics_event() -> Result<()> { enable_codex_api_key_env: false, initialize: InitializeParams { client_info: ClientInfo { - name: "codex-tui".to_string(), + name: client_name.to_string(), title: None, version: "0.1.0".to_string(), }, @@ -155,15 +212,6 @@ async fn embedded_app_server_startup_tracks_analytics_event() -> Result<()> { }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) - .await?; - - let event = - wait_for_analytics_event(&server, Duration::from_secs(10), "codex_app_server_started") - .await?; - - assert_app_server_started_event(&event, "in_process"); - client.shutdown().await?; - Ok(()) } fn assert_app_server_started_event(event: &Value, rpc_transport: &str) { @@ -178,12 +226,27 @@ fn assert_app_server_started_event(event: &Value, rpc_transport: &str) { } pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + mount_analytics_endpoint(server).await; + + let config_path = codex_home.join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!("{config}\n[analytics]\nenabled = true\n"), + )?; + + write_analytics_auth(codex_home) +} + +async fn mount_analytics_endpoint(server: &MockServer) { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) .respond_with(ResponseTemplate::new(200)) .mount(server) .await; +} +fn write_analytics_auth(codex_home: &Path) -> Result<()> { write_chatgpt_auth( codex_home, ChatGptAuthFixture::new("chatgpt-token") @@ -196,6 +259,31 @@ pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Pa Ok(()) } +async fn assert_analytics_event_not_received( + server: &MockServer, + wait_duration: Duration, + event_type: &str, +) -> Result<()> { + tokio::time::sleep(wait_duration).await; + let Some(requests) = server.received_requests().await else { + return Ok(()); + }; + for request in &requests { + if request.method != "POST" || request.url.path() != "/codex/analytics-events/events" { + continue; + } + let payload: Value = serde_json::from_slice(&request.body) + .map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"))?; + if payload["events"] + .as_array() + .is_some_and(|events| events.iter().any(|event| event["event_type"] == event_type)) + { + anyhow::bail!("received unexpected {event_type} analytics event"); + } + } + Ok(()) +} + pub(crate) async fn wait_for_thread_initialized_payload( server: &MockServer, read_timeout: Duration, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 2ccf98fa7f0..e1db1068317 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -763,6 +763,7 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { ) .await; configure_remote_plugin_test(codex_home.path(), &server)?; + enable_analytics(codex_home.path())?; mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; @@ -1333,7 +1334,16 @@ plugins = true fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { std::fs::write( codex_home.join("config.toml"), - format!("chatgpt_base_url = \"{base_url}\"\n"), + format!("chatgpt_base_url = \"{base_url}\"\n\n[analytics]\nenabled = true\n"), + ) +} + +fn enable_analytics(codex_home: &std::path::Path) -> std::io::Result<()> { + let config_path = codex_home.join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!("{config}\n[analytics]\nenabled = true\n"), ) } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index 31894d940b9..84a39ca2b72 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -87,7 +87,7 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { std::fs::write( codex_home.path().join("config.toml"), format!( - "chatgpt_base_url = \"{}\"\n\n[features]\nplugins = true\n\n[plugins.\"sample-plugin@debug\"]\nenabled = true\n", + "chatgpt_base_url = \"{}\"\n\n[features]\nplugins = true\n\n[analytics]\nenabled = true\n\n[plugins.\"sample-plugin@debug\"]\nenabled = true\n", analytics_server.uri() ), )?; From 9276d1cabd032fe81e33d917af163548512d9ffd Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Mon, 1 Jun 2026 19:44:32 +0000 Subject: [PATCH 5/6] codex: fix CI failure on PR #25193 --- codex-rs/app-server/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index de274d0df75..a939265f478 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -1103,8 +1103,11 @@ fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransp #[cfg(test)] mod tests { use super::LogFormat; + #[cfg(unix)] use super::analytics_rpc_transport; + #[cfg(unix)] use crate::transport::AppServerTransport; + #[cfg(unix)] use codex_analytics::AppServerRpcTransport; use pretty_assertions::assert_eq; From a0abb0f16122e428822f2369dc6c935e3de509e6 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Mon, 1 Jun 2026 20:27:11 +0000 Subject: [PATCH 6/6] codex: address PR review feedback (#25193) --- codex-rs/app-server/src/lib.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a939265f478..9f188b3faeb 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -1103,11 +1103,8 @@ fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransp #[cfg(test)] mod tests { use super::LogFormat; - #[cfg(unix)] use super::analytics_rpc_transport; - #[cfg(unix)] use crate::transport::AppServerTransport; - #[cfg(unix)] use codex_analytics::AppServerRpcTransport; use pretty_assertions::assert_eq; @@ -1129,10 +1126,9 @@ mod tests { assert_eq!(LogFormat::from_env_value(Some("jsonl")), LogFormat::Default); } - #[cfg(unix)] #[test] fn analytics_rpc_transport_preserves_unix_socket() { - let transport = "unix:///tmp/codex-app-server.sock" + let transport = "unix://codex-app-server.sock" .parse::() .expect("unix socket transport should parse");