diff --git a/Cargo.lock b/Cargo.lock index 6e2d8be7..a3a5599c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,6 +365,12 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -593,6 +599,28 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -612,9 +640,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -1125,6 +1156,18 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "metrics-exporter-otel" +version = "0.1.0" +dependencies = [ + "metrics", + "metrics-util", + "opentelemetry", + "opentelemetry_sdk", + "portable-atomic", + "scc", +] + [[package]] name = "metrics-exporter-prometheus" version = "0.17.2" @@ -1414,6 +1457,38 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "opentelemetry" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f644aa9e5e31d11896e024305d7e3c98a88884d9f8919dbf37a9991bc47a4b" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.0", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -1458,6 +1533,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + [[package]] name = "petgraph" version = "0.7.1" @@ -1497,6 +1578,9 @@ name = "portable-atomic" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +dependencies = [ + "critical-section", +] [[package]] name = "portable-atomic-util" @@ -1995,6 +2079,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22b2d775fb28f245817589471dd49c5edf64237f4a19d10ce9a92ff4651a27f4" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.27" @@ -2010,6 +2103,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f5557d2bbddd5afd236ba7856b0e494f5acc7ce805bb0774cc5674b20a06b4" + [[package]] name = "security-framework" version = "3.2.0" @@ -2323,9 +2422,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "tokio-rustls" version = "0.26.2" @@ -2336,6 +2447,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.14" diff --git a/Cargo.toml b/Cargo.toml index 6fb3621e..05273bb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "metrics", "metrics-benchmark", "metrics-exporter-dogstatsd", + "metrics-exporter-otel", "metrics-exporter-prometheus", "metrics-exporter-tcp", "metrics-observer", @@ -48,6 +49,8 @@ ndarray = { version = "0.16", default-features = false } ndarray-stats = { version = "0.6", default-features = false } noisy_float = { version = "0.2", default-features = false } once_cell = { version = "1", default-features = false, features = ["std"] } +opentelemetry = { version = "0.30", default-features = false } +opentelemetry_sdk = { version = "0.30", default-features = false } ordered-float = { version = "4.2", default-features = false } parking_lot = { version = "0.12", default-features = false } portable-atomic = { version = "1", default-features = false } @@ -66,6 +69,7 @@ radix_trie = { version = "0.2", default-features = false } rand = { version = "0.9", default-features = false, features = [ "thread_rng" ] } rand_xoshiro = { version = "0.7", default-features = false } ratatui = { version = "0.28", default-features = false } +scc = { version = "2.1", default-features = false } sketches-ddsketch = { version = "0.3", default-features = false } thiserror = { version = "2", default-features = false } tokio = { version = "1", default-features = false, features = ["rt", "net", "time", "rt-multi-thread"] } diff --git a/metrics-exporter-otel/CHANGELOG.md b/metrics-exporter-otel/CHANGELOG.md new file mode 100644 index 00000000..de5315f4 --- /dev/null +++ b/metrics-exporter-otel/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Initial release of OpenTelemetry metrics exporter for `metrics` +- Support for counters, gauges, and histograms +- Attribute/label support \ No newline at end of file diff --git a/metrics-exporter-otel/Cargo.toml b/metrics-exporter-otel/Cargo.toml new file mode 100644 index 00000000..d3550067 --- /dev/null +++ b/metrics-exporter-otel/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "metrics-exporter-otel" +version = "0.1.0" +edition = "2021" +rust-version = "1.75.0" + +description = "A metrics-compatible exporter for sending metrics to OpenTelemetry collectors." +license = { workspace = true } +authors = ["Metrics Contributors"] +repository = { workspace = true } +homepage = { workspace = true } +documentation = "https://docs.rs/metrics-exporter-otel" +readme = "README.md" + +categories = ["development-tools::debugging"] +keywords = ["metrics", "telemetry", "opentelemetry", "otel", "observability"] + +[dependencies] +metrics = { version = "^0.24", path = "../metrics" } +metrics-util = { version = "^0.20", path = "../metrics-util", default-features = false, features = ["registry"] } +opentelemetry = { workspace = true, features = ["metrics"] } +portable-atomic = { workspace = true, features = ["float", "critical-section"] } +scc = { workspace = true } + +[dev-dependencies] +opentelemetry_sdk = { workspace = true, features = ["metrics", "rt-tokio", "testing"] } + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/metrics-exporter-otel/README.md b/metrics-exporter-otel/README.md new file mode 100644 index 00000000..8da60df8 --- /dev/null +++ b/metrics-exporter-otel/README.md @@ -0,0 +1,100 @@ +# metrics-exporter-otel + +[![docs-badge][docs-badge]][docs] [![crates-badge][crates-badge]][crates] [![license-badge][license-badge]][license] + +[docs-badge]: https://docs.rs/metrics-exporter-otel/badge.svg +[docs]: https://docs.rs/metrics-exporter-otel +[crates-badge]: https://img.shields.io/crates/v/metrics-exporter-otel.svg +[crates]: https://crates.io/crates/metrics-exporter-otel +[license-badge]: https://img.shields.io/crates/l/metrics-exporter-otel.svg +[license]: #license + +A [`metrics`]-compatible exporter for sending metrics to OpenTelemetry collectors. + +[`metrics`]: https://docs.rs/metrics/ + +## Overview + +A [`metrics`]-compatible exporter for OpenTelemetry collectors and OTLP endpoints. + +## Features + +- Counters, gauges, and histograms +- Custom histogram bucket boundaries +- Metric descriptions and units +- Lock-free concurrent data structures +- Works with any OpenTelemetry [`Meter`] + +[`Meter`]: https://docs.rs/opentelemetry/latest/opentelemetry/metrics/trait.Meter.html + +## Quick Start + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +metrics = "0.24" +metrics-exporter-otel = "0.1" +opentelemetry = "0.30" +opentelemetry_sdk = "0.30" +``` + +Basic usage: + +```rust +use metrics_exporter_otel::OpenTelemetryRecorder; +use opentelemetry::metrics::MeterProvider; +use opentelemetry_sdk::metrics::SdkMeterProvider; + +// Create an OpenTelemetry meter +let provider = SdkMeterProvider::default(); +let meter = provider.meter("my_application"); + +// Create and install the recorder +let recorder = OpenTelemetryRecorder::new(meter); +metrics::set_global_recorder(recorder).expect("failed to install recorder"); + +// Use metrics as normal +metrics::counter!("requests_total", "method" => "GET").increment(1); +metrics::gauge!("cpu_usage", "core" => "0").set(45.2); +metrics::histogram!("response_time", "endpoint" => "/api/users").record(0.123); +``` + +## Custom Histogram Boundaries + +```rust +let recorder = OpenTelemetryRecorder::new(meter); + +recorder.set_histogram_bounds( + &metrics::KeyName::from("response_time"), + vec![0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] +); + +metrics::set_global_recorder(recorder).expect("failed to install recorder"); +``` + +## Metric Descriptions and Units + +You can provide descriptions and units for your metrics using the `describe_*` macros. + +CAUTION: These macros must be called before the metrics are recorded. The instruments created before calling these macros will not have descriptions or units. + +```rust +use metrics::{describe_counter, describe_histogram, Unit}; + +describe_counter!("requests_total", Unit::Count, "Total HTTP requests"); +describe_histogram!("response_time", Unit::Seconds, "Response time distribution"); + +metrics::counter!("requests_total").increment(1); +metrics::histogram!("response_time").record(0.045); +``` + +## Compatibility + +### Metric Type Mapping + +| `metrics` Type | OpenTelemetry Instrument | +|----------------|-------------------------| +| `Counter` | `ObservableCounter` (u64) | +| `Gauge` | `ObservableGauge` (f64) | +| `Histogram` | `Histogram` (f64) | diff --git a/metrics-exporter-otel/examples/basic.rs b/metrics-exporter-otel/examples/basic.rs new file mode 100644 index 00000000..4defc0a7 --- /dev/null +++ b/metrics-exporter-otel/examples/basic.rs @@ -0,0 +1,32 @@ +use metrics::{counter, describe_counter, describe_histogram, gauge, histogram, Unit}; +use metrics_exporter_otel::OpenTelemetryRecorder; +use opentelemetry::metrics::MeterProvider; +use opentelemetry_sdk::metrics::SdkMeterProvider; +use std::thread; +use std::time::Duration; + +fn main() { + // Create OpenTelemetry provider and meter + let provider = SdkMeterProvider::default(); + let meter = provider.meter("example_app"); + + // Create and install the OpenTelemetry recorder + let recorder = OpenTelemetryRecorder::new(meter); + metrics::set_global_recorder(recorder).expect("failed to install recorder"); + + // Register metrics with descriptions and units + describe_counter!("requests_total", Unit::Count, "Total HTTP requests"); + describe_histogram!("response_time", Unit::Seconds, "Response time distribution"); + + // Loop and record metrics + for i in 0..10 { + counter!("requests_total", "method" => "GET", "status" => "200").increment(1); + gauge!("cpu_usage").set(45.0 + (i as f64 * 2.0)); + histogram!("response_time").record(0.1 + (i as f64 * 0.01)); + + println!("Recorded metrics iteration {}", i + 1); + thread::sleep(Duration::from_millis(500)); + } + + println!("Example completed"); +} diff --git a/metrics-exporter-otel/examples/custom_histogram_bounds.rs b/metrics-exporter-otel/examples/custom_histogram_bounds.rs new file mode 100644 index 00000000..2e7cbe19 --- /dev/null +++ b/metrics-exporter-otel/examples/custom_histogram_bounds.rs @@ -0,0 +1,50 @@ +use metrics::{describe_histogram, histogram, KeyName, Unit}; +use metrics_exporter_otel::OpenTelemetryRecorder; +use opentelemetry::metrics::MeterProvider; +use opentelemetry_sdk::metrics::SdkMeterProvider; +use std::thread; +use std::time::Duration; + +fn main() { + // Create OpenTelemetry provider and meter + let provider = SdkMeterProvider::default(); + let meter = provider.meter("histogram_example"); + + // Create the recorder + let recorder = OpenTelemetryRecorder::new(meter); + + // Set custom histogram boundaries BEFORE installing the recorder + recorder.set_histogram_bounds( + &KeyName::from("latency"), + vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0], + ); + + recorder.set_histogram_bounds( + &KeyName::from("request_size"), + vec![1024.0, 4096.0, 16384.0, 65536.0, 262144.0, 1048576.0], + ); + + // Install the recorder + metrics::set_global_recorder(recorder).expect("failed to install recorder"); + + // Describe the histograms + describe_histogram!("latency", Unit::Seconds, "Request latency"); + describe_histogram!("request_size", Unit::Bytes, "Request size"); + + // Record various values to see the custom buckets in action + let latency_values = [0.002, 0.008, 0.015, 0.035, 0.075, 0.15, 0.3, 0.7, 1.5, 3.0, 7.0]; + let size_values = [512.0, 2048.0, 8192.0, 32768.0, 131072.0, 524288.0, 2097152.0]; + + for (i, &latency) in latency_values.iter().enumerate() { + histogram!("latency", "endpoint" => "/api/users").record(latency); + + if i < size_values.len() { + histogram!("request_size", "endpoint" => "/api/users").record(size_values[i]); + } + + println!("Recorded latency: {}s, size: {}B", latency, size_values.get(i).unwrap_or(&0.0)); + thread::sleep(Duration::from_millis(300)); + } + + println!("Custom histogram bounds example completed"); +} diff --git a/metrics-exporter-otel/src/instruments.rs b/metrics-exporter-otel/src/instruments.rs new file mode 100644 index 00000000..6928c8e7 --- /dev/null +++ b/metrics-exporter-otel/src/instruments.rs @@ -0,0 +1,95 @@ +//! OpenTelemetry instrument wrappers for metrics traits. + +use metrics::{CounterFn, GaugeFn, HistogramFn}; +use opentelemetry::metrics::{ + AsyncInstrumentBuilder, Histogram, ObservableCounter, ObservableGauge, +}; +use opentelemetry::KeyValue; +use portable_atomic::{AtomicF64, Ordering}; +use std::sync::atomic::AtomicU64; +use std::sync::Arc; + +pub struct OtelCounter { + #[allow(dead_code)] // prevent from drop + counter: ObservableCounter, + value: Arc, +} + +impl OtelCounter { + pub fn new( + counter_builder: AsyncInstrumentBuilder, u64>, + attributes: Vec, + ) -> Self { + let value = Arc::new(AtomicU64::new(0)); + let value_moved = Arc::clone(&value); + let otel_counter = counter_builder + .with_callback(move |observer| { + observer.observe(value_moved.load(Ordering::Relaxed), &attributes); + }) + .build(); + Self { counter: otel_counter, value } + } +} + +impl CounterFn for OtelCounter { + fn increment(&self, value: u64) { + self.value.fetch_add(value, Ordering::Relaxed); + } + + fn absolute(&self, value: u64) { + self.value.store(value, Ordering::Relaxed); + } +} + +pub struct OtelGauge { + #[allow(dead_code)] // prevent from drop + gauge: ObservableGauge, + value: Arc, +} + +impl OtelGauge { + pub fn new( + gauge_builder: AsyncInstrumentBuilder, f64>, + attributes: Vec, + ) -> Self { + let value = Arc::new(AtomicF64::new(0.0)); + let value_moved = value.clone(); + let otel_gauge = gauge_builder + .with_callback(move |observer| { + observer.observe(value_moved.load(Ordering::Relaxed), &attributes); + }) + .build(); + Self { gauge: otel_gauge, value } + } +} + +impl GaugeFn for OtelGauge { + fn increment(&self, value: f64) { + self.value.fetch_add(value, Ordering::Relaxed); + } + + fn decrement(&self, value: f64) { + self.value.fetch_sub(value, Ordering::Relaxed); + } + + fn set(&self, value: f64) { + self.value.store(value, Ordering::Relaxed); + } +} + +pub struct OtelHistogram { + histogram: Histogram, + attributes: Vec, +} + +impl OtelHistogram { + pub fn new(histogram: Histogram, attributes: Vec) -> Self { + Self { histogram, attributes } + } +} + +impl HistogramFn for OtelHistogram { + fn record(&self, value: f64) { + self.histogram.record(value, &self.attributes); + } +} diff --git a/metrics-exporter-otel/src/lib.rs b/metrics-exporter-otel/src/lib.rs new file mode 100644 index 00000000..be79b117 --- /dev/null +++ b/metrics-exporter-otel/src/lib.rs @@ -0,0 +1,99 @@ +#![doc = include_str!("../README.md")] +#![cfg_attr(docsrs, feature(doc_cfg), deny(rustdoc::broken_intra_doc_links))] +#![deny(missing_docs)] + +mod instruments; +mod metadata; +mod storage; + +use std::sync::Arc; +use crate::metadata::MetricMetadata; +use crate::storage::OtelMetricStorage; +use metrics::{Counter, Gauge, Histogram, Key, KeyName, Recorder, SharedString, Unit}; +use metrics_util::registry::Registry; +use metrics_util::MetricKind; +use opentelemetry::metrics::Meter; + +/// A [`Recorder`] that exports metrics to OpenTelemetry. +/// +/// Clone is shallow; Clones share the same underlying data. +/// +/// ```rust,no_run +/// use opentelemetry::metrics::MeterProvider; +/// use metrics_exporter_otel::OpenTelemetryRecorder; +/// use opentelemetry_sdk::metrics::SdkMeterProvider; +/// +/// let provider = SdkMeterProvider::default(); +/// let meter = provider.meter("my_app"); +/// let recorder = OpenTelemetryRecorder::new(meter); +/// +/// metrics::set_global_recorder(recorder).expect("failed to install recorder"); +/// ``` +#[derive(Clone)] +pub struct OpenTelemetryRecorder { + registry: Arc>, + metadata: MetricMetadata, +} + +impl OpenTelemetryRecorder { + /// Creates a new OpenTelemetry recorder with the given meter. + pub fn new(meter: Meter) -> Self { + let metadata = MetricMetadata::new(); + let storage = OtelMetricStorage::new(meter, metadata.clone()); + Self { registry: Arc::new(Registry::new(storage)), metadata } + } + + /// Sets custom bucket boundaries for a histogram metric. + /// + /// Must be called before the histogram is first created. Boundaries cannot be + /// changed after a histogram has been created. + pub fn set_histogram_bounds(&self, key: &KeyName, bounds: Vec) { + self.metadata.set_histogram_bounds(key.clone(), bounds); + } + + /// Gets a description entry for testing purposes. + #[cfg(test)] + pub fn get_description( + &self, + key_name: KeyName, + metric_kind: MetricKind, + ) -> Option { + self.metadata.get_description(&key_name, metric_kind) + } +} + +impl Recorder for OpenTelemetryRecorder { + fn describe_counter( + &self, + _key_name: KeyName, + _unit: Option, + _description: SharedString, + ) { + self.metadata.set_description(_key_name, MetricKind::Counter, _unit, _description); + } + + fn describe_gauge(&self, _key_name: KeyName, _unit: Option, _description: SharedString) { + self.metadata.set_description(_key_name, MetricKind::Gauge, _unit, _description); + } + + fn describe_histogram( + &self, + _key_name: KeyName, + _unit: Option, + _description: SharedString, + ) { + self.metadata.set_description(_key_name, MetricKind::Histogram, _unit, _description); + } + + fn register_counter(&self, key: &Key, _metadata: &metrics::Metadata<'_>) -> Counter { + self.registry.get_or_create_counter(key, |c| Counter::from_arc(c.clone())) + } + + fn register_gauge(&self, key: &Key, _metadata: &metrics::Metadata<'_>) -> Gauge { + self.registry.get_or_create_gauge(key, |g| Gauge::from_arc(g.clone())) + } + + fn register_histogram(&self, key: &Key, _metadata: &metrics::Metadata<'_>) -> Histogram { + self.registry.get_or_create_histogram(key, |h| Histogram::from_arc(h.clone())) + } +} diff --git a/metrics-exporter-otel/src/metadata.rs b/metrics-exporter-otel/src/metadata.rs new file mode 100644 index 00000000..c67a1306 --- /dev/null +++ b/metrics-exporter-otel/src/metadata.rs @@ -0,0 +1,78 @@ +use metrics::{KeyName, SharedString, Unit}; +use metrics_util::MetricKind; +use scc::HashMap; +use std::sync::Arc; + +/// A metric description containing unit and textual description. +/// +/// This structure holds the metadata associated with a metric, including its unit of +/// measurement and human-readable description. This information is used to enrich +/// the OpenTelemetry metric output. +#[derive(Clone)] +pub struct MetricDescription { + /// The unit of measurement for this metric (e.g., bytes, seconds, count) + unit: Option, + /// Human-readable description of what this metric measures + description: SharedString, +} + +impl MetricDescription { + /// Returns the unit of measurement for this metric. + pub fn unit(&self) -> Option { + self.unit + } + + /// Returns the human-readable description of this metric. + pub fn description(&self) -> SharedString { + self.description.clone() + } +} + +/// Stores all metric metadata including descriptions and histogram bounds. +/// +/// This structure maintains a centralized store of metadata for all metrics, providing +/// lock-free concurrent access through SCC (Scalable Concurrent Collections) HashMaps. +/// It stores both metric descriptions (with units) and custom histogram bucket boundaries. +/// +/// # Thread Safety +/// +/// This structure is designed for high-performance concurrent access. Multiple threads +/// can safely read and write metadata simultaneously with minimal contention. +#[derive(Clone, Default)] +pub struct MetricMetadata { + descriptions: Arc>, + histogram_bounds: Arc>>, +} + +impl MetricMetadata { + pub fn new() -> Self { + Self { descriptions: Arc::new(HashMap::new()), histogram_bounds: Arc::new(HashMap::new()) } + } + + pub fn set_description( + &self, + key_name: KeyName, + metric_kind: MetricKind, + unit: Option, + description: SharedString, + ) { + let new_entry = MetricDescription { unit, description }; + let _ = self.descriptions.insert((key_name, metric_kind), new_entry); + } + + pub fn get_description( + &self, + key_name: &KeyName, + metric_kind: MetricKind, + ) -> Option { + self.descriptions.read(&(key_name.clone(), metric_kind), |_, v| v.clone()) + } + + pub fn set_histogram_bounds(&self, key_name: KeyName, bounds: Vec) { + let _ = self.histogram_bounds.insert(key_name, bounds); + } + + pub fn get_histogram_bounds(&self, key_name: &KeyName) -> Option> { + self.histogram_bounds.read(key_name, |_, v| v.clone()) + } +} diff --git a/metrics-exporter-otel/src/storage.rs b/metrics-exporter-otel/src/storage.rs new file mode 100644 index 00000000..e758bdb8 --- /dev/null +++ b/metrics-exporter-otel/src/storage.rs @@ -0,0 +1,106 @@ +use crate::instruments::{OtelCounter, OtelGauge, OtelHistogram}; +use crate::metadata::{MetricDescription, MetricMetadata}; +use metrics::{Key, KeyName}; +use metrics_util::registry::Storage; +use metrics_util::MetricKind; +use opentelemetry::metrics::{AsyncInstrumentBuilder, HistogramBuilder, Meter}; +use opentelemetry::KeyValue; +use std::sync::Arc; + +pub struct OtelMetricStorage { + meter: Meter, + metadata: MetricMetadata, +} + +impl OtelMetricStorage { + pub fn new(meter: Meter, metadata: MetricMetadata) -> Self { + Self { meter, metadata } + } + + fn get_attributes(key: &Key) -> Vec { + key.labels() + .map(|label| KeyValue::new(label.key().to_string(), label.value().to_string())) + .collect() + } + + fn with_description<'a, I, M>( + description: &MetricDescription, + builder: AsyncInstrumentBuilder<'a, I, M>, + ) -> AsyncInstrumentBuilder<'a, I, M> { + match description.unit() { + Some(unit) => builder + .with_description(description.description().to_string()) + .with_unit(unit.as_canonical_label()), + None => builder.with_description(description.description().to_string()), + } + } + + fn with_description_histogram<'a, T>( + description: &MetricDescription, + builder: HistogramBuilder<'a, T>, + ) -> HistogramBuilder<'a, T> { + match description.unit() { + Some(unit) => builder + .with_description(description.description().to_string()) + .with_unit(unit.as_canonical_label()), + None => builder.with_description(description.description().to_string()), + } + } +} + +impl Storage for OtelMetricStorage { + type Counter = Arc; + type Gauge = Arc; + type Histogram = Arc; + + fn counter(&self, key: &Key) -> Self::Counter { + let builder = self.meter.u64_observable_counter(key.name().to_string()); + let key_name = KeyName::from(key.name().to_string()); + let builder = if let Some(description) = + self.metadata.get_description(&key_name, MetricKind::Counter) + { + Self::with_description(&description, builder) + } else { + builder + }; + let attributes = Self::get_attributes(key); + Arc::new(OtelCounter::new(builder, attributes)) + } + + fn gauge(&self, key: &Key) -> Self::Gauge { + let builder = self.meter.f64_observable_gauge(key.name().to_string()); + let key_name = KeyName::from(key.name().to_string()); + let builder = if let Some(description) = + self.metadata.get_description(&key_name, MetricKind::Gauge) + { + Self::with_description(&description, builder) + } else { + builder + }; + let attributes = Self::get_attributes(key); + Arc::new(OtelGauge::new(builder, attributes)) + } + + fn histogram(&self, key: &Key) -> Self::Histogram { + let builder = self.meter.f64_histogram(key.name().to_string()); + let key_name = KeyName::from(key.name().to_string()); + + let builder = if let Some(description) = + self.metadata.get_description(&key_name, MetricKind::Histogram) + { + Self::with_description_histogram(&description, builder) + } else { + builder + }; + + // Apply histogram bounds if they exist + let builder = if let Some(bounds) = self.metadata.get_histogram_bounds(&key_name) { + builder.with_boundaries(bounds) + } else { + builder + }; + + let attributes = Self::get_attributes(key); + Arc::new(OtelHistogram::new(builder.build(), attributes)) + } +} diff --git a/metrics-exporter-otel/tests/integration_test.rs b/metrics-exporter-otel/tests/integration_test.rs new file mode 100644 index 00000000..c28f1853 --- /dev/null +++ b/metrics-exporter-otel/tests/integration_test.rs @@ -0,0 +1,514 @@ +use metrics::{ + counter, describe_counter, describe_gauge, describe_histogram, gauge, histogram, Recorder, Unit, +}; +use metrics_exporter_otel::OpenTelemetryRecorder; +use opentelemetry::metrics::MeterProvider; +use opentelemetry::{Key, Value}; +use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; +use opentelemetry_sdk::metrics::{InMemoryMetricExporter, PeriodicReader, SdkMeterProvider}; +use std::time::Duration; + +#[test] +fn test_counter_increments_correctly() { + // Given: OpenTelemetry recorder with in-memory exporter + let exporter = InMemoryMetricExporter::default(); + let reader = + PeriodicReader::builder(exporter.clone()).with_interval(Duration::from_millis(100)).build(); + let provider = SdkMeterProvider::builder().with_reader(reader).build(); + let meter = provider.meter("test_meter"); + let recorder = OpenTelemetryRecorder::new(meter); + let _ = metrics::set_global_recorder(recorder); + + // When: Counter is incremented with different labels + describe_counter!("requests_total", Unit::Count, "Total number of requests"); + counter!("requests_total", "method" => "GET", "status" => "200").increment(1); + counter!("requests_total", "method" => "POST", "status" => "201").increment(2); + provider.force_flush().unwrap(); + + // Then: Counter values are recorded correctly + let metrics = exporter.get_finished_metrics().unwrap(); + let requests_metric = metrics + .last() + .unwrap() + .scope_metrics() + .flat_map(|sm| sm.metrics()) + .find(|m| m.name() == "requests_total") + .expect("requests_total metric should exist"); + + let AggregatedMetrics::U64(metric_data) = requests_metric.data() else { + panic!("Counter should be U64"); + }; + let MetricData::Sum(sum) = metric_data else { + panic!("Counter should be Sum"); + }; + + let data_points: Vec<_> = sum.data_points().collect(); + assert_eq!(data_points.len(), 2, "Should have 2 data points for different label combinations"); + + let get_point = data_points + .iter() + .find(|dp| { + dp.attributes().any(|a| a.key == Key::from("method") && a.value == Value::from("GET")) + }) + .expect("Should have GET data point"); + assert_eq!(get_point.value(), 1, "GET counter should be 1"); + + let post_point = data_points + .iter() + .find(|dp| { + dp.attributes().any(|a| a.key == Key::from("method") && a.value == Value::from("POST")) + }) + .expect("Should have POST data point"); + assert_eq!(post_point.value(), 2, "POST counter should be 2"); +} + +#[test] +fn test_counter_accumulates_increments() { + // Given: OpenTelemetry recorder with counter already incremented + let exporter = InMemoryMetricExporter::default(); + let reader = + PeriodicReader::builder(exporter.clone()).with_interval(Duration::from_millis(100)).build(); + let provider = SdkMeterProvider::builder().with_reader(reader).build(); + let meter = provider.meter("test_meter"); + let recorder = OpenTelemetryRecorder::new(meter); + let _ = metrics::set_global_recorder(recorder); + + counter!("events_total").increment(5); + provider.force_flush().unwrap(); + + // Then: First flush should have counter value of 5 + let metrics = exporter.get_finished_metrics().unwrap(); + assert!(!metrics.is_empty(), "Should have metrics after first flush"); + let first_metric = metrics[0] + .scope_metrics() + .flat_map(|sm| sm.metrics()) + .find(|m| m.name() == "events_total") + .expect("events_total metric should exist in first flush"); + + let AggregatedMetrics::U64(first_data) = first_metric.data() else { + panic!("Counter should be U64"); + }; + let MetricData::Sum(first_sum) = first_data else { + panic!("Counter should be Sum"); + }; + + let first_point = first_sum.data_points().next().expect("Should have data point"); + assert_eq!(first_point.value(), 5, "First flush should have counter value of 5"); + + // When: Same counter is incremented again + counter!("events_total").increment(3); + provider.force_flush().unwrap(); + + // Then: Counter value should accumulate in the last metric + let metrics = exporter.get_finished_metrics().unwrap(); + let events_metric = metrics + .last() + .unwrap() + .scope_metrics() + .flat_map(|sm| sm.metrics()) + .find(|m| m.name() == "events_total") + .expect("events_total metric should exist"); + + let AggregatedMetrics::U64(metric_data) = events_metric.data() else { + panic!("Counter should be U64"); + }; + let MetricData::Sum(sum) = metric_data else { + panic!("Counter should be Sum"); + }; + + let point = sum.data_points().next().expect("Should have data point"); + assert_eq!(point.value(), 8, "Counter should accumulate to 8"); +} + +#[test] +fn test_gauge_sets_value_correctly() { + // Given: OpenTelemetry recorder with in-memory exporter + let exporter = InMemoryMetricExporter::default(); + let reader = + PeriodicReader::builder(exporter.clone()).with_interval(Duration::from_millis(100)).build(); + let provider = SdkMeterProvider::builder().with_reader(reader).build(); + let meter = provider.meter("test_meter"); + let recorder = OpenTelemetryRecorder::new(meter); + let _ = metrics::set_global_recorder(recorder); + + // When: Gauge values are set with different labels + describe_gauge!("cpu_usage", Unit::Percent, "Current CPU usage"); + gauge!("cpu_usage", "core" => "0").set(45.5); + gauge!("cpu_usage", "core" => "1").set(62.3); + provider.force_flush().unwrap(); + + // Then: Gauge values are recorded correctly + let metrics = exporter.get_finished_metrics().unwrap(); + let cpu_metric = metrics + .last() + .unwrap() + .scope_metrics() + .flat_map(|sm| sm.metrics()) + .find(|m| m.name() == "cpu_usage") + .expect("cpu_usage metric should exist"); + + let AggregatedMetrics::F64(metric_data) = cpu_metric.data() else { + panic!("Gauge should be F64"); + }; + let MetricData::Gauge(gauge_data) = metric_data else { + panic!("Gauge should be Gauge type"); + }; + + let data_points: Vec<_> = gauge_data.data_points().collect(); + assert_eq!(data_points.len(), 2, "Should have 2 data points for different cores"); + + let core0_point = data_points + .iter() + .find(|dp| { + dp.attributes().any(|a| a.key == Key::from("core") && a.value == Value::from("0")) + }) + .expect("Should have core 0 data point"); + assert_eq!(core0_point.value(), 45.5, "Core 0 usage should be 45.5"); + + let core1_point = data_points + .iter() + .find(|dp| { + dp.attributes().any(|a| a.key == Key::from("core") && a.value == Value::from("1")) + }) + .expect("Should have core 1 data point"); + assert_eq!(core1_point.value(), 62.3, "Core 1 usage should be 62.3"); +} + +#[test] +fn test_gauge_updates_value() { + // Given: OpenTelemetry recorder with gauge already set + let exporter = InMemoryMetricExporter::default(); + let reader = + PeriodicReader::builder(exporter.clone()).with_interval(Duration::from_millis(100)).build(); + let provider = SdkMeterProvider::builder().with_reader(reader).build(); + let meter = provider.meter("test_meter"); + let recorder = OpenTelemetryRecorder::new(meter); + let _ = metrics::set_global_recorder(recorder); + + gauge!("memory_usage").set(1024.0); + provider.force_flush().unwrap(); + + // When: Gauge value is updated + gauge!("memory_usage").set(2048.0); + provider.force_flush().unwrap(); + + // Then: Latest gauge value should be recorded + let metrics = exporter.get_finished_metrics().unwrap(); + let memory_metric = metrics + .last() + .unwrap() + .scope_metrics() + .flat_map(|sm| sm.metrics()) + .find(|m| m.name() == "memory_usage") + .expect("memory_usage metric should exist"); + + let AggregatedMetrics::F64(metric_data) = memory_metric.data() else { + panic!("Gauge should be F64"); + }; + let MetricData::Gauge(gauge_data) = metric_data else { + panic!("Gauge should be Gauge type"); + }; + + let point = gauge_data.data_points().next().expect("Should have data point"); + assert_eq!(point.value(), 2048.0, "Gauge should have latest value 2048.0"); +} + +#[test] +fn test_histogram_records_values() { + // Given: OpenTelemetry recorder with in-memory exporter + let exporter = InMemoryMetricExporter::default(); + let reader = + PeriodicReader::builder(exporter.clone()).with_interval(Duration::from_millis(100)).build(); + let provider = SdkMeterProvider::builder().with_reader(reader).build(); + let meter = provider.meter("test_meter"); + let recorder = OpenTelemetryRecorder::new(meter); + let _ = metrics::set_global_recorder(recorder); + + // When: Histogram values are recorded + describe_histogram!("response_time", Unit::Seconds, "Response time distribution"); + histogram!("response_time", "endpoint" => "/api/users").record(0.123); + histogram!("response_time", "endpoint" => "/api/users").record(0.456); + histogram!("response_time", "endpoint" => "/api/posts").record(0.789); + provider.force_flush().unwrap(); + + // Then: Histogram values are recorded correctly + let metrics = exporter.get_finished_metrics().unwrap(); + let response_metric = metrics + .last() + .unwrap() + .scope_metrics() + .flat_map(|sm| sm.metrics()) + .find(|m| m.name() == "response_time") + .expect("response_time metric should exist"); + + let AggregatedMetrics::F64(metric_data) = response_metric.data() else { + panic!("Histogram should be F64"); + }; + let MetricData::Histogram(hist_data) = metric_data else { + panic!("Should be Histogram type"); + }; + + let data_points: Vec<_> = hist_data.data_points().collect(); + assert_eq!(data_points.len(), 2, "Should have 2 data points for different endpoints"); + + let users_point = data_points + .iter() + .find(|dp| { + dp.attributes() + .any(|a| a.key == Key::from("endpoint") && a.value == Value::from("/api/users")) + }) + .expect("Should have /api/users data point"); + assert_eq!(users_point.count(), 2, "/api/users should have 2 recordings"); + assert_eq!(users_point.sum(), 0.123 + 0.456, "Sum should be correct"); + + let posts_point = data_points + .iter() + .find(|dp| { + dp.attributes() + .any(|a| a.key == Key::from("endpoint") && a.value == Value::from("/api/posts")) + }) + .expect("Should have /api/posts data point"); + assert_eq!(posts_point.count(), 1, "/api/posts should have 1 recording"); + assert_eq!(posts_point.sum(), 0.789, "Sum should be 0.789"); +} + +#[test] +fn test_metrics_without_descriptions() { + // Given: OpenTelemetry recorder with in-memory exporter + let exporter = InMemoryMetricExporter::default(); + let reader = + PeriodicReader::builder(exporter.clone()).with_interval(Duration::from_millis(100)).build(); + let provider = SdkMeterProvider::builder().with_reader(reader).build(); + let meter = provider.meter("test_meter"); + let recorder = OpenTelemetryRecorder::new(meter); + let _ = metrics::set_global_recorder(recorder); + + // When: Metrics are used without descriptions + counter!("events").increment(10); + gauge!("temperature").set(23.5); + histogram!("duration").record(1.5); + provider.force_flush().unwrap(); + + // Then: Metrics should still be recorded + let metrics = exporter.get_finished_metrics().unwrap(); + let scope_metrics: Vec<_> = metrics.last().unwrap().scope_metrics().collect(); + assert!(!scope_metrics.is_empty(), "Should have scope metrics"); + + let all_metrics: Vec<_> = scope_metrics.iter().flat_map(|sm| sm.metrics()).collect(); + + assert!(all_metrics.iter().any(|m| m.name() == "events"), "Should have events counter"); + assert!(all_metrics.iter().any(|m| m.name() == "temperature"), "Should have temperature gauge"); + assert!(all_metrics.iter().any(|m| m.name() == "duration"), "Should have duration histogram"); +} + +#[test] +fn test_metric_descriptions_are_stored() { + // Given: OpenTelemetry recorder + let exporter = InMemoryMetricExporter::default(); + let reader = + PeriodicReader::builder(exporter.clone()).with_interval(Duration::from_millis(100)).build(); + let provider = SdkMeterProvider::builder().with_reader(reader).build(); + let meter = provider.meter("test_meter"); + let recorder = OpenTelemetryRecorder::new(meter); + + // When: Metrics are described directly on the recorder + recorder.describe_counter( + "test.counter".into(), + Some(Unit::Count), + "Test counter description".into(), + ); + recorder.describe_gauge( + "test.gauge".into(), + Some(Unit::Bytes), + "Test gauge description".into(), + ); + recorder.describe_histogram( + "test.histogram".into(), + Some(Unit::Milliseconds), + "Test histogram description".into(), + ); + + // And: Metrics are registered and used + let key = metrics::Key::from_name("test.counter"); + let metadata = metrics::Metadata::new("test", metrics::Level::INFO, Some("test.counter")); + let counter = recorder.register_counter(&key, &metadata); + counter.increment(1); + + let key = metrics::Key::from_name("test.gauge"); + let gauge = recorder.register_gauge(&key, &metadata); + gauge.set(42.0); + + let key = metrics::Key::from_name("test.histogram"); + let histogram = recorder.register_histogram(&key, &metadata); + histogram.record(0.5); + + provider.force_flush().unwrap(); + + // Then: Metrics should have correct descriptions and units + let metrics = exporter.get_finished_metrics().unwrap(); + let all_metrics: Vec<_> = + metrics.last().unwrap().scope_metrics().flat_map(|sm| sm.metrics()).collect(); + + assert_eq!(all_metrics.len(), 3, "Should have all 3 metrics registered"); + + // Check counter description and unit + let counter_metric = all_metrics + .iter() + .find(|m| m.name() == "test.counter") + .expect("Should have test.counter metric"); + assert_eq!( + counter_metric.description(), + "Test counter description", + "Counter should have correct description" + ); + assert_eq!(counter_metric.unit(), "", "Counter should have unit '' for Count"); + + // Check gauge description and unit + let gauge_metric = all_metrics + .iter() + .find(|m| m.name() == "test.gauge") + .expect("Should have test.gauge metric"); + assert_eq!( + gauge_metric.description(), + "Test gauge description", + "Gauge should have correct description" + ); + assert_eq!(gauge_metric.unit(), "B", "Gauge should have unit 'B' for Bytes"); + + // Check histogram description and unit + let histogram_metric = all_metrics + .iter() + .find(|m| m.name() == "test.histogram") + .expect("Should have test.histogram metric"); + assert_eq!( + histogram_metric.description(), + "Test histogram description", + "Histogram should have correct description" + ); + assert_eq!(histogram_metric.unit(), "ms", "Histogram should have unit 'ms' for Milliseconds"); +} + +#[test] +fn test_metrics_with_multiple_labels() { + // Given: OpenTelemetry recorder + let exporter = InMemoryMetricExporter::default(); + let reader = + PeriodicReader::builder(exporter.clone()).with_interval(Duration::from_millis(100)).build(); + let provider = SdkMeterProvider::builder().with_reader(reader).build(); + let meter = provider.meter("test_meter"); + let recorder = OpenTelemetryRecorder::new(meter); + + // When: Metrics are registered with multiple labels + let key = metrics::Key::from_parts( + "http_requests", + vec![ + metrics::Label::new("method", "GET"), + metrics::Label::new("status", "200"), + metrics::Label::new("path", "/api/v1/users"), + ], + ); + let metadata = metrics::Metadata::new("test", metrics::Level::INFO, Some("http_requests")); + let counter = recorder.register_counter(&key, &metadata); + counter.increment(5); + + provider.force_flush().unwrap(); + + // Then: All labels should be recorded as attributes + let metrics = exporter.get_finished_metrics().unwrap(); + let http_metric = metrics + .last() + .unwrap() + .scope_metrics() + .flat_map(|sm| sm.metrics()) + .find(|m| m.name() == "http_requests") + .expect("http_requests metric should exist"); + + let AggregatedMetrics::U64(metric_data) = http_metric.data() else { + panic!("Counter should be U64"); + }; + let MetricData::Sum(sum) = metric_data else { + panic!("Counter should be Sum"); + }; + + let point = sum.data_points().next().expect("Should have data point"); + let attrs: Vec<_> = point.attributes().collect(); + + assert_eq!(attrs.len(), 3, "Should have 3 attributes"); + assert!(attrs.iter().any(|a| a.key == Key::from("method") && a.value == Value::from("GET"))); + assert!(attrs.iter().any(|a| a.key == Key::from("status") && a.value == Value::from("200"))); + assert!(attrs + .iter() + .any(|a| a.key == Key::from("path") && a.value == Value::from("/api/v1/users"))); + assert_eq!(point.value(), 5, "Counter value should be 5"); +} + +#[test] +fn test_histogram_with_custom_bounds() { + // Given: OpenTelemetry recorder with custom histogram bounds + let exporter = InMemoryMetricExporter::default(); + let reader = + PeriodicReader::builder(exporter.clone()).with_interval(Duration::from_millis(100)).build(); + let provider = SdkMeterProvider::builder().with_reader(reader).build(); + let meter = provider.meter("test_meter"); + let recorder = OpenTelemetryRecorder::new(meter); + + // When: Custom bounds are set for a histogram before it's created + recorder.set_histogram_bounds( + &metrics::KeyName::from("latency"), + vec![0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0], + ); + + // And: Histogram is registered and values are recorded + let key = metrics::Key::from_name("latency"); + let metadata = metrics::Metadata::new("test", metrics::Level::INFO, Some("latency")); + let histogram = recorder.register_histogram(&key, &metadata); + histogram.record(0.03); + histogram.record(0.12); + histogram.record(0.75); + histogram.record(3.5); + + provider.force_flush().unwrap(); + + // Then: Histogram should be recorded with values + let metrics = exporter.get_finished_metrics().unwrap(); + let latency_metric = metrics + .last() + .unwrap() + .scope_metrics() + .flat_map(|sm| sm.metrics()) + .find(|m| m.name() == "latency") + .expect("latency metric should exist"); + + let AggregatedMetrics::F64(metric_data) = latency_metric.data() else { + panic!("Histogram should be F64"); + }; + let MetricData::Histogram(hist_data) = metric_data else { + panic!("Should be Histogram type"); + }; + + let point = hist_data.data_points().next().expect("Should have data point"); + + // Verify the histogram has recorded values + assert_eq!(point.count(), 4, "Should have 4 recordings"); + assert_eq!(point.sum(), 0.03 + 0.12 + 0.75 + 3.5, "Sum should be correct"); + + // Check bucket boundaries are applied (9 boundaries create 10 buckets) + let boundaries: Vec<_> = point.bounds().collect(); + assert_eq!(boundaries.len(), 9, "Should have 9 boundaries"); + assert_eq!(boundaries, vec![0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]); + + // Check bucket counts + let counts: Vec<_> = point.bucket_counts().collect(); + assert_eq!(counts.len(), 10, "Should have 10 buckets"); + // Values: 0.03 (bucket 1), 0.12 (bucket 3), 0.75 (bucket 5), 3.5 (bucket 7) + assert_eq!(counts[0], 0, "Bucket [0, 0.01) should be empty"); + assert_eq!(counts[1], 1, "Bucket [0.01, 0.05) should have 1 value (0.03)"); + assert_eq!(counts[2], 0, "Bucket [0.05, 0.1) should be empty"); + assert_eq!(counts[3], 1, "Bucket [0.1, 0.25) should have 1 value (0.12)"); + assert_eq!(counts[4], 0, "Bucket [0.25, 0.5) should be empty"); + assert_eq!(counts[5], 1, "Bucket [0.5, 1.0) should have 1 value (0.75)"); + assert_eq!(counts[6], 0, "Bucket [1.0, 2.5) should be empty"); + assert_eq!(counts[7], 1, "Bucket [2.5, 5.0) should have 1 value (3.5)"); + assert_eq!(counts[8], 0, "Bucket [5.0, 10.0) should be empty"); + assert_eq!(counts[9], 0, "Bucket [10.0, +∞) should be empty"); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index ee9a0f0f..1734b65e 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] # Note that this is greater than the MSRV of the workspace (1.70) due to metrics-observer needing -# 1.74, while all the other crates only require 1.70. See +# 1.74 and metrics-exporter-otel needing 1.75, while all the other crates only require 1.70. See # https://github.com/metrics-rs/metrics/pull/505#discussion_r1724092556 for more information. -channel = "1.74.0" +channel = "1.75.0"