Honor the OTLP metrics temporality preference (#51220)

## Why

Some metrics backends require cumulative metrics, while configured OTLP exporters previously always used delta temporality.

## What changed

Read `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` for configured HTTP and gRPC metrics exporters. Accept `delta`, `cumulative`, and `lowmemory` case-insensitively, retaining delta for unset, empty, or unrecognized values. Document the setting and preserve existing temporality for the built-in Statsig exporter and runtime snapshots.

## Testing

Update the HTTP loopback test to set the preference in an isolated subprocess and assert that the exported counter uses cumulative aggregation temporality.

GitOrigin-RevId: ca212eea20054b947005d323186c8b9a819ace9d
This commit is contained in:
daniel-oai
2026-10-06 01:36:17 +00:00
committed by copyberry
parent 4171362e44
commit 28b91c7c31
3 changed files with 67 additions and 2 deletions
+6
View File
@@ -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.
+20 -1
View File
@@ -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,
@@ -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"),