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:
Ian MacLeod
2026-10-14 03:52:30 +00:00
committed by copyberry
parent e342df7b66
commit c22a06c180
16 changed files with 349 additions and 16 deletions
+84 -6
View File
@@ -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");
}