Files
codex/codex-rs/otel/tests/suite/send.rs
T
Matthew Zeng 456212ca21 Use explicit histogram buckets for tool and skill context metrics (#48819)
## What changed

- Give tool fragment sizes and namespace counts logarithmic boundaries through 32,768.
- Give enabled and kept skill counts integer boundaries from 0 through 512, removed description characters logarithmic boundaries through 131,072, and skill truncation flags boundaries at 0 and 1.
- Record these metrics with explicit boundaries, separating zero values and retaining overflow buckets above each range.

## Testing

Extend tool and skill metric tests to assert explicit boundaries. Add an export test covering zero, one, the upper boundary, and overflow, including bucket counts, ordering, and sample totals.

GitOrigin-RevId: 9fbf3acbe352d7c74f3ad11f0b690148c083a6ef
2026-09-28 00:05:54 +00:00

328 lines
12 KiB
Rust

use crate::harness::attributes_to_map;
use crate::harness::build_metrics_with_defaults;
use crate::harness::find_metric;
use crate::harness::histogram_data;
use crate::harness::latest_metrics;
use codex_otel::Result;
use codex_otel::THREAD_SKILLS_COUNT_METRIC_BUCKETS;
use codex_otel::THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_BUCKETS;
use codex_otel::THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC;
use codex_otel::THREAD_SKILLS_ENABLED_TOTAL_METRIC;
use codex_otel::THREAD_SKILLS_TRUNCATED_BUCKETS;
use codex_otel::THREAD_SKILLS_TRUNCATED_METRIC;
use codex_otel::THREAD_TOOLS_FRAGMENT_BYTES_METRIC;
use codex_otel::THREAD_TOOLS_METRIC_BUCKETS;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
// Ensures counters/histograms render with default + per-call tags.
#[test]
fn send_builds_payload_with_tags_and_histograms() -> Result<()> {
let (metrics, exporter) =
build_metrics_with_defaults(&[("service", "codex-cli"), ("env", "prod")])?;
metrics.counter_with_description(
"codex.turns",
"Total number of Codex turns.",
/*inc*/ 1,
&[("model", "gpt-5.1"), ("env", "dev")],
)?;
metrics.histogram(
"codex.tool_latency",
/*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);
let counter = find_metric(&resource_metrics, "codex.turns").expect("counter metric missing");
assert_eq!(counter.description(), "Total number of Codex turns.");
let counter_attributes = match counter.data() {
opentelemetry_sdk::metrics::data::AggregatedMetrics::U64(data) => match data {
opentelemetry_sdk::metrics::data::MetricData::Sum(sum) => {
let points: Vec<_> = sum.data_points().collect();
assert_eq!(points.len(), 1);
assert_eq!(points[0].value(), 1);
attributes_to_map(points[0].attributes())
}
_ => panic!("unexpected counter aggregation"),
},
_ => panic!("unexpected counter data type"),
};
let expected_counter_attributes = BTreeMap::from([
("service".to_string(), "codex-cli".to_string()),
("env".to_string(), "dev".to_string()),
("model".to_string(), "gpt-5.1".to_string()),
]);
assert_eq!(counter_attributes, expected_counter_attributes);
let (bounds, bucket_counts, sum, count) =
histogram_data(&resource_metrics, "codex.tool_latency");
assert!(!bounds.is_empty());
assert_eq!(bucket_counts.iter().sum::<u64>(), 1);
assert_eq!(sum, 25.0);
assert_eq!(count, 1);
let histogram_attrs = attributes_to_map(
find_metric(&resource_metrics, "codex.tool_latency")
.and_then(|metric| match metric.data() {
opentelemetry_sdk::metrics::data::AggregatedMetrics::F64(
opentelemetry_sdk::metrics::data::MetricData::Histogram(histogram),
) => histogram
.data_points()
.next()
.map(opentelemetry_sdk::metrics::data::HistogramDataPoint::attributes),
_ => None,
})
.expect("codex.tool_latency histogram attributes should exist"),
);
let expected_histogram_attributes = BTreeMap::from([
("service".to_string(), "codex-cli".to_string()),
("env".to_string(), "prod".to_string()),
("tool".to_string(), "shell".to_string()),
]);
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(())
}
#[test]
fn histogram_uses_explicit_bucket_boundaries() -> Result<()> {
let (metrics, exporter) = build_metrics_with_defaults(&[])?;
metrics.histogram_with_boundaries(
"codex.payload_bytes",
/*value*/ 1024,
&[256.0, 1024.0, 4096.0],
&[],
)?;
metrics.shutdown()?;
assert_eq!(
histogram_data(&latest_metrics(&exporter), "codex.payload_bytes"),
(vec![256.0, 1024.0, 4096.0], vec![0, 1, 0, 0], 1024.0, 1)
);
Ok(())
}
#[test]
fn context_histograms_preserve_zero_and_family_ranges() -> Result<()> {
let (metrics, exporter) = build_metrics_with_defaults(&[])?;
let cases = [
(
THREAD_TOOLS_FRAGMENT_BYTES_METRIC,
THREAD_TOOLS_METRIC_BUCKETS.as_slice(),
32_768_i64,
512,
),
(
THREAD_SKILLS_ENABLED_TOTAL_METRIC,
THREAD_SKILLS_COUNT_METRIC_BUCKETS.as_slice(),
512,
514,
),
(
THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC,
THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_BUCKETS.as_slice(),
131_072,
512,
),
(
THREAD_SKILLS_TRUNCATED_METRIC,
THREAD_SKILLS_TRUNCATED_BUCKETS,
1,
3,
),
];
for (name, boundaries, maximum, _) in cases {
for value in [0, 1, maximum, maximum + 1] {
metrics.histogram_with_boundaries(name, value, boundaries, &[])?;
}
}
metrics.shutdown()?;
let resource_metrics = latest_metrics(&exporter);
for (name, expected_bounds, maximum, bucket_count) in cases {
let (bounds, counts, sum, count) = histogram_data(&resource_metrics, name);
assert_eq!(bounds.as_slice(), expected_bounds);
assert_eq!(bounds.len(), bucket_count - 1);
assert_eq!(bounds[0], 0.0);
assert_eq!(bounds[1], 1.0);
assert_eq!(bounds[bucket_count - 2], maximum as f64);
assert!(bounds.windows(/*size*/ 2).all(|pair| pair[0] < pair[1]));
let mut expected_counts = vec![0; bucket_count];
for index in [0, 1, bucket_count - 2, bucket_count - 1] {
expected_counts[index] += 1;
}
assert_eq!(counts, expected_counts);
assert_eq!((sum, count), ((2 * maximum + 2) as f64, 4));
}
Ok(())
}
// Ensures defaults merge per line and overrides take precedence.
#[test]
fn send_merges_default_tags_per_line() -> Result<()> {
let (metrics, exporter) = build_metrics_with_defaults(&[
("service", "codex-cli"),
("env", "prod"),
("region", "us"),
])?;
metrics.counter(
"codex.alpha",
/*inc*/ 1,
&[("env", "dev"), ("component", "alpha")],
)?;
metrics.counter(
"codex.beta",
/*inc*/ 2,
&[("service", "worker"), ("component", "beta")],
)?;
metrics.shutdown()?;
let resource_metrics = latest_metrics(&exporter);
let alpha_metric =
find_metric(&resource_metrics, "codex.alpha").expect("codex.alpha metric missing");
let alpha_point = match alpha_metric.data() {
opentelemetry_sdk::metrics::data::AggregatedMetrics::U64(data) => match data {
opentelemetry_sdk::metrics::data::MetricData::Sum(sum) => {
let points: Vec<_> = sum.data_points().collect();
assert_eq!(points.len(), 1);
points[0]
}
_ => panic!("unexpected counter aggregation"),
},
_ => panic!("unexpected counter data type"),
};
assert_eq!(alpha_point.value(), 1);
let alpha_attrs = attributes_to_map(alpha_point.attributes());
let expected_alpha_attrs = BTreeMap::from([
("component".to_string(), "alpha".to_string()),
("env".to_string(), "dev".to_string()),
("region".to_string(), "us".to_string()),
("service".to_string(), "codex-cli".to_string()),
]);
assert_eq!(alpha_attrs, expected_alpha_attrs);
let beta_metric =
find_metric(&resource_metrics, "codex.beta").expect("codex.beta metric missing");
let beta_point = match beta_metric.data() {
opentelemetry_sdk::metrics::data::AggregatedMetrics::U64(data) => match data {
opentelemetry_sdk::metrics::data::MetricData::Sum(sum) => {
let points: Vec<_> = sum.data_points().collect();
assert_eq!(points.len(), 1);
points[0]
}
_ => panic!("unexpected counter aggregation"),
},
_ => panic!("unexpected counter data type"),
};
assert_eq!(beta_point.value(), 2);
let beta_attrs = attributes_to_map(beta_point.attributes());
let expected_beta_attrs = BTreeMap::from([
("component".to_string(), "beta".to_string()),
("env".to_string(), "prod".to_string()),
("region".to_string(), "us".to_string()),
("service".to_string(), "worker".to_string()),
]);
assert_eq!(beta_attrs, expected_beta_attrs);
Ok(())
}
// Verifies enqueued metrics are delivered by the background worker.
#[test]
fn client_sends_enqueued_metric() -> Result<()> {
let (metrics, exporter) = build_metrics_with_defaults(&[])?;
metrics.counter("codex.turns", /*inc*/ 1, &[("model", "gpt-5.1")])?;
metrics.shutdown()?;
let resource_metrics = latest_metrics(&exporter);
let counter = find_metric(&resource_metrics, "codex.turns").expect("counter metric missing");
let points = match counter.data() {
opentelemetry_sdk::metrics::data::AggregatedMetrics::U64(data) => match data {
opentelemetry_sdk::metrics::data::MetricData::Sum(sum) => {
sum.data_points().collect::<Vec<_>>()
}
_ => panic!("unexpected counter aggregation"),
},
_ => panic!("unexpected counter data type"),
};
assert_eq!(points.len(), 1);
let point = points[0];
assert_eq!(point.value(), 1);
let attrs = attributes_to_map(point.attributes());
assert_eq!(attrs.get("model").map(String::as_str), Some("gpt-5.1"));
Ok(())
}
// Ensures shutdown flushes successfully with in-memory exporters.
#[test]
fn shutdown_flushes_in_memory_exporter() -> Result<()> {
let (metrics, exporter) = build_metrics_with_defaults(&[])?;
metrics.counter("codex.turns", /*inc*/ 1, &[])?;
metrics.shutdown()?;
let resource_metrics = latest_metrics(&exporter);
let counter = find_metric(&resource_metrics, "codex.turns").expect("counter metric missing");
let points = match counter.data() {
opentelemetry_sdk::metrics::data::AggregatedMetrics::U64(data) => match data {
opentelemetry_sdk::metrics::data::MetricData::Sum(sum) => {
sum.data_points().collect::<Vec<_>>()
}
_ => panic!("unexpected counter aggregation"),
},
_ => panic!("unexpected counter data type"),
};
assert_eq!(points.len(), 1);
Ok(())
}
// Ensures shutting down without recording metrics does not export anything.
#[test]
fn shutdown_without_metrics_exports_nothing() -> Result<()> {
let (metrics, exporter) = build_metrics_with_defaults(&[])?;
metrics.shutdown()?;
let finished = exporter.get_finished_metrics().unwrap();
assert!(finished.is_empty(), "expected no metrics exported");
Ok(())
}