mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Record bucketed terminal sizes in TUI telemetry (#53859)
## What changed - Add `terminal_columns` and `terminal_rows` buckets to `codex.tui.start`. - Record `codex.tui.terminal_size` for new turns in the active thread and for resize bucket changes after the terminal size settles for 15 seconds. Reuse launch context and suppress duplicate turn notifications. - Respect analytics consent for the built-in metrics exporter while allowing explicitly configured OTLP collectors. Apply consent checks to the active exporter across provider replacements. ## Testing Add coverage for exporter replacement and consent after config reloads. Extend the PTY integration test to verify startup size buckets, turn-start observations, and delayed resize observations with analytics disabled and an explicit OTLP collector. GitOrigin-RevId: e01a900cd3f04a36731078f725c75374596cb01b
This commit is contained in:
@@ -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::<Vec<_>>();
|
||||
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::<anyhow::Result<HashMap<_, _>>>()?;
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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<HashMap<String, Histogram<f64>>>,
|
||||
duration_histograms: Mutex<HashMap<InstrumentKey, Histogram<f64>>>,
|
||||
runtime_reader: Option<Arc<ManualReader>>,
|
||||
requires_analytics_consent: bool,
|
||||
statsig_disabled_metrics: &'static [&'static str],
|
||||
default_tags: BTreeMap<String, String>,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -536,6 +536,7 @@ pub(crate) struct App {
|
||||
config_notification_test_pending: Option<u64>,
|
||||
model_catalog: Arc<ModelCatalog>,
|
||||
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<WorkspaceCommandRunner>,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -26,6 +26,7 @@ pub(crate) async fn make_test_app() -> Box<App> {
|
||||
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,
|
||||
|
||||
@@ -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<App> {
|
||||
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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ThreadId> {
|
||||
self.thread_id
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<F: FnOnce(&AppServerTarget, bool)>(pub(super) Option<F>);
|
||||
pub(super) struct Launch<F: FnOnce(&AppServerTarget, bool) -> LaunchTags>(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) -> LaunchTags> Launch<F> {
|
||||
pub(super) fn record(mut self, target: &AppServerTarget, connected: bool) -> LaunchTags {
|
||||
self.0
|
||||
.take()
|
||||
.map(|record| record(target, connected))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl<F: FnOnce(&AppServerTarget, bool)> Drop for Launch<F> {
|
||||
impl<F: FnOnce(&AppServerTarget, bool) -> LaunchTags> Drop for Launch<F> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(record) = self.0.take() {
|
||||
record(&AppServerTarget::Embedded, /*connected*/ false);
|
||||
|
||||
@@ -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<ManagedTuiWorktree>,
|
||||
daemon_startup_warning: Option<String>,
|
||||
managed_daemon: bool,
|
||||
launch_telemetry: daemon_telemetry::Launch<impl FnOnce(&AppServerTarget, bool)>,
|
||||
launch_telemetry: daemon_telemetry::Launch<
|
||||
impl FnOnce(&AppServerTarget, bool) -> terminal_size_telemetry::LaunchTags,
|
||||
>,
|
||||
startup_draft: startup_draft::StartupDraft,
|
||||
) -> color_eyre::Result<AppExitInfo> {
|
||||
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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -664,6 +664,7 @@ pub struct Tui {
|
||||
frame_requester: FrameRequester,
|
||||
draw_tx: broadcast::Sender<()>,
|
||||
event_broker: Arc<EventBroker>,
|
||||
pub(crate) terminal_size_tags: crate::terminal_size_telemetry::LaunchTags,
|
||||
pub(crate) terminal: Terminal,
|
||||
pending_history_lines: Vec<PendingHistoryLines>,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user