Skip to content

Commit 55d30c1

Browse files
palindrom615claude
andcommitted
feat(opentelemetry): implement set_histogram_bounds for custom bucket boundaries
Add support for configuring custom histogram bucket boundaries in the OpenTelemetry exporter. This allows users to define specific bucket boundaries for individual metrics, enabling better histogram resolution tailored to expected value distributions. - Add histogram_bounds HashMap shared between recorder and storage via Arc<RwLock<>> - Implement set_histogram_bounds() method to configure boundaries before histogram creation - Apply custom boundaries when creating histograms in OtelMetricStorage - Add comprehensive test coverage for custom histogram bounds functionality - Fix integration tests to use InMemoryMetricExporter with proper assertions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ba3b1ff commit 55d30c1

File tree

3 files changed

+109
-6
lines changed

3 files changed

+109
-6
lines changed

metrics-exporter-opentelemetry/src/lib.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,38 @@ use metrics::{Counter, Gauge, Histogram, Key, KeyName, Recorder, SharedString, U
99
use metrics_util::registry::Registry;
1010
use metrics_util::MetricKind;
1111
use opentelemetry::metrics::Meter;
12-
use std::sync::Arc;
12+
use std::collections::HashMap;
13+
use std::sync::{Arc, RwLock};
1314

1415
/// The OpenTelemetry recorder.
1516
pub struct OpenTelemetryRecorder {
1617
registry: Registry<Key, OtelMetricStorage>,
1718
description_table: Arc<DescriptionTable>,
19+
histogram_bounds: Arc<RwLock<HashMap<KeyName, Vec<f64>>>>,
1820
}
1921

2022
impl OpenTelemetryRecorder {
2123
/// Creates a new OpenTelemetry recorder with the given meter.
2224
pub fn new(meter: Meter) -> Self {
2325
let description_table = Arc::new(DescriptionTable::default());
24-
let storage = OtelMetricStorage::new(meter, description_table.clone());
25-
Self { registry: Registry::new(storage), description_table }
26+
let histogram_bounds = Arc::new(RwLock::new(HashMap::new()));
27+
let storage = OtelMetricStorage::new(meter, description_table.clone(), histogram_bounds.clone());
28+
Self {
29+
registry: Registry::new(storage),
30+
description_table,
31+
histogram_bounds,
32+
}
2633
}
2734

35+
pub fn set_histogram_bounds(
36+
&self,
37+
key: &KeyName,
38+
bounds: Vec<f64>,
39+
) {
40+
let mut bounds_map = self.histogram_bounds.write().unwrap();
41+
bounds_map.insert(key.clone(), bounds);
42+
}
43+
2844
/// Gets a description entry for testing purposes.
2945
#[cfg(test)]
3046
pub fn get_description(

metrics-exporter-opentelemetry/src/storage.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,22 @@ use metrics_util::registry::Storage;
55
use metrics_util::MetricKind;
66
use opentelemetry::metrics::{AsyncInstrumentBuilder, HistogramBuilder, Meter};
77
use opentelemetry::KeyValue;
8-
use std::sync::Arc;
8+
use std::collections::HashMap;
9+
use std::sync::{Arc, PoisonError, RwLock};
910

1011
pub struct OtelMetricStorage {
1112
meter: Meter,
1213
description_table: Arc<DescriptionTable>,
14+
histogram_bounds: Arc<RwLock<HashMap<KeyName, Vec<f64>>>>,
1315
}
1416

1517
impl OtelMetricStorage {
16-
pub fn new(meter: Meter, description_table: Arc<DescriptionTable>) -> Self {
17-
Self { meter, description_table }
18+
pub fn new(meter: Meter, description_table: Arc<DescriptionTable>, histogram_bounds: Arc<RwLock<HashMap<KeyName, Vec<f64>>>>) -> Self {
19+
Self {
20+
meter,
21+
description_table,
22+
histogram_bounds,
23+
}
1824
}
1925

2026
fn get_attributes(key: &Key) -> Vec<KeyValue> {
@@ -92,6 +98,18 @@ impl Storage<Key> for OtelMetricStorage {
9298
} else {
9399
builder
94100
};
101+
102+
// Apply histogram bounds if they exist
103+
let key_name = KeyName::from(key.name().to_string());
104+
let builder = {
105+
let bounds_map = self.histogram_bounds.read().unwrap_or_else(PoisonError::into_inner);;
106+
if let Some(bounds) = bounds_map.get(&key_name) {
107+
builder.with_boundaries(bounds.clone())
108+
} else {
109+
builder
110+
}
111+
};
112+
95113
let attributes = Self::get_attributes(key);
96114
Arc::new(OtelHistogram::new(builder.build(), attributes))
97115
}

metrics-exporter-opentelemetry/tests/integration_test.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,3 +391,72 @@ fn test_metrics_with_multiple_labels() {
391391
assert!(attrs.iter().any(|a| a.key == Key::from("path") && a.value == Value::from("/api/v1/users")));
392392
assert_eq!(point.value(), 5, "Counter value should be 5");
393393
}
394+
395+
#[test]
396+
fn test_histogram_with_custom_bounds() {
397+
// Given: OpenTelemetry recorder with custom histogram bounds
398+
let exporter = InMemoryMetricExporter::default();
399+
let reader = PeriodicReader::builder(exporter.clone())
400+
.with_interval(Duration::from_millis(100))
401+
.build();
402+
let provider = SdkMeterProvider::builder().with_reader(reader).build();
403+
let meter = provider.meter("test_meter");
404+
let recorder = OpenTelemetryRecorder::new(meter);
405+
406+
// When: Custom bounds are set for a histogram before it's created
407+
recorder.set_histogram_bounds(
408+
&metrics::KeyName::from("latency"),
409+
vec![0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
410+
);
411+
412+
// And: Histogram is registered and values are recorded
413+
let key = metrics::Key::from_name("latency");
414+
let metadata = metrics::Metadata::new("test", metrics::Level::INFO, Some("latency"));
415+
let histogram = recorder.register_histogram(&key, &metadata);
416+
histogram.record(0.03);
417+
histogram.record(0.12);
418+
histogram.record(0.75);
419+
histogram.record(3.5);
420+
421+
provider.force_flush().unwrap();
422+
423+
// Then: Histogram should be recorded with values
424+
let metrics = exporter.get_finished_metrics().unwrap();
425+
let latency_metric = metrics.last().unwrap().scope_metrics()
426+
.flat_map(|sm| sm.metrics())
427+
.find(|m| m.name() == "latency")
428+
.expect("latency metric should exist");
429+
430+
let AggregatedMetrics::F64(metric_data) = latency_metric.data() else {
431+
panic!("Histogram should be F64");
432+
};
433+
let MetricData::Histogram(hist_data) = metric_data else {
434+
panic!("Should be Histogram type");
435+
};
436+
437+
let point = hist_data.data_points().next().expect("Should have data point");
438+
439+
// Verify the histogram has recorded values
440+
assert_eq!(point.count(), 4, "Should have 4 recordings");
441+
assert_eq!(point.sum(), 0.03 + 0.12 + 0.75 + 3.5, "Sum should be correct");
442+
443+
// Check bucket boundaries are applied (9 boundaries create 10 buckets)
444+
let boundaries: Vec<_> = point.bounds().collect();
445+
assert_eq!(boundaries.len(), 9, "Should have 9 boundaries");
446+
assert_eq!(boundaries, vec![0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]);
447+
448+
// Check bucket counts
449+
let counts: Vec<_> = point.bucket_counts().collect();
450+
assert_eq!(counts.len(), 10, "Should have 10 buckets");
451+
// Values: 0.03 (bucket 1), 0.12 (bucket 3), 0.75 (bucket 5), 3.5 (bucket 7)
452+
assert_eq!(counts[0], 0, "Bucket [0, 0.01) should be empty");
453+
assert_eq!(counts[1], 1, "Bucket [0.01, 0.05) should have 1 value (0.03)");
454+
assert_eq!(counts[2], 0, "Bucket [0.05, 0.1) should be empty");
455+
assert_eq!(counts[3], 1, "Bucket [0.1, 0.25) should have 1 value (0.12)");
456+
assert_eq!(counts[4], 0, "Bucket [0.25, 0.5) should be empty");
457+
assert_eq!(counts[5], 1, "Bucket [0.5, 1.0) should have 1 value (0.75)");
458+
assert_eq!(counts[6], 0, "Bucket [1.0, 2.5) should be empty");
459+
assert_eq!(counts[7], 1, "Bucket [2.5, 5.0) should have 1 value (3.5)");
460+
assert_eq!(counts[8], 0, "Bucket [5.0, 10.0) should be empty");
461+
assert_eq!(counts[9], 0, "Bucket [10.0, +∞) should be empty");
462+
}

0 commit comments

Comments
 (0)