Skip to content

Commit 25b4503

Browse files
feat(metrics): add bound instruments behind experimental feature flag (#3421)
Co-authored-by: Cijo Thomas <cijo.thomas@gmail.com>
1 parent c992379 commit 25b4503

20 files changed

Lines changed: 2348 additions & 12 deletions

File tree

opentelemetry-sdk/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@
55
- Removed `SimpleConcurrentLogProcessor` and the `experimental_logs_concurrent_log_processor`
66
feature flag. The use cases it was designed for (ETW/user_events exporters) are
77
better served by modeling those exporters as processors directly.
8+
- **Added** `Counter::bind()` and `Histogram::bind()` SDK implementations that
9+
return pre-bound measurement handles (`BoundCounter<T>`, `BoundHistogram<T>`).
10+
Bound instruments resolve the attribute-to-aggregator mapping once at bind time
11+
and cache the result, eliminating per-call HashMap lookups. View attribute
12+
filtering is applied at bind time so the hot path stays free of per-call
13+
attribute processing. Bound and unbound recordings with the same (post-view)
14+
attribute set always aggregate into the same data point, including the empty
15+
attribute set. Bound entries are never evicted during delta collection while
16+
a handle exists — idle cycles produce no export but the tracker persists. If
17+
`bind()` is called at the cardinality limit, the handle binds directly to
18+
the overflow tracker — its writes stay on the same direct (no-lookup) hot
19+
path and consistently land in the `otel.metric.overflow=true` bucket for
20+
the lifetime of the handle. To recover a bound handle after delta collection
21+
frees space, drop the existing handle and call `bind()` again. Gated behind
22+
the `experimental_metrics_bound_instruments` feature flag. Benchmarks show
23+
~28x speedup for counter operations and ~9x for histograms.
824
- Delta metrics collection now uses in-place eviction instead of draining the
925
HashMap on every collect cycle. Stale attribute sets that received no measurements
1026
since the last collection are evicted. Note: recovery from cardinality overflow

opentelemetry-sdk/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ experimental_metrics_custom_reader = ["metrics"]
5959
experimental_logs_batch_log_processor_with_async_runtime = ["logs", "experimental_async_runtime"]
6060
experimental_trace_batch_span_processor_with_async_runtime = ["tokio/sync", "trace", "experimental_async_runtime"]
6161
experimental_metrics_disable_name_validation = ["metrics"]
62+
experimental_metrics_bound_instruments = ["metrics", "opentelemetry/experimental_metrics_bound_instruments"]
6263
bench_profiling = []
6364

6465
[[bench]]
@@ -123,6 +124,11 @@ name = "log"
123124
harness = false
124125
required-features = ["logs"]
125126

127+
[[bench]]
128+
name = "bound_instruments"
129+
harness = false
130+
required-features = ["metrics", "experimental_metrics_custom_reader", "experimental_metrics_bound_instruments", "spec_unstable_metrics_views"]
131+
126132
[lib]
127133
bench = false
128134

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
use criterion::{criterion_group, criterion_main, Criterion};
2+
use opentelemetry::{metrics::MeterProvider as _, Key, KeyValue};
3+
use opentelemetry_sdk::metrics::{Instrument, ManualReader, SdkMeterProvider, Stream, Temporality};
4+
5+
// Run this benchmark with:
6+
// cargo bench --bench bound_instruments --features metrics,experimental_metrics_custom_reader,experimental_metrics_bound_instruments,spec_unstable_metrics_views
7+
//
8+
// Apple M4 Max, 16 cores (12 performance + 4 efficiency), macOS 15.4
9+
//
10+
// Results (3 attributes: method, status, path):
11+
// Counter_Unbound_Delta time: [50.20 ns]
12+
// Counter_Bound_Delta time: [ 1.80 ns] ~28x faster
13+
// Counter_Bound_With_View_Delta time: [ 1.82 ns] view filter applied at bind, not on hot path
14+
// Counter_Bound_AtOverflow_Delta time: [ 1.82 ns] bind() at cardinality limit binds directly to the overflow
15+
// tracker — perf parity with a normal bind, no per-call resolution
16+
// Histogram_Unbound_Delta time: [58.64 ns]
17+
// Histogram_Bound_Delta time: [ 6.50 ns] ~9.0x faster
18+
// Histogram_Bound_AtOverflow_Delta time: [ 6.58 ns] perf parity with a normal bind
19+
// Counter_Bound_Multithread/2 time: [21.59 µs] (100 adds/thread)
20+
// Counter_Bound_Multithread/4 time: [37.21 µs] (100 adds/thread)
21+
// Counter_Bound_Multithread/8 time: [71.70 µs] (100 adds/thread)
22+
//
23+
// Note: criterion does not fail CI on regression by itself. These numbers are
24+
// reference values for human review; use `cargo criterion --baseline` locally
25+
// if you need automated comparison against a saved baseline.
26+
27+
fn create_provider(temporality: Temporality) -> SdkMeterProvider {
28+
let reader = ManualReader::builder()
29+
.with_temporality(temporality)
30+
.build();
31+
SdkMeterProvider::builder().with_reader(reader).build()
32+
}
33+
34+
fn bench_bound_instruments(c: &mut Criterion) {
35+
let mut group = c.benchmark_group("BoundInstruments");
36+
group.sample_size(100);
37+
38+
let attrs = [
39+
KeyValue::new("method", "GET"),
40+
KeyValue::new("status", "200"),
41+
KeyValue::new("path", "/api/v1/users"),
42+
];
43+
44+
// Counter: Unbound vs Bound (Delta)
45+
{
46+
let provider = create_provider(Temporality::Delta);
47+
let meter = provider.meter("bench");
48+
let counter = meter.u64_counter("unbound").build();
49+
group.bench_function("Counter_Unbound_Delta", |b| {
50+
b.iter(|| counter.add(1, &attrs));
51+
});
52+
}
53+
54+
{
55+
let provider = create_provider(Temporality::Delta);
56+
let meter = provider.meter("bench");
57+
let counter = meter.u64_counter("bound").build();
58+
let bound = counter.bind(&attrs);
59+
group.bench_function("Counter_Bound_Delta", |b| {
60+
b.iter(|| bound.add(1));
61+
});
62+
}
63+
64+
// Counter: Bound with a View filter — confirms the filter is applied at
65+
// bind() time and the hot path stays free of attribute processing.
66+
{
67+
let view = |i: &opentelemetry_sdk::metrics::Instrument| {
68+
if i.name() == "bound_with_view" {
69+
Stream::builder()
70+
.with_allowed_attribute_keys(vec![
71+
Key::new("method"),
72+
Key::new("status"),
73+
Key::new("path"),
74+
])
75+
.build()
76+
.ok()
77+
} else {
78+
None
79+
}
80+
};
81+
let reader = ManualReader::builder()
82+
.with_temporality(Temporality::Delta)
83+
.build();
84+
let provider = SdkMeterProvider::builder()
85+
.with_reader(reader)
86+
.with_view(view)
87+
.build();
88+
let meter = provider.meter("bench");
89+
let counter = meter.u64_counter("bound_with_view").build();
90+
let bound = counter.bind(&attrs);
91+
group.bench_function("Counter_Bound_With_View_Delta", |b| {
92+
b.iter(|| bound.add(1));
93+
});
94+
}
95+
96+
// Counter: Bound at overflow — confirms that binding when the cardinality
97+
// limit is exhausted yields the same hot-path performance as a normal bind
98+
// (writes go directly to the overflow tracker, no per-call resolution).
99+
{
100+
let cardinality_limit = 4;
101+
let view = move |i: &Instrument| {
102+
if i.name() == "bound_at_overflow" {
103+
Stream::builder()
104+
.with_cardinality_limit(cardinality_limit)
105+
.build()
106+
.ok()
107+
} else {
108+
None
109+
}
110+
};
111+
let reader = ManualReader::builder()
112+
.with_temporality(Temporality::Delta)
113+
.build();
114+
let provider = SdkMeterProvider::builder()
115+
.with_reader(reader)
116+
.with_view(view)
117+
.build();
118+
let meter = provider.meter("bench");
119+
let counter = meter.u64_counter("bound_at_overflow").build();
120+
// Saturate cardinality with unbound calls so bind() lands in overflow.
121+
for i in 0..cardinality_limit {
122+
counter.add(1, &[KeyValue::new("filler", i as i64)]);
123+
}
124+
let bound = counter.bind(&attrs);
125+
group.bench_function("Counter_Bound_AtOverflow_Delta", |b| {
126+
b.iter(|| bound.add(1));
127+
});
128+
}
129+
130+
// Histogram: Unbound vs Bound (Delta)
131+
{
132+
let provider = create_provider(Temporality::Delta);
133+
let meter = provider.meter("bench");
134+
let histogram = meter.f64_histogram("unbound_hist").build();
135+
group.bench_function("Histogram_Unbound_Delta", |b| {
136+
b.iter(|| histogram.record(1.5, &attrs));
137+
});
138+
}
139+
140+
{
141+
let provider = create_provider(Temporality::Delta);
142+
let meter = provider.meter("bench");
143+
let histogram = meter.f64_histogram("bound_hist").build();
144+
let bound = histogram.bind(&attrs);
145+
group.bench_function("Histogram_Bound_Delta", |b| {
146+
b.iter(|| bound.record(1.5));
147+
});
148+
}
149+
150+
// Histogram: Bound at overflow — same property as the counter version.
151+
{
152+
let cardinality_limit = 4;
153+
let view = move |i: &Instrument| {
154+
if i.name() == "bound_hist_at_overflow" {
155+
Stream::builder()
156+
.with_cardinality_limit(cardinality_limit)
157+
.build()
158+
.ok()
159+
} else {
160+
None
161+
}
162+
};
163+
let reader = ManualReader::builder()
164+
.with_temporality(Temporality::Delta)
165+
.build();
166+
let provider = SdkMeterProvider::builder()
167+
.with_reader(reader)
168+
.with_view(view)
169+
.build();
170+
let meter = provider.meter("bench");
171+
let histogram = meter.f64_histogram("bound_hist_at_overflow").build();
172+
for i in 0..cardinality_limit {
173+
histogram.record(1.5, &[KeyValue::new("filler", i as i64)]);
174+
}
175+
let bound = histogram.bind(&attrs);
176+
group.bench_function("Histogram_Bound_AtOverflow_Delta", |b| {
177+
b.iter(|| bound.record(1.5));
178+
});
179+
}
180+
181+
// Multi-threaded bound counter
182+
for num_threads in [2, 4, 8] {
183+
let provider = create_provider(Temporality::Delta);
184+
let meter = provider.meter("bench");
185+
let counter = meter.u64_counter("mt_bound").build();
186+
let bound = counter.bind(&attrs);
187+
188+
group.bench_function(format!("Counter_Bound_Multithread/{num_threads}"), |b| {
189+
b.iter(|| {
190+
std::thread::scope(|s| {
191+
for _ in 0..num_threads {
192+
s.spawn(|| {
193+
for _ in 0..100 {
194+
bound.add(1);
195+
}
196+
});
197+
}
198+
});
199+
});
200+
});
201+
}
202+
203+
group.finish();
204+
}
205+
206+
criterion_group!(benches, bench_bound_instruments);
207+
criterion_main!(benches);

opentelemetry-sdk/src/metrics/instrument.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
use std::{borrow::Cow, collections::HashSet, error::Error, sync::Arc};
22

3+
#[cfg(feature = "experimental_metrics_bound_instruments")]
4+
use opentelemetry::metrics::BoundSyncInstrument;
35
use opentelemetry::{
46
metrics::{AsyncInstrument, SyncInstrument},
57
InstrumentationScope, Key, KeyValue,
68
};
79

10+
#[cfg(feature = "experimental_metrics_bound_instruments")]
11+
use crate::metrics::internal::BoundMeasure;
812
use crate::metrics::{aggregation::Aggregation, internal::Measure};
913

1014
use super::meter::{
@@ -388,6 +392,29 @@ impl<T: Copy + 'static> SyncInstrument<T> for ResolvedMeasures<T> {
388392
measure.call(val, attrs)
389393
}
390394
}
395+
396+
#[cfg(feature = "experimental_metrics_bound_instruments")]
397+
fn bind(&self, attrs: &[KeyValue]) -> Box<dyn BoundSyncInstrument<T> + Send + Sync> {
398+
let bound_measures: Vec<Box<dyn BoundMeasure<T>>> =
399+
self.measures.iter().map(|m| m.bind(attrs)).collect();
400+
Box::new(ResolvedBoundMeasures {
401+
measures: bound_measures,
402+
})
403+
}
404+
}
405+
406+
#[cfg(feature = "experimental_metrics_bound_instruments")]
407+
pub(crate) struct ResolvedBoundMeasures<T> {
408+
measures: Vec<Box<dyn BoundMeasure<T>>>,
409+
}
410+
411+
#[cfg(feature = "experimental_metrics_bound_instruments")]
412+
impl<T: Copy + 'static> BoundSyncInstrument<T> for ResolvedBoundMeasures<T> {
413+
fn measure(&self, val: T) {
414+
for measure in &self.measures {
415+
measure.call(val);
416+
}
417+
}
391418
}
392419

393420
#[derive(Clone)]

opentelemetry-sdk/src/metrics/internal/aggregate.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,39 @@ use super::{
1818
/// Receives measurements to be aggregated.
1919
pub(crate) trait Measure<T>: Send + Sync + 'static {
2020
fn call(&self, measurement: T, attrs: &[KeyValue]);
21+
22+
#[cfg(feature = "experimental_metrics_bound_instruments")]
23+
fn bind(&self, attrs: &[KeyValue]) -> Box<dyn BoundMeasure<T>>;
24+
}
25+
26+
/// A pre-bound measurement handle that bypasses attribute lookup.
27+
#[cfg(feature = "experimental_metrics_bound_instruments")]
28+
pub(crate) trait BoundMeasure<T>: Send + Sync + 'static {
29+
fn call(&self, measurement: T);
30+
}
31+
32+
/// A bound handle that drops every measurement silently. Used when
33+
/// `ValueMap::bind` returns `None` because the trackers `RwLock` is poisoned —
34+
/// an extremely rare degenerate state in which the SDK can no longer aggregate
35+
/// reliably. Returning a noop here mirrors `measure()`'s own poison handling
36+
/// (silent drop) rather than panicking on the user's hot path.
37+
#[cfg(feature = "experimental_metrics_bound_instruments")]
38+
pub(crate) struct NoopBoundMeasure<T> {
39+
_marker: marker::PhantomData<T>,
40+
}
41+
42+
#[cfg(feature = "experimental_metrics_bound_instruments")]
43+
impl<T> NoopBoundMeasure<T> {
44+
pub(crate) fn new() -> Self {
45+
Self {
46+
_marker: marker::PhantomData,
47+
}
48+
}
49+
}
50+
51+
#[cfg(feature = "experimental_metrics_bound_instruments")]
52+
impl<T: Send + Sync + 'static> BoundMeasure<T> for NoopBoundMeasure<T> {
53+
fn call(&self, _measurement: T) {}
2154
}
2255

2356
/// Stores the aggregate of measurements into the aggregation and returns the number

0 commit comments

Comments
 (0)