From 1a0566bffde137f3ccfe0387cc05b587532bc32f Mon Sep 17 00:00:00 2001 From: Whoemoon Jang Date: Fri, 11 Jul 2025 12:11:12 +0900 Subject: [PATCH 01/10] feat: add metrics-exporter-opentelemetry crate --- Cargo.lock | 28 ++++++ Cargo.toml | 5 + metrics-exporter-opentelemetry/CHANGELOG.md | 13 +++ metrics-exporter-opentelemetry/Cargo.toml | 26 +++++ metrics-exporter-opentelemetry/README.md | 62 ++++++++++++ .../src/instruments.rs | 95 +++++++++++++++++++ metrics-exporter-opentelemetry/src/lib.rs | 60 ++++++++++++ metrics-exporter-opentelemetry/src/storage.rs | 54 +++++++++++ rust-toolchain.toml | 4 +- 9 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 metrics-exporter-opentelemetry/CHANGELOG.md create mode 100644 metrics-exporter-opentelemetry/Cargo.toml create mode 100644 metrics-exporter-opentelemetry/README.md create mode 100644 metrics-exporter-opentelemetry/src/instruments.rs create mode 100644 metrics-exporter-opentelemetry/src/lib.rs create mode 100644 metrics-exporter-opentelemetry/src/storage.rs diff --git a/Cargo.lock b/Cargo.lock index 6e2d8be7..36be67cc 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" @@ -1125,6 +1131,16 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "metrics-exporter-opentelemetry" +version = "0.1.0" +dependencies = [ + "metrics", + "metrics-util", + "opentelemetry", + "portable-atomic", +] + [[package]] name = "metrics-exporter-prometheus" version = "0.17.2" @@ -1414,6 +1430,15 @@ 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 = [ + "js-sys", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -1497,6 +1522,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" diff --git a/Cargo.toml b/Cargo.toml index 6fb3621e..0e7a5a0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "metrics", "metrics-benchmark", "metrics-exporter-dogstatsd", + "metrics-exporter-opentelemetry", "metrics-exporter-prometheus", "metrics-exporter-tcp", "metrics-observer", @@ -48,6 +49,10 @@ 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 } +opentelemetry-otlp = { version = "0.30", default-features = false } +opentelemetry-stdout = { 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 } diff --git a/metrics-exporter-opentelemetry/CHANGELOG.md b/metrics-exporter-opentelemetry/CHANGELOG.md new file mode 100644 index 00000000..de5315f4 --- /dev/null +++ b/metrics-exporter-opentelemetry/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-opentelemetry/Cargo.toml b/metrics-exporter-opentelemetry/Cargo.toml new file mode 100644 index 00000000..ce4546fd --- /dev/null +++ b/metrics-exporter-opentelemetry/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "metrics-exporter-opentelemetry" +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-opentelemetry" +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"] } + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/metrics-exporter-opentelemetry/README.md b/metrics-exporter-opentelemetry/README.md new file mode 100644 index 00000000..a6f29ae4 --- /dev/null +++ b/metrics-exporter-opentelemetry/README.md @@ -0,0 +1,62 @@ +# metrics-exporter-opentelemetry + +[![Documentation](https://docs.rs/metrics-exporter-opentelemetry/badge.svg)](https://docs.rs/metrics-exporter-opentelemetry) + +A [`metrics`][metrics] exporter for [OpenTelemetry]. + +## Features + +- Export metrics to OpenTelemetry collectors using OTLP +- Support for counters, gauges, and histograms +- Integration with the OpenTelemetry SDK +- Configurable export intervals and endpoints + +## Usage + +```rust +use metrics::{counter, gauge, histogram}; +use metrics_exporter_opentelemetry::OpenTelemetryBuilder; +use opentelemetry_otlp::WithExportConfig; +use opentelemetry_sdk::{ + metrics::{PeriodicReader, SdkMeterProvider}, + runtime, +}; + +// Configure OpenTelemetry exporter +let exporter = opentelemetry_otlp::MetricExporter::builder() + .with_tonic() + .with_endpoint("http://localhost:4317") + .build()?; + +// Create a periodic reader +let reader = PeriodicReader::builder(exporter, runtime::Tokio) + .with_interval(Duration::from_secs(10)) + .build(); + +// Build the meter provider +let provider = SdkMeterProvider::builder() + .with_reader(reader) + .build(); + +// Install the metrics exporter +OpenTelemetryBuilder::new() + .with_meter_provider(provider) + .install()?; + +// Now you can use the metrics macros +counter!("requests_total").increment(1); +gauge!("cpu_usage").set(0.75); +histogram!("request_duration").record(0.234); +``` + +## Examples + +- [`opentelemetry_push`](examples/opentelemetry_push.rs): Demonstrates exporting metrics to an OpenTelemetry collector +- [`opentelemetry_stdout`](examples/opentelemetry_stdout.rs): Shows metrics export to stdout for debugging + +## License + +This project is licensed under the MIT license. + +[metrics]: https://github.com/metrics-rs/metrics +[OpenTelemetry]: https://opentelemetry.io/ \ No newline at end of file diff --git a/metrics-exporter-opentelemetry/src/instruments.rs b/metrics-exporter-opentelemetry/src/instruments.rs new file mode 100644 index 00000000..6928c8e7 --- /dev/null +++ b/metrics-exporter-opentelemetry/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-opentelemetry/src/lib.rs b/metrics-exporter-opentelemetry/src/lib.rs new file mode 100644 index 00000000..11be5a73 --- /dev/null +++ b/metrics-exporter-opentelemetry/src/lib.rs @@ -0,0 +1,60 @@ +//! An OpenTelemetry metrics exporter for `metrics`. +mod instruments; +mod storage; + +use metrics::{Counter, Gauge, Histogram, Key, KeyName, Recorder, SharedString, Unit}; +use metrics_util::registry::{Registry, Storage}; +use opentelemetry::metrics::Meter; +use crate::storage::OtelMetricStorage; + +/// The OpenTelemetry recorder. +pub struct OpenTelemetryRecorder { + registry: Registry, +} + +impl OpenTelemetryRecorder { + /// Creates a new OpenTelemetry recorder with the given meter. + pub fn new(meter: Meter) -> Self { + let storage = OtelMetricStorage::new(meter); + Self { + registry: Registry::new(storage), + } + } +} + +impl Recorder for OpenTelemetryRecorder { + fn describe_counter(&self, _key_name: KeyName, _unit: Option, _description: SharedString) { + // Descriptions are handled when creating instruments + } + + fn describe_gauge(&self, _key_name: KeyName, _unit: Option, _description: SharedString) { + // Descriptions are handled when creating instruments + } + + fn describe_histogram( + &self, + _key_name: KeyName, + _unit: Option, + _description: SharedString, + ) { + // Descriptions are handled when creating instruments + } + + 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()) + }) + } +} \ No newline at end of file diff --git a/metrics-exporter-opentelemetry/src/storage.rs b/metrics-exporter-opentelemetry/src/storage.rs new file mode 100644 index 00000000..5dc1a978 --- /dev/null +++ b/metrics-exporter-opentelemetry/src/storage.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; +use opentelemetry::KeyValue; +use opentelemetry::metrics::Meter; +use metrics::Key; +use metrics_util::registry::Storage; +use crate::instruments::{OtelCounter, OtelGauge, OtelHistogram}; + +pub struct OtelMetricStorage { + meter: Meter, +} + +impl OtelMetricStorage { + pub fn new(meter: Meter) -> Self { + Self { meter } + } + + fn get_attributes(key: &Key) -> Vec { + key.labels() + .map(|label| KeyValue::new(label.key().to_string(), label.value().to_string())) + .collect() + } +} + +impl Storage for OtelMetricStorage { + type Counter = Arc; + type Gauge = Arc; + type Histogram = Arc; + + fn counter(&self, key: &Key) -> Self::Counter { + let otel_counter_builder = self + .meter + .u64_observable_counter(key.name().to_string()); + let attributes = Self::get_attributes(key); + Arc::new(OtelCounter::new(otel_counter_builder, attributes)) + } + + fn gauge(&self, key: &Key) -> Self::Gauge { + let builder = self + .meter + .f64_observable_gauge(key.name().to_string()); + let attributes = Self::get_attributes(key); + Arc::new(OtelGauge::new(builder, attributes)) + } + + fn histogram(&self, key: &Key) -> Self::Histogram { + let histogram = self + .meter + .f64_histogram(key.name().to_string()) + .build(); + let attributes = Self::get_attributes(key); + Arc::new(OtelHistogram::new(histogram, attributes)) + + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index ee9a0f0f..8f520fd2 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-opentelemetry 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" From 3f2c6972f0d3648aa85ad102a74537671936629d Mon Sep 17 00:00:00 2001 From: Whoemoon Jang Date: Fri, 11 Jul 2025 17:30:08 +0900 Subject: [PATCH 02/10] fix: support description --- .../src/description.rs | 54 ++++++++++++ metrics-exporter-opentelemetry/src/lib.rs | 53 +++++++----- metrics-exporter-opentelemetry/src/storage.rs | 84 ++++++++++++++----- 3 files changed, 151 insertions(+), 40 deletions(-) create mode 100644 metrics-exporter-opentelemetry/src/description.rs diff --git a/metrics-exporter-opentelemetry/src/description.rs b/metrics-exporter-opentelemetry/src/description.rs new file mode 100644 index 00000000..69cc24b6 --- /dev/null +++ b/metrics-exporter-opentelemetry/src/description.rs @@ -0,0 +1,54 @@ +use metrics::{KeyName, SharedString, Unit}; +use metrics_util::MetricKind; + +use std::collections::HashMap; +use std::sync::{PoisonError, RwLock}; + +#[derive(Clone)] +pub struct DescriptionEntry { + unit: Option, + description: SharedString, +} + +impl DescriptionEntry { + pub fn unit(&self) -> Option { + self.unit + } + pub fn description(&self) -> SharedString { + self.description.clone() + } +} + +#[derive(Default)] +pub struct DescriptionTable { + table: RwLock>, +} + +impl DescriptionTable { + pub fn add_describe( + &self, + key_name: KeyName, + metric_kind: MetricKind, + unit: Option, + description: SharedString, + ) { + let new_entry = DescriptionEntry { unit, description }; + self.table + .write() + .unwrap_or_else(PoisonError::into_inner) + .entry((key_name, metric_kind)) + .and_modify(|e| { + *e = new_entry.clone(); + }) + .or_insert(new_entry); + } + + pub fn get_describe( + &self, + key_name: KeyName, + metric_kind: MetricKind, + ) -> Option { + let table = self.table.read().unwrap_or_else(PoisonError::into_inner); + table.get(&(key_name, metric_kind)).cloned() + } +} diff --git a/metrics-exporter-opentelemetry/src/lib.rs b/metrics-exporter-opentelemetry/src/lib.rs index 11be5a73..03addcbc 100644 --- a/metrics-exporter-opentelemetry/src/lib.rs +++ b/metrics-exporter-opentelemetry/src/lib.rs @@ -1,34 +1,53 @@ //! An OpenTelemetry metrics exporter for `metrics`. +mod description; mod instruments; mod storage; +use crate::description::DescriptionTable; +use crate::storage::OtelMetricStorage; use metrics::{Counter, Gauge, Histogram, Key, KeyName, Recorder, SharedString, Unit}; -use metrics_util::registry::{Registry, Storage}; +use metrics_util::registry::Registry; +use metrics_util::MetricKind; use opentelemetry::metrics::Meter; -use crate::storage::OtelMetricStorage; +use std::sync::Arc; /// The OpenTelemetry recorder. pub struct OpenTelemetryRecorder { registry: Registry, + description_table: Arc, } impl OpenTelemetryRecorder { /// Creates a new OpenTelemetry recorder with the given meter. pub fn new(meter: Meter) -> Self { - let storage = OtelMetricStorage::new(meter); - Self { - registry: Registry::new(storage), - } + let description_table = Arc::new(DescriptionTable::default()); + let storage = OtelMetricStorage::new(meter, description_table.clone()); + Self { registry: Registry::new(storage), description_table } + } + + /// Gets a description entry for testing purposes. + #[cfg(test)] + pub fn get_description( + &self, + key_name: KeyName, + metric_kind: MetricKind, + ) -> Option { + self.description_table.get_describe(key_name, metric_kind) } } impl Recorder for OpenTelemetryRecorder { - fn describe_counter(&self, _key_name: KeyName, _unit: Option, _description: SharedString) { - // Descriptions are handled when creating instruments + fn describe_counter( + &self, + _key_name: KeyName, + _unit: Option, + _description: SharedString, + ) { + self.description_table.add_describe(_key_name, MetricKind::Counter, _unit, _description); } fn describe_gauge(&self, _key_name: KeyName, _unit: Option, _description: SharedString) { - // Descriptions are handled when creating instruments + self.description_table.add_describe(_key_name, MetricKind::Gauge, _unit, _description); } fn describe_histogram( @@ -37,24 +56,18 @@ impl Recorder for OpenTelemetryRecorder { _unit: Option, _description: SharedString, ) { - // Descriptions are handled when creating instruments + self.description_table.add_describe(_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()) - }) + 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()) - }) + 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()) - }) + self.registry.get_or_create_histogram(key, |h| Histogram::from_arc(h.clone())) } -} \ No newline at end of file +} diff --git a/metrics-exporter-opentelemetry/src/storage.rs b/metrics-exporter-opentelemetry/src/storage.rs index 5dc1a978..516774ae 100644 --- a/metrics-exporter-opentelemetry/src/storage.rs +++ b/metrics-exporter-opentelemetry/src/storage.rs @@ -1,17 +1,20 @@ -use std::sync::Arc; -use opentelemetry::KeyValue; -use opentelemetry::metrics::Meter; -use metrics::Key; -use metrics_util::registry::Storage; +use crate::description::{DescriptionEntry, DescriptionTable}; use crate::instruments::{OtelCounter, OtelGauge, OtelHistogram}; +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, + description_table: Arc, } impl OtelMetricStorage { - pub fn new(meter: Meter) -> Self { - Self { meter } + pub fn new(meter: Meter, description_table: Arc) -> Self { + Self { meter, description_table } } fn get_attributes(key: &Key) -> Vec { @@ -19,6 +22,31 @@ impl OtelMetricStorage { .map(|label| KeyValue::new(label.key().to_string(), label.value().to_string())) .collect() } + + fn with_description_entry<'a, I, M>( + description_entry: &DescriptionEntry, + builder: AsyncInstrumentBuilder<'a, I, M>, + ) -> AsyncInstrumentBuilder<'a, I, M> { + // let builder = builder.with_description(self.description.to_string()); + match description_entry.unit() { + Some(unit) => builder + .with_description(description_entry.description().to_string()) + .with_unit(unit.as_canonical_label()), + None => builder.with_description(description_entry.description().to_string()), + } + } + fn with_description_entry_histogram<'a, T>( + description_entry: &DescriptionEntry, + builder: HistogramBuilder<'a, T>, + ) -> HistogramBuilder<'a, T> { + // let builder = builder.with_description(self.description.to_string()); + match description_entry.unit() { + Some(unit) => builder + .with_description(description_entry.description().to_string()) + .with_unit(unit.as_canonical_label()), + None => builder.with_description(description_entry.description().to_string()), + } + } } impl Storage for OtelMetricStorage { @@ -27,28 +55,44 @@ impl Storage for OtelMetricStorage { type Histogram = Arc; fn counter(&self, key: &Key) -> Self::Counter { - let otel_counter_builder = self - .meter - .u64_observable_counter(key.name().to_string()); + let builder = self.meter.u64_observable_counter(key.name().to_string()); + let description = self + .description_table + .get_describe(KeyName::from(key.name().to_string()), MetricKind::Counter); + let builder = if let Some(description) = description { + Self::with_description_entry(&description, builder) + } else { + builder + }; let attributes = Self::get_attributes(key); - Arc::new(OtelCounter::new(otel_counter_builder, attributes)) + 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 builder = self.meter.f64_observable_gauge(key.name().to_string()); + let description = self + .description_table + .get_describe(KeyName::from(key.name().to_string()), MetricKind::Gauge); + let builder = if let Some(description) = description { + Self::with_description_entry(&description, builder) + } else { + builder + }; let attributes = Self::get_attributes(key); Arc::new(OtelGauge::new(builder, attributes)) } fn histogram(&self, key: &Key) -> Self::Histogram { - let histogram = self - .meter - .f64_histogram(key.name().to_string()) - .build(); + let builder = self.meter.f64_histogram(key.name().to_string()); + let description = self + .description_table + .get_describe(KeyName::from(key.name().to_string()), MetricKind::Histogram); + let builder = if let Some(description) = description { + Self::with_description_entry_histogram(&description, builder) + } else { + builder + }; let attributes = Self::get_attributes(key); - Arc::new(OtelHistogram::new(histogram, attributes)) - + Arc::new(OtelHistogram::new(builder.build(), attributes)) } } From 02f77bc259b65869fbe4b615cc3916457b7d9a3b Mon Sep 17 00:00:00 2001 From: jang whoemoon Date: Sat, 12 Jul 2025 14:47:10 +0900 Subject: [PATCH 03/10] test: add otel exporter integration test --- Cargo.lock | 78 +++ metrics-exporter-opentelemetry/Cargo.toml | 3 + .../tests/integration_test.rs | 443 ++++++++++++++++++ 3 files changed, 524 insertions(+) create mode 100644 metrics-exporter-opentelemetry/tests/integration_test.rs diff --git a/Cargo.lock b/Cargo.lock index 36be67cc..409c163a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -599,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" @@ -618,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]] @@ -1138,6 +1163,7 @@ dependencies = [ "metrics", "metrics-util", "opentelemetry", + "opentelemetry_sdk", "portable-atomic", ] @@ -1436,7 +1462,30 @@ 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]] @@ -1483,6 +1532,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" @@ -2351,9 +2406,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" @@ -2364,6 +2431,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/metrics-exporter-opentelemetry/Cargo.toml b/metrics-exporter-opentelemetry/Cargo.toml index ce4546fd..c40a4064 100644 --- a/metrics-exporter-opentelemetry/Cargo.toml +++ b/metrics-exporter-opentelemetry/Cargo.toml @@ -21,6 +21,9 @@ metrics-util = { version = "^0.20", path = "../metrics-util", default-features = opentelemetry = { workspace = true, features = ["metrics"] } portable-atomic = { workspace = true, features = ["float", "critical-section"] } +[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-opentelemetry/tests/integration_test.rs b/metrics-exporter-opentelemetry/tests/integration_test.rs new file mode 100644 index 00000000..7fa08f89 --- /dev/null +++ b/metrics-exporter-opentelemetry/tests/integration_test.rs @@ -0,0 +1,443 @@ +use metrics::{ + counter, describe_counter, describe_gauge, describe_histogram, gauge, histogram, Recorder, Unit, +}; +use metrics_exporter_opentelemetry::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"); +} From 260e4de4dd0b790043df65a3607ecbb8a7c645a9 Mon Sep 17 00:00:00 2001 From: jang whoemoon Date: Sat, 12 Jul 2025 17:53:15 +0900 Subject: [PATCH 04/10] feat(opentelemetry): implement set_histogram_bounds for custom bucket boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> - 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 --- metrics-exporter-opentelemetry/src/lib.rs | 22 +++++- metrics-exporter-opentelemetry/src/storage.rs | 24 ++++++- .../tests/integration_test.rs | 71 +++++++++++++++++++ 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/metrics-exporter-opentelemetry/src/lib.rs b/metrics-exporter-opentelemetry/src/lib.rs index 03addcbc..643c8380 100644 --- a/metrics-exporter-opentelemetry/src/lib.rs +++ b/metrics-exporter-opentelemetry/src/lib.rs @@ -9,22 +9,38 @@ use metrics::{Counter, Gauge, Histogram, Key, KeyName, Recorder, SharedString, U use metrics_util::registry::Registry; use metrics_util::MetricKind; use opentelemetry::metrics::Meter; -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; /// The OpenTelemetry recorder. pub struct OpenTelemetryRecorder { registry: Registry, description_table: Arc, + histogram_bounds: Arc>>>, } impl OpenTelemetryRecorder { /// Creates a new OpenTelemetry recorder with the given meter. pub fn new(meter: Meter) -> Self { let description_table = Arc::new(DescriptionTable::default()); - let storage = OtelMetricStorage::new(meter, description_table.clone()); - Self { registry: Registry::new(storage), description_table } + let histogram_bounds = Arc::new(RwLock::new(HashMap::new())); + let storage = OtelMetricStorage::new(meter, description_table.clone(), histogram_bounds.clone()); + Self { + registry: Registry::new(storage), + description_table, + histogram_bounds, + } } + pub fn set_histogram_bounds( + &self, + key: &KeyName, + bounds: Vec, + ) { + let mut bounds_map = self.histogram_bounds.write().unwrap(); + bounds_map.insert(key.clone(), bounds); + } + /// Gets a description entry for testing purposes. #[cfg(test)] pub fn get_description( diff --git a/metrics-exporter-opentelemetry/src/storage.rs b/metrics-exporter-opentelemetry/src/storage.rs index 516774ae..732f1c36 100644 --- a/metrics-exporter-opentelemetry/src/storage.rs +++ b/metrics-exporter-opentelemetry/src/storage.rs @@ -5,16 +5,22 @@ use metrics_util::registry::Storage; use metrics_util::MetricKind; use opentelemetry::metrics::{AsyncInstrumentBuilder, HistogramBuilder, Meter}; use opentelemetry::KeyValue; -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, PoisonError, RwLock}; pub struct OtelMetricStorage { meter: Meter, description_table: Arc, + histogram_bounds: Arc>>>, } impl OtelMetricStorage { - pub fn new(meter: Meter, description_table: Arc) -> Self { - Self { meter, description_table } + pub fn new(meter: Meter, description_table: Arc, histogram_bounds: Arc>>>) -> Self { + Self { + meter, + description_table, + histogram_bounds, + } } fn get_attributes(key: &Key) -> Vec { @@ -92,6 +98,18 @@ impl Storage for OtelMetricStorage { } else { builder }; + + // Apply histogram bounds if they exist + let key_name = KeyName::from(key.name().to_string()); + let builder = { + let bounds_map = self.histogram_bounds.read().unwrap_or_else(PoisonError::into_inner);; + if let Some(bounds) = bounds_map.get(&key_name) { + builder.with_boundaries(bounds.clone()) + } else { + builder + } + }; + let attributes = Self::get_attributes(key); Arc::new(OtelHistogram::new(builder.build(), attributes)) } diff --git a/metrics-exporter-opentelemetry/tests/integration_test.rs b/metrics-exporter-opentelemetry/tests/integration_test.rs index 7fa08f89..ab795e79 100644 --- a/metrics-exporter-opentelemetry/tests/integration_test.rs +++ b/metrics-exporter-opentelemetry/tests/integration_test.rs @@ -441,3 +441,74 @@ fn test_metrics_with_multiple_labels() { .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"); +} From ec1dac5cd596baa1816469fe45e796fdc4e5c61b Mon Sep 17 00:00:00 2001 From: jang whoemoon Date: Sat, 12 Jul 2025 18:04:39 +0900 Subject: [PATCH 05/10] refactor: unify metadata management into single shared state --- .../src/description.rs | 54 ------------ metrics-exporter-opentelemetry/src/lib.rs | 30 +++---- .../src/metadata.rs | 77 +++++++++++++++++ metrics-exporter-opentelemetry/src/storage.rs | 86 ++++++++----------- 4 files changed, 127 insertions(+), 120 deletions(-) delete mode 100644 metrics-exporter-opentelemetry/src/description.rs create mode 100644 metrics-exporter-opentelemetry/src/metadata.rs diff --git a/metrics-exporter-opentelemetry/src/description.rs b/metrics-exporter-opentelemetry/src/description.rs deleted file mode 100644 index 69cc24b6..00000000 --- a/metrics-exporter-opentelemetry/src/description.rs +++ /dev/null @@ -1,54 +0,0 @@ -use metrics::{KeyName, SharedString, Unit}; -use metrics_util::MetricKind; - -use std::collections::HashMap; -use std::sync::{PoisonError, RwLock}; - -#[derive(Clone)] -pub struct DescriptionEntry { - unit: Option, - description: SharedString, -} - -impl DescriptionEntry { - pub fn unit(&self) -> Option { - self.unit - } - pub fn description(&self) -> SharedString { - self.description.clone() - } -} - -#[derive(Default)] -pub struct DescriptionTable { - table: RwLock>, -} - -impl DescriptionTable { - pub fn add_describe( - &self, - key_name: KeyName, - metric_kind: MetricKind, - unit: Option, - description: SharedString, - ) { - let new_entry = DescriptionEntry { unit, description }; - self.table - .write() - .unwrap_or_else(PoisonError::into_inner) - .entry((key_name, metric_kind)) - .and_modify(|e| { - *e = new_entry.clone(); - }) - .or_insert(new_entry); - } - - pub fn get_describe( - &self, - key_name: KeyName, - metric_kind: MetricKind, - ) -> Option { - let table = self.table.read().unwrap_or_else(PoisonError::into_inner); - table.get(&(key_name, metric_kind)).cloned() - } -} diff --git a/metrics-exporter-opentelemetry/src/lib.rs b/metrics-exporter-opentelemetry/src/lib.rs index 643c8380..7670eb29 100644 --- a/metrics-exporter-opentelemetry/src/lib.rs +++ b/metrics-exporter-opentelemetry/src/lib.rs @@ -1,34 +1,29 @@ //! An OpenTelemetry metrics exporter for `metrics`. -mod description; mod instruments; +mod metadata; mod storage; -use crate::description::DescriptionTable; +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; -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; /// The OpenTelemetry recorder. pub struct OpenTelemetryRecorder { registry: Registry, - description_table: Arc, - histogram_bounds: Arc>>>, + metadata: MetricMetadata, } impl OpenTelemetryRecorder { /// Creates a new OpenTelemetry recorder with the given meter. pub fn new(meter: Meter) -> Self { - let description_table = Arc::new(DescriptionTable::default()); - let histogram_bounds = Arc::new(RwLock::new(HashMap::new())); - let storage = OtelMetricStorage::new(meter, description_table.clone(), histogram_bounds.clone()); + let metadata = MetricMetadata::new(); + let storage = OtelMetricStorage::new(meter, metadata.clone()); Self { registry: Registry::new(storage), - description_table, - histogram_bounds, + metadata, } } @@ -37,8 +32,7 @@ impl OpenTelemetryRecorder { key: &KeyName, bounds: Vec, ) { - let mut bounds_map = self.histogram_bounds.write().unwrap(); - bounds_map.insert(key.clone(), bounds); + self.metadata.set_histogram_bounds(key.clone(), bounds); } /// Gets a description entry for testing purposes. @@ -47,8 +41,8 @@ impl OpenTelemetryRecorder { &self, key_name: KeyName, metric_kind: MetricKind, - ) -> Option { - self.description_table.get_describe(key_name, metric_kind) + ) -> Option { + self.metadata.get_description(&key_name, metric_kind) } } @@ -59,11 +53,11 @@ impl Recorder for OpenTelemetryRecorder { _unit: Option, _description: SharedString, ) { - self.description_table.add_describe(_key_name, MetricKind::Counter, _unit, _description); + self.metadata.add_description(_key_name, MetricKind::Counter, _unit, _description); } fn describe_gauge(&self, _key_name: KeyName, _unit: Option, _description: SharedString) { - self.description_table.add_describe(_key_name, MetricKind::Gauge, _unit, _description); + self.metadata.add_description(_key_name, MetricKind::Gauge, _unit, _description); } fn describe_histogram( @@ -72,7 +66,7 @@ impl Recorder for OpenTelemetryRecorder { _unit: Option, _description: SharedString, ) { - self.description_table.add_describe(_key_name, MetricKind::Histogram, _unit, _description); + self.metadata.add_description(_key_name, MetricKind::Histogram, _unit, _description); } fn register_counter(&self, key: &Key, _metadata: &metrics::Metadata<'_>) -> Counter { diff --git a/metrics-exporter-opentelemetry/src/metadata.rs b/metrics-exporter-opentelemetry/src/metadata.rs new file mode 100644 index 00000000..933e5eda --- /dev/null +++ b/metrics-exporter-opentelemetry/src/metadata.rs @@ -0,0 +1,77 @@ +use metrics::{KeyName, SharedString, Unit}; +use metrics_util::MetricKind; +use std::collections::HashMap; +use std::sync::{Arc, PoisonError, RwLock}; + +#[derive(Clone)] +pub struct MetricDescription { + unit: Option, + description: SharedString, +} + +impl MetricDescription { + pub fn unit(&self) -> Option { + self.unit + } + + pub fn description(&self) -> SharedString { + self.description.clone() + } +} + +/// Stores all metric metadata including descriptions and histogram bounds +#[derive(Default)] +pub struct MetricMetadata { + inner: Arc>, +} + +#[derive(Default)] +struct MetricMetadataInner { + descriptions: HashMap<(KeyName, MetricKind), MetricDescription>, + histogram_bounds: HashMap>, +} + +impl MetricMetadata { + pub fn new() -> Self { + Self::default() + } + + pub fn add_description( + &self, + key_name: KeyName, + metric_kind: MetricKind, + unit: Option, + description: SharedString, + ) { + let new_entry = MetricDescription { unit, description }; + let mut inner = self.inner.write().unwrap_or_else(PoisonError::into_inner); + inner.descriptions.insert((key_name, metric_kind), new_entry); + } + + pub fn get_description( + &self, + key_name: &KeyName, + metric_kind: MetricKind, + ) -> Option { + let inner = self.inner.read().unwrap_or_else(PoisonError::into_inner); + inner.descriptions.get(&(key_name.clone(), metric_kind)).cloned() + } + + pub fn set_histogram_bounds(&self, key_name: KeyName, bounds: Vec) { + let mut inner = self.inner.write().unwrap_or_else(PoisonError::into_inner); + inner.histogram_bounds.insert(key_name, bounds); + } + + pub fn get_histogram_bounds(&self, key_name: &KeyName) -> Option> { + let inner = self.inner.read().unwrap_or_else(PoisonError::into_inner); + inner.histogram_bounds.get(key_name).cloned() + } +} + +impl Clone for MetricMetadata { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} \ No newline at end of file diff --git a/metrics-exporter-opentelemetry/src/storage.rs b/metrics-exporter-opentelemetry/src/storage.rs index 732f1c36..e758bdb8 100644 --- a/metrics-exporter-opentelemetry/src/storage.rs +++ b/metrics-exporter-opentelemetry/src/storage.rs @@ -1,26 +1,20 @@ -use crate::description::{DescriptionEntry, DescriptionTable}; 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::collections::HashMap; -use std::sync::{Arc, PoisonError, RwLock}; +use std::sync::Arc; pub struct OtelMetricStorage { meter: Meter, - description_table: Arc, - histogram_bounds: Arc>>>, + metadata: MetricMetadata, } impl OtelMetricStorage { - pub fn new(meter: Meter, description_table: Arc, histogram_bounds: Arc>>>) -> Self { - Self { - meter, - description_table, - histogram_bounds, - } + pub fn new(meter: Meter, metadata: MetricMetadata) -> Self { + Self { meter, metadata } } fn get_attributes(key: &Key) -> Vec { @@ -29,28 +23,27 @@ impl OtelMetricStorage { .collect() } - fn with_description_entry<'a, I, M>( - description_entry: &DescriptionEntry, + fn with_description<'a, I, M>( + description: &MetricDescription, builder: AsyncInstrumentBuilder<'a, I, M>, ) -> AsyncInstrumentBuilder<'a, I, M> { - // let builder = builder.with_description(self.description.to_string()); - match description_entry.unit() { + match description.unit() { Some(unit) => builder - .with_description(description_entry.description().to_string()) + .with_description(description.description().to_string()) .with_unit(unit.as_canonical_label()), - None => builder.with_description(description_entry.description().to_string()), + None => builder.with_description(description.description().to_string()), } } - fn with_description_entry_histogram<'a, T>( - description_entry: &DescriptionEntry, + + fn with_description_histogram<'a, T>( + description: &MetricDescription, builder: HistogramBuilder<'a, T>, ) -> HistogramBuilder<'a, T> { - // let builder = builder.with_description(self.description.to_string()); - match description_entry.unit() { + match description.unit() { Some(unit) => builder - .with_description(description_entry.description().to_string()) + .with_description(description.description().to_string()) .with_unit(unit.as_canonical_label()), - None => builder.with_description(description_entry.description().to_string()), + None => builder.with_description(description.description().to_string()), } } } @@ -62,11 +55,11 @@ impl Storage for OtelMetricStorage { fn counter(&self, key: &Key) -> Self::Counter { let builder = self.meter.u64_observable_counter(key.name().to_string()); - let description = self - .description_table - .get_describe(KeyName::from(key.name().to_string()), MetricKind::Counter); - let builder = if let Some(description) = description { - Self::with_description_entry(&description, builder) + 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 }; @@ -76,11 +69,11 @@ impl Storage for OtelMetricStorage { fn gauge(&self, key: &Key) -> Self::Gauge { let builder = self.meter.f64_observable_gauge(key.name().to_string()); - let description = self - .description_table - .get_describe(KeyName::from(key.name().to_string()), MetricKind::Gauge); - let builder = if let Some(description) = description { - Self::with_description_entry(&description, builder) + 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 }; @@ -90,26 +83,23 @@ impl Storage for OtelMetricStorage { fn histogram(&self, key: &Key) -> Self::Histogram { let builder = self.meter.f64_histogram(key.name().to_string()); - let description = self - .description_table - .get_describe(KeyName::from(key.name().to_string()), MetricKind::Histogram); - let builder = if let Some(description) = description { - Self::with_description_entry_histogram(&description, builder) + 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 key_name = KeyName::from(key.name().to_string()); - let builder = { - let bounds_map = self.histogram_bounds.read().unwrap_or_else(PoisonError::into_inner);; - if let Some(bounds) = bounds_map.get(&key_name) { - builder.with_boundaries(bounds.clone()) - } else { - builder - } + 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)) } From 878c999aa9f8541eede3a200c15fc4f054af029d Mon Sep 17 00:00:00 2001 From: jang whoemoon Date: Sat, 12 Jul 2025 18:28:16 +0900 Subject: [PATCH 06/10] perf: replace RwLock with SCC HashMap for lock-free metadata access --- Cargo.lock | 16 +++++++ Cargo.toml | 1 + metrics-exporter-opentelemetry/Cargo.toml | 1 + metrics-exporter-opentelemetry/src/lib.rs | 6 +-- .../src/metadata.rs | 42 +++++++------------ 5 files changed, 35 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 409c163a..425489a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1165,6 +1165,7 @@ dependencies = [ "opentelemetry", "opentelemetry_sdk", "portable-atomic", + "scc", ] [[package]] @@ -2078,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" @@ -2093,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" diff --git a/Cargo.toml b/Cargo.toml index 0e7a5a0a..4cd09455 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,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-opentelemetry/Cargo.toml b/metrics-exporter-opentelemetry/Cargo.toml index c40a4064..2f19da8b 100644 --- a/metrics-exporter-opentelemetry/Cargo.toml +++ b/metrics-exporter-opentelemetry/Cargo.toml @@ -20,6 +20,7 @@ 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"] } diff --git a/metrics-exporter-opentelemetry/src/lib.rs b/metrics-exporter-opentelemetry/src/lib.rs index 7670eb29..f0e3d697 100644 --- a/metrics-exporter-opentelemetry/src/lib.rs +++ b/metrics-exporter-opentelemetry/src/lib.rs @@ -53,11 +53,11 @@ impl Recorder for OpenTelemetryRecorder { _unit: Option, _description: SharedString, ) { - self.metadata.add_description(_key_name, MetricKind::Counter, _unit, _description); + self.metadata.set_description(_key_name, MetricKind::Counter, _unit, _description); } fn describe_gauge(&self, _key_name: KeyName, _unit: Option, _description: SharedString) { - self.metadata.add_description(_key_name, MetricKind::Gauge, _unit, _description); + self.metadata.set_description(_key_name, MetricKind::Gauge, _unit, _description); } fn describe_histogram( @@ -66,7 +66,7 @@ impl Recorder for OpenTelemetryRecorder { _unit: Option, _description: SharedString, ) { - self.metadata.add_description(_key_name, MetricKind::Histogram, _unit, _description); + self.metadata.set_description(_key_name, MetricKind::Histogram, _unit, _description); } fn register_counter(&self, key: &Key, _metadata: &metrics::Metadata<'_>) -> Counter { diff --git a/metrics-exporter-opentelemetry/src/metadata.rs b/metrics-exporter-opentelemetry/src/metadata.rs index 933e5eda..e615997e 100644 --- a/metrics-exporter-opentelemetry/src/metadata.rs +++ b/metrics-exporter-opentelemetry/src/metadata.rs @@ -1,7 +1,7 @@ use metrics::{KeyName, SharedString, Unit}; use metrics_util::MetricKind; -use std::collections::HashMap; -use std::sync::{Arc, PoisonError, RwLock}; +use scc::HashMap; +use std::sync::Arc; #[derive(Clone)] pub struct MetricDescription { @@ -20,23 +20,21 @@ impl MetricDescription { } /// Stores all metric metadata including descriptions and histogram bounds -#[derive(Default)] +#[derive(Clone, Default)] pub struct MetricMetadata { - inner: Arc>, -} - -#[derive(Default)] -struct MetricMetadataInner { - descriptions: HashMap<(KeyName, MetricKind), MetricDescription>, - histogram_bounds: HashMap>, + descriptions: Arc>, + histogram_bounds: Arc>>, } impl MetricMetadata { pub fn new() -> Self { - Self::default() + Self { + descriptions: Arc::new(HashMap::new()), + histogram_bounds: Arc::new(HashMap::new()), + } } - pub fn add_description( + pub fn set_description( &self, key_name: KeyName, metric_kind: MetricKind, @@ -44,8 +42,7 @@ impl MetricMetadata { description: SharedString, ) { let new_entry = MetricDescription { unit, description }; - let mut inner = self.inner.write().unwrap_or_else(PoisonError::into_inner); - inner.descriptions.insert((key_name, metric_kind), new_entry); + let _ = self.descriptions.insert((key_name, metric_kind), new_entry); } pub fn get_description( @@ -53,25 +50,14 @@ impl MetricMetadata { key_name: &KeyName, metric_kind: MetricKind, ) -> Option { - let inner = self.inner.read().unwrap_or_else(PoisonError::into_inner); - inner.descriptions.get(&(key_name.clone(), metric_kind)).cloned() + self.descriptions.read(&(key_name.clone(), metric_kind), |_, v| v.clone()) } pub fn set_histogram_bounds(&self, key_name: KeyName, bounds: Vec) { - let mut inner = self.inner.write().unwrap_or_else(PoisonError::into_inner); - inner.histogram_bounds.insert(key_name, bounds); + let _ = self.histogram_bounds.insert(key_name, bounds); } pub fn get_histogram_bounds(&self, key_name: &KeyName) -> Option> { - let inner = self.inner.read().unwrap_or_else(PoisonError::into_inner); - inner.histogram_bounds.get(key_name).cloned() + self.histogram_bounds.read(key_name, |_, v| v.clone()) } } - -impl Clone for MetricMetadata { - fn clone(&self) -> Self { - Self { - inner: Arc::clone(&self.inner), - } - } -} \ No newline at end of file From 09deb5472b2caa31e541743189d1736120a86e43 Mon Sep 17 00:00:00 2001 From: jang whoemoon Date: Sat, 12 Jul 2025 18:36:12 +0900 Subject: [PATCH 07/10] docs: add comprehensive rustdoc documentation for OpenTelemetry exporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detailed documentation following patterns from other metrics exporters: - Module-level overview with features, usage examples, and performance notes - Comprehensive API documentation for OpenTelemetryRecorder - Detailed method docs with examples and behavioral constraints - Internal type documentation for MetricDescription and MetricMetadata - Missing docs enforcement and broken link detection 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- metrics-exporter-opentelemetry/README.md | 132 +++++++++++------- metrics-exporter-opentelemetry/src/lib.rs | 36 +++-- .../src/metadata.rs | 35 +++-- 3 files changed, 134 insertions(+), 69 deletions(-) diff --git a/metrics-exporter-opentelemetry/README.md b/metrics-exporter-opentelemetry/README.md index a6f29ae4..251d2f14 100644 --- a/metrics-exporter-opentelemetry/README.md +++ b/metrics-exporter-opentelemetry/README.md @@ -1,62 +1,100 @@ # metrics-exporter-opentelemetry -[![Documentation](https://docs.rs/metrics-exporter-opentelemetry/badge.svg)](https://docs.rs/metrics-exporter-opentelemetry) +[![docs-badge][docs-badge]][docs] [![crates-badge][crates-badge]][crates] [![license-badge][license-badge]][license] -A [`metrics`][metrics] exporter for [OpenTelemetry]. +[docs-badge]: https://docs.rs/metrics-exporter-opentelemetry/badge.svg +[docs]: https://docs.rs/metrics-exporter-opentelemetry +[crates-badge]: https://img.shields.io/crates/v/metrics-exporter-opentelemetry.svg +[crates]: https://crates.io/crates/metrics-exporter-opentelemetry +[license-badge]: https://img.shields.io/crates/l/metrics-exporter-opentelemetry.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 -- Export metrics to OpenTelemetry collectors using OTLP -- Support for counters, gauges, and histograms -- Integration with the OpenTelemetry SDK -- Configurable export intervals and endpoints +- 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 -## Usage +Add this to your `Cargo.toml`: + +```toml +[dependencies] +metrics = "0.24" +metrics-exporter-opentelemetry = "0.1" +opentelemetry = "0.30" +opentelemetry_sdk = "0.30" +``` + +Basic usage: ```rust -use metrics::{counter, gauge, histogram}; -use metrics_exporter_opentelemetry::OpenTelemetryBuilder; -use opentelemetry_otlp::WithExportConfig; -use opentelemetry_sdk::{ - metrics::{PeriodicReader, SdkMeterProvider}, - runtime, -}; - -// Configure OpenTelemetry exporter -let exporter = opentelemetry_otlp::MetricExporter::builder() - .with_tonic() - .with_endpoint("http://localhost:4317") - .build()?; - -// Create a periodic reader -let reader = PeriodicReader::builder(exporter, runtime::Tokio) - .with_interval(Duration::from_secs(10)) - .build(); - -// Build the meter provider -let provider = SdkMeterProvider::builder() - .with_reader(reader) - .build(); - -// Install the metrics exporter -OpenTelemetryBuilder::new() - .with_meter_provider(provider) - .install()?; - -// Now you can use the metrics macros -counter!("requests_total").increment(1); -gauge!("cpu_usage").set(0.75); -histogram!("request_duration").record(0.234); +use metrics_exporter_opentelemetry::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); ``` -## Examples +## Custom Histogram Boundaries + +```rust +let recorder = OpenTelemetryRecorder::new(meter); -- [`opentelemetry_push`](examples/opentelemetry_push.rs): Demonstrates exporting metrics to an OpenTelemetry collector -- [`opentelemetry_stdout`](examples/opentelemetry_stdout.rs): Shows metrics export to stdout for debugging +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); +``` -## License +## Compatibility -This project is licensed under the MIT license. +### Metric Type Mapping -[metrics]: https://github.com/metrics-rs/metrics -[OpenTelemetry]: https://opentelemetry.io/ \ No newline at end of file +| `metrics` Type | OpenTelemetry Instrument | +|----------------|-------------------------| +| `Counter` | `ObservableCounter` (u64) | +| `Gauge` | `ObservableGauge` (f64) | +| `Histogram` | `Histogram` (f64) | diff --git a/metrics-exporter-opentelemetry/src/lib.rs b/metrics-exporter-opentelemetry/src/lib.rs index f0e3d697..97e0f746 100644 --- a/metrics-exporter-opentelemetry/src/lib.rs +++ b/metrics-exporter-opentelemetry/src/lib.rs @@ -1,4 +1,7 @@ -//! An OpenTelemetry metrics exporter for `metrics`. +#![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; @@ -10,7 +13,19 @@ use metrics_util::registry::Registry; use metrics_util::MetricKind; use opentelemetry::metrics::Meter; -/// The OpenTelemetry recorder. +/// A [`Recorder`] that exports metrics to OpenTelemetry. +/// +/// ```rust,no_run +/// use opentelemetry::metrics::MeterProvider; +/// use metrics_exporter_opentelemetry::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"); +/// ``` pub struct OpenTelemetryRecorder { registry: Registry, metadata: MetricMetadata, @@ -21,20 +36,17 @@ impl OpenTelemetryRecorder { pub fn new(meter: Meter) -> Self { let metadata = MetricMetadata::new(); let storage = OtelMetricStorage::new(meter, metadata.clone()); - Self { - registry: Registry::new(storage), - metadata, - } + Self { registry: Registry::new(storage), metadata } } - pub fn set_histogram_bounds( - &self, - key: &KeyName, - bounds: Vec, - ) { + /// 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( diff --git a/metrics-exporter-opentelemetry/src/metadata.rs b/metrics-exporter-opentelemetry/src/metadata.rs index e615997e..c67a1306 100644 --- a/metrics-exporter-opentelemetry/src/metadata.rs +++ b/metrics-exporter-opentelemetry/src/metadata.rs @@ -3,23 +3,41 @@ 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 +/// 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>, @@ -28,12 +46,9 @@ pub struct MetricMetadata { impl MetricMetadata { pub fn new() -> Self { - Self { - descriptions: Arc::new(HashMap::new()), - histogram_bounds: Arc::new(HashMap::new()), - } + Self { descriptions: Arc::new(HashMap::new()), histogram_bounds: Arc::new(HashMap::new()) } } - + pub fn set_description( &self, key_name: KeyName, @@ -44,7 +59,7 @@ impl MetricMetadata { let new_entry = MetricDescription { unit, description }; let _ = self.descriptions.insert((key_name, metric_kind), new_entry); } - + pub fn get_description( &self, key_name: &KeyName, @@ -52,11 +67,11 @@ impl MetricMetadata { ) -> 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()) } From 9b63be837f14115b41004d68b80bfc9f79454d57 Mon Sep 17 00:00:00 2001 From: jang whoemoon Date: Sat, 12 Jul 2025 19:30:41 +0900 Subject: [PATCH 08/10] docs: add examples and improve documentation brevity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add basic usage example - Add custom histogram bounds example - Add OTLP exporter example - Significantly reduce documentation verbosity - Delegate unit conversion details to Unit documentation - Clarify that descriptions/bounds only apply to metrics created after they are set 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Cargo.toml | 2 - .../examples/basic.rs | 32 ++++++++++++ .../examples/custom_histogram_bounds.rs | 50 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 metrics-exporter-opentelemetry/examples/basic.rs create mode 100644 metrics-exporter-opentelemetry/examples/custom_histogram_bounds.rs diff --git a/Cargo.toml b/Cargo.toml index 4cd09455..0b3d60e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,8 +51,6 @@ 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 } -opentelemetry-otlp = { version = "0.30", default-features = false } -opentelemetry-stdout = { 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 } diff --git a/metrics-exporter-opentelemetry/examples/basic.rs b/metrics-exporter-opentelemetry/examples/basic.rs new file mode 100644 index 00000000..7ab51920 --- /dev/null +++ b/metrics-exporter-opentelemetry/examples/basic.rs @@ -0,0 +1,32 @@ +use metrics::{counter, describe_counter, describe_histogram, gauge, histogram, Unit}; +use metrics_exporter_opentelemetry::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-opentelemetry/examples/custom_histogram_bounds.rs b/metrics-exporter-opentelemetry/examples/custom_histogram_bounds.rs new file mode 100644 index 00000000..f14ccf79 --- /dev/null +++ b/metrics-exporter-opentelemetry/examples/custom_histogram_bounds.rs @@ -0,0 +1,50 @@ +use metrics::{describe_histogram, histogram, KeyName, Unit}; +use metrics_exporter_opentelemetry::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"); +} From 899055994a71514a7fa50b2ee2b3a139b888ab25 Mon Sep 17 00:00:00 2001 From: Whoemoon Jang Date: Tue, 15 Jul 2025 14:54:48 +0900 Subject: [PATCH 09/10] fix: rename crate to metrics-exporter-otel Found the name already took by someone https://crates.io/crates/metrics-exporter-opentelemetry --- Cargo.lock | 2 +- Cargo.toml | 2 +- .../CHANGELOG.md | 0 .../Cargo.toml | 4 ++-- .../README.md | 16 ++++++++-------- .../examples/basic.rs | 2 +- .../examples/custom_histogram_bounds.rs | 2 +- .../src/instruments.rs | 0 .../src/lib.rs | 2 +- .../src/metadata.rs | 0 .../src/storage.rs | 0 .../tests/integration_test.rs | 2 +- rust-toolchain.toml | 2 +- 13 files changed, 17 insertions(+), 17 deletions(-) rename {metrics-exporter-opentelemetry => metrics-exporter-otel}/CHANGELOG.md (100%) rename {metrics-exporter-opentelemetry => metrics-exporter-otel}/Cargo.toml (90%) rename {metrics-exporter-opentelemetry => metrics-exporter-otel}/README.md (88%) rename {metrics-exporter-opentelemetry => metrics-exporter-otel}/examples/basic.rs (95%) rename {metrics-exporter-opentelemetry => metrics-exporter-otel}/examples/custom_histogram_bounds.rs (96%) rename {metrics-exporter-opentelemetry => metrics-exporter-otel}/src/instruments.rs (100%) rename {metrics-exporter-opentelemetry => metrics-exporter-otel}/src/lib.rs (98%) rename {metrics-exporter-opentelemetry => metrics-exporter-otel}/src/metadata.rs (100%) rename {metrics-exporter-opentelemetry => metrics-exporter-otel}/src/storage.rs (100%) rename {metrics-exporter-opentelemetry => metrics-exporter-otel}/tests/integration_test.rs (99%) diff --git a/Cargo.lock b/Cargo.lock index 425489a2..a3a5599c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1157,7 +1157,7 @@ dependencies = [ ] [[package]] -name = "metrics-exporter-opentelemetry" +name = "metrics-exporter-otel" version = "0.1.0" dependencies = [ "metrics", diff --git a/Cargo.toml b/Cargo.toml index 0b3d60e0..05273bb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = [ "metrics", "metrics-benchmark", "metrics-exporter-dogstatsd", - "metrics-exporter-opentelemetry", + "metrics-exporter-otel", "metrics-exporter-prometheus", "metrics-exporter-tcp", "metrics-observer", diff --git a/metrics-exporter-opentelemetry/CHANGELOG.md b/metrics-exporter-otel/CHANGELOG.md similarity index 100% rename from metrics-exporter-opentelemetry/CHANGELOG.md rename to metrics-exporter-otel/CHANGELOG.md diff --git a/metrics-exporter-opentelemetry/Cargo.toml b/metrics-exporter-otel/Cargo.toml similarity index 90% rename from metrics-exporter-opentelemetry/Cargo.toml rename to metrics-exporter-otel/Cargo.toml index 2f19da8b..d3550067 100644 --- a/metrics-exporter-opentelemetry/Cargo.toml +++ b/metrics-exporter-otel/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "metrics-exporter-opentelemetry" +name = "metrics-exporter-otel" version = "0.1.0" edition = "2021" rust-version = "1.75.0" @@ -9,7 +9,7 @@ license = { workspace = true } authors = ["Metrics Contributors"] repository = { workspace = true } homepage = { workspace = true } -documentation = "https://docs.rs/metrics-exporter-opentelemetry" +documentation = "https://docs.rs/metrics-exporter-otel" readme = "README.md" categories = ["development-tools::debugging"] diff --git a/metrics-exporter-opentelemetry/README.md b/metrics-exporter-otel/README.md similarity index 88% rename from metrics-exporter-opentelemetry/README.md rename to metrics-exporter-otel/README.md index 251d2f14..8da60df8 100644 --- a/metrics-exporter-opentelemetry/README.md +++ b/metrics-exporter-otel/README.md @@ -1,12 +1,12 @@ -# metrics-exporter-opentelemetry +# 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-opentelemetry/badge.svg -[docs]: https://docs.rs/metrics-exporter-opentelemetry -[crates-badge]: https://img.shields.io/crates/v/metrics-exporter-opentelemetry.svg -[crates]: https://crates.io/crates/metrics-exporter-opentelemetry -[license-badge]: https://img.shields.io/crates/l/metrics-exporter-opentelemetry.svg +[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. @@ -34,7 +34,7 @@ Add this to your `Cargo.toml`: ```toml [dependencies] metrics = "0.24" -metrics-exporter-opentelemetry = "0.1" +metrics-exporter-otel = "0.1" opentelemetry = "0.30" opentelemetry_sdk = "0.30" ``` @@ -42,7 +42,7 @@ opentelemetry_sdk = "0.30" Basic usage: ```rust -use metrics_exporter_opentelemetry::OpenTelemetryRecorder; +use metrics_exporter_otel::OpenTelemetryRecorder; use opentelemetry::metrics::MeterProvider; use opentelemetry_sdk::metrics::SdkMeterProvider; diff --git a/metrics-exporter-opentelemetry/examples/basic.rs b/metrics-exporter-otel/examples/basic.rs similarity index 95% rename from metrics-exporter-opentelemetry/examples/basic.rs rename to metrics-exporter-otel/examples/basic.rs index 7ab51920..4defc0a7 100644 --- a/metrics-exporter-opentelemetry/examples/basic.rs +++ b/metrics-exporter-otel/examples/basic.rs @@ -1,5 +1,5 @@ use metrics::{counter, describe_counter, describe_histogram, gauge, histogram, Unit}; -use metrics_exporter_opentelemetry::OpenTelemetryRecorder; +use metrics_exporter_otel::OpenTelemetryRecorder; use opentelemetry::metrics::MeterProvider; use opentelemetry_sdk::metrics::SdkMeterProvider; use std::thread; diff --git a/metrics-exporter-opentelemetry/examples/custom_histogram_bounds.rs b/metrics-exporter-otel/examples/custom_histogram_bounds.rs similarity index 96% rename from metrics-exporter-opentelemetry/examples/custom_histogram_bounds.rs rename to metrics-exporter-otel/examples/custom_histogram_bounds.rs index f14ccf79..2e7cbe19 100644 --- a/metrics-exporter-opentelemetry/examples/custom_histogram_bounds.rs +++ b/metrics-exporter-otel/examples/custom_histogram_bounds.rs @@ -1,5 +1,5 @@ use metrics::{describe_histogram, histogram, KeyName, Unit}; -use metrics_exporter_opentelemetry::OpenTelemetryRecorder; +use metrics_exporter_otel::OpenTelemetryRecorder; use opentelemetry::metrics::MeterProvider; use opentelemetry_sdk::metrics::SdkMeterProvider; use std::thread; diff --git a/metrics-exporter-opentelemetry/src/instruments.rs b/metrics-exporter-otel/src/instruments.rs similarity index 100% rename from metrics-exporter-opentelemetry/src/instruments.rs rename to metrics-exporter-otel/src/instruments.rs diff --git a/metrics-exporter-opentelemetry/src/lib.rs b/metrics-exporter-otel/src/lib.rs similarity index 98% rename from metrics-exporter-opentelemetry/src/lib.rs rename to metrics-exporter-otel/src/lib.rs index 97e0f746..21ce12db 100644 --- a/metrics-exporter-opentelemetry/src/lib.rs +++ b/metrics-exporter-otel/src/lib.rs @@ -17,7 +17,7 @@ use opentelemetry::metrics::Meter; /// /// ```rust,no_run /// use opentelemetry::metrics::MeterProvider; -/// use metrics_exporter_opentelemetry::OpenTelemetryRecorder; +/// use metrics_exporter_otel::OpenTelemetryRecorder; /// use opentelemetry_sdk::metrics::SdkMeterProvider; /// /// let provider = SdkMeterProvider::default(); diff --git a/metrics-exporter-opentelemetry/src/metadata.rs b/metrics-exporter-otel/src/metadata.rs similarity index 100% rename from metrics-exporter-opentelemetry/src/metadata.rs rename to metrics-exporter-otel/src/metadata.rs diff --git a/metrics-exporter-opentelemetry/src/storage.rs b/metrics-exporter-otel/src/storage.rs similarity index 100% rename from metrics-exporter-opentelemetry/src/storage.rs rename to metrics-exporter-otel/src/storage.rs diff --git a/metrics-exporter-opentelemetry/tests/integration_test.rs b/metrics-exporter-otel/tests/integration_test.rs similarity index 99% rename from metrics-exporter-opentelemetry/tests/integration_test.rs rename to metrics-exporter-otel/tests/integration_test.rs index ab795e79..c28f1853 100644 --- a/metrics-exporter-opentelemetry/tests/integration_test.rs +++ b/metrics-exporter-otel/tests/integration_test.rs @@ -1,7 +1,7 @@ use metrics::{ counter, describe_counter, describe_gauge, describe_histogram, gauge, histogram, Recorder, Unit, }; -use metrics_exporter_opentelemetry::OpenTelemetryRecorder; +use metrics_exporter_otel::OpenTelemetryRecorder; use opentelemetry::metrics::MeterProvider; use opentelemetry::{Key, Value}; use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 8f520fd2..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 and metrics-exporter-opentelemetry needing 1.75, 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.75.0" From a59c9890ae85aeb9257aa6184c8151df43f66ed0 Mon Sep 17 00:00:00 2001 From: Whoemoon Jang Date: Wed, 16 Jul 2025 17:30:28 +0900 Subject: [PATCH 10/10] fix(otel): make OpenTelemetryRecorder clone shallow --- metrics-exporter-otel/src/lib.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/metrics-exporter-otel/src/lib.rs b/metrics-exporter-otel/src/lib.rs index 21ce12db..be79b117 100644 --- a/metrics-exporter-otel/src/lib.rs +++ b/metrics-exporter-otel/src/lib.rs @@ -6,6 +6,7 @@ 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}; @@ -15,6 +16,8 @@ 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; @@ -26,8 +29,9 @@ use opentelemetry::metrics::Meter; /// /// metrics::set_global_recorder(recorder).expect("failed to install recorder"); /// ``` +#[derive(Clone)] pub struct OpenTelemetryRecorder { - registry: Registry, + registry: Arc>, metadata: MetricMetadata, } @@ -36,7 +40,7 @@ impl OpenTelemetryRecorder { pub fn new(meter: Meter) -> Self { let metadata = MetricMetadata::new(); let storage = OtelMetricStorage::new(meter, metadata.clone()); - Self { registry: Registry::new(storage), metadata } + Self { registry: Arc::new(Registry::new(storage)), metadata } } /// Sets custom bucket boundaries for a histogram metric.