diff --git a/src/encoding.rs b/src/encoding.rs index 7f77efa0..8d11d300 100644 --- a/src/encoding.rs +++ b/src/encoding.rs @@ -4,6 +4,7 @@ pub use prometheus_client_derive_encode::*; use crate::metrics::exemplar::Exemplar; use crate::metrics::MetricType; +use crate::metrics::summary::Numeric; use crate::registry::{Prefix, Unit}; use std::borrow::Cow; use std::collections::HashMap; @@ -186,6 +187,21 @@ impl MetricEncoder<'_> { ) } + /// Encode a summary. + pub fn encode_summary( + &mut self, + sum: f64, + count: u64, + quantiles: &[(f64, T)], + ) -> Result<(), std::fmt::Error> { + for_both_mut!( + self, + MetricEncoderInner, + e, + e.encode_summary(sum, count, quantiles) + ) + } + /// Encode a metric family. pub fn encode_family<'s, S: EncodeLabelSet>( &'s mut self, diff --git a/src/encoding/protobuf.rs b/src/encoding/protobuf.rs index 095a3dba..58f1ce6d 100644 --- a/src/encoding/protobuf.rs +++ b/src/encoding/protobuf.rs @@ -33,6 +33,7 @@ pub mod openmetrics_data_model { use std::{borrow::Cow, collections::HashMap}; use crate::metrics::MetricType; +use crate::metrics::summary::Numeric; use crate::registry::{Registry, Unit}; use crate::{metrics::exemplar::Exemplar, registry::Prefix}; @@ -53,6 +54,7 @@ impl From for openmetrics_data_model::MetricType { MetricType::Counter => openmetrics_data_model::MetricType::Counter, MetricType::Gauge => openmetrics_data_model::MetricType::Gauge, MetricType::Histogram => openmetrics_data_model::MetricType::Histogram, + MetricType::Summary => openmetrics_data_model::MetricType::Summary, MetricType::Info => openmetrics_data_model::MetricType::Info, MetricType::Unknown => openmetrics_data_model::MetricType::Unknown, } @@ -288,6 +290,41 @@ impl MetricEncoder<'_> { Ok(()) } + + pub fn encode_summary( + &mut self, + sum: f64, + count: u64, + quantiles: &[(f64, T)], + ) -> Result<(), std::fmt::Error> { + let quantiles = quantiles.iter() + .map(|(q, v)| { + openmetrics_data_model::summary_value::Quantile { + quantile: *q, + value: v.as_f64(), + } + }) + .collect::>(); + + self.family.push(openmetrics_data_model::Metric { + labels: self.labels.clone(), + metric_points: vec![openmetrics_data_model::MetricPoint { + value: Some(openmetrics_data_model::metric_point::Value::SummaryValue( + openmetrics_data_model::SummaryValue { + count, + created: None, + quantile: quantiles, + sum: Some(openmetrics_data_model::summary_value::Sum::DoubleValue( + sum, + )), + }, + )), + ..Default::default() + }], + }); + + Ok(()) + } } impl TryFrom<&Exemplar> @@ -448,6 +485,7 @@ mod tests { use crate::metrics::family::Family; use crate::metrics::gauge::Gauge; use crate::metrics::histogram::{exponential_buckets, Histogram}; + use crate::metrics::summary::Summary; use crate::metrics::info::Info; use crate::registry::Unit; use std::borrow::Cow; @@ -817,6 +855,93 @@ mod tests { } } + #[test] + fn encode_summary() { + let mut registry = Registry::default(); + let summary = Summary::default(); + registry.register("my_summary", "My Summary", summary.clone()); + let quantiles = vec![ + (0.5, 100.1), + (0.9, 300.2), + (0.99, 700.3), + ]; + let _ = summary.reset(123.0, 10, quantiles); + + let metric_set = encode(®istry).unwrap(); + + let family = metric_set.metric_families.first().unwrap(); + assert_eq!("my_summary", family.name); + assert_eq!("My Summary.", family.help); + assert_eq!( + openmetrics_data_model::MetricType::Summary as i32, + extract_metric_type(&metric_set) + ); + match extract_metric_point_value(&metric_set) { + openmetrics_data_model::metric_point::Value::SummaryValue(value) => { + assert_eq!( + Some(openmetrics_data_model::summary_value::Sum::DoubleValue( + 123.0 + )), + value.sum + ); + assert_eq!(10, value.count); + assert_eq!(3, value.quantile.len()); + } + _ => panic!("wrong value type"), + } + } + + #[test] + fn encode_summary_family() { + let mut registry = Registry::default(); + let family = + Family::new_with_constructor(|| Summary::default()); + registry.register("my_summary", "My Summary", family.clone()); + let quantiles = vec![ + (0.5, 100_u64), + (0.9, 300), + (0.99, 700), + ]; + let _ = family + .get_or_create(&vec![ + ("method".to_string(), "POST".to_string()), + ("status".to_string(), "200".to_string()), + ]) + .reset(123.0, 10, quantiles); + + let metric_set = encode(®istry).unwrap(); + + let family = metric_set.metric_families.first().unwrap(); + assert_eq!("my_summary", family.name); + assert_eq!("My Summary.", family.help); + assert_eq!( + openmetrics_data_model::MetricType::Summary as i32, + extract_metric_type(&metric_set) + ); + + let metric = family.metrics.first().unwrap(); + assert_eq!(2, metric.labels.len()); + assert_eq!("method", metric.labels[0].name); + assert_eq!("POST", metric.labels[0].value); + assert_eq!("status", metric.labels[1].name); + assert_eq!("200", metric.labels[1].value); + + match extract_metric_point_value(&metric_set) { + openmetrics_data_model::metric_point::Value::SummaryValue(value) => { + assert_eq!(None, value.created); + assert_eq!( + Some(openmetrics_data_model::summary_value::Sum::DoubleValue( + 123.0 + )), + value.sum + ); + assert_eq!(10, value.count); + assert_eq!(3, value.quantile.len()); + } + _ => panic!("wrong value type"), + } + } + #[test] fn encode_family_counter_histogram() { let mut registry = Registry::default(); diff --git a/src/encoding/text.rs b/src/encoding/text.rs index 4946fe35..e64467bc 100644 --- a/src/encoding/text.rs +++ b/src/encoding/text.rs @@ -462,6 +462,41 @@ impl<'a> MetricEncoder<'a> { Ok(()) } + pub fn encode_summary( + &mut self, + sum: f64, + count: u64, + quantiles: &[(f64, T)], + ) -> Result<(), std::fmt::Error> { + self.write_prefix_name_unit()?; + self.write_suffix("sum")?; + self.encode_labels::(None)?; + self.writer.write_str(" ")?; + self.writer.write_str(dtoa::Buffer::new().format(sum))?; + self.newline()?; + + self.write_prefix_name_unit()?; + self.write_suffix("count")?; + self.encode_labels::(None)?; + self.writer.write_str(" ")?; + self.writer.write_str(itoa::Buffer::new().format(count))?; + self.newline()?; + + for (quantile, value) in quantiles { + self.write_prefix_name_unit()?; + + self.encode_labels(Some(&[("quantile", *quantile)]))?; + + self.writer.write_str(" ")?; + self.writer + .write_str(&value.to_string())?; + + self.newline()?; + } + + Ok(()) + } + fn newline(&mut self) -> Result<(), std::fmt::Error> { self.writer.write_str("\n") } @@ -730,6 +765,7 @@ mod tests { use crate::metrics::family::Family; use crate::metrics::gauge::Gauge; use crate::metrics::histogram::{exponential_buckets, Histogram}; + use crate::metrics::summary::Summary; use crate::metrics::info::Info; use crate::metrics::{counter::Counter, exemplar::CounterWithExemplar}; use pyo3::{prelude::*, types::PyModule}; @@ -981,6 +1017,103 @@ mod tests { parse_with_python_client(encoded); } + #[test] + fn encode_summary() { + let mut registry = Registry::default(); + let summary = Summary::default(); + registry.register("my_summary", "My Summary", summary.clone()); + let quantiles = vec![ + (0.5, 100.1), + (0.9, 300.2), + (0.99, 700.3), + ]; + let _ = summary.reset(123.0, 10, quantiles); + + let mut encoded = String::new(); + encode(&mut encoded, ®istry).unwrap(); + + let expected = "# HELP my_summary My Summary.\n".to_owned() + + "# TYPE my_summary summary\n" + + "my_summary_sum 123.0\n" + + "my_summary_count 10\n" + + "my_summary{quantile=\"0.5\"} 100.1\n" + + "my_summary{quantile=\"0.9\"} 300.2\n" + + "my_summary{quantile=\"0.99\"} 700.3\n" + + "# EOF\n"; + + assert_eq!(expected, encoded); + } + + #[test] + fn encode_summary_family() { + let mut registry = Registry::default(); + let family = + Family::new_with_constructor(|| Summary::default()); + registry.register("my_summary", "My Summary", family.clone()); + let quantiles = vec![ + (0.5, 100_u64), + (0.9, 300), + (0.99, 700), + ]; + let _ = family + .get_or_create(&vec![ + ("method".to_string(), "GET".to_string()), + ("status".to_string(), "200".to_string()), + ]) + .reset(123.0, 10, quantiles); + + let mut encoded = String::new(); + encode(&mut encoded, ®istry).unwrap(); + + let expected = "# HELP my_summary My Summary.\n".to_owned() + + "# TYPE my_summary summary\n" + + "my_summary_sum{method=\"GET\",status=\"200\"} 123.0\n" + + "my_summary_count{method=\"GET\",status=\"200\"} 10\n" + + "my_summary{quantile=\"0.5\",method=\"GET\",status=\"200\"} 100\n" + + "my_summary{quantile=\"0.9\",method=\"GET\",status=\"200\"} 300\n" + + "my_summary{quantile=\"0.99\",method=\"GET\",status=\"200\"} 700\n" + + "# EOF\n"; + + assert_eq!(expected, encoded); + } + + #[test] + fn encode_summary_family_with_empty_struct_family_labels() { + let mut registry = Registry::default(); + let family = + Family::new_with_constructor(|| Summary::default()); + registry.register("my_summary", "My Summary", family.clone()); + + #[derive(Eq, PartialEq, Hash, Debug, Clone)] + struct EmptyLabels {} + + impl EncodeLabelSet for EmptyLabels { + fn encode(&self, _encoder: crate::encoding::LabelSetEncoder) -> Result<(), Error> { + Ok(()) + } + } + + let quantiles = vec![ + (0.5, 100.1), + (0.9, 300.2), + (0.99, 700.3), + ]; + let _ = family.get_or_create(&EmptyLabels {}).reset(123.0, 10, quantiles); + let mut encoded = String::new(); + encode(&mut encoded, ®istry).unwrap(); + + let expected = "# HELP my_summary My Summary.\n".to_owned() + + "# TYPE my_summary summary\n" + + "my_summary_sum{} 123.0\n" + + "my_summary_count{} 10\n" + + "my_summary{quantile=\"0.5\"} 100.1\n" + + "my_summary{quantile=\"0.9\"} 300.2\n" + + "my_summary{quantile=\"0.99\"} 700.3\n" + + "# EOF\n"; + + assert_eq!(expected, encoded); + } + #[test] fn sub_registry_with_prefix_and_label() { let top_level_metric_name = "my_top_level_metric"; diff --git a/src/metrics.rs b/src/metrics.rs index cd389527..80d5471d 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -5,6 +5,7 @@ pub mod exemplar; pub mod family; pub mod gauge; pub mod histogram; +pub mod summary; pub mod info; /// A metric that is aware of its Open Metrics metric type. @@ -20,13 +21,13 @@ pub enum MetricType { Counter, Gauge, Histogram, + Summary, Info, Unknown, // Not (yet) supported metric types. // // GaugeHistogram, // StateSet, - // Summary } impl MetricType { @@ -36,6 +37,7 @@ impl MetricType { MetricType::Counter => "counter", MetricType::Gauge => "gauge", MetricType::Histogram => "histogram", + MetricType::Summary => "summary", MetricType::Info => "info", MetricType::Unknown => "unknown", } diff --git a/src/metrics/summary.rs b/src/metrics/summary.rs new file mode 100644 index 00000000..3558185c --- /dev/null +++ b/src/metrics/summary.rs @@ -0,0 +1,223 @@ +//! Module implementing an Open Metrics summary metric. +//! +//! See [`Summary`] for details. + +use std::fmt::Error; +use crate::encoding::{EncodeMetric, MetricEncoder}; + +use super::{MetricType, TypedMetric}; +use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard}; +use std::sync::Arc; + +/// A marker trait for types that represent numeric values. +/// +/// This trait is intended to abstract over numeric types such as integers +/// and floating-point numbers. Implementors of this trait are expected to +/// behave like numeric values in terms of operations and comparisons. +pub trait Numeric { + /// In the definition of protobuf it is the f64 type. + #[cfg(feature = "protobuf")] + fn as_f64(&self) -> f64; +} +impl Numeric for u64 { + #[cfg(feature = "protobuf")] + fn as_f64(&self) -> f64 { + *self as f64 + } +} +impl Numeric for i64 { + #[cfg(feature = "protobuf")] + fn as_f64(&self) -> f64 { + *self as f64 + } +} +impl Numeric for f64 { + #[cfg(feature = "protobuf")] + fn as_f64(&self) -> f64 { + *self + } +} +/// Open Metrics [`Summary`] for capturing aggregate statistics, such as sum, count, and quantiles. +/// +/// This implementation is flexible, allowing users to choose their own algorithms for calculating +/// quantiles. Instead of computing quantiles directly, the [`Summary`] struct assumes that users +/// will provide the quantile values as inputs. +/// +/// # Example +/// +/// ``` +/// # use prometheus_client::metrics::summary::Summary; +/// use std::sync::Arc; +/// +/// let summary = Summary::default(); +/// let quantiles = vec![(0.5, 10.0), (0.9, 20.0), (0.99, 30.0)]; +/// summary.reset(100.0, 42, quantiles); +/// +/// let (sum, count, quantiles) = summary.get(); +/// assert_eq!(sum, 100.0); +/// assert_eq!(count, 42); +/// assert_eq!(quantiles.as_slice(), &[(0.5, 10.0), (0.9, 20.0), (0.99, 30.0)]); +/// ``` +/// +/// # Implementation Details +/// +/// - The struct internally uses an [`Arc>`] to allow shared and mutable access in +/// a thread-safe manner. +/// - [`Summary`] does not implement specific quantile algorithms, delegating that responsibility +/// to users. This provides flexibility for users to integrate different quantile calculation +/// methods according to their needs. +#[derive(Debug)] +pub struct Summary +where + T: Numeric, +{ + inner: Arc>>, +} + +impl Clone for Summary +where + T: Numeric, +{ + /// Creates a new instance of [`Summary`] sharing the same underlying data. + fn clone(&self) -> Self { + Summary { + inner: self.inner.clone(), + } + } +} + +#[derive(Debug)] +pub(crate) struct Inner { + /// The total sum of observed values. + sum: f64, + /// The count of observed values. + count: u64, + /// Precomputed quantile values (quantile, value). + quantiles: Vec<(f64, T)>, +} + +impl Default for Summary +where + T: Numeric + Default, +{ + /// Creates a default instance of [`Summary`], initializing with zero values + /// and a single quantile set to `(0.0, 0.0)`. + fn default() -> Self { + Self { + inner: Arc::new(RwLock::new(Inner { + sum: Default::default(), + count: Default::default(), + quantiles: vec![(Default::default(), T::default())], + })), + } + } +} +impl Summary +where + T: Numeric, +{ + /// Resets the state of the [`Summary`] with new data. + /// + /// This method allows users to update the sum, count, and quantile values + /// stored in the [`Summary`]. + /// + /// # Arguments + /// - `sum`: The new sum of observed values. + /// - `count`: The new count of observed values. + /// - `quantiles`: A vector of quantile values in the form of `(quantile, value)`. + pub fn reset(&self, sum: f64, count: u64, quantiles: Vec<(f64, T)>) -> Result<(), String> { + let quantiles_slice = &quantiles; + for &(q, _) in quantiles_slice { + if q < 0.0 || q > 1.0 { + return Err(format!("Invalid quantile value: {}", q)); + } + } + + if !quantiles_slice.windows(2).all(|w| w[0].0 <= w[1].0) { + return Err("Quantiles must be sorted by their quantile values".to_string()); + } + + let mut inner = self.inner.write(); + inner.sum = sum; + inner.count = count; + inner.quantiles = quantiles; + Ok(()) + } + + /// Retrieves the current state of the [`Summary`]. + /// + /// Returns a tuple containing: + /// - `sum`: The total sum of observed values. + /// - `count`: The count of observed values. + /// - `quantiles`: A reference to the vector of quantiles. + pub fn get(&self) -> (f64, u64, MappedRwLockReadGuard>) { + let inner = self.inner.read(); + let sum = inner.sum; + let count = inner.count; + let quantiles = RwLockReadGuard::map(inner, |inner| &inner.quantiles); + (sum, count, quantiles) + } +} + +impl TypedMetric for Summary +where + T: Numeric, +{ + /// Specifies the metric type as [`MetricType::Summary`]. + const TYPE: MetricType = MetricType::Summary; +} + +impl EncodeMetric for Summary +where + T: Numeric + ToString, +{ + /// Encodes the current state of the [`Summary`] into the provided `MetricEncoder`. + /// + /// This method delegates encoding of the `sum`, `count`, and quantiles to the encoder. + fn encode(&self, mut encoder: MetricEncoder) -> Result<(), Error> { + let (sum, count, quantiles) = self.get(); + encoder.encode_summary(sum, count, &quantiles) + } + + fn metric_type(&self) -> MetricType { + Self::TYPE + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_summary_reset_valid() { + let summary = Summary::default(); + let quantiles = vec![(0.5, 10.0), (0.9, 20.0)]; + assert!(summary.reset(100.0, 42, quantiles).is_ok()); + } + + #[test] + fn test_summary_reset_invalid_quantile() { + let summary = Summary::default(); + let quantiles = vec![(1.5, 10.0), (0.9, 20.0)]; // quantile out of range + assert!(summary.reset(100.0, 42, quantiles).is_err()); + } + + #[test] + fn test_summary_reset_unsorted_quantiles() { + let summary = Summary::default(); + let quantiles = vec![(0.9, 20.0), (0.5, 10.0)]; // unsorted quantile + assert!(summary.reset(100.0, 42, quantiles).is_err()); + } + + #[test] + fn test_summary_get() { + let summary = Summary::default(); + let quantiles = vec![(0.5, 10.0), (0.9, 20.0)]; + summary.reset(100.0, 42, quantiles.clone()).unwrap(); + + let (sum, count, q) = summary.get(); + assert_eq!(sum, 100.0); + assert_eq!(count, 42); + assert_eq!(q.as_slice(), &quantiles); + } +} \ No newline at end of file