diff --git a/codex-rs/otel/README.md b/codex-rs/otel/README.md index 0de50826a2..1140964f14 100644 --- a/codex-rs/otel/README.md +++ b/codex-rs/otel/README.md @@ -180,6 +180,12 @@ Modes: - OTLP: exports metrics via the OpenTelemetry OTLP exporter (HTTP or gRPC). - In-memory: records via `opentelemetry_sdk::metrics::InMemoryMetricExporter` for tests/assertions; call `shutdown()` to flush. +Configured OTLP metrics exporters (HTTP or gRPC) default to delta temporality. Set +`OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=cumulative` for backends that require +cumulative metrics. The values `delta`, `cumulative`, and `lowmemory` are case-insensitive; +unset, empty, or unrecognized values retain the delta default. This setting does not change +the built-in Statsig exporter or on-demand runtime snapshots. + `codex-otel` also provides `OtelExporter::Statsig`, a shorthand for exporting OTLP/HTTP JSON metrics to Statsig using Codex-internal defaults. diff --git a/codex-rs/otel/src/metrics/client.rs b/codex-rs/otel/src/metrics/client.rs index ca7fa74393..8bbb12cdc5 100644 --- a/codex-rs/otel/src/metrics/client.rs +++ b/codex-rs/otel/src/metrics/client.rs @@ -363,10 +363,16 @@ impl MetricsClient { build_provider(resource, exporter, export_interval, runtime_reader.clone()) } MetricsExporter::Otlp(exporter) => { + let temporality = otlp_metrics_temporality( + &exporter, + std::env::var("OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE") + .ok() + .as_deref(), + ); let exporter = crate::network_policy::PolicyExporter { exporter: build_otlp_metric_exporter( exporter, - Temporality::Delta, + temporality, &http_client_factory, )?, policy: http_client_factory.network_policy().clone(), @@ -589,6 +595,19 @@ where (provider, meter) } +fn otlp_metrics_temporality(exporter: &OtelExporter, preference: Option<&str>) -> Temporality { + // Select before resolving Statsig to OTLP so its built-in route stays Delta. + match exporter { + OtelExporter::None | OtelExporter::Statsig => Temporality::Delta, + OtelExporter::OtlpGrpc { .. } | OtelExporter::OtlpHttp { .. } => match preference { + Some(value) if value.eq_ignore_ascii_case("cumulative") => Temporality::Cumulative, + Some(value) if value.eq_ignore_ascii_case("lowmemory") => Temporality::LowMemory, + // Preserve Codex's existing default for unset, empty, or unknown values. + _ => Temporality::Delta, + }, + } +} + fn build_otlp_metric_exporter( exporter: OtelExporter, temporality: Temporality, diff --git a/codex-rs/otel/tests/suite/otlp_http_loopback.rs b/codex-rs/otel/tests/suite/otlp_http_loopback.rs index d0cedbef52..3fd9249603 100644 --- a/codex-rs/otel/tests/suite/otlp_http_loopback.rs +++ b/codex-rs/otel/tests/suite/otlp_http_loopback.rs @@ -11,6 +11,7 @@ use codex_otel::Result; use codex_otel::current_span_w3c_trace_context; use codex_otel::set_parent_from_w3c_trace_context; use codex_protocol::protocol::W3cTraceContext; +use pretty_assertions::assert_eq; use std::collections::BTreeMap; use std::collections::HashMap; use std::io::Read as _; @@ -143,7 +144,34 @@ fn write_http_response(stream: &mut TcpStream, status: &str) -> std::io::Result< } #[test] -fn otlp_http_exporter_sends_metrics_to_collector() -> Result<()> { +fn otlp_http_exporter_honors_cumulative_temporality_env() -> Result<()> { + // Isolate the environment setting from other tests, including under Bazel/libtest. + const CHILD_ENV: &str = "CODEX_OTEL_TEMPORALITY_TEST_CHILD"; + if std::env::var_os(CHILD_ENV).is_none() { + let current_thread = thread::current(); + let output = std::process::Command::new(std::env::current_exe().expect("test executable")) + .args([ + "--exact", + current_thread.name().expect("test name"), + "--nocapture", + ]) + .env(CHILD_ENV, "1") + .env( + "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", + "cumulative", + ) + .env("OTEL_METRIC_EXPORT_INTERVAL", "60000") + .output() + .expect("run test with cumulative temporality configured"); + assert!( + output.status.success(), + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + return Ok(()); + } + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); let addr = listener.local_addr().expect("local_addr"); listener.set_nonblocking(true).expect("set_nonblocking"); @@ -252,6 +280,18 @@ fn otlp_http_exporter_sends_metrics_to_collector() -> Result<()> { "unexpected content-type: {content_type}" ); + let payload: serde_json::Value = serde_json::from_slice(&request.body).expect("OTLP JSON"); + let counter = payload["resourceMetrics"][0]["scopeMetrics"][0]["metrics"] + .as_array() + .expect("exported metrics") + .iter() + .find(|metric| metric["name"] == "codex.turns") + .expect("exported counter"); + assert_eq!( + counter["sum"]["aggregationTemporality"], + serde_json::json!(2) + ); + let body = String::from_utf8_lossy(&request.body); assert!( body.contains("codex.turns"),