From b0659c53865dd48b0cd69c454368cea3980017cc Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 05:10:13 +0000 Subject: [PATCH] Record daemon startup and update telemetry with consent handling (#46126) ## Why TUI startup metrics report the selected app-server mode before a connection succeeds, which can misrepresent embedded fallback or failed startup. Daemon startup and update actions also need distinct outcome observations. ## What changed - Record `codex.tui.start` once after the first connection attempt, using the actual mode or `unconfirmed`, and include daemon selection and auto-start tags. - Add `codex.daemon.start` for TUI auto-start and `codex.daemon.update` for foreground CLI updates and TUI update handoffs, using existing analytics consent and identity handling. - Report TUI handoffs as `handoff_requested` and suppress duplicate child reporting. Remove the suppression flag when spawning long-lived daemon processes. - Tag daemon settings by enabled state and explicit presence, without exporting setting values or contents; report invalid or unreadable settings as `unknown`. ## Testing Add coverage for analytics defaults and explicit consent overrides, unconfirmed CLI updates, settings presence, and exactly-once launch observations. Extend the worktree TUI test to check startup tags and a single update handoff observation. GitOrigin-RevId: 176d2ee594f930033fa1ece55d4c1c9603dbbc16 --- .../src/backend/pid_start.rs | 2 + codex-rs/app-server-daemon/src/lib.rs | 1 + codex-rs/app-server-daemon/src/settings.rs | 32 +++++ .../app-server-daemon/src/settings_tests.rs | 24 ++++ codex-rs/app-server-daemon/src/telemetry.rs | 21 ++++ codex-rs/cli/src/daemon_telemetry.rs | 60 +++++++++ codex-rs/cli/src/main.rs | 31 ++++- codex-rs/cli/tests/daemon_telemetry.rs | 67 ++++++++++ codex-rs/cli/tests/worktree.rs | 45 ++++++- codex-rs/tui/src/daemon_startup_tests.rs | 19 +++ codex-rs/tui/src/daemon_telemetry.rs | 65 ++++++++++ codex-rs/tui/src/lib.rs | 3 + codex-rs/tui/src/startup_orchestration.rs | 117 ++++++++++++++---- 13 files changed, 454 insertions(+), 33 deletions(-) create mode 100644 codex-rs/app-server-daemon/src/telemetry.rs create mode 100644 codex-rs/cli/src/daemon_telemetry.rs create mode 100644 codex-rs/cli/tests/daemon_telemetry.rs create mode 100644 codex-rs/tui/src/daemon_telemetry.rs diff --git a/codex-rs/app-server-daemon/src/backend/pid_start.rs b/codex-rs/app-server-daemon/src/backend/pid_start.rs index e0bcba1e11..5d994e3a85 100644 --- a/codex-rs/app-server-daemon/src/backend/pid_start.rs +++ b/codex-rs/app-server-daemon/src/backend/pid_start.rs @@ -89,6 +89,8 @@ impl PidBackend { } }; command + // Handoff suppression belongs to the foreground CLI, not its long-lived children. + .env_remove(crate::telemetry::HANDOFF_ENV) .args(self.command_args()) .stdin(Stdio::null()) .stdout(Stdio::null()) diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index 8294ad656a..e2ffd54e3f 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -11,6 +11,7 @@ pub use prepare_install::InstallRequest; pub use prepare_install::update_from_cli; mod remote_control_client; mod settings; +pub mod telemetry; mod thread_recovery; mod update_loop; diff --git a/codex-rs/app-server-daemon/src/settings.rs b/codex-rs/app-server-daemon/src/settings.rs index b9af0e1cdd..0921adb902 100644 --- a/codex-rs/app-server-daemon/src/settings.rs +++ b/codex-rs/app-server-daemon/src/settings.rs @@ -178,6 +178,38 @@ impl DaemonSettings { } } +pub(crate) async fn telemetry_tags(path: &Path) -> Result<[(&'static str, &'static str); 4]> { + let raw: Map = read_settings(path).await?; + // Validate the same snapshot used for presence checks, using the existing settings parser. + let settings: StoredSettings = serde_json::from_value(Value::Object(raw.clone()))?; + settings.updater.validate()?; + validate_shutdown_grace(settings.shutdown_grace_seconds)?; + let updater = raw.get("updater"); + let presence = |configured| if configured { "configured" } else { "default" }; + Ok([ + ( + "auto_update", + if settings.updater.auto_update_enabled { + "enabled" + } else { + "disabled" + }, + ), + ( + "auto_update_setting", + presence(updater.is_some_and(|value| value.get("autoUpdateEnabled").is_some())), + ), + ( + "update_interval_setting", + presence(updater.is_some_and(|value| value.get("updateIntervalMinutes").is_some())), + ), + ( + "shutdown_grace_setting", + presence(raw.contains_key("shutdownGraceSeconds")), + ), + ]) +} + async fn read_settings(path: &Path) -> Result { let contents = match fs::read_to_string(path).await { Ok(contents) => contents, diff --git a/codex-rs/app-server-daemon/src/settings_tests.rs b/codex-rs/app-server-daemon/src/settings_tests.rs index 487759a0f5..387f044db8 100644 --- a/codex-rs/app-server-daemon/src/settings_tests.rs +++ b/codex-rs/app-server-daemon/src/settings_tests.rs @@ -128,3 +128,27 @@ async fn update_interval_accepts_long_values_and_rejects_zero() { } } } + +#[tokio::test] +async fn telemetry_distinguishes_presence_from_default_values() -> anyhow::Result<()> { + let home = TempDir::new()?; + let dir = home.path().join("app-server-daemon"); + tokio::fs::create_dir(&dir).await?; + let path = dir.join("settings.json"); + for (contents, presence) in [ + ("{}", "default"), + ( + r#"{"updater":{"autoUpdateEnabled":true,"updateIntervalMinutes":60},"shutdownGraceSeconds":60}"#, + "configured", + ), + ] { + tokio::fs::write(&path, contents).await?; + assert_eq!( + crate::telemetry::settings_tags(home.path()) + .await + .map(|(_, value)| value), + ["enabled", presence, presence, presence] + ); + } + Ok(()) +} diff --git a/codex-rs/app-server-daemon/src/telemetry.rs b/codex-rs/app-server-daemon/src/telemetry.rs new file mode 100644 index 0000000000..7ea80db954 --- /dev/null +++ b/codex-rs/app-server-daemon/src/telemetry.rs @@ -0,0 +1,21 @@ +//! Bounded observations for foreground callers. This module never exports telemetry; +//! in particular, the detached updater must not create a telemetry provider. + +use std::path::Path; + +/// Suppress child reporting when the foreground TUI handoff owns the observation. +pub const HANDOFF_ENV: &str = "CODEX_DAEMON_TELEMETRY_HANDOFF"; + +/// Only presence and enabled state leave the settings reader, never values or contents. +/// Missing files mean defaults; unreadable or invalid settings remain unknown. +pub async fn settings_tags(codex_home: &Path) -> [(&'static str, &'static str); 4] { + let path = codex_home + .join(crate::STATE_DIR_NAME) + .join(crate::SETTINGS_FILE_NAME); + crate::settings::telemetry_tags(&path).await.unwrap_or([ + ("auto_update", "unknown"), + ("auto_update_setting", "unknown"), + ("update_interval_setting", "unknown"), + ("shutdown_grace_setting", "unknown"), + ]) +} diff --git a/codex-rs/cli/src/daemon_telemetry.rs b/codex-rs/cli/src/daemon_telemetry.rs new file mode 100644 index 0000000000..0bc3fed79f --- /dev/null +++ b/codex-rs/cli/src/daemon_telemetry.rs @@ -0,0 +1,60 @@ +//! Foreground daemon observations use the CLI's existing consent and identity handling. +//! Detached updater entry points never call this module. + +use codex_utils_cli::CliConfigOverrides; +use std::time::Duration; +use tokio::time::timeout; + +pub(crate) async fn record_command( + overrides: &CliConfigOverrides, + analytics_default_enabled: bool, + target: &'static str, + result: &anyhow::Result>, +) { + if std::env::var_os(codex_app_server_daemon::telemetry::HANDOFF_ENV).is_some() { + return; + } + // Skip telemetry if consent-aware configuration cannot be loaded promptly. + let Ok(Ok(config)) = timeout( + Duration::from_secs(/*secs*/ 2), + crate::cloud_config::load_config(overrides, codex_config::LoaderOverrides::default()), + ) + .await + else { + return; + }; + use codex_app_server_daemon::UpdateStatus; + let outcome = match result { + Ok(Some(output)) => match output.status { + UpdateStatus::Updated => "updated", + UpdateStatus::NoUpdate => "no_update", + UpdateStatus::Unsupported => "unsupported", + }, + Ok(None) => "cancelled", + Err(_) => "unconfirmed", + }; + let Ok(Ok(Some(otel))) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + codex_core::otel_init::build_provider( + &config, + env!("CARGO_PKG_VERSION"), + /*service_name_override*/ None, + analytics_default_enabled, + ) + })) else { + return; + }; + if let Some(metrics) = otel.metrics() { + let mut tags = codex_app_server_daemon::telemetry::settings_tags(&config.codex_home) + .await + .to_vec(); + tags.extend([ + ("initiation_source", "cli"), + ("update_target", target), + ("outcome", outcome), + ]); + let _ = metrics.counter("codex.daemon.update", /*inc*/ 1, &tags); + } + let _ = otel + .shutdown_with_timeout(std::time::Duration::from_secs(/*secs*/ 2)) + .await; +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index d474558eb8..e58c94fcec 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -58,6 +58,7 @@ static ALLOCATOR: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; mod app_cmd; mod cloud_config; mod daemon_install; +mod daemon_telemetry; #[cfg(any(target_os = "macos", target_os = "windows"))] mod desktop_app; mod doctor; @@ -936,6 +937,7 @@ fn run_update_action( println!("Updating the local background server..."); let status = std::process::Command::new(executable) .args(source.command_args()) + .env(codex_app_server_daemon::telemetry::HANDOFF_ENV, "1") .status()?; anyhow::ensure!( status.success(), @@ -1451,11 +1453,18 @@ async fn cli_main( from_cli: true, yes, } => { - if let Some(output) = codex_app_server_daemon::update_from_cli(|request| { + let result = codex_app_server_daemon::update_from_cli(|request| { daemon_install::confirm_install(request, yes) }) - .await? - { + .await; + daemon_telemetry::record_command( + &root_config_overrides, + analytics_default_enabled, + "this_cli", + &result, + ) + .await; + if let Some(output) = result? { println!("{}", serde_json::to_string(&output)?); } } @@ -1499,9 +1508,19 @@ async fn cli_main( daemon_cli.subcommand, AppServerDaemonSubcommand::Update { .. } ) { - let output = - codex_app_server_daemon::update(http_client_factory).await?; - println!("{}", serde_json::to_string(&output)?); + let result = codex_app_server_daemon::update(http_client_factory) + .await + .map(Some); + daemon_telemetry::record_command( + &root_config_overrides, + analytics_default_enabled, + "public_stable", + &result, + ) + .await; + if let Some(output) = result? { + println!("{}", serde_json::to_string(&output)?); + } } else { let AppServerDaemonSubcommand::PidUpdateLoop { restore_release, .. diff --git a/codex-rs/cli/tests/daemon_telemetry.rs b/codex-rs/cli/tests/daemon_telemetry.rs new file mode 100644 index 0000000000..48ea0b207e --- /dev/null +++ b/codex-rs/cli/tests/daemon_telemetry.rs @@ -0,0 +1,67 @@ +//! Foreground updates honor app-server consent and never claim unconfirmed installation. + +use anyhow::Context as _; +use pretty_assertions::assert_eq; +use serde_json::Value; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; + +#[tokio::test] +async fn foreground_update_respects_consent_and_reports_unconfirmed() -> anyhow::Result<()> { + for (analytics_config, analytics_default_enabled, expected_requests) in [ + ("", false, 0), + ("", true, 1), + ("analytics.enabled = true\n", false, 1), + ("analytics.enabled = false\n", true, 0), + ] { + let codex = codex_utils_cargo_bin::cargo_bin("codex")?; + let server = MockServer::start().await; + Mock::given(wiremock::matchers::path("/metrics")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + let home = tempfile::tempdir()?; + std::fs::write( + home.path().join("config.toml"), + format!( + "cli_auth_credentials_store = \"file\"\n{analytics_config}[otel]\nmetrics_exporter = {{ otlp-http = {{ endpoint = \"{}/metrics\", protocol = \"json\" }} }}\n", + server.uri(), + ), + )?; + // No managed installation exists, so the command cannot confirm an applied update. + let mut command = tokio::process::Command::new(&codex); + command + .current_dir(home.path()) + .env("CODEX_HOME", home.path()) + .env_remove(codex_app_server_daemon::telemetry::HANDOFF_ENV) + .arg("app-server"); + if analytics_default_enabled { + command.arg("--analytics-default-enabled"); + } + let output = command + .args(["daemon", "update", "--from-cli", "--yes"]) + .output() + .await?; + assert!(!output.status.success()); + let requests = server + .received_requests() + .await + .context("metric requests")?; + assert_eq!(requests.len(), expected_requests); + if expected_requests == 0 { + continue; + } + let body: Value = serde_json::from_slice(&requests[0].body)?; + let metric = &body["resourceMetrics"][0]["scopeMetrics"][0]["metrics"][0]; + assert_eq!(metric["name"], "codex.daemon.update"); + assert!( + metric["sum"]["dataPoints"][0]["attributes"] + .as_array() + .context("metric attributes")? + .iter() + .any(|tag| tag["key"] == "outcome" && tag["value"]["stringValue"] == "unconfirmed") + ); + } + Ok(()) +} diff --git a/codex-rs/cli/tests/worktree.rs b/codex-rs/cli/tests/worktree.rs index 7031791918..7e71ee5fc7 100644 --- a/codex-rs/cli/tests/worktree.rs +++ b/codex-rs/cli/tests/worktree.rs @@ -446,7 +446,29 @@ trust_level = "trusted" ); } if matches!(startup_result, Ok(Ok(()))) && renamed.is_ok() { - session.writer_sender().send(b"/quit\r".to_vec()).await?; + if !fork { + for (input, expected) in [ + ("/daemon\r", "Install latest public stable"), + ("\r", "Update and exit"), + ("\x1b[B\r", "Updating the local background server..."), + ] { + session + .writer_sender() + .send(input.as_bytes().to_vec()) + .await?; + tokio::time::timeout(Duration::from_secs(/*secs*/ 10), async { + while !output.contains(expected) { + let bytes = stdout.recv().await.context("TUI exited before handoff")?; + output.push_str(&String::from_utf8_lossy(&bytes)); + } + Ok::<_, anyhow::Error>(()) + }) + .await + .with_context(|| format!("waiting for {expected}: {output}"))??; + } + } else { + session.writer_sender().send(b"/quit\r".to_vec()).await?; + } } else { session.terminate(); } @@ -459,7 +481,9 @@ trust_level = "trusted" } }) .await; - assert_eq!(exit, 0, "{output}"); + let elevated_handoff = cfg!(windows) + && output.contains("start the Windows daemon from a non-elevated terminal"); + assert_eq!(exit, i32::from(elevated_handoff), "{output}"); assert!(!output.contains("The checkout was kept"), "{output}"); let metrics = server .received_requests() @@ -470,11 +494,20 @@ trust_level = "trusted" .map(|request| serde_json::from_slice::(&request.body)) .collect::>>()?; if analytics { - let point = metrics + let exported = metrics .iter() .flat_map(|payload| payload["resourceMetrics"].as_array().into_iter().flatten()) .flat_map(|resource| resource["scopeMetrics"].as_array().into_iter().flatten()) .flat_map(|scope| scope["metrics"].as_array().into_iter().flatten()) + .collect::>(); + let update = exported + .iter() + .find(|metric| metric["name"] == "codex.daemon.update") + .context("handoff metric")?; + let update_point = &update["sum"]["dataPoints"][0]; + assert_eq!(update_point["asInt"], 1); + let point = exported + .iter() .filter(|metric| metric["name"] == "codex.tui.start") .flat_map(|metric| metric["sum"]["dataPoints"].as_array().into_iter().flatten()) .next() @@ -498,6 +531,12 @@ trust_level = "trusted" ("app_server_mode", "in_process"), ("terminal_name", "unknown"), ("multiplexer", "none"), + ("daemon_selection_reason", "incompatible_option"), + ("daemon_auto_start", "disabled"), + ("auto_update", "enabled"), + ("auto_update_setting", "default"), + ("update_interval_setting", "default"), + ("shutdown_grace_setting", "default"), ]) ); } else { diff --git a/codex-rs/tui/src/daemon_startup_tests.rs b/codex-rs/tui/src/daemon_startup_tests.rs index 48fe876481..db2eb55a52 100644 --- a/codex-rs/tui/src/daemon_startup_tests.rs +++ b/codex-rs/tui/src/daemon_startup_tests.rs @@ -5,6 +5,25 @@ use crate::legacy_core::config::ConfigBuilder; use pretty_assertions::assert_eq; use tempfile::TempDir; +#[test] +fn daemon_launch_telemetry_records_once_on_connection_or_early_return() { + for connected in [false, true] { + let observations = std::cell::RefCell::new(Vec::new()); + let launch = daemon_telemetry::Launch(Some(|target: &AppServerTarget, actual: bool| { + observations.borrow_mut().push((target.clone(), actual)); + })); + if connected { + launch.record(&AppServerTarget::Embedded, connected); + } else { + drop(launch); + } + assert_eq!( + observations.into_inner(), + vec![(AppServerTarget::Embedded, connected)] + ); + } +} + #[cfg(windows)] #[tokio::test] async fn daemon_connection_rejects_unprotected_socket_before_handshake() -> color_eyre::Result<()> { diff --git a/codex-rs/tui/src/daemon_telemetry.rs b/codex-rs/tui/src/daemon_telemetry.rs new file mode 100644 index 0000000000..7f74003589 --- /dev/null +++ b/codex-rs/tui/src/daemon_telemetry.rs @@ -0,0 +1,65 @@ +//! Records automatic daemon startup and ensures launch observations survive setup failures. + +use super::*; + +/// Emit once after connection, or as unconfirmed if startup returns early. +pub(super) struct Launch(pub(super) Option); + +impl Launch { + pub(super) fn record(mut self, target: &AppServerTarget, connected: bool) { + if let Some(record) = self.0.take() { + record(target, connected); + } + } +} + +impl Drop for Launch { + fn drop(&mut self) { + if let Some(record) = self.0.take() { + record(&AppServerTarget::Embedded, /*connected*/ false); + } + } +} + +pub(super) async fn record_start( + config: &Config, + result: &anyhow::Result, +) { + use codex_app_server_daemon::LifecycleStatus; + let outcome = match result { + Ok(output) => match output.status { + LifecycleStatus::Started => "started", + LifecycleStatus::AlreadyRunning => "already_running", + LifecycleStatus::Restarted => "restarted", + LifecycleStatus::Stopped | LifecycleStatus::NotRunning | LifecycleStatus::Running => { + "unconfirmed" + } + }, + Err(_) => "failed", + }; + // Failure can happen before normal TUI telemetry is initialized. This short-lived + // provider uses exactly the same consent and identity construction as TUI startup. + let Ok(Ok(Some(otel))) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + codex_app_server_client::build_otel_provider( + config, + env!("CARGO_PKG_VERSION"), + /*service_name_override*/ None, + /*default_analytics_enabled*/ true, + ) + })) else { + return; + }; + if let Some(metrics) = otel.metrics() { + let mut tags = codex_app_server_daemon::telemetry::settings_tags(&config.codex_home) + .await + .to_vec(); + tags.extend([ + ("initiation_source", "tui_auto_start"), + ("outcome", outcome), + ]); + let _ = metrics.counter("codex.daemon.start", /*inc*/ 1, &tags); + } + let _ = otel + .shutdown_with_timeout(INTERACTIVE_OTEL_SHUTDOWN_TIMEOUT) + .await; +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 013231e6c8..b52213a617 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1061,6 +1061,7 @@ async fn run_ratatui_app( environment_manager: Arc, managed_worktree: Option, daemon_startup_warning: Option, + launch_telemetry: daemon_telemetry::Launch, startup_draft: startup_draft::StartupDraft, ) -> color_eyre::Result { let uses_remote_workspace = app_server_target.uses_remote_workspace(); @@ -1126,6 +1127,7 @@ async fn run_ratatui_app( ), ) .await; + launch_telemetry.record(&app_server_target, matches!(&startup_app_server, Ok(Ok(_)))); let app_server_session = match startup_app_server { Ok(Ok(app_server)) => { AppServerSession::new(app_server, app_server_target.thread_params_mode()) @@ -2185,6 +2187,7 @@ fn should_show_bedrock_setup_wizard( } mod daemon_startup; +mod daemon_telemetry; #[cfg(test)] #[path = "daemon_startup_tests.rs"] diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index b138dd0d92..d8acaa1921 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -418,11 +418,13 @@ pub(super) async fn run_main_inner( .with_restored(|| async { // Package installation may print progress; keep ordinary Ctrl+C handling. crossterm::terminal::disable_raw_mode()?; - codex_app_server_daemon::run(codex_app_server_daemon::LifecycleCommand::Start) - .await - .map_err(|err| { - std::io::Error::other(format!("{err:#}\n{}", daemon_startup::FAILURE_HINT)) - }) + let result = + codex_app_server_daemon::run(codex_app_server_daemon::LifecycleCommand::Start) + .await; + daemon_telemetry::record_start(&config, &result).await; + result.map_err(|err| { + std::io::Error::other(format!("{err:#}\n{}", daemon_startup::FAILURE_HINT)) + }) }) .await?; app_server_target = AppServerTarget::LocalDaemon { @@ -517,13 +519,52 @@ pub(super) async fn run_main_inner( None } }; - if let Some(metrics) = otel.as_ref().and_then(codex_otel::OtelProvider::metrics) { + let metrics = otel + .as_ref() + .and_then(codex_otel::OtelProvider::metrics) + .cloned(); + if let Some(metrics) = &metrics { let _ = codex_otel::record_process_start_once(metrics, otel_originator.as_str()); - // Count the selected mode once per TUI launch, independently of reconnects. - let app_server_mode = match &app_server_target { - AppServerTarget::Embedded => "in_process", - AppServerTarget::LocalDaemon { .. } => "local_daemon", - AppServerTarget::Remote { .. } => "remote", + let telemetry = + codex_rollout::sqlite_telemetry_recorder(metrics.clone(), otel_originator.as_str()); + let _ = codex_state::install_process_db_telemetry(telemetry); + } + let selection_reason = match (&app_server_target, daemon_exclusion) { + (AppServerTarget::Remote { .. }, _) => "explicit_remote", + _ if cli.agents_overview => "agents", + (_, Some("--no-daemon")) => "explicit_no_daemon", + (_, Some(_)) => "incompatible_option", + _ if auto_start_daemon => "auto_start", + (AppServerTarget::LocalDaemon { .. }, _) => "existing_daemon", + (AppServerTarget::Embedded, None) => "auto_start_disabled", + }; + let daemon_settings = if metrics.is_some() { + codex_app_server_daemon::telemetry::settings_tags(&config.codex_home) + .await + .to_vec() + } else { + Vec::new() + }; + let mut launch_tags = daemon_settings.to_vec(); + launch_tags.extend([ + ("daemon_selection_reason", selection_reason), + ( + "daemon_auto_start", + if config.features.enabled(Feature::DaemonAutoStart) { + "enabled" + } else { + "disabled" + }, + ), + ]); + // Record the first connection attempt's actual mode, including embedded fallback; never reconnects. + let launch_telemetry = move |target: &AppServerTarget, connected: bool| { + let Some(metrics) = metrics else { return }; + let app_server_mode = match (connected, target) { + (false, _) => "unconfirmed", + (true, AppServerTarget::Embedded) => "in_process", + (true, AppServerTarget::LocalDaemon { .. }) => "local_daemon", + (true, AppServerTarget::Remote { .. }) => "remote", }; // Use a fixed category, not the versioned or user-provided terminal identifier. let terminal_info = codex_terminal_detection::terminal_info(); @@ -548,19 +589,14 @@ pub(super) async fn run_main_inner( Some(Multiplexer::Zellij { .. }) => "zellij", None => "none", }; - let _ = metrics.counter( - "codex.tui.start", - /*inc*/ 1, - &[ - ("app_server_mode", app_server_mode), - ("terminal_name", terminal_name), - ("multiplexer", multiplexer), - ], - ); - let telemetry = - codex_rollout::sqlite_telemetry_recorder(metrics.clone(), otel_originator.as_str()); - let _ = codex_state::install_process_db_telemetry(telemetry); - } + launch_tags.extend([ + ("app_server_mode", app_server_mode), + ("terminal_name", terminal_name), + ("multiplexer", multiplexer), + ]); + let _ = metrics.counter("codex.tui.start", /*inc*/ 1, &launch_tags); + }; + let launch_telemetry = daemon_telemetry::Launch(Some(launch_telemetry)); let state_db = startup_draft .run_until(init_state_db_for_app_server_target( &config, @@ -592,6 +628,12 @@ pub(super) async fn run_main_inner( if let Some(worktree) = managed_worktree.as_ref() { worktree.report_startup_failure(); } + launch_telemetry.record(&app_server_target, /*connected*/ false); + if let Some(otel) = otel { + let _ = otel + .shutdown_with_timeout(INTERACTIVE_OTEL_SHUTDOWN_TIMEOUT) + .await; + } std::process::exit(1); } } @@ -607,6 +649,12 @@ pub(super) async fn run_main_inner( if let Some(worktree) = managed_worktree.as_ref() { worktree.report_startup_failure(); } + launch_telemetry.record(&app_server_target, /*connected*/ false); + if let Some(otel) = otel { + let _ = otel + .shutdown_with_timeout(INTERACTIVE_OTEL_SHUTDOWN_TIMEOUT) + .await; + } std::process::exit(1); } } @@ -715,6 +763,7 @@ pub(super) async fn run_main_inner( environment_manager, managed_worktree.clone(), daemon_startup_warning, + launch_telemetry, startup_draft, ) .await @@ -727,6 +776,26 @@ pub(super) async fn run_main_inner( worktree.report_startup_failure(); } + // The TUI owns this request's consent. The child is silent; installation remains unconfirmed. + if let Ok(exit) = &app_result + && let Some(UpdateAction::Daemon(source)) = exit.update_action + && let Some(metrics) = otel.as_ref().and_then(codex_otel::OtelProvider::metrics) + { + let mut tags = daemon_settings.to_vec(); + tags.extend([ + ("initiation_source", "tui_handoff"), + ( + "update_target", + match source { + DaemonUpdateSource::PublicStable => "public_stable", + DaemonUpdateSource::ThisCli => "this_cli", + }, + ), + ("outcome", "handoff_requested"), + ]); + let _ = metrics.counter("codex.daemon.update", /*inc*/ 1, &tags); + } + if let Some(otel) = otel && let Err(err) = otel .shutdown_with_timeout(INTERACTIVE_OTEL_SHUTDOWN_TIMEOUT)