exec-server: cover metric export end to end

This commit is contained in:
Richard Lee
2026-06-10 13:49:50 -07:00
parent 9dd39b74a3
commit e4eae35494
2 changed files with 175 additions and 0 deletions
+102
View File
@@ -2,6 +2,7 @@ use std::io::Read as _;
use std::io::Write as _;
use std::net::TcpListener;
use std::path::Path;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::time::Instant;
@@ -55,6 +56,53 @@ fn local_exec_server_ignores_invalid_config_without_strict_config() -> Result<()
Ok(())
}
#[test]
fn local_exec_server_exports_real_otel_metrics() -> Result<()> {
let collector = TestCollector::start()?;
let codex_home = TempDir::new()?;
let base_url = &collector.base_url;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"
[analytics]
enabled = true
[otel]
environment = "test"
metrics_exporter = {{ otlp-http = {{ endpoint = "{base_url}/v1/metrics", protocol = "json" }} }}
"#
),
)?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["exec-server", "--listen", "stdio"])
.write_stdin(
r#"{"id":1,"method":"initialize","params":{"clientName":"otel-test","resumeSessionId":null}}"#,
)
.assert()
.success();
let requests = collector.finish()?;
let metrics = requests
.iter()
.filter(|request| request.path == "/v1/metrics")
.map(|request| request.body.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
metrics.contains("exec_server_connections_active"),
"{metrics}"
);
assert!(metrics.contains("exec_server_requests_total"), "{metrics}");
assert!(metrics.contains("initialize"), "{metrics}");
assert!(
metrics.contains("success") || metrics.contains("disconnected"),
"{metrics}"
);
Ok(())
}
#[test]
fn remote_exec_server_preserves_websocket_error_in_stderr() -> Result<()> {
let failed_websocket_listener = TcpListener::bind("127.0.0.1:0")?;
@@ -109,6 +157,60 @@ struct CapturedRequest {
body: String,
}
struct TestCollector {
base_url: String,
requests: mpsc::Receiver<Vec<CapturedRequest>>,
stop: mpsc::Sender<()>,
server: thread::JoinHandle<()>,
}
impl TestCollector {
fn start() -> Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
listener.set_nonblocking(true)?;
let (tx, requests) = mpsc::channel();
let (stop, stop_rx) = mpsc::channel();
let server = thread::spawn(move || {
let mut captured = Vec::new();
loop {
match listener.accept() {
Ok((mut stream, _)) => {
if let Ok(request) = read_http_request(&mut stream) {
captured.push(request);
}
let _ = stream.write_all(
b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
);
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
if stop_rx.try_recv().is_ok() {
break;
}
thread::sleep(Duration::from_millis(10));
}
Err(_) => break,
}
}
let _ = tx.send(captured);
});
Ok(Self {
base_url: format!("http://{addr}"),
requests,
stop,
server,
})
}
fn finish(self) -> Result<Vec<CapturedRequest>> {
let _ = self.stop.send(());
self.server
.join()
.map_err(|_| anyhow::anyhow!("collector thread panicked"))?;
Ok(self.requests.recv_timeout(Duration::from_secs(1))?)
}
}
struct TestRegistry {
base_url: String,
server: thread::JoinHandle<Result<()>>,
@@ -6,6 +6,9 @@ use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_otel::MetricsConfig;
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
use opentelemetry_sdk::metrics::data::ScopeMetrics;
use pretty_assertions::assert_eq;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
@@ -112,6 +115,76 @@ async fn stdio_listen_transport_serves_initialize() {
.expect("stdio transport should not fail");
}
#[tokio::test]
async fn stdio_listen_transport_emits_connection_and_request_metrics() {
let exporter = InMemoryMetricExporter::default();
let metrics = codex_otel::MetricsClient::new(MetricsConfig::in_memory(
"test",
"codex-exec-server",
env!("CARGO_PKG_VERSION"),
exporter.clone(),
))
.expect("metrics");
let telemetry = crate::ExecServerTelemetry::new(Some(metrics.clone()));
let (mut client_writer, server_reader) = duplex(1 << 20);
let (server_writer, client_reader) = duplex(1 << 20);
let server_task = tokio::spawn(run_stdio_connection_with_io(
server_reader,
server_writer,
test_runtime_paths(),
telemetry,
));
let mut client_lines = BufReader::new(client_reader).lines();
write_jsonrpc_line(
&mut client_writer,
&JSONRPCMessage::Request(JSONRPCRequest {
id: RequestId::Integer(1),
method: INITIALIZE_METHOD.to_string(),
params: Some(
serde_json::to_value(InitializeParams {
client_name: "exec-server-metrics-test".to_string(),
resume_session_id: None,
})
.expect("initialize params should serialize"),
),
trace: None,
}),
)
.await;
let _response = timeout(Duration::from_secs(1), client_lines.next_line())
.await
.expect("initialize response should arrive")
.expect("initialize response read should succeed")
.expect("initialize response should be present");
drop(client_writer);
drop(client_lines);
timeout(Duration::from_secs(1), server_task)
.await
.expect("stdio transport should finish after client disconnect")
.expect("stdio transport task should join")
.expect("stdio transport should not fail");
metrics.shutdown().expect("shutdown metrics");
let metric_names = exporter
.get_finished_metrics()
.expect("finished metrics")
.into_iter()
.flat_map(|metrics| {
metrics
.scope_metrics()
.flat_map(ScopeMetrics::metrics)
.map(|metric| metric.name().to_string())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
assert!(metric_names.contains(&"exec_server_connections_active".to_string()));
assert!(metric_names.contains(&"exec_server_connections_total".to_string()));
assert!(metric_names.contains(&"exec_server_requests_total".to_string()));
assert!(metric_names.contains(&"exec_server_request_duration_seconds".to_string()));
}
#[test]
fn parse_listen_url_accepts_websocket_url() {
let transport =