[codex] Add reusable OTEL gauge instruments (#27057)

## Why

Exec-server observability needs current-value measurements in addition
to counters. The reusable OTEL client should expose that primitive
without coupling it to exec-server runtime behavior.

## What changed

- Adds integer gauge instruments, with optional descriptions.
- Caches gauges by name and description so instrument metadata remains
part of the declaration identity.
- Covers gauge values, descriptions, merged attributes, and OTLP HTTP
export.

This PR only adds the gauge primitive. It does not add second-based
duration histograms or exec-server adoption.

## Stack

1. #26091: counter descriptions
2. **#27057: gauge instruments**
3. #27058: second-based duration histograms

Related independent coverage: #27059 tests OTLP HTTP log and trace event
export.

## Validation

- `just test -p codex-otel`
- `just fix -p codex-otel`
- `just fmt`
This commit is contained in:
richardopenai
2026-06-10 14:36:38 -07:00
committed by GitHub
Unverified
parent 22dd6ebc7d
commit 7e5e41daea
3 changed files with 91 additions and 0 deletions
+48
View File
@@ -12,6 +12,7 @@ use crate::metrics::validation::validate_tags;
use codex_utils_string::sanitize_metric_tag_value;
use opentelemetry::KeyValue;
use opentelemetry::metrics::Counter;
use opentelemetry::metrics::Gauge;
use opentelemetry::metrics::Histogram;
use opentelemetry::metrics::Meter;
use opentelemetry::metrics::MeterProvider as _;
@@ -89,6 +90,7 @@ struct MetricsClientInner {
meter_provider: SdkMeterProvider,
meter: Meter,
counters: Mutex<HashMap<InstrumentKey, Counter<u64>>>,
gauges: Mutex<HashMap<InstrumentKey, Gauge<i64>>>,
histograms: Mutex<HashMap<String, Histogram<f64>>>,
duration_histograms: Mutex<HashMap<String, Histogram<f64>>>,
runtime_reader: Option<Arc<ManualReader>>,
@@ -146,6 +148,35 @@ impl MetricsClientInner {
Ok(())
}
fn gauge(
&self,
name: &str,
description: Option<&str>,
value: i64,
tags: &[(&str, &str)],
) -> Result<()> {
validate_metric_name(name)?;
let attributes = self.attributes(tags)?;
let mut gauges = self
.gauges
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let key = InstrumentKey {
name: name.to_string(),
description: description.map(str::to_string),
};
let gauge = gauges.entry(key).or_insert_with(|| {
let builder = self.meter.i64_gauge(name.to_string());
match description {
Some(description) => builder.with_description(description.to_string()).build(),
None => builder.build(),
}
});
gauge.record(value, &attributes);
Ok(())
}
fn duration_histogram(&self, name: &str, value: i64, tags: &[(&str, &str)]) -> Result<()> {
validate_metric_name(name)?;
let attributes = self.attributes(tags)?;
@@ -253,6 +284,7 @@ impl MetricsClient {
meter_provider,
meter,
counters: Mutex::new(HashMap::new()),
gauges: Mutex::new(HashMap::new()),
histograms: Mutex::new(HashMap::new()),
duration_histograms: Mutex::new(HashMap::new()),
runtime_reader,
@@ -281,6 +313,22 @@ impl MetricsClient {
self.0.histogram(name, value, tags)
}
/// Send a single gauge measurement.
pub fn gauge(&self, name: &str, value: i64, tags: &[(&str, &str)]) -> Result<()> {
self.0.gauge(name, /*description*/ None, value, tags)
}
/// Send a single gauge measurement with an instrument description.
pub fn gauge_with_description(
&self,
name: &str,
description: &str,
value: i64,
tags: &[(&str, &str)],
) -> Result<()> {
self.0.gauge(name, Some(description), value, tags)
}
/// Record a duration in milliseconds using a histogram.
pub fn record_duration(
&self,
@@ -186,6 +186,12 @@ fn otlp_http_exporter_sends_metrics_to_collector() -> Result<()> {
))?;
metrics.counter("codex.turns", /*inc*/ 1, &[("source", "test")])?;
metrics.gauge_with_description(
"codex.active",
"Number of active Codex operations.",
/*value*/ 1,
&[("component", "test")],
)?;
metrics.shutdown()?;
server.join().expect("server join");
@@ -220,6 +226,16 @@ fn otlp_http_exporter_sends_metrics_to_collector() -> Result<()> {
"expected metric name not found; body prefix: {}",
&body.chars().take(2000).collect::<String>()
);
assert!(
body.contains("codex.active"),
"expected gauge not found; body prefix: {}",
&body.chars().take(2000).collect::<String>()
);
assert!(
body.contains("component") && body.contains("test"),
"expected gauge tag not found; body prefix: {}",
&body.chars().take(2000).collect::<String>()
);
Ok(())
}
+27
View File
@@ -24,6 +24,12 @@ fn send_builds_payload_with_tags_and_histograms() -> Result<()> {
/*value*/ 25,
&[("tool", "shell")],
)?;
metrics.gauge_with_description(
"codex.active",
"Number of active Codex operations.",
/*value*/ 2,
&[("component", "test")],
)?;
metrics.shutdown()?;
let resource_metrics = latest_metrics(&exporter);
@@ -80,6 +86,27 @@ fn send_builds_payload_with_tags_and_histograms() -> Result<()> {
]);
assert_eq!(histogram_attrs, expected_histogram_attributes);
let gauge = find_metric(&resource_metrics, "codex.active").expect("gauge metric missing");
assert_eq!(gauge.description(), "Number of active Codex operations.");
let gauge_point = match gauge.data() {
opentelemetry_sdk::metrics::data::AggregatedMetrics::I64(data) => match data {
opentelemetry_sdk::metrics::data::MetricData::Gauge(gauge) => {
gauge.data_points().next().expect("gauge point")
}
_ => panic!("unexpected gauge aggregation"),
},
_ => panic!("unexpected gauge metric data type"),
};
assert_eq!(gauge_point.value(), 2);
assert_eq!(
attributes_to_map(gauge_point.attributes()),
BTreeMap::from([
("component".to_string(), "test".to_string()),
("env".to_string(), "prod".to_string()),
("service".to_string(), "codex-cli".to_string()),
])
);
Ok(())
}