diff --git a/codex-rs/cli/tests/worktree.rs b/codex-rs/cli/tests/worktree.rs index 664b2e1d65..f3c41547ec 100644 --- a/codex-rs/cli/tests/worktree.rs +++ b/codex-rs/cli/tests/worktree.rs @@ -9,6 +9,7 @@ use std::io::Write as _; use std::path::Path; use std::process::Command; use std::time::Duration; +use std::time::Instant; #[cfg(not(unix))] use tempfile::TempDir; use wiremock::Mock; @@ -252,6 +253,10 @@ trust_level = "trusted" env.insert("TERM".into(), "xterm-256color".into()); env.insert("TERM_PROGRAM".into(), "unrecognized-terminal".into()); env.insert("OTEL_METRIC_EXPORT_INTERVAL".into(), "100".into()); + env.insert( + "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE".into(), + "delta".into(), + ); for key in [ "CODEX_EXEC_SERVER_URL", "CODEX_ACCESS_TOKEN", @@ -335,8 +340,8 @@ trust_level = "trusted" } let (metric_tx, mut metric_rx) = tokio::sync::mpsc::unbounded_channel(); Mock::given(wiremock::matchers::path("/metrics")) - .respond_with(move |_: &wiremock::Request| { - let _ = metric_tx.send(()); + .respond_with(move |request: &wiremock::Request| { + let _ = metric_tx.send(request.body.clone()); ResponseTemplate::new(200).set_body_json(serde_json::json!({})) }) .mount(&server) @@ -409,7 +414,8 @@ trust_level = "trusted" if analytics { args.extend([ "-c".into(), - "analytics.enabled=true".into(), + // An explicit collector is independent of built-in analytics consent. + "analytics.enabled=false".into(), "-c".into(), format!( "otel.metrics_exporter={{otlp-http={{endpoint=\"{}/metrics\",protocol=\"json\"}}}}", @@ -551,9 +557,44 @@ trust_level = "trusted" .with_context(|| format!("daemon status timed out: {output}"))??; } if analytics { - tokio::time::timeout(Duration::from_secs(/*secs*/ 10), metric_rx.recv()) - .await? - .context("metrics export while the TUI is running")?; + for reason in ["turn_start", "resize"] { + let started = Instant::now(); + let wait = if reason == "resize" { + session.resize(codex_utils_pty::TerminalSize { + rows: 80, + cols: 240, + })?; + Duration::from_secs(/*secs*/ 30) + } else { + Duration::from_secs(/*secs*/ 10) + }; + tokio::time::timeout(wait, async { + loop { + tokio::select! { + body = metric_rx.recv() => { + let payload: Value = serde_json::from_slice(&body.context("metric body")?)?; + let found = 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()) + .filter(|metric| metric["name"] == "codex.tui.terminal_size") + .flat_map(|metric| metric["sum"]["dataPoints"].as_array().into_iter().flatten()) + .any(|point| point["attributes"].as_array().into_iter().flatten().any(|attribute| + attribute["key"] == "observation_reason" && attribute["value"]["stringValue"] == reason)); + if found { return Ok::<_, anyhow::Error>(()); } + } + bytes = stdout.recv() => { + let bytes = bytes.context("TUI exited before size observation")?; + output.push_str(&String::from_utf8_lossy(&bytes)); + let replies = queries.feed(&bytes); + if !replies.is_empty() { session.writer_sender().send(replies).await?; } + } + } + } + }).await.with_context(|| format!("waiting for {reason} observation"))??; + if reason == "resize" { + assert!(started.elapsed() >= Duration::from_secs(/*secs*/ 15)); + } + } } let startup_result = observed.as_ref().map(|result| result.as_ref().map(|_| ())); assert!( @@ -637,6 +678,7 @@ trust_level = "trusted" .flat_map(|metric| metric["sum"]["dataPoints"].as_array().into_iter().flatten()) .next() .context("codex.tui.start data point")?; + assert_eq!(point["asInt"], 1); let tags = point["attributes"] .as_array() .context("codex.tui.start attributes")? @@ -653,6 +695,8 @@ trust_level = "trusted" let mut expected_tags = HashMap::from([ ("app_server_mode", "in_process"), ("terminal_name", "unknown"), + ("terminal_columns", "120-239"), + ("terminal_rows", "40-79"), ("multiplexer", "none"), ("daemon_selection_reason", "incompatible_option"), ( @@ -680,6 +724,40 @@ trust_level = "trusted" ]); } assert_eq!(tags, expected_tags); + let observations = exported + .iter() + .filter(|metric| metric["name"] == "codex.tui.terminal_size") + .flat_map(|metric| metric["sum"]["dataPoints"].as_array().into_iter().flatten()) + .collect::>(); + assert_eq!(observations.len(), 2); + for observation in observations { + assert_eq!(observation["asInt"], 1); + let observation_tags = observation["attributes"] + .as_array() + .context("observation attributes")? + .iter() + .map(|attribute| { + Ok(( + attribute["key"].as_str().context("observation key")?, + attribute["value"]["stringValue"] + .as_str() + .context("observation value")?, + )) + }) + .collect::>>()?; + let reason = observation_tags["observation_reason"]; + let mut expected = expected_tags.clone(); + expected.insert("observation_reason", reason); + match reason { + "turn_start" => {} + "resize" => { + expected.insert("terminal_columns", "240-479"); + expected.insert("terminal_rows", "80-119"); + } + other => anyhow::bail!("unexpected observation: {other}"), + } + assert_eq!(observation_tags, expected); + } } else { assert!(metrics.is_empty(), "metrics exporter disabled"); } diff --git a/codex-rs/otel/src/metrics/client.rs b/codex-rs/otel/src/metrics/client.rs index 8bbb12cdc5..9564f1eef4 100644 --- a/codex-rs/otel/src/metrics/client.rs +++ b/codex-rs/otel/src/metrics/client.rs @@ -43,6 +43,10 @@ use std::sync::Weak; use std::time::Duration; use tracing::debug; +#[cfg(test)] +#[path = "client_tests.rs"] +mod tests; + const ENV_ATTRIBUTE: &str = "env"; const METER_NAME: &str = "codex"; const MILLISECOND_DURATION_UNIT: &str = "ms"; @@ -109,6 +113,7 @@ pub(super) struct MetricsClientInner { histograms: Mutex>>, duration_histograms: Mutex>>, runtime_reader: Option>, + requires_analytics_consent: bool, statsig_disabled_metrics: &'static [&'static str], default_tags: BTreeMap, } @@ -358,6 +363,8 @@ impl MetricsClient { ) }); + let requires_analytics_consent = + matches!(exporter, MetricsExporter::Otlp(OtelExporter::Statsig)); let (meter_provider, meter) = match exporter { MetricsExporter::InMemory(exporter) => { build_provider(resource, exporter, export_interval, runtime_reader.clone()) @@ -391,6 +398,7 @@ impl MetricsClient { histograms: Mutex::new(HashMap::new()), duration_histograms: Mutex::new(HashMap::new()), runtime_reader, + requires_analytics_consent, statsig_disabled_metrics, default_tags, }), @@ -414,6 +422,22 @@ impl MetricsClient { .counter(name, /*description*/ None, inc, tags) } + /// Record a counter while applying analytics consent only to the built-in exporter. + /// The consent check and recording use the same exporter even during provider replacement. + pub fn counter_with_analytics_consent( + &self, + name: &str, + inc: i64, + tags: &[(&str, &str)], + analytics_enabled: bool, + ) -> Result<()> { + let inner = self.active_inner(); + if inner.requires_analytics_consent && !analytics_enabled { + return Ok(()); + } + inner.counter(name, /*description*/ None, inc, tags) + } + /// Send a single counter increment with an instrument description. pub fn counter_with_description( &self, diff --git a/codex-rs/otel/src/metrics/client_tests.rs b/codex-rs/otel/src/metrics/client_tests.rs new file mode 100644 index 0000000000..5363a3f53e --- /dev/null +++ b/codex-rs/otel/src/metrics/client_tests.rs @@ -0,0 +1,57 @@ +//! Consent follows the actual counter destination across exporter replacements. + +use super::*; +use opentelemetry_sdk::metrics::data::AggregatedMetrics; +use opentelemetry_sdk::metrics::data::MetricData; +use opentelemetry_sdk::metrics::data::ScopeMetrics; +use opentelemetry_sdk::metrics::data::SumDataPoint; +use pretty_assertions::assert_eq; + +#[test] +fn counter_consent_uses_the_active_exporter() -> Result<()> { + let mut builtin = MetricsClient::new( + MetricsConfig::in_memory("test", "test", "1", Default::default()).with_runtime_reader(), + )?; + // Use the existing in-memory exporter to observe the built-in consent policy without + // contacting Statsig (the real Statsig exporter is disabled in debug builds). + Arc::get_mut(&mut builtin.inner) + .expect("unique provider") + .requires_analytics_consent = true; + let custom = MetricsClient::new( + MetricsConfig::in_memory("test", "test", "1", Default::default()).with_runtime_reader(), + )?; + let active = Arc::new(RwLock::new(Arc::clone(&builtin.inner))); + let metrics = MetricsClient { + inner: Arc::clone(&builtin.inner), + active: Some(Arc::clone(&active)), + }; + + // A config reload alone does not replace the installed built-in exporter. + for analytics_enabled in [false, true] { + metrics.counter_with_analytics_consent( + "test.observations", + /*inc*/ 1, + &[], + analytics_enabled, + )?; + } + *active.write().unwrap() = Arc::clone(&custom.inner); + metrics.counter_with_analytics_consent( + "test.observations", + /*inc*/ 1, + &[], + /*analytics_enabled*/ false, + )?; + + let mut counts = Vec::new(); + for destination in [&builtin, &custom] { + let snapshot = destination.snapshot()?; + for metric in snapshot.scope_metrics().flat_map(ScopeMetrics::metrics) { + if let AggregatedMetrics::U64(MetricData::Sum(sum)) = metric.data() { + counts.extend(sum.data_points().map(SumDataPoint::value)); + } + } + } + assert_eq!(counts, vec![1, 1]); + Ok(()) +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1b07e9826d..9a324aa3bd 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -536,6 +536,7 @@ pub(crate) struct App { config_notification_test_pending: Option, model_catalog: Arc, pub(crate) session_telemetry: SessionTelemetry, + terminal_size_tags: crate::terminal_size_telemetry::LaunchTags, pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, workspace_command_runner: Option, diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 4c329f3787..c053d540f1 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -889,6 +889,7 @@ See the Codex keymap documentation for supported actions and examples." config_notification_test_pending: None, model_catalog, session_telemetry: session_telemetry.clone(), + terminal_size_tags: tui.terminal_size_tags.clone(), app_event_tx, chat_widget, workspace_command_runner: Some(workspace_command_runner), @@ -1160,6 +1161,9 @@ See the Codex keymap documentation for supported actions and examples." ); } + let mut last_screen_size = tui.terminal.last_known_screen_size; + let mut settled_size_tags = crate::terminal_size_telemetry::tags(last_screen_size); + let mut pending_resize: Option<(ratatui::layout::Size, Instant)> = None; let mut listen_for_app_server_events = true; let mut reconnect = None; let mut waiting_for_initial_session_configured = wait_for_initial_session_configured; @@ -1263,6 +1267,19 @@ See the Codex keymap documentation for supported actions and examples." && has_pending_app_events || (!waiting_for_initial_session_configured && app.has_queued_startup_protected_request()); + // The screen cache is physical geometry, not the inline viewport. This also + // notices the final size after returning from a standalone nested screen. + let size = tui.terminal.last_known_screen_size; + if !crate::terminal_size_telemetry::enabled(&app.config) + || app.terminal_size_tags.is_empty() + { + pending_resize = None; + settled_size_tags = crate::terminal_size_telemetry::tags(size); + } else if size != last_screen_size { + pending_resize = + Some((size, Instant::now() + Duration::from_secs(/*secs*/ 15))); + } + last_screen_size = size; let rate_limit_poll_deadline = app .chat_widget .rate_limit_refresh_interval() @@ -1390,6 +1407,28 @@ See the Codex keymap documentation for supported actions and examples." } AppRunControl::Continue } + () = async { + match pending_resize { + Some((_, deadline)) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await, + None => std::future::pending().await, + } + } => { + if let Some((expected, _)) = pending_resize.take() + && crate::terminal_size_telemetry::enabled(&app.config) + { + let actual = crate::terminal_size_telemetry::read_size(); + if actual != expected { + pending_resize = Some((actual, Instant::now() + Duration::from_secs(/*secs*/ 15))); + } else { + let tags = crate::terminal_size_telemetry::tags(actual); + if tags != settled_size_tags { + crate::terminal_size_telemetry::record(&app.terminal_size_tags, &app.config, "resize", actual); + } + settled_size_tags = tags; + } + } + AppRunControl::Continue + } () = async { match rate_limit_poll_deadline { Some(deadline) => { diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 9394604dd2..a0efb33101 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -26,6 +26,7 @@ pub(crate) async fn make_test_app() -> Box { config_notification_test_pending: None, model_catalog: chat_widget.model_catalog(), session_telemetry, + terminal_size_tags: Vec::new(), app_event_tx, chat_widget, workspace_command_runner: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 40c30df923..4fb6b8ac08 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1,5 +1,8 @@ //! App-level orchestration tests for the TUI. +#[path = "tests/terminal_size_telemetry_tests.rs"] +mod terminal_size_telemetry_tests; + #[path = "tests/copy_mode_tests.rs"] mod copy_mode_tests; #[path = "tests/mcp_login_tests.rs"] @@ -6264,6 +6267,7 @@ async fn make_test_app() -> Box { config_notification_test_pending: None, model_catalog: chat_widget.model_catalog(), session_telemetry, + terminal_size_tags: Vec::new(), app_event_tx, chat_widget, workspace_command_runner: None, @@ -6389,6 +6393,7 @@ pub(super) async fn make_test_app_with_channels() -> ( config_notification_test_pending: None, model_catalog: chat_widget.model_catalog(), session_telemetry, + terminal_size_tags: Vec::new(), app_event_tx, chat_widget, workspace_command_runner: None, diff --git a/codex-rs/tui/src/app/tests/terminal_size_telemetry_tests.rs b/codex-rs/tui/src/app/tests/terminal_size_telemetry_tests.rs new file mode 100644 index 0000000000..9542f599f2 --- /dev/null +++ b/codex-rs/tui/src/app/tests/terminal_size_telemetry_tests.rs @@ -0,0 +1,26 @@ +//! Terminal-size observation eligibility follows the config reloaded for resume. + +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn terminal_size_telemetry_respects_reloaded_exporter_and_consent() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; + let cwd = app.config.cwd.to_path_buf(); + let mut enabled = Vec::new(); + for config in [ + "[otel]\nmetrics_exporter = \"statsig\"\n", + "[otel]\nmetrics_exporter = \"none\"\n", + "[otel]\nmetrics_exporter = \"statsig\"\n[analytics]\nenabled = false\n", + "analytics.enabled = false\n[otel.metrics_exporter.otlp-http]\nendpoint = \"http://127.0.0.1:4318/v1/metrics\"\nprotocol = \"json\"\n", + ] { + std::fs::write(app.config.codex_home.join("config.toml"), config)?; + let (config, local_settings) = + Box::pin(app.rebuild_config_for_resume_or_fallback(&cwd, cwd.clone())).await?; + app.config = config; + app.local_settings = local_settings; + enabled.push(crate::terminal_size_telemetry::enabled(&app.config)); + } + assert_eq!(enabled, vec![true, false, false, true]); + Ok(()) +} diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index c8d16dc9c6..f73bca912a 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -2059,6 +2059,23 @@ impl App { } pub(super) fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) { + // Replay has its own handler; suppress repeated notifications for the current turn. + if crate::terminal_size_telemetry::enabled(&self.config) + && !self.terminal_size_tags.is_empty() + && let ThreadBufferedEvent::Notification(notification) = &event + && let ServerNotification::TurnStarted(started) = notification.as_ref() + && self + .active_thread_id + .is_some_and(|id| id.to_string() == started.thread_id) + && self.chat_widget.last_turn_id() != Some(started.turn.id.as_str()) + { + crate::terminal_size_telemetry::record( + &self.terminal_size_tags, + &self.config, + "turn_start", + crate::terminal_size_telemetry::read_size(), + ); + } let needs_refresh = matches!( &event, ThreadBufferedEvent::Notification(notification) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2f74f843d5..fccd6e2c20 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1950,6 +1950,10 @@ impl ChatWidget { .unwrap_or_default() } + pub(crate) fn last_turn_id(&self) -> Option<&str> { + self.turn_lifecycle.last_turn_id.as_deref() + } + pub(crate) fn thread_id(&self) -> Option { self.thread_id } diff --git a/codex-rs/tui/src/daemon_startup_tests.rs b/codex-rs/tui/src/daemon_startup_tests.rs index 2a6a2eaf1f..0d534c4a0c 100644 --- a/codex-rs/tui/src/daemon_startup_tests.rs +++ b/codex-rs/tui/src/daemon_startup_tests.rs @@ -125,6 +125,7 @@ fn daemon_launch_telemetry_records_once_on_connection_or_early_return() { 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)); + Vec::new() })); if connected { launch.record(&AppServerTarget::Embedded, connected); diff --git a/codex-rs/tui/src/daemon_telemetry.rs b/codex-rs/tui/src/daemon_telemetry.rs index 7f74003589..0c5a050791 100644 --- a/codex-rs/tui/src/daemon_telemetry.rs +++ b/codex-rs/tui/src/daemon_telemetry.rs @@ -1,19 +1,21 @@ //! Records automatic daemon startup and ensures launch observations survive setup failures. use super::*; +use crate::terminal_size_telemetry::LaunchTags; /// Emit once after connection, or as unconfirmed if startup returns early. -pub(super) struct Launch(pub(super) Option); +pub(super) struct Launch LaunchTags>(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 LaunchTags> Launch { + pub(super) fn record(mut self, target: &AppServerTarget, connected: bool) -> LaunchTags { + self.0 + .take() + .map(|record| record(target, connected)) + .unwrap_or_default() } } -impl Drop for Launch { +impl LaunchTags> Drop for Launch { fn drop(&mut self) { if let Some(record) = self.0.take() { record(&AppServerTarget::Embedded, /*connected*/ false); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 734c0f7f1b..545c0d48d3 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -230,6 +230,7 @@ mod temporary_structured_request; mod terminal_hyperlinks; mod terminal_palette; mod terminal_probe; +mod terminal_size_telemetry; mod terminal_title; mod terminal_visualization_instructions; mod text_formatting; @@ -1159,7 +1160,9 @@ async fn run_ratatui_app( managed_worktree: Option, daemon_startup_warning: Option, managed_daemon: bool, - launch_telemetry: daemon_telemetry::Launch, + launch_telemetry: daemon_telemetry::Launch< + impl FnOnce(&AppServerTarget, bool) -> terminal_size_telemetry::LaunchTags, + >, startup_draft: startup_draft::StartupDraft, ) -> color_eyre::Result { let uses_remote_workspace = app_server_target.uses_remote_workspace(); @@ -1226,7 +1229,8 @@ async fn run_ratatui_app( ), ) .await; - launch_telemetry.record(&app_server_target, matches!(&startup_app_server, Ok(Ok(_)))); + tui.terminal_size_tags = + launch_telemetry.record(&app_server_target, matches!(&startup_app_server, Ok(Ok(_)))); let mut app_server_session = match startup_app_server { Ok(Ok(app_server)) => { AppServerSession::new(app_server, app_server_target.thread_params_mode()) diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index c29d464adc..a96e25bbfd 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -285,6 +285,9 @@ pub(super) async fn run_main_inner( }) .await .map_err(std::io::Error::other)??; + // Snapshot the backend size sampled during init, before any viewport changes or resizes. + let initial_terminal_size = initialized_terminal.terminal.last_known_screen_size; + let terminal_size_tags = crate::terminal_size_telemetry::tags(initial_terminal_size); let startup_presentation::StartupPresentation { bootstrap_config, config_cwd, @@ -684,6 +687,7 @@ pub(super) async fn run_main_inner( Vec::new() }; let mut launch_tags = daemon_settings.to_vec(); + launch_tags.extend(terminal_size_tags); launch_tags.extend([ ("daemon_selection_reason", selection_reason), ( @@ -697,7 +701,9 @@ pub(super) async fn run_main_inner( ]); // 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 Some(metrics) = metrics else { + return Vec::new(); + }; let app_server_mode = match (connected, target) { (false, _) => "unconfirmed", (true, AppServerTarget::Embedded) => "in_process", @@ -733,6 +739,8 @@ pub(super) async fn run_main_inner( ("multiplexer", multiplexer), ]); let _ = metrics.counter("codex.tui.start", /*inc*/ 1, &launch_tags); + launch_tags.retain(|(key, _)| !matches!(*key, "terminal_columns" | "terminal_rows")); + launch_tags }; let launch_telemetry = daemon_telemetry::Launch(Some(launch_telemetry)); let state_db = startup_draft diff --git a/codex-rs/tui/src/terminal_size_telemetry.rs b/codex-rs/tui/src/terminal_size_telemetry.rs new file mode 100644 index 0000000000..336a59a491 --- /dev/null +++ b/codex-rs/tui/src/terminal_size_telemetry.rs @@ -0,0 +1,64 @@ +//! Shared terminal-cell buckets and best-effort observations using the existing metrics provider. + +use crate::legacy_core::config::Config; +use codex_config::types::OtelExporterKind; +use ratatui::layout::Size; + +pub(crate) type LaunchTags = Vec<(&'static str, &'static str)>; + +pub(crate) fn enabled(config: &Config) -> bool { + match config.otel.metrics_exporter { + OtelExporterKind::None => false, + OtelExporterKind::Statsig => config.analytics_enabled != Some(false), + OtelExporterKind::OtlpHttp { .. } | OtelExporterKind::OtlpGrpc { .. } => true, + } +} + +pub(crate) fn tags(size: Size) -> [(&'static str, &'static str); 2] { + // Keep values compatible with metrics tag validation; `<` and `+` are rejected. + let columns = match size.width { + 0 => "unknown", + 1..40 => "under-40", + 40..80 => "40-79", + 80..120 => "80-119", + 120..240 => "120-239", + 240..480 => "240-479", + 480.. => "480-and-up", + }; + let rows = match size.height { + 0 => "unknown", + 1..15 => "under-15", + 15..24 => "15-23", + 24..40 => "24-39", + 40..80 => "40-79", + 80..120 => "80-119", + 120.. => "120-and-up", + }; + [("terminal_columns", columns), ("terminal_rows", rows)] +} + +pub(crate) fn read_size() -> Size { + // On Unix, size() may synchronously spawn tput when the direct query fails. + #[cfg(unix)] + let size = crossterm::terminal::window_size().map(|size| Size::new(size.columns, size.rows)); + // Windows size() queries the console directly; window_size() is unsupported there. + #[cfg(not(unix))] + let size = crossterm::terminal::size().map(|(width, height)| Size::new(width, height)); + size.unwrap_or_default() +} + +pub(crate) fn record(context: &LaunchTags, config: &Config, reason: &'static str, size: Size) { + if !context.is_empty() + && let Some(metrics) = codex_otel::global() + { + let mut labels = context.clone(); + labels.extend(tags(size)); + labels.push(("observation_reason", reason)); + let _ = metrics.counter_with_analytics_consent( + "codex.tui.terminal_size", + /*inc*/ 1, + &labels, + config.analytics_enabled != Some(false), + ); + } +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index dd0fc95ccd..01201ec1a0 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -664,6 +664,7 @@ pub struct Tui { frame_requester: FrameRequester, draw_tx: broadcast::Sender<()>, event_broker: Arc, + pub(crate) terminal_size_tags: crate::terminal_size_telemetry::LaunchTags, pub(crate) terminal: Terminal, pending_history_lines: Vec, clear_thread_switch_on_draw: bool, @@ -741,6 +742,7 @@ impl Tui { frame_requester, draw_tx, event_broker: Arc::new(event_broker), + terminal_size_tags: Vec::new(), terminal, pending_history_lines: vec![], clear_thread_switch_on_draw: false,