Skip to content

Commit a6ff02d

Browse files
committed
test(metrics): cover view filter and histogram empty-attr mixing
Tests: * bound_histogram_empty_attributes_shares_with_unbound mirrors the existing counter version for histograms. * bound_counter_view_filters_attributes_at_bind_time and a histogram analog confirm a view filters attributes at bind() time, with bound and unbound recordings collapsing onto the same filtered data point. Bench: * Counter_Bound_With_View_Delta confirms view filtering happens at bind time, leaving the hot path at unfiltered-bound speed. Updated bench required-features and added a note that criterion does not enforce regression on its own. CHANGELOG entries for both crates updated to mention Clone, the noop trait default, and the converged bound/unbound semantics.
1 parent c8dbd93 commit a6ff02d

5 files changed

Lines changed: 219 additions & 11 deletions

File tree

opentelemetry-sdk/CHANGELOG.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
- **Added** `Counter::bind()` and `Histogram::bind()` SDK implementations that
99
return pre-bound measurement handles (`BoundCounter<T>`, `BoundHistogram<T>`).
1010
Bound instruments resolve the attribute-to-aggregator mapping once at bind time
11-
and cache the result, eliminating per-call HashMap lookups. Bound entries are
12-
never evicted during delta collection — idle cycles produce no export but the
13-
tracker persists. If `bind()` is called at the cardinality limit, the handle
14-
transparently falls back to the unbound measurement path. Gated behind the
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 transparently falls
18+
back to the unbound measurement path. Gated behind the
1519
`experimental_metrics_bound_instruments` feature flag. Benchmarks show ~28x
1620
speedup for counter operations and ~9x for histograms.
1721
- Delta metrics collection now uses in-place eviction instead of draining the

opentelemetry-sdk/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ required-features = ["logs"]
127127
[[bench]]
128128
name = "bound_instruments"
129129
harness = false
130-
required-features = ["metrics", "experimental_metrics_custom_reader", "experimental_metrics_bound_instruments"]
130+
required-features = ["metrics", "experimental_metrics_custom_reader", "experimental_metrics_bound_instruments", "spec_unstable_metrics_views"]
131131

132132
[lib]
133133
bench = false

opentelemetry-sdk/benches/bound_instruments.rs

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
use criterion::{criterion_group, criterion_main, Criterion};
2-
use opentelemetry::{metrics::MeterProvider as _, KeyValue};
3-
use opentelemetry_sdk::metrics::{ManualReader, SdkMeterProvider, Temporality};
2+
use opentelemetry::{metrics::MeterProvider as _, Key, KeyValue};
3+
use opentelemetry_sdk::metrics::{ManualReader, SdkMeterProvider, Stream, Temporality};
44

55
// Run this benchmark with:
6-
// cargo bench --bench bound_instruments --features metrics,experimental_metrics_custom_reader,experimental_metrics_bound_instruments
6+
// cargo bench --bench bound_instruments --features metrics,experimental_metrics_custom_reader,experimental_metrics_bound_instruments,spec_unstable_metrics_views
77
//
88
// Apple M4 Max, 16 cores (12 performance + 4 efficiency), macOS 15.4
99
//
1010
// Results (3 attributes: method, status, path):
1111
// Counter_Unbound_Delta time: [53.20 ns]
1212
// Counter_Bound_Delta time: [1.87 ns] ~28x faster
13+
// Counter_Bound_With_View_Delta time: [~1.9 ns] view filter applied at bind, not on hot path
1314
// Histogram_Unbound_Delta time: [58.58 ns]
1415
// Histogram_Bound_Delta time: [6.57 ns] ~8.9x faster
1516
// Counter_Bound_Multithread/2 time: [22.19 µs] (100 adds/thread)
1617
// Counter_Bound_Multithread/4 time: [35.32 µs] (100 adds/thread)
1718
// Counter_Bound_Multithread/8 time: [66.49 µs] (100 adds/thread)
19+
//
20+
// Note: criterion does not fail CI on regression by itself. These numbers are
21+
// reference values for human review; use `cargo criterion --baseline` locally
22+
// if you need automated comparison against a saved baseline.
1823

1924
fn create_provider(temporality: Temporality) -> SdkMeterProvider {
2025
let reader = ManualReader::builder()
@@ -53,6 +58,38 @@ fn bench_bound_instruments(c: &mut Criterion) {
5358
});
5459
}
5560

61+
// Counter: Bound with a View filter — confirms the filter is applied at
62+
// bind() time and the hot path stays free of attribute processing.
63+
{
64+
let view = |i: &opentelemetry_sdk::metrics::Instrument| {
65+
if i.name() == "bound_with_view" {
66+
Stream::builder()
67+
.with_allowed_attribute_keys(vec![
68+
Key::new("method"),
69+
Key::new("status"),
70+
Key::new("path"),
71+
])
72+
.build()
73+
.ok()
74+
} else {
75+
None
76+
}
77+
};
78+
let reader = ManualReader::builder()
79+
.with_temporality(Temporality::Delta)
80+
.build();
81+
let provider = SdkMeterProvider::builder()
82+
.with_reader(reader)
83+
.with_view(view)
84+
.build();
85+
let meter = provider.meter("bench");
86+
let counter = meter.u64_counter("bound_with_view").build();
87+
let bound = counter.bind(&attrs);
88+
group.bench_function("Counter_Bound_With_View_Delta", |b| {
89+
b.iter(|| bound.add(1));
90+
});
91+
}
92+
5693
// Histogram: Unbound vs Bound (Delta)
5794
{
5895
let provider = create_provider(Temporality::Delta);

opentelemetry-sdk/src/metrics/mod.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5364,4 +5364,168 @@ mod tests {
53645364
);
53655365
assert_eq!(sum.data_points[0].value, 100);
53665366
}
5367+
5368+
#[cfg(feature = "experimental_metrics_bound_instruments")]
5369+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
5370+
async fn bound_histogram_empty_attributes_shares_with_unbound() {
5371+
let mut test_context = TestContext::new(Temporality::Cumulative);
5372+
let histogram = test_context
5373+
.meter()
5374+
.u64_histogram("my_histogram")
5375+
.with_boundaries(vec![5.0, 10.0, 25.0])
5376+
.build();
5377+
let bound = histogram.bind(&[]);
5378+
5379+
histogram.record(3, &[]);
5380+
bound.record(7);
5381+
histogram.record(20, &[]);
5382+
test_context.flush_metrics();
5383+
5384+
let MetricData::Histogram(hist) = test_context.get_aggregation::<u64>("my_histogram", None)
5385+
else {
5386+
unreachable!()
5387+
};
5388+
5389+
assert_eq!(
5390+
hist.data_points.len(),
5391+
1,
5392+
"Bound and unbound with empty attributes must share the same data point"
5393+
);
5394+
let dp = &hist.data_points[0];
5395+
assert!(dp.attributes.is_empty());
5396+
assert_eq!(dp.count, 3);
5397+
assert_eq!(dp.sum, 30);
5398+
}
5399+
5400+
#[cfg(feature = "experimental_metrics_bound_instruments")]
5401+
#[cfg(feature = "spec_unstable_metrics_views")]
5402+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
5403+
async fn bound_counter_view_filters_attributes_at_bind_time() {
5404+
use opentelemetry::Key;
5405+
5406+
let exporter = InMemoryMetricExporter::default();
5407+
let view = |i: &Instrument| {
5408+
if i.name() == "my_counter" {
5409+
Stream::builder()
5410+
.with_allowed_attribute_keys(vec![Key::new("k1"), Key::new("k2")])
5411+
.build()
5412+
.ok()
5413+
} else {
5414+
None
5415+
}
5416+
};
5417+
let meter_provider = SdkMeterProvider::builder()
5418+
.with_periodic_exporter(exporter.clone())
5419+
.with_view(view)
5420+
.build();
5421+
let meter = meter_provider.meter("test");
5422+
let counter = meter.u64_counter("my_counter").build();
5423+
5424+
// bind with k3 included — view should drop it at bind time
5425+
let bound = counter.bind(&[
5426+
KeyValue::new("k1", "v1"),
5427+
KeyValue::new("k2", "v2"),
5428+
KeyValue::new("k3", "v3"),
5429+
]);
5430+
bound.add(10);
5431+
bound.add(20);
5432+
5433+
// unbound call with a *different* k3 value: after view filtering both
5434+
// bound and unbound must collapse into the same data point.
5435+
counter.add(
5436+
7,
5437+
&[
5438+
KeyValue::new("k1", "v1"),
5439+
KeyValue::new("k2", "v2"),
5440+
KeyValue::new("k3", "different"),
5441+
],
5442+
);
5443+
5444+
meter_provider.force_flush().unwrap();
5445+
let resource_metrics = exporter
5446+
.get_finished_metrics()
5447+
.expect("metrics are expected to be exported.");
5448+
let metric = &resource_metrics[0].scope_metrics[0].metrics[0];
5449+
let data::AggregatedMetrics::U64(MetricData::Sum(sum)) = &metric.data else {
5450+
unreachable!()
5451+
};
5452+
5453+
assert_eq!(
5454+
sum.data_points.len(),
5455+
1,
5456+
"view should filter k3, leaving bound+unbound to aggregate together"
5457+
);
5458+
assert_eq!(sum.data_points[0].value, 37);
5459+
let attrs = &sum.data_points[0].attributes;
5460+
assert_eq!(attrs.len(), 2);
5461+
assert!(attrs.iter().any(|kv| kv.key.as_str() == "k1"));
5462+
assert!(attrs.iter().any(|kv| kv.key.as_str() == "k2"));
5463+
assert!(!attrs.iter().any(|kv| kv.key.as_str() == "k3"));
5464+
}
5465+
5466+
#[cfg(feature = "experimental_metrics_bound_instruments")]
5467+
#[cfg(feature = "spec_unstable_metrics_views")]
5468+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
5469+
async fn bound_histogram_view_filters_attributes_at_bind_time() {
5470+
use opentelemetry::Key;
5471+
5472+
let exporter = InMemoryMetricExporter::default();
5473+
let view = |i: &Instrument| {
5474+
if i.name() == "my_hist" {
5475+
Stream::builder()
5476+
.with_allowed_attribute_keys(vec![Key::new("k1"), Key::new("k2")])
5477+
.build()
5478+
.ok()
5479+
} else {
5480+
None
5481+
}
5482+
};
5483+
let meter_provider = SdkMeterProvider::builder()
5484+
.with_periodic_exporter(exporter.clone())
5485+
.with_view(view)
5486+
.build();
5487+
let meter = meter_provider.meter("test");
5488+
let histogram = meter
5489+
.u64_histogram("my_hist")
5490+
.with_boundaries(vec![5.0, 10.0, 25.0])
5491+
.build();
5492+
5493+
let bound = histogram.bind(&[
5494+
KeyValue::new("k1", "v1"),
5495+
KeyValue::new("k2", "v2"),
5496+
KeyValue::new("k3", "v3"),
5497+
]);
5498+
bound.record(3);
5499+
bound.record(20);
5500+
histogram.record(
5501+
7,
5502+
&[
5503+
KeyValue::new("k1", "v1"),
5504+
KeyValue::new("k2", "v2"),
5505+
KeyValue::new("k3", "different"),
5506+
],
5507+
);
5508+
5509+
meter_provider.force_flush().unwrap();
5510+
let resource_metrics = exporter
5511+
.get_finished_metrics()
5512+
.expect("metrics are expected to be exported.");
5513+
let metric = &resource_metrics[0].scope_metrics[0].metrics[0];
5514+
let data::AggregatedMetrics::U64(MetricData::Histogram(hist)) = &metric.data else {
5515+
unreachable!()
5516+
};
5517+
5518+
assert_eq!(
5519+
hist.data_points.len(),
5520+
1,
5521+
"view should filter k3, leaving bound+unbound to aggregate together"
5522+
);
5523+
let dp = &hist.data_points[0];
5524+
assert_eq!(dp.count, 3);
5525+
assert_eq!(dp.sum, 30);
5526+
assert_eq!(dp.attributes.len(), 2);
5527+
assert!(dp.attributes.iter().any(|kv| kv.key.as_str() == "k1"));
5528+
assert!(dp.attributes.iter().any(|kv| kv.key.as_str() == "k2"));
5529+
assert!(!dp.attributes.iter().any(|kv| kv.key.as_str() == "k3"));
5530+
}
53675531
}

opentelemetry/CHANGELOG.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
aggregator references for a fixed attribute set. Created via `Counter::bind()`
77
and `Histogram::bind()`, bound instruments bypass per-call attribute lookup,
88
providing significant performance improvements for hot paths where the same
9-
attributes are used repeatedly. Also adds the `SyncInstrument::bind()` trait
10-
method and `BoundSyncInstrument<T>` trait for SDK implementors. Gated behind
11-
the `experimental_metrics_bound_instruments` feature flag.
9+
attributes are used repeatedly. Both types implement `Clone` so a single bound
10+
state can be shared across threads or modules without re-binding. Also adds
11+
the `SyncInstrument::bind()` trait method and `BoundSyncInstrument<T>` trait
12+
for SDK implementors; the trait method has a no-op default so custom
13+
`SyncInstrument` impls degrade gracefully without panicking. Gated behind the
14+
`experimental_metrics_bound_instruments` feature flag.
1215
- Add `reserve` method to `opentelemetry::propagation::Injector` to hint at the number of elements that will be added to avoid multiple resize operations of the underlying data structure. Has an empty default implementation.
1316
- **Breaking** Removed the following public fields and methods from the `SpanBuilder` [#3227][3227]:
1417
- `trace_id`, `span_id`, `end_time`, `status`, `sampling_result`

0 commit comments

Comments
 (0)