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
This commit is contained in:
Eric Traut
2026-09-17 05:26:45 +00:00
committed by copyberry
parent 1bd1bfa7ca
commit b0659c5386
13 changed files with 454 additions and 33 deletions
@@ -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())
+1
View File
@@ -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;
@@ -178,6 +178,38 @@ impl DaemonSettings {
}
}
pub(crate) async fn telemetry_tags(path: &Path) -> Result<[(&'static str, &'static str); 4]> {
let raw: Map<String, Value> = 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<T: DeserializeOwned + Default>(path: &Path) -> Result<T> {
let contents = match fs::read_to_string(path).await {
Ok(contents) => contents,
@@ -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(())
}
@@ -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"),
])
}
+60
View File
@@ -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<Option<codex_app_server_daemon::UpdateOutput>>,
) {
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;
}
+25 -6
View File
@@ -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, ..
+67
View File
@@ -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(())
}
+42 -3
View File
@@ -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::<Value>(&request.body))
.collect::<serde_json::Result<Vec<_>>>()?;
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::<Vec<_>>();
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 {
+19
View File
@@ -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<()> {
+65
View File
@@ -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<F: FnOnce(&AppServerTarget, bool)>(pub(super) Option<F>);
impl<F: FnOnce(&AppServerTarget, bool)> Launch<F> {
pub(super) fn record(mut self, target: &AppServerTarget, connected: bool) {
if let Some(record) = self.0.take() {
record(target, connected);
}
}
}
impl<F: FnOnce(&AppServerTarget, bool)> Drop for Launch<F> {
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<codex_app_server_daemon::LifecycleOutput>,
) {
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;
}
+3
View File
@@ -1061,6 +1061,7 @@ async fn run_ratatui_app(
environment_manager: Arc<EnvironmentManager>,
managed_worktree: Option<ManagedTuiWorktree>,
daemon_startup_warning: Option<String>,
launch_telemetry: daemon_telemetry::Launch<impl FnOnce(&AppServerTarget, bool)>,
startup_draft: startup_draft::StartupDraft,
) -> color_eyre::Result<AppExitInfo> {
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"]
+93 -24
View File
@@ -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)