Skip to content

Commit d05cd3f

Browse files
Merge pull request #236 from Daniele-Cangi/test/common-otel-metrics-contracts
test(common): add hermetic OpenTelemetry metrics contracts
2 parents 9a4664d + ae91dea commit d05cd3f

3 files changed

Lines changed: 214 additions & 1 deletion

File tree

‎.github/workflows/core-build-checks.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
run: cargo check --locked -p cortexbrain-common ${{ matrix.args }}
4141

4242
test-common-contracts:
43-
name: Test common event contracts
43+
name: Test common contracts
4444
runs-on: ubuntu-latest
4545

4646
steps:
@@ -51,6 +51,10 @@ jobs:
5151
working-directory: core
5252
run: cargo test --locked -p cortexbrain-common --test public_contracts --no-default-features --features network-structs,monitoring-structs
5353

54+
- name: Test cortexbrain-common OpenTelemetry metrics
55+
working-directory: core
56+
run: cargo test --locked -p cortexbrain-common --test otel_metrics_contracts --no-default-features --features monitoring-structs
57+
5458
test-cli:
5559
name: Test CLI
5660
runs-on: ubuntu-latest

‎core/common/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ tokio = {version="1.49.0",features=["sync"]}
3636
serde = { version = "1.0", features = ["derive"] }
3737
serde_json = "1.0"
3838

39+
[dev-dependencies]
40+
opentelemetry_sdk = { version = "0.32.0", features = ["testing"] }
41+
3942
[features]
4043
map-handlers = []
4144
program-handlers = []
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#![cfg(feature = "monitoring-structs")]
2+
3+
use bytemuck::Zeroable;
4+
use cortexbrain_common::buffer_type::{
5+
CpuFrequency, CpuIdle, MemAlloc, PacketLossMetrics, SchedStatRuntime, SchedStatWait, SslEvent,
6+
TimeStampMetrics,
7+
};
8+
use cortexbrain_common::metadata::Metadata;
9+
use cortexbrain_common::otel_metrics::Metrics;
10+
use opentelemetry::KeyValue;
11+
use opentelemetry::metrics::MeterProvider;
12+
use opentelemetry_sdk::metrics::data::{AggregatedMetrics, Metric, MetricData, ResourceMetrics};
13+
use opentelemetry_sdk::metrics::{InMemoryMetricExporter, SdkMeterProvider};
14+
15+
fn collect(record: impl FnOnce(&Metrics)) -> ResourceMetrics {
16+
let exporter = InMemoryMetricExporter::default();
17+
let provider = SdkMeterProvider::builder()
18+
.with_periodic_exporter(exporter.clone())
19+
.build();
20+
let metrics = Metrics::new(&provider.meter("cortexbrain-common-test"));
21+
22+
record(&metrics);
23+
provider
24+
.force_flush()
25+
.expect("metric export should succeed");
26+
let snapshot = exporter
27+
.get_finished_metrics()
28+
.expect("in-memory metrics should be readable")
29+
.pop()
30+
.expect("metric export should contain a snapshot");
31+
provider
32+
.shutdown()
33+
.expect("meter provider should shut down");
34+
snapshot
35+
}
36+
37+
fn metric<'a>(snapshot: &'a ResourceMetrics, name: &str) -> &'a Metric {
38+
snapshot
39+
.scope_metrics()
40+
.flat_map(|scope| scope.metrics())
41+
.find(|metric| metric.name() == name)
42+
.unwrap_or_else(|| panic!("missing metric {name}"))
43+
}
44+
45+
fn sum(snapshot: &ResourceMetrics, name: &str, expected: u64) -> Vec<KeyValue> {
46+
let AggregatedMetrics::U64(MetricData::Sum(sum)) = metric(snapshot, name).data() else {
47+
panic!("{name} should be a u64 counter");
48+
};
49+
let points: Vec<_> = sum.data_points().collect();
50+
assert_eq!(points.len(), 1, "{name} should have one attribute set");
51+
assert_eq!(points[0].value(), expected, "wrong {name} count");
52+
points[0].attributes().cloned().collect()
53+
}
54+
55+
fn gauge(snapshot: &ResourceMetrics, name: &str, expected: i64) -> Vec<KeyValue> {
56+
let AggregatedMetrics::I64(MetricData::Gauge(gauge)) = metric(snapshot, name).data() else {
57+
panic!("{name} should be an i64 gauge");
58+
};
59+
let points: Vec<_> = gauge.data_points().collect();
60+
assert_eq!(points.len(), 1, "{name} should have one attribute set");
61+
assert_eq!(points[0].value(), expected, "wrong {name} value");
62+
points[0].attributes().cloned().collect()
63+
}
64+
65+
fn histogram(snapshot: &ResourceMetrics, name: &str, count: u64, total: u64) {
66+
let AggregatedMetrics::U64(MetricData::Histogram(histogram)) = metric(snapshot, name).data()
67+
else {
68+
panic!("{name} should be a u64 histogram");
69+
};
70+
let points: Vec<_> = histogram.data_points().collect();
71+
assert_eq!(points.len(), 1, "{name} should have one attribute set");
72+
assert_eq!(points[0].count(), count, "wrong {name} observation count");
73+
assert_eq!(points[0].sum(), total, "wrong {name} sum");
74+
}
75+
76+
#[test]
77+
fn packet_loss_counts_events_and_exports_process_and_container_attributes() {
78+
let mut metadata = Metadata::from_ebpf(Some(42), Some(123), b"worker\0");
79+
metadata.container_name = Some("container-a".into());
80+
metadata.container_id = Some("container-id".into());
81+
metadata.pod_name = Some("pod-a".into());
82+
metadata.namespace = Some("team-a".into());
83+
84+
let snapshot = collect(|metrics| {
85+
let mut event = PacketLossMetrics::zeroed();
86+
event.sk_drops = 3;
87+
event.sk_err = -2;
88+
metrics.record_packet_loss_metrics(&event, &metadata);
89+
event.sk_drops = 7;
90+
event.sk_err = -4;
91+
metrics.record_packet_loss_metrics(&event, &metadata);
92+
});
93+
94+
let attrs = sum(&snapshot, "events_total", 2);
95+
sum(&snapshot, "socket_events_total", 2);
96+
gauge(&snapshot, "sk_drops", 7);
97+
gauge(&snapshot, "sk_err", -4);
98+
assert_eq!(attrs.len(), 6);
99+
for expected in [
100+
KeyValue::new("tgid", 42_i64),
101+
KeyValue::new("command", "worker"),
102+
KeyValue::new("container.name", "container-a"),
103+
KeyValue::new("container.id", "container-id"),
104+
KeyValue::new("k8s.pod.name", "pod-a"),
105+
KeyValue::new("k8s.namespace.name", "team-a"),
106+
] {
107+
assert!(attrs.contains(&expected), "missing {expected:?}");
108+
}
109+
}
110+
111+
#[test]
112+
fn latency_histogram_aggregates_observations_without_optional_attributes() {
113+
let metadata = Metadata::from_ebpf(None, None, b"host-process\0");
114+
let snapshot = collect(|metrics| {
115+
let mut event = TimeStampMetrics::zeroed();
116+
event.delta_us = 7;
117+
metrics.record_timestamp_metrics(&event, &metadata);
118+
event.delta_us = 11;
119+
metrics.record_timestamp_metrics(&event, &metadata);
120+
});
121+
122+
let attrs = sum(&snapshot, "events_total", 2);
123+
histogram(&snapshot, "latency_us", 2, 18);
124+
assert_eq!(attrs.len(), 2);
125+
assert!(attrs.contains(&KeyValue::new("tgid", -1_i64)));
126+
assert!(attrs.contains(&KeyValue::new("command", "host-process")));
127+
}
128+
129+
#[test]
130+
fn memory_allocation_records_its_dedicated_counter_and_gauge() {
131+
let metadata = Metadata::from_ebpf(Some(7), None, b"allocator");
132+
let snapshot = collect(|metrics| {
133+
let mut event = MemAlloc::zeroed();
134+
event.length = 4096;
135+
metrics.record_enter_mem_alloc(&event, &metadata);
136+
});
137+
138+
sum(&snapshot, "mem_alloc_events_total", 1);
139+
gauge(&snapshot, "enter_mem_alloc", 4096);
140+
// Do not freeze events_total here: its documentation and implementation disagree.
141+
}
142+
143+
#[test]
144+
fn scheduler_events_record_latest_values_and_distributions() {
145+
let metadata = Metadata::from_ebpf(Some(8), None, b"scheduler");
146+
let snapshot = collect(|metrics| {
147+
let mut wait = SchedStatWait::zeroed();
148+
wait.delay = 10;
149+
metrics.record_sched_stat_wait(&wait, &metadata);
150+
wait.delay = 20;
151+
metrics.record_sched_stat_wait(&wait, &metadata);
152+
153+
let mut runtime = SchedStatRuntime::zeroed();
154+
runtime.runtime = 15;
155+
metrics.record_sched_stat_runtime(&runtime, &metadata);
156+
});
157+
158+
sum(&snapshot, "events_total", 3);
159+
gauge(&snapshot, "sched_stat_wait", 20);
160+
histogram(&snapshot, "sched_stat_wait_distribution", 2, 30);
161+
gauge(&snapshot, "sched_stat_runtime", 15);
162+
histogram(&snapshot, "sched_stat_runtime_distribution", 1, 15);
163+
}
164+
165+
#[test]
166+
fn cpu_idle_and_ssl_events_keep_their_dedicated_series() {
167+
let metadata = Metadata::from_ebpf(Some(9), None, b"monitor");
168+
let snapshot = collect(|metrics| {
169+
let mut cpu = CpuFrequency::zeroed();
170+
cpu.bytes_alloc = 256;
171+
metrics.record_cpu_bytes_alloc(&cpu, &metadata);
172+
173+
let mut idle = CpuIdle::zeroed();
174+
idle.cpu_id = 2;
175+
idle.state = 3;
176+
metrics.record_cpu_idle(&idle, &metadata);
177+
178+
let mut ssl = SslEvent::zeroed();
179+
ssl.size = 64;
180+
metrics.record_ssl_read_bytes(&ssl, &metadata);
181+
ssl.size = 128;
182+
metrics.record_ssl_write_bytes(&ssl, &metadata);
183+
});
184+
185+
sum(&snapshot, "bytes_alloc_events_total", 1);
186+
gauge(&snapshot, "cpu_bytes_alloc", 256);
187+
let idle_attrs = gauge(&snapshot, "cpu_idle_state", 3);
188+
assert!(idle_attrs.contains(&KeyValue::new("cpu_id", 2_i64)));
189+
gauge(&snapshot, "ssl_read_bytes", 64);
190+
gauge(&snapshot, "ssl_write_bytes", 128);
191+
let AggregatedMetrics::U64(MetricData::Sum(total)) = metric(&snapshot, "events_total").data()
192+
else {
193+
panic!("events_total should be a u64 counter");
194+
};
195+
let points: Vec<_> = total.data_points().collect();
196+
assert_eq!(points.len(), 2);
197+
assert!(points.iter().any(|point| {
198+
point.value() == 1
199+
&& point
200+
.attributes()
201+
.any(|attr| attr == &KeyValue::new("cpu_id", 2_i64))
202+
}));
203+
assert!(points.iter().any(|point| {
204+
point.value() == 2 && point.attributes().all(|attr| attr.key.as_str() != "cpu_id")
205+
}));
206+
}

0 commit comments

Comments
 (0)