From d4882eb1aaa8ed21d84df9700728d23612612d03 Mon Sep 17 00:00:00 2001 From: John Howard Date: Fri, 8 May 2026 15:33:37 -0700 Subject: [PATCH] Add native histogram support Add native-only histograms and histograms that expose both classic and native buckets. Encode native sparse bucket spans and deltas through the Prometheus protobuf encoder while keeping text and OpenMetrics protobuf behavior on the classic representation when available. Signed-off-by: John Howard --- CHANGELOG.md | 8 + Cargo.toml | 6 +- benches/histogram.rs | 42 + src/encoding.rs | 46 + src/encoding/openmetrics_protobuf.rs | 31 +- src/encoding/prometheus_protobuf.rs | 197 ++- src/encoding/text.rs | 56 +- src/metrics/histogram.rs | 1945 +++++++++++++++++++++++++- 8 files changed, 2282 insertions(+), 49 deletions(-) create mode 100644 benches/histogram.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 54b03a7..dcb7b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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). +## [0.26.0] + +### Added + +- Added native histogram support, including native-only histograms and + histograms with both classic and native buckets. +- Added Prometheus protobuf encoding for native histogram sparse buckets. + ## [0.25.0] ### Added diff --git a/Cargo.toml b/Cargo.toml index 7ef44a5..29576eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "prometheus-client" -version = "0.25.0" +version = "0.26.0" authors = ["Max Inden "] edition = "2021" description = "Open Metrics client library allowing users to natively instrument applications." @@ -67,6 +67,10 @@ harness = false name = "family" harness = false +[[bench]] +name = "histogram" +harness = false + [[bench]] name = "text" path = "benches/encoding/text.rs" diff --git a/benches/histogram.rs b/benches/histogram.rs new file mode 100644 index 0000000..6df5696 --- /dev/null +++ b/benches/histogram.rs @@ -0,0 +1,42 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use prometheus_client::metrics::histogram::{ + exponential_buckets, Histogram, NativeHistogramConfig, +}; + +const OBSERVATION: f64 = 64.0; + +pub fn histogram(c: &mut Criterion) { + let mut group = c.benchmark_group("observe"); + + group.bench_function("histogram", |b| { + let histogram = Histogram::new(exponential_buckets(1.0, 2.0, 10)); + + b.iter(|| { + histogram.observe(black_box(OBSERVATION)); + }) + }); + + group.bench_function("native histogram", |b| { + let histogram = Histogram::new_native(NativeHistogramConfig::with_schema(0)); + + b.iter(|| { + histogram.observe(black_box(OBSERVATION)); + }) + }); + + group.bench_function("classic and native histogram", |b| { + let histogram = Histogram::new_classic_and_native( + exponential_buckets(1.0, 2.0, 10), + NativeHistogramConfig::with_schema(0), + ); + + b.iter(|| { + histogram.observe(black_box(OBSERVATION)); + }) + }); + + group.finish(); +} + +criterion_group!(benches, histogram); +criterion_main!(benches); diff --git a/src/encoding.rs b/src/encoding.rs index 8226c97..8b47d09 100644 --- a/src/encoding.rs +++ b/src/encoding.rs @@ -33,6 +33,32 @@ pub mod text; #[deprecated(note = "Use openmetrics_protobuf instead.")] pub use openmetrics_protobuf as protobuf; +/// Native histogram fields shared by encoders. +#[derive(Clone, Copy, Debug)] +pub struct NativeHistogram<'a> { + /// Native histogram schema. + pub schema: i32, + /// Breadth of the zero bucket. + pub zero_threshold: f64, + /// Count in the zero bucket. + pub zero_count: u64, + /// Negative sparse buckets. + pub negative: NativeHistogramBuckets<'a>, + /// Positive sparse buckets. + pub positive: NativeHistogramBuckets<'a>, + /// Native histogram creation timestamp. + pub created: Option, +} + +/// Sparse bucket span and delta encoding for one side of a native histogram. +#[derive(Clone, Copy, Debug)] +pub struct NativeHistogramBuckets<'a> { + /// Bucket spans. + pub spans: &'a [(i32, u32)], + /// Bucket count deltas. + pub deltas: &'a [i64], +} + macro_rules! for_both_mut { ($self:expr, $inner:ident, $pattern:pat, $fn:expr) => { match &mut $self.0 { @@ -237,6 +263,26 @@ impl MetricEncoder<'_> { ) } + /// Encode a histogram that may have native buckets. + /// + /// Encoders without native histogram support encode the classic buckets when + /// present and reject native-only histograms. + pub fn encode_histogram_with_native( + &mut self, + sum: f64, + count: u64, + buckets: &[(f64, u64)], + exemplars: Option<&HashMap>>, + native: NativeHistogram<'_>, + ) -> Result<(), std::fmt::Error> { + for_both_mut!( + self, + MetricEncoderInner, + e, + e.encode_histogram_with_native(sum, count, buckets, exemplars, native) + ) + } + /// Encode a metric family. pub fn encode_family<'s, S: EncodeLabelSet>( &'s mut self, diff --git a/src/encoding/openmetrics_protobuf.rs b/src/encoding/openmetrics_protobuf.rs index c7334c5..d33e060 100644 --- a/src/encoding/openmetrics_protobuf.rs +++ b/src/encoding/openmetrics_protobuf.rs @@ -36,7 +36,9 @@ use crate::metrics::MetricType; use crate::registry::{Registry, Unit}; use crate::{metrics::exemplar::Exemplar, registry::Prefix}; -use super::{EncodeCounterValue, EncodeExemplarValue, EncodeGaugeValue, EncodeLabelSet}; +use super::{ + EncodeCounterValue, EncodeExemplarValue, EncodeGaugeValue, EncodeLabelSet, NativeHistogram, +}; /// Encode the metrics registered with the provided [`Registry`] into MetricSet /// using the OpenMetrics protobuf format. @@ -288,6 +290,21 @@ impl MetricEncoder<'_> { Ok(()) } + + pub fn encode_histogram_with_native( + &mut self, + sum: f64, + count: u64, + buckets: &[(f64, u64)], + exemplars: Option<&HashMap>>, + _native: NativeHistogram<'_>, + ) -> Result<(), std::fmt::Error> { + if buckets.is_empty() { + return Err(std::fmt::Error); + } + + self.encode_histogram(sum, count, buckets, exemplars) + } } impl TryFrom<&Exemplar> @@ -454,7 +471,7 @@ mod tests { use crate::metrics::exemplar::{CounterWithExemplar, HistogramWithExemplars}; use crate::metrics::family::Family; use crate::metrics::gauge::Gauge; - use crate::metrics::histogram::{exponential_buckets, Histogram}; + use crate::metrics::histogram::{exponential_buckets, Histogram, NativeHistogramConfig}; use crate::metrics::info::Info; use crate::registry::Unit; use std::borrow::Cow; @@ -819,6 +836,16 @@ mod tests { } } + #[test] + fn encode_native_only_histogram_errors() { + let mut registry = Registry::default(); + let histogram = Histogram::new_native(NativeHistogramConfig::with_schema(0)); + registry.register("my_histogram", "My histogram", histogram.clone()); + histogram.observe(1.0); + + assert!(encode(®istry).is_err()); + } + #[test] fn encode_histogram_with_exemplars() { let now = SystemTime::now(); diff --git a/src/encoding/prometheus_protobuf.rs b/src/encoding/prometheus_protobuf.rs index 54252bd..5ad0fad 100644 --- a/src/encoding/prometheus_protobuf.rs +++ b/src/encoding/prometheus_protobuf.rs @@ -35,13 +35,19 @@ pub mod prometheus_data_model { } use prost::Message; -use std::{borrow::Cow, collections::HashMap}; +use std::{ + borrow::Cow, + collections::HashMap, + time::{SystemTime, UNIX_EPOCH}, +}; use crate::metrics::MetricType; use crate::registry::{Registry, Unit}; use crate::{metrics::exemplar::Exemplar, registry::Prefix}; -use super::{EncodeCounterValue, EncodeExemplarValue, EncodeGaugeValue, EncodeLabelSet}; +use super::{ + EncodeCounterValue, EncodeExemplarValue, EncodeGaugeValue, EncodeLabelSet, NativeHistogram, +}; /// Encode the metrics registered with the provided [`Registry`] into /// Prometheus `MetricFamily` messages. @@ -321,23 +327,7 @@ impl MetricEncoder<'_> { buckets: &[(f64, u64)], exemplars: Option<&HashMap>>, ) -> Result<(), std::fmt::Error> { - let mut cumulative_count = 0; - let bucket = buckets - .iter() - .enumerate() - .map(|(i, (upper_bound, count))| { - cumulative_count += count; - Ok(prometheus_data_model::Bucket { - cumulative_count, - // not needed; if set would override cumulative_count. - cumulative_count_float: 0.0, - upper_bound: *upper_bound, - exemplar: exemplars - .and_then(|exemplars| exemplars.get(&i).map(|exemplar| exemplar.try_into())) - .transpose()?, - }) - }) - .collect::, std::fmt::Error>>()?; + let bucket = classic_buckets(buckets, exemplars)?; self.family.push(prometheus_data_model::Metric { label: self.labels.clone(), @@ -354,6 +344,99 @@ impl MetricEncoder<'_> { Ok(()) } + + pub fn encode_histogram_with_native( + &mut self, + sum: f64, + count: u64, + buckets: &[(f64, u64)], + exemplars: Option<&HashMap>>, + native: NativeHistogram<'_>, + ) -> Result<(), std::fmt::Error> { + let bucket = classic_buckets(buckets, exemplars)?; + let start_timestamp = native.created.map(system_time_to_timestamp).transpose()?; + + let negative_span = native + .negative + .spans + .iter() + .map(|(offset, length)| prometheus_data_model::BucketSpan { + offset: *offset, + length: *length, + }) + .collect(); + + let positive_span = native + .positive + .spans + .iter() + .map(|(offset, length)| prometheus_data_model::BucketSpan { + offset: *offset, + length: *length, + }) + .collect(); + + self.family.push(prometheus_data_model::Metric { + label: self.labels.clone(), + histogram: Some(prometheus_data_model::Histogram { + sample_count: count, + sample_count_float: 0.0, + sample_sum: sum, + bucket, + start_timestamp, + schema: native.schema, + zero_threshold: native.zero_threshold, + zero_count: native.zero_count, + zero_count_float: 0.0, + negative_span, + negative_delta: native.negative.deltas.to_vec(), + negative_count: Vec::new(), + positive_span, + positive_delta: native.positive.deltas.to_vec(), + positive_count: Vec::new(), + exemplars: Vec::new(), + }), + ..Default::default() + }); + + Ok(()) + } +} + +fn classic_buckets( + buckets: &[(f64, u64)], + exemplars: Option<&HashMap>>, +) -> Result, std::fmt::Error> { + let mut cumulative_count = 0; + buckets + .iter() + .enumerate() + .map(|(i, (upper_bound, count))| { + cumulative_count += count; + Ok(prometheus_data_model::Bucket { + cumulative_count, + // not needed; if set would override cumulative_count. + cumulative_count_float: 0.0, + upper_bound: *upper_bound, + exemplar: exemplars + .and_then(|exemplars| exemplars.get(&i).map(|exemplar| exemplar.try_into())) + .transpose()?, + }) + }) + .collect() +} + +fn system_time_to_timestamp( + system_time: SystemTime, +) -> Result { + let duration = system_time + .duration_since(UNIX_EPOCH) + .map_err(|_| std::fmt::Error)?; + + Ok(prost_types::Timestamp { + seconds: duration.as_secs() as i64, + nanos: duration.subsec_nanos() as i32, + }) } impl TryFrom<&Exemplar> @@ -516,7 +599,9 @@ mod tests { use crate::metrics::exemplar::{CounterWithExemplar, HistogramWithExemplars}; use crate::metrics::family::Family; use crate::metrics::gauge::Gauge; - use crate::metrics::histogram::{exponential_buckets, Histogram}; + use crate::metrics::histogram::{ + exponential_buckets, Histogram, NativeHistogramConfig, NATIVE_HISTOGRAM_ZERO_THRESHOLD_ZERO, + }; use crate::metrics::info::Info; use crate::registry::Unit; use std::borrow::Cow; @@ -761,6 +846,78 @@ mod tests { assert_eq!("99", exemplar.label[0].value); } + #[test] + fn encode_native_histogram() { + let histogram = Histogram::new_native(NativeHistogramConfig::with_schema(0)); + let mut registry = Registry::default(); + registry.register("my_histogram", "My histogram", histogram.clone()); + + histogram.observe(1.0); + histogram.observe(4.0); + histogram.observe(-2.0); + + let metric_families = encode(®istry).unwrap(); + let histogram = metric_families[0].metric[0].histogram.as_ref().unwrap(); + + assert_eq!(3, histogram.sample_count); + assert_eq!(3.0, histogram.sample_sum); + assert_eq!(0, histogram.schema); + assert_eq!(1, histogram.negative_span.len()); + assert_eq!(1, histogram.positive_span.len()); + assert_eq!(vec![1], histogram.negative_delta); + assert_eq!(vec![1, -1, 1], histogram.positive_delta); + assert!(histogram.bucket.is_empty()); + assert!(histogram.start_timestamp.is_some()); + } + + #[test] + fn encode_native_histogram_nan_only_has_no_op_span() { + let histogram = Histogram::new_native( + NativeHistogramConfig::with_schema(0) + .zero_threshold(NATIVE_HISTOGRAM_ZERO_THRESHOLD_ZERO), + ); + let mut registry = Registry::default(); + registry.register("my_histogram", "My histogram", histogram.clone()); + + histogram.observe(f64::NAN); + + let metric_families = encode(®istry).unwrap(); + let histogram = metric_families[0].metric[0].histogram.as_ref().unwrap(); + + assert_eq!(1, histogram.sample_count); + assert!(histogram.sample_sum.is_nan()); + assert_eq!(0.0, histogram.zero_threshold); + assert_eq!(0, histogram.zero_count); + assert_eq!(1, histogram.positive_span.len()); + assert_eq!(0, histogram.positive_span[0].offset); + assert_eq!(0, histogram.positive_span[0].length); + assert!(histogram.positive_delta.is_empty()); + assert!(histogram.negative_span.is_empty()); + } + + #[test] + fn encode_classic_and_native_histogram() { + let histogram = + Histogram::new_classic_and_native([1.0, 2.0], NativeHistogramConfig::with_schema(0)); + let mut registry = Registry::default(); + registry.register("my_histogram", "My histogram", histogram.clone()); + + histogram.observe(1.0); + histogram.observe(4.0); + + let metric_families = encode(®istry).unwrap(); + let histogram = metric_families[0].metric[0].histogram.as_ref().unwrap(); + + assert_eq!(2, histogram.sample_count); + assert_eq!(5.0, histogram.sample_sum); + assert_eq!(3, histogram.bucket.len()); + assert_eq!(1, histogram.bucket[0].cumulative_count); + assert_eq!(2, histogram.bucket[2].cumulative_count); + assert_eq!(0, histogram.schema); + assert!(!histogram.positive_span.is_empty()); + assert!(!histogram.positive_delta.is_empty()); + } + #[test] fn encode_family_and_counter_and_histogram() { let mut registry = Registry::default(); diff --git a/src/encoding/text.rs b/src/encoding/text.rs index bc3f650..53377cd 100644 --- a/src/encoding/text.rs +++ b/src/encoding/text.rs @@ -37,7 +37,9 @@ //! assert_eq!(expected_msg, buffer); //! ``` -use crate::encoding::{EncodeExemplarTime, EncodeExemplarValue, EncodeLabelSet, NoLabelSet}; +use crate::encoding::{ + EncodeExemplarTime, EncodeExemplarValue, EncodeLabelSet, NativeHistogram, NoLabelSet, +}; use crate::metrics::exemplar::Exemplar; use crate::metrics::MetricType; use crate::registry::{Prefix, Registry, Unit}; @@ -444,6 +446,21 @@ impl MetricEncoder<'_> { Ok(()) } + pub fn encode_histogram_with_native( + &mut self, + sum: f64, + count: u64, + buckets: &[(f64, u64)], + exemplars: Option<&HashMap>>, + _native: NativeHistogram<'_>, + ) -> Result<(), std::fmt::Error> { + if buckets.is_empty() { + return Err(std::fmt::Error); + } + + self.encode_histogram(sum, count, buckets, exemplars) + } + /// Encode an exemplar for the given metric. fn encode_exemplar( &mut self, @@ -745,7 +762,7 @@ mod tests { use crate::metrics::exemplar::HistogramWithExemplars; use crate::metrics::family::Family; use crate::metrics::gauge::Gauge; - use crate::metrics::histogram::{exponential_buckets, Histogram}; + use crate::metrics::histogram::{exponential_buckets, Histogram, NativeHistogramConfig}; use crate::metrics::info::Info; use crate::metrics::{counter::Counter, exemplar::CounterWithExemplar}; use pyo3::{prelude::*, types::PyModule}; @@ -941,6 +958,41 @@ mod tests { parse_with_python_client(encoded); } + #[test] + fn encode_classic_and_native_histogram_as_classic_text() { + let mut registry = Registry::default(); + let histogram = Histogram::new_classic_and_native( + [1.0, 2.0], + NativeHistogramConfig::with_bucket_factor(1.1), + ); + registry.register("my_histogram", "My histogram", histogram.clone()); + histogram.observe(1.0); + + let mut encoded = String::new(); + encode(&mut encoded, ®istry).unwrap(); + + let expected = "# HELP my_histogram My histogram.\n".to_owned() + + "# TYPE my_histogram histogram\n" + + "my_histogram_sum 1.0\n" + + "my_histogram_count 1\n" + + "my_histogram_bucket{le=\"1.0\"} 1\n" + + "my_histogram_bucket{le=\"2.0\"} 1\n" + + "my_histogram_bucket{le=\"+Inf\"} 1\n" + + "# EOF\n"; + assert_eq!(expected, encoded); + } + + #[test] + fn encode_native_only_histogram_errors() { + let mut registry = Registry::default(); + let histogram = Histogram::new_native(NativeHistogramConfig::with_schema(0)); + registry.register("my_histogram", "My histogram", histogram.clone()); + histogram.observe(1.0); + + let mut encoded = String::new(); + assert!(encode(&mut encoded, ®istry).is_err()); + } + #[test] fn encode_histogram_family() { let mut registry = Registry::default(); diff --git a/src/metrics/histogram.rs b/src/metrics/histogram.rs index 8a4a28a..e6661aa 100644 --- a/src/metrics/histogram.rs +++ b/src/metrics/histogram.rs @@ -2,12 +2,24 @@ //! //! See [`Histogram`] for details. -use crate::encoding::{EncodeMetric, MetricEncoder, NoLabelSet}; +use crate::encoding::{ + EncodeMetric, MetricEncoder, NativeHistogram, NativeHistogramBuckets, NoLabelSet, +}; use super::{MetricType, TypedMetric}; -use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard}; +use parking_lot::Mutex; use std::iter::{self, once}; use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +/// Special zero-threshold sentinel that configures a zero-width zero bucket. +pub const NATIVE_HISTOGRAM_ZERO_THRESHOLD_ZERO: f64 = -1.0; + +/// Default bucket factor for native histograms. +pub const DEFAULT_NATIVE_HISTOGRAM_BUCKET_FACTOR: f64 = 1.1; + +const SCHEMA_MIN: i8 = -4; +const SCHEMA_MAX: i8 = 8; /// Open Metrics [`Histogram`] to measure distributions of discrete events. /// @@ -35,7 +47,7 @@ use std::sync::Arc; // https://github.com/tikv/rust-prometheus/pull/314. #[derive(Debug)] pub struct Histogram { - inner: Arc>, + inner: Arc>, } impl Clone for Histogram { @@ -53,6 +65,234 @@ pub(crate) struct Inner { count: u64, // TODO: Consider being generic over the bucket length. buckets: Vec<(f64, u64)>, + native: Option, +} + +/// Configuration for native histogram buckets. +/// +/// Native histogram exemplars are not currently emitted. +#[derive(Clone, Copy, Debug)] +pub struct NativeHistogramConfig { + schema: i8, + zero_threshold: f64, + max_buckets: usize, + min_reset_duration: Option, + max_zero_threshold: f64, +} + +impl NativeHistogramConfig { + /// Create a native histogram configuration with the provided bucket factor. + /// + /// The schema is chosen so that the bucket growth factor is the largest + /// supported factor that is still <= `bucket_factor`. The default zero + /// threshold is 2^-128, as recommended by the Prometheus native histogram + /// specification. + pub fn new(bucket_factor: f64) -> Self { + Self::with_bucket_factor(bucket_factor) + } + + /// Create a native histogram configuration with the provided schema. + /// + /// Valid standard schema values are in [-4, 8]. Prefer [`Self::new`] unless + /// you explicitly need a schema-level configuration. + pub fn with_schema(schema: i8) -> Self { + assert!((SCHEMA_MIN..=SCHEMA_MAX).contains(&schema)); + + Self { + schema, + zero_threshold: 2f64.powi(-128), + max_buckets: 0, + min_reset_duration: None, + max_zero_threshold: 0.0, + } + } + + /// Create a native histogram configuration by choosing the schema whose + /// growth factor is the largest factor <= `bucket_factor`. + pub fn with_bucket_factor(bucket_factor: f64) -> Self { + assert!(bucket_factor > 1.0); + Self::with_schema(pick_schema(bucket_factor)) + } + + /// Set a custom zero threshold. + pub fn zero_threshold(mut self, zero_threshold: f64) -> Self { + assert!(zero_threshold.is_finite()); + assert!(zero_threshold >= 0.0 || zero_threshold == NATIVE_HISTOGRAM_ZERO_THRESHOLD_ZERO); + self.zero_threshold = zero_threshold; + self + } + + /// Set a best-effort maximum number of sparse buckets across both sides. + /// + /// `0` means unbounded. If the lowest supported resolution still exceeds + /// the limit, observations remain in sparse buckets until a later zero + /// bucket widening can merge them. + pub fn max_buckets(mut self, max_buckets: usize) -> Self { + self.max_buckets = max_buckets; + self + } + + /// Reset the histogram instead of reducing resolution if the bucket limit + /// is exceeded after at least this duration has elapsed since creation or + /// the last reset. A zero duration disables reset-based limiting. + pub fn min_reset_duration(mut self, min_reset_duration: Duration) -> Self { + self.min_reset_duration = + (min_reset_duration != Duration::ZERO).then_some(min_reset_duration); + self + } + + /// Set the maximum zero threshold allowed while enforcing `max_buckets`. + /// + /// The default is `0.0`, which disables zero-bucket widening unless the + /// current threshold is already below a configured positive maximum. + pub fn max_zero_threshold(mut self, max_zero_threshold: f64) -> Self { + assert!(max_zero_threshold.is_finite()); + assert!(max_zero_threshold >= 0.0); + self.max_zero_threshold = max_zero_threshold; + self + } +} + +impl Default for NativeHistogramConfig { + fn default() -> Self { + Self::new(DEFAULT_NATIVE_HISTOGRAM_BUCKET_FACTOR) + } +} + +#[derive(Debug)] +struct NativeHistogramState { + initial_zero_threshold: f64, + initial_schema: i8, + zero_threshold: f64, + zero_count: u64, + schema: i8, + max_buckets: usize, + min_reset_duration: Option, + max_zero_threshold: f64, + created: SystemTime, + scheduled_reset: Option, + positive: NativeBuckets, + negative: NativeBuckets, +} + +type NativeBuckets = Vec<(i32, u64)>; + +#[derive(Debug)] +struct NativeHistogramSnapshot { + schema: i32, + zero_threshold: f64, + zero_count: u64, + negative: NativeBucketEncoding, + positive: NativeBucketEncoding, + created: SystemTime, +} + +#[derive(Debug)] +struct NativeBucketEncoding { + spans: Vec<(i32, u32)>, + deltas: Vec, +} + +#[derive(Debug)] +struct HistogramSnapshot { + sum: f64, + count: u64, + buckets: Vec<(f64, u64)>, + native: Option, +} + +impl NativeHistogramState { + fn new(config: NativeHistogramConfig) -> Self { + let created = SystemTime::now(); + Self { + initial_zero_threshold: config.zero_threshold, + initial_schema: config.schema, + zero_threshold: config.zero_threshold, + zero_count: 0, + schema: config.schema, + max_buckets: config.max_buckets, + min_reset_duration: config.min_reset_duration, + max_zero_threshold: config.max_zero_threshold, + created, + scheduled_reset: None, + positive: Vec::new(), + negative: Vec::new(), + } + } + + fn observe(&mut self, v: f64) -> bool { + if in_zero_bucket(self.zero_threshold, v) { + self.zero_count += 1; + return enforce_bucket_limit(self); + } + + let index = bucket_index(self.schema, v.abs(), v.is_infinite()); + if v.is_sign_negative() { + increment_bucket(&mut self.negative, index); + } else { + increment_bucket(&mut self.positive, index); + } + + enforce_bucket_limit(self) + } + + fn reset(&mut self, created: SystemTime) { + self.zero_threshold = self.initial_zero_threshold; + self.zero_count = 0; + self.schema = self.initial_schema; + self.created = created; + self.scheduled_reset = None; + self.positive.clear(); + self.negative.clear(); + } + + fn reset_is_due(&self, now: SystemTime) -> bool { + self.scheduled_reset + .and_then(|scheduled_reset| now.duration_since(scheduled_reset).ok()) + .is_some() + } + + fn schedule_reset_after_degradation(&mut self) { + if self.scheduled_reset.is_none() { + self.scheduled_reset = self + .min_reset_duration + .and_then(|duration| self.created.checked_add(duration)); + } + } + + fn snapshot(&self) -> Result { + let mut negative = encode_spans_and_deltas(&self.negative)?; + let mut positive = encode_spans_and_deltas(&self.positive)?; + + let exported_zero_threshold = if self.zero_threshold < 0.0 { + 0.0 + } else { + self.zero_threshold + }; + + // Distinguish a native histogram from a classic histogram in protobuf + // when it has no sparse bucket data and zero threshold is zero. + if exported_zero_threshold == 0.0 + && self.zero_count == 0 + && positive.spans.is_empty() + && negative.spans.is_empty() + { + positive.spans.push((0, 0)); + } + + if self.negative.is_empty() { + negative.spans.clear(); + } + + Ok(NativeHistogramSnapshot { + schema: self.schema as i32, + zero_threshold: exported_zero_threshold, + zero_count: self.zero_count, + negative, + positive, + created: self.created, + }) + } } impl Histogram { @@ -63,8 +303,13 @@ impl Histogram { /// let histogram = Histogram::new([10.0, 100.0, 1_000.0]); /// ``` pub fn new(buckets: impl IntoIterator) -> Self { + Self::new_classic(buckets) + } + + /// Create a new classic [`Histogram`]. + pub fn new_classic(buckets: impl IntoIterator) -> Self { Self { - inner: Arc::new(RwLock::new(Inner { + inner: Arc::new(Mutex::new(Inner { sum: Default::default(), count: Default::default(), buckets: buckets @@ -72,10 +317,41 @@ impl Histogram { .chain(once(f64::MAX)) .map(|upper_bound| (upper_bound, 0)) .collect(), + native: None, + })), + } + } + + /// Create a new native [`Histogram`] without classic buckets. + /// + /// Native-only histograms can only be encoded by the Prometheus protobuf + /// encoder. Text and OpenMetrics protobuf encoders do not have native + /// histogram fields and reject native-only histograms. + pub fn new_native(native: NativeHistogramConfig) -> Self { + Self { + inner: Arc::new(Mutex::new(Inner { + sum: Default::default(), + count: Default::default(), + buckets: Vec::new(), + native: Some(NativeHistogramState::new(native)), })), } } + /// Create a new [`Histogram`] with both classic and native buckets. + /// + /// Prometheus protobuf encodes both representations in one histogram + /// message. Encoders without native histogram support encode the classic + /// buckets. + pub fn new_classic_and_native( + buckets: impl IntoIterator, + native: NativeHistogramConfig, + ) -> Self { + let histogram = Self::new_classic(buckets); + histogram.inner.lock().native = Some(NativeHistogramState::new(native)); + histogram + } + /// Observe the given value. pub fn observe(&self, v: f64) { self.observe_and_bucket(v); @@ -84,13 +360,13 @@ impl Histogram { /// Returns the current sum of all observations. #[cfg(any(test, feature = "test-util"))] pub fn sum(&self) -> f64 { - self.inner.read().sum + self.inner.lock().sum } /// Returns the current number of observations. #[cfg(any(test, feature = "test-util"))] pub fn count(&self) -> u64 { - self.inner.read().count + self.inner.lock().count } /// Observes the given value, returning the index of the first bucket the @@ -99,32 +375,54 @@ impl Histogram { /// Needed in /// [`HistogramWithExemplars`](crate::metrics::exemplar::HistogramWithExemplars). pub(crate) fn observe_and_bucket(&self, v: f64) -> Option { - let mut inner = self.inner.write(); - inner.sum += v; - inner.count += 1; + let mut inner = self.inner.lock(); + reset_if_scheduled(&mut inner); + let mut bucket = observe_classic(&mut inner, v); - let first_bucket = inner - .buckets - .iter_mut() - .enumerate() - .find(|(_i, (upper_bound, _value))| upper_bound >= &v); + let reset = if let Some(native) = &mut inner.native { + !v.is_nan() && native.observe(v) + } else { + false + }; - match first_bucket { - Some((i, (_upper_bound, value))) => { - *value += 1; - Some(i) + if reset { + let created = SystemTime::now(); + reset_observations(&mut inner, created); + bucket = observe_classic(&mut inner, v); + if let Some(native) = &mut inner.native { + if !v.is_nan() { + native.observe(v); + } } - None => None, } + + bucket } - pub(crate) fn get(&self) -> (f64, u64, MappedRwLockReadGuard<'_, Vec<(f64, u64)>>) { - let inner = self.inner.read(); + pub(crate) fn get(&self) -> (f64, u64, Vec<(f64, u64)>) { + let inner = self.inner.lock(); let sum = inner.sum; let count = inner.count; - let buckets = RwLockReadGuard::map(inner, |inner| &inner.buckets); + let buckets = inner.buckets.clone(); (sum, count, buckets) } + + fn snapshot(&self) -> Result { + let mut inner = self.inner.lock(); + reset_if_scheduled(&mut inner); + let native = inner + .native + .as_ref() + .map(NativeHistogramState::snapshot) + .transpose()?; + + Ok(HistogramSnapshot { + sum: inner.sum, + count: inner.count, + buckets: inner.buckets.clone(), + native, + }) + } } impl TypedMetric for Histogram { @@ -173,8 +471,35 @@ pub fn linear_buckets(start: f64, width: f64, length: u16) -> impl Iterator Result<(), std::fmt::Error> { - let (sum, count, buckets) = self.get(); - encoder.encode_histogram::(sum, count, &buckets, None) + let snapshot = self.snapshot()?; + match snapshot.native { + Some(native) => encoder.encode_histogram_with_native::( + snapshot.sum, + snapshot.count, + &snapshot.buckets, + None, + NativeHistogram { + schema: native.schema, + zero_threshold: native.zero_threshold, + zero_count: native.zero_count, + negative: NativeHistogramBuckets { + spans: &native.negative.spans, + deltas: &native.negative.deltas, + }, + positive: NativeHistogramBuckets { + spans: &native.positive.spans, + deltas: &native.positive.deltas, + }, + created: Some(native.created), + }, + ), + None => encoder.encode_histogram::( + snapshot.sum, + snapshot.count, + &snapshot.buckets, + None, + ), + } } fn metric_type(&self) -> MetricType { @@ -182,6 +507,838 @@ impl EncodeMetric for Histogram { } } +fn observe_classic(inner: &mut Inner, v: f64) -> Option { + inner.sum += v; + inner.count += 1; + + if inner.buckets.is_empty() { + return None; + } + + let first_bucket = if v.is_nan() { + inner.buckets.iter_mut().enumerate().next_back() + } else { + inner + .buckets + .iter_mut() + .enumerate() + .find(|(_i, (upper_bound, _value))| upper_bound >= &v) + }; + + match first_bucket { + Some((i, (_upper_bound, value))) => { + *value += 1; + Some(i) + } + None => None, + } +} + +fn reset_observations(inner: &mut Inner, created: SystemTime) { + inner.sum = 0.0; + inner.count = 0; + for (_, count) in &mut inner.buckets { + *count = 0; + } + if let Some(native) = &mut inner.native { + native.reset(created); + } +} + +fn reset_if_scheduled(inner: &mut Inner) { + let Some(native) = inner.native.as_ref() else { + return; + }; + if native.scheduled_reset.is_none() { + return; + } + + let now = SystemTime::now(); + if native.reset_is_due(now) { + reset_observations(inner, now); + } +} + +fn in_zero_bucket(zero_threshold: f64, v: f64) -> bool { + if zero_threshold < 0.0 { + v == 0.0 + } else { + v.abs() <= zero_threshold + } +} + +fn increment_bucket(buckets: &mut NativeBuckets, index: i32) { + match buckets.binary_search_by_key(&index, |(bucket_index, _)| *bucket_index) { + Ok(position) => buckets[position].1 += 1, + Err(position) => buckets.insert(position, (index, 1)), + } +} + +fn pick_schema(bucket_factor: f64) -> i8 { + let floor = bucket_factor.log2().log2().floor(); + if floor <= -(SCHEMA_MAX as f64) { + return SCHEMA_MAX; + } + + if floor >= -(SCHEMA_MIN as f64) { + return SCHEMA_MIN; + } + + -(floor as i8) +} + +fn native_histogram_bounds() -> &'static [&'static [f64]; 9] { + &NATIVE_HISTOGRAM_BOUNDS +} + +// copied from https://github.com/prometheus/client_golang/blob/f23aad527b9740eda20fe5db147e6cd621c2c1bc/prometheus/histogram.go#L46 +const NATIVE_HISTOGRAM_BOUNDS: [&[f64]; 9] = [ + &[0.5], + &[0.5, 0.7071067811865475], + &[ + 0.5, + 0.5946035575013605, + 0.7071067811865475, + 0.8408964152537144, + ], + &[ + 0.5, + 0.5452538663326288, + 0.5946035575013605, + 0.6484197773255048, + 0.7071067811865475, + 0.7711054127039704, + 0.8408964152537144, + 0.9170040432046711, + ], + &[ + 0.5, + 0.5221368912137069, + 0.5452538663326288, + 0.5693943173783458, + 0.5946035575013605, + 0.620928906036742, + 0.6484197773255048, + 0.6771277734684463, + 0.7071067811865475, + 0.7384130729697496, + 0.7711054127039704, + 0.805245165974627, + 0.8408964152537144, + 0.8781260801866495, + 0.9170040432046711, + 0.9576032806985735, + ], + &[ + 0.5, + 0.5109485743270583, + 0.5221368912137069, + 0.5335702003384117, + 0.5452538663326288, + 0.5571933712979462, + 0.5693943173783458, + 0.5818624293887887, + 0.5946035575013605, + 0.6076236799902344, + 0.620928906036742, + 0.6345254785958666, + 0.6484197773255048, + 0.6626183215798706, + 0.6771277734684463, + 0.6919549409819159, + 0.7071067811865475, + 0.7225904034885232, + 0.7384130729697496, + 0.7545822137967112, + 0.7711054127039704, + 0.7879904225539431, + 0.805245165974627, + 0.8228777390769823, + 0.8408964152537144, + 0.8593096490612387, + 0.8781260801866495, + 0.8973545375015533, + 0.9170040432046711, + 0.9370838170551498, + 0.9576032806985735, + 0.9785720620876999, + ], + &[ + 0.5, + 0.5054446430258502, + 0.5109485743270583, + 0.5165124395106142, + 0.5221368912137069, + 0.5278225891802786, + 0.5335702003384117, + 0.5393803988785598, + 0.5452538663326288, + 0.5511912916539204, + 0.5571933712979462, + 0.5632608093041209, + 0.5693943173783458, + 0.5755946149764913, + 0.5818624293887887, + 0.5881984958251406, + 0.5946035575013605, + 0.6010783657263515, + 0.6076236799902344, + 0.6142402680534349, + 0.620928906036742, + 0.6276903785123455, + 0.6345254785958666, + 0.6414350080393891, + 0.6484197773255048, + 0.6554806057623822, + 0.6626183215798706, + 0.6698337620266515, + 0.6771277734684463, + 0.6845012114872953, + 0.6919549409819159, + 0.6994898362691555, + 0.7071067811865475, + 0.7148066691959849, + 0.7225904034885232, + 0.7304588970903234, + 0.7384130729697496, + 0.7464538641456323, + 0.7545822137967112, + 0.762799075372269, + 0.7711054127039704, + 0.7795022001189185, + 0.7879904225539431, + 0.7965710756711334, + 0.805245165974627, + 0.8140137109286738, + 0.8228777390769823, + 0.8318382901633681, + 0.8408964152537144, + 0.8500531768592616, + 0.8593096490612387, + 0.8686669176368529, + 0.8781260801866495, + 0.8876882462632604, + 0.8973545375015533, + 0.9071260877501991, + 0.9170040432046711, + 0.9269895625416926, + 0.9370838170551498, + 0.9472879907934827, + 0.9576032806985735, + 0.9680308967461471, + 0.9785720620876999, + 0.9892280131939752, + ], + &[ + 0.5, + 0.5027149505564014, + 0.5054446430258502, + 0.5081891574554764, + 0.5109485743270583, + 0.5137229745593818, + 0.5165124395106142, + 0.5193170509806894, + 0.5221368912137069, + 0.5249720429003435, + 0.5278225891802786, + 0.5306886136446309, + 0.5335702003384117, + 0.5364674337629877, + 0.5393803988785598, + 0.5423091811066545, + 0.5452538663326288, + 0.5482145409081883, + 0.5511912916539204, + 0.5541842058618393, + 0.5571933712979462, + 0.5602188762048033, + 0.5632608093041209, + 0.5663192597993595, + 0.5693943173783458, + 0.572486072215902, + 0.5755946149764913, + 0.5787200368168754, + 0.5818624293887887, + 0.585021884841625, + 0.5881984958251406, + 0.5913923554921704, + 0.5946035575013605, + 0.5978321960199137, + 0.6010783657263515, + 0.6043421618132907, + 0.6076236799902344, + 0.6109230164863786, + 0.6142402680534349, + 0.6175755319684665, + 0.620928906036742, + 0.6243004885946023, + 0.6276903785123455, + 0.6310986751971253, + 0.6345254785958666, + 0.637970889198196, + 0.6414350080393891, + 0.6449179367033329, + 0.6484197773255048, + 0.6519406325959679, + 0.6554806057623822, + 0.659039800633032, + 0.6626183215798706, + 0.6662162735415805, + 0.6698337620266515, + 0.6734708931164728, + 0.6771277734684463, + 0.6808045103191123, + 0.6845012114872953, + 0.688217985377265, + 0.6919549409819159, + 0.6957121878859629, + 0.6994898362691555, + 0.7032879969095076, + 0.7071067811865475, + 0.7109463010845827, + 0.7148066691959849, + 0.718687998724491, + 0.7225904034885232, + 0.7265139979245261, + 0.7304588970903234, + 0.7344252166684908, + 0.7384130729697496, + 0.7424225829363761, + 0.7464538641456323, + 0.7505070348132126, + 0.7545822137967112, + 0.7586795205991071, + 0.762799075372269, + 0.7669409989204777, + 0.7711054127039704, + 0.7752924388424999, + 0.7795022001189185, + 0.7837348199827764, + 0.7879904225539431, + 0.7922691326262467, + 0.7965710756711334, + 0.8008963778413465, + 0.805245165974627, + 0.8096175675974316, + 0.8140137109286738, + 0.8184337248834821, + 0.8228777390769823, + 0.8273458838280969, + 0.8318382901633681, + 0.8363550898207981, + 0.8408964152537144, + 0.8454623996346523, + 0.8500531768592616, + 0.8546688815502312, + 0.8593096490612387, + 0.8639756154809185, + 0.8686669176368529, + 0.8733836930995842, + 0.8781260801866495, + 0.8828942179666361, + 0.8876882462632604, + 0.8925083056594671, + 0.8973545375015533, + 0.9022270839033115, + 0.9071260877501991, + 0.9120516927035263, + 0.9170040432046711, + 0.9219832844793128, + 0.9269895625416926, + 0.9320230241988943, + 0.9370838170551498, + 0.9421720895161669, + 0.9472879907934827, + 0.9524316709088368, + 0.9576032806985735, + 0.9628029718180622, + 0.9680308967461471, + 0.9732872087896164, + 0.9785720620876999, + 0.9838856116165875, + 0.9892280131939752, + 0.9945994234836328, + ], + &[ + 0.5, + 0.5013556375251013, + 0.5027149505564014, + 0.5040779490592088, + 0.5054446430258502, + 0.5068150424757447, + 0.5081891574554764, + 0.509566998038869, + 0.5109485743270583, + 0.5123338964485679, + 0.5137229745593818, + 0.5151158188430205, + 0.5165124395106142, + 0.5179128468009786, + 0.5193170509806894, + 0.520725062344158, + 0.5221368912137069, + 0.5235525479396449, + 0.5249720429003435, + 0.526395386502313, + 0.5278225891802786, + 0.5292536613972564, + 0.5306886136446309, + 0.5321274564422321, + 0.5335702003384117, + 0.5350168559101208, + 0.5364674337629877, + 0.5379219445313954, + 0.5393803988785598, + 0.5408428074966075, + 0.5423091811066545, + 0.5437795304588847, + 0.5452538663326288, + 0.5467321995364429, + 0.5482145409081883, + 0.549700901315111, + 0.5511912916539204, + 0.5526857228508706, + 0.5541842058618393, + 0.5556867516724088, + 0.5571933712979462, + 0.5587040757836845, + 0.5602188762048033, + 0.5617377836665098, + 0.5632608093041209, + 0.564787964283144, + 0.5663192597993595, + 0.5678547070789026, + 0.5693943173783458, + 0.5709381019847808, + 0.572486072215902, + 0.5740382394200894, + 0.5755946149764913, + 0.5771552102951081, + 0.5787200368168754, + 0.5802891060137493, + 0.5818624293887887, + 0.5834400184762408, + 0.585021884841625, + 0.5866080400818185, + 0.5881984958251406, + 0.5897932637314379, + 0.5913923554921704, + 0.5929957828304968, + 0.5946035575013605, + 0.5962156912915756, + 0.5978321960199137, + 0.5994530835371903, + 0.6010783657263515, + 0.6027080545025619, + 0.6043421618132907, + 0.6059806996384005, + 0.6076236799902344, + 0.6092711149137041, + 0.6109230164863786, + 0.6125793968185725, + 0.6142402680534349, + 0.6159056423670379, + 0.6175755319684665, + 0.6192499490999082, + 0.620928906036742, + 0.622612415087629, + 0.6243004885946023, + 0.6259931389331581, + 0.6276903785123455, + 0.6293922197748583, + 0.6310986751971253, + 0.6328097572894031, + 0.6345254785958666, + 0.6362458516947014, + 0.637970889198196, + 0.6397006037528346, + 0.6414350080393891, + 0.6431741147730128, + 0.6449179367033329, + 0.6466664866145447, + 0.6484197773255048, + 0.6501778216898253, + 0.6519406325959679, + 0.6537082229673385, + 0.6554806057623822, + 0.6572577939746774, + 0.659039800633032, + 0.6608266388015788, + 0.6626183215798706, + 0.6644148621029772, + 0.6662162735415805, + 0.6680225691020727, + 0.6698337620266515, + 0.6716498655934177, + 0.6734708931164728, + 0.6752968579460171, + 0.6771277734684463, + 0.6789636531064505, + 0.6808045103191123, + 0.6826503586020058, + 0.6845012114872953, + 0.6863570825438342, + 0.688217985377265, + 0.690083933630119, + 0.6919549409819159, + 0.6938310211492645, + 0.6957121878859629, + 0.6975984549830999, + 0.6994898362691555, + 0.7013863456101023, + 0.7032879969095076, + 0.7051948041086352, + 0.7071067811865475, + 0.7090239421602076, + 0.7109463010845827, + 0.7128738720527471, + 0.7148066691959849, + 0.7167447066838943, + 0.718687998724491, + 0.7206365595643126, + 0.7225904034885232, + 0.7245495448210174, + 0.7265139979245261, + 0.7284837772007218, + 0.7304588970903234, + 0.7324393720732029, + 0.7344252166684908, + 0.7364164454346837, + 0.7384130729697496, + 0.7404151139112358, + 0.7424225829363761, + 0.7444354947621984, + 0.7464538641456323, + 0.7484777058836176, + 0.7505070348132126, + 0.7525418658117031, + 0.7545822137967112, + 0.7566280937263048, + 0.7586795205991071, + 0.7607365094544071, + 0.762799075372269, + 0.7648672334736434, + 0.7669409989204777, + 0.7690203869158282, + 0.7711054127039704, + 0.7731960915705107, + 0.7752924388424999, + 0.7773944698885442, + 0.7795022001189185, + 0.7816156449856788, + 0.7837348199827764, + 0.7858597406461707, + 0.7879904225539431, + 0.7901268813264122, + 0.7922691326262467, + 0.7944171921585818, + 0.7965710756711334, + 0.7987307989543135, + 0.8008963778413465, + 0.8030678282083853, + 0.805245165974627, + 0.8074284071024302, + 0.8096175675974316, + 0.8118126635086642, + 0.8140137109286738, + 0.8162207259936375, + 0.8184337248834821, + 0.820652723822003, + 0.8228777390769823, + 0.8251087869603088, + 0.8273458838280969, + 0.8295890460808079, + 0.8318382901633681, + 0.8340936325652911, + 0.8363550898207981, + 0.8386226785089391, + 0.8408964152537144, + 0.8431763167241966, + 0.8454623996346523, + 0.8477546807446661, + 0.8500531768592616, + 0.8523579048290255, + 0.8546688815502312, + 0.8569861239649629, + 0.8593096490612387, + 0.8616394738731368, + 0.8639756154809185, + 0.8663180910111553, + 0.8686669176368529, + 0.871022112577578, + 0.8733836930995842, + 0.8757516765159389, + 0.8781260801866495, + 0.8805069215187917, + 0.8828942179666361, + 0.8852879870317771, + 0.8876882462632604, + 0.890095013257712, + 0.8925083056594671, + 0.8949281411607002, + 0.8973545375015533, + 0.8997875124702672, + 0.9022270839033115, + 0.9046732696855155, + 0.9071260877501991, + 0.909585556079304, + 0.9120516927035263, + 0.9145245157024483, + 0.9170040432046711, + 0.9194902933879467, + 0.9219832844793128, + 0.9244830347552253, + 0.9269895625416926, + 0.92950288621441, + 0.9320230241988943, + 0.9345499949706191, + 0.9370838170551498, + 0.93962450902828, + 0.9421720895161669, + 0.9447265771954693, + 0.9472879907934827, + 0.9498563490882775, + 0.9524316709088368, + 0.9550139751351947, + 0.9576032806985735, + 0.9601996065815236, + 0.9628029718180622, + 0.9654133954938133, + 0.9680308967461471, + 0.9706554947643201, + 0.9732872087896164, + 0.9759260581154889, + 0.9785720620876999, + 0.9812252401044634, + 0.9838856116165875, + 0.9865531961276168, + 0.9892280131939752, + 0.9919100824251095, + 0.9945994234836328, + 0.9972960560854698, + ], +]; + +fn bucket_index(schema: i8, v: f64, is_infinite: bool) -> i32 { + let v = if is_infinite { f64::MAX } else { v }; + let (frac, exp) = frexp(v); + let mut index = if schema > 0 { + let bounds = &native_histogram_bounds()[schema as usize]; + let bucket = bounds.partition_point(|bound| *bound < frac) as i32; + bucket + (exp - 1) * bounds.len() as i32 + } else { + let mut key = exp; + if frac == 0.5 { + key -= 1; + } + let offset = (1_i32 << (-(schema as i32) as u32)) - 1; + (key + offset) >> (-(schema as i32) as u32) + }; + + if is_infinite { + index = index.saturating_add(1); + } + index +} + +fn frexp(v: f64) -> (f64, i32) { + debug_assert!(v >= 0.0); + debug_assert!(!v.is_nan()); + + if v == 0.0 { + return (0.0, 0); + } + + let bits = v.to_bits(); + let exponent = ((bits >> 52) & 0x7ff) as i32; + let mantissa = bits & ((1_u64 << 52) - 1); + + if exponent == 0 { + let p = 63 - mantissa.leading_zeros() as i32; + let frac = mantissa as f64 / 2f64.powi(p + 1); + (frac, p - 1073) + } else { + let frac = ((1_u64 << 52) | mantissa) as f64 / 2f64.powi(53); + (frac, exponent - 1022) + } +} + +fn enforce_bucket_limit(inner: &mut NativeHistogramState) -> bool { + if inner.max_buckets == 0 { + return false; + } + + if inner.positive.len() + inner.negative.len() <= inner.max_buckets { + return false; + } + + if inner.scheduled_reset.is_none() + && inner + .min_reset_duration + .and_then(|min_reset_duration| { + inner + .created + .elapsed() + .ok() + .map(|e| e >= min_reset_duration) + }) + .unwrap_or(false) + { + return true; + } + + let mut degraded = false; + while inner.positive.len() + inner.negative.len() > inner.max_buckets { + if widen_zero_bucket(inner) { + degraded = true; + continue; + } + + if inner.schema > SCHEMA_MIN { + inner.schema -= 1; + inner.positive = downsample_buckets(&inner.positive); + inner.negative = downsample_buckets(&inner.negative); + degraded = true; + continue; + } + + break; + } + + if degraded { + inner.schedule_reset_after_degradation(); + } + + false +} + +fn widen_zero_bucket(inner: &mut NativeHistogramState) -> bool { + let smallest_key = match ( + inner.positive.first().map(|(index, _)| *index), + inner.negative.first().map(|(index, _)| *index), + ) { + (Some(positive), Some(negative)) => positive.min(negative), + (Some(positive), None) => positive, + (None, Some(negative)) => negative, + (None, None) => return false, + }; + + let new_threshold = positive_upper_bound(inner.schema, smallest_key); + let current_threshold = if inner.zero_threshold < 0.0 { + 0.0 + } else { + inner.zero_threshold + }; + if new_threshold <= current_threshold || new_threshold > inner.max_zero_threshold { + return false; + } + + let moved_positive = move_to_zero_bucket(inner.schema, new_threshold, &mut inner.positive); + let moved_negative = move_to_zero_bucket(inner.schema, new_threshold, &mut inner.negative); + + let moved = moved_positive + moved_negative; + if moved == 0 { + return false; + } + + inner.zero_count += moved; + inner.zero_threshold = new_threshold; + true +} + +fn move_to_zero_bucket(schema: i8, threshold: f64, buckets: &mut NativeBuckets) -> u64 { + if buckets.is_empty() { + return 0; + } + + let mut split = 0; + for (index, _count) in buckets.iter() { + let upper = positive_upper_bound(schema, *index); + if upper <= threshold { + split += 1; + } else { + break; + } + } + + let moved = buckets[..split].iter().map(|(_, count)| *count).sum(); + buckets.drain(..split); + + moved +} + +fn positive_upper_bound(schema: i8, index: i32) -> f64 { + if schema < 0 { + let exp = index << (-(schema as i32) as u32); + if exp == 1024 { + return f64::MAX; + } + return 2f64.powi(exp); + } + + let bounds = &native_histogram_bounds()[schema as usize]; + let frac = bounds[(index & ((1 << schema) - 1)) as usize]; + let exp = (index >> schema) + 1; + if frac == 0.5 && exp == 1025 { + return f64::MAX; + } + frac * 2f64.powi(exp) +} + +fn downsample_buckets(buckets: &NativeBuckets) -> NativeBuckets { + let mut downsampled: NativeBuckets = Vec::with_capacity(buckets.len()); + for (index, count) in buckets { + let mut key = *index; + if key > 0 { + key += 1; + } + key /= 2; + if let Some((last_key, last_count)) = downsampled.last_mut() { + if *last_key == key { + *last_count += *count; + continue; + } + } + downsampled.push((key, *count)); + } + downsampled +} + +fn encode_spans_and_deltas( + buckets: &NativeBuckets, +) -> Result { + let mut deltas = Vec::with_capacity(buckets.len()); + let mut previous_count = 0i64; + let mut next_index = 0i32; + let mut spans: Vec<(i32, u32)> = Vec::new(); + + let mut append_delta = |count: i64, spans: &mut Vec<(i32, u32)>| { + if let Some((_, len)) = spans.last_mut() { + *len += 1; + } + deltas.push(count - previous_count); + previous_count = count; + }; + + for (n, &(index, count)) in buckets.iter().enumerate() { + let count = i64::try_from(count).map_err(|_| std::fmt::Error)?; + let index_delta = index - next_index; + + if n == 0 || index_delta > 2 { + spans.push((index_delta, 0)); + } else { + for _ in 0..index_delta { + append_delta(0, &mut spans); + } + } + + append_delta(count, &mut spans); + next_index = index + 1; + } + + Ok(NativeBucketEncoding { spans, deltas }) +} + #[cfg(test)] mod tests { use super::*; @@ -273,4 +1430,744 @@ mod tests { "histogram sum records accurate sum of observations" ); } + + #[test] + fn native_histogram_stores_sparse_buckets() { + let h = Histogram::new_native(NativeHistogramConfig::with_schema(0)); + h.observe(1.0); + h.observe(4.0); + h.observe(-2.0); + h.observe(0.0); + + let inner = h.inner.lock(); + let native = inner.native.as_ref().unwrap(); + assert_eq!(4, inner.count); + assert_eq!(1, native.zero_count); + assert_eq!(2, native.positive.len()); + assert_eq!(3, native.negative.len() + native.positive.len()); + } + + #[test] + fn native_histogram_counts_nan_without_sparse_bucket() { + let h = Histogram::new_native(NativeHistogramConfig::with_schema(2)); + h.observe(f64::NAN); + assert_eq!(1, h.count()); + assert!(h.sum().is_nan()); + + let inner = h.inner.lock(); + let native = inner.native.as_ref().unwrap(); + assert_eq!(0, native.zero_count); + assert!(native.positive.is_empty()); + assert!(native.negative.is_empty()); + } + + #[test] + fn classic_histogram_counts_nan_in_infinity_bucket() { + let h = Histogram::new_classic([1.0, 2.0]); + h.observe(f64::NAN); + + let inner = h.inner.lock(); + assert_eq!(1, inner.count); + assert!(inner.sum.is_nan()); + assert_eq!(1, inner.buckets[2].1); + } + + #[test] + fn native_histogram_supports_zero_width_zero_bucket() { + let h = Histogram::new_native( + NativeHistogramConfig::with_schema(0) + .zero_threshold(NATIVE_HISTOGRAM_ZERO_THRESHOLD_ZERO), + ); + h.observe(0.0); + h.observe(0.01); + + let inner = h.inner.lock(); + let native = inner.native.as_ref().unwrap(); + assert_eq!(1, native.zero_count); + assert_eq!( + 1, + native.positive.iter().map(|(_, count)| *count).sum::() + ); + } + + #[test] + #[should_panic] + fn native_histogram_rejects_negative_zero_threshold_except_sentinel() { + NativeHistogramConfig::with_schema(0).zero_threshold(-2.0); + } + + #[test] + fn native_histogram_reduces_resolution_when_max_bucket_limit_is_hit() { + let h = Histogram::new_native(NativeHistogramConfig::with_schema(8).max_buckets(1)); + h.observe(1.0); + h.observe(1.1); + + let inner = h.inner.lock(); + let native = inner.native.as_ref().unwrap(); + assert!(native.schema < 8); + } + + #[test] + fn native_histogram_widens_zero_bucket_before_reducing_resolution() { + let h = Histogram::new_native( + NativeHistogramConfig::with_schema(8) + .max_buckets(1) + .max_zero_threshold(1.0), + ); + h.observe(2f64.powi(-100)); + h.observe(1.0); + + let inner = h.inner.lock(); + let native = inner.native.as_ref().unwrap(); + assert_eq!(8, native.schema); + assert_eq!(1, native.zero_count); + assert_eq!(1, native.positive.len()); + } + + #[test] + fn native_histogram_schedules_reset_after_degradation() { + let h = Histogram::new_native( + NativeHistogramConfig::with_schema(8) + .max_buckets(1) + .min_reset_duration(Duration::from_secs(60)), + ); + h.observe(1.0); + h.observe(1.1); + + { + let mut inner = h.inner.lock(); + let native = inner.native.as_mut().unwrap(); + assert!(native.schema < 8); + assert!(native.scheduled_reset.is_some()); + native.scheduled_reset = Some(SystemTime::now() - Duration::from_secs(1)); + } + + let snapshot = h.snapshot().unwrap(); + let native = snapshot.native.unwrap(); + + assert_eq!(0, snapshot.count); + assert_eq!(8, native.schema); + assert_eq!(0, native.zero_count); + assert!(native.positive.spans.is_empty()); + assert!(native.negative.spans.is_empty()); + } + + #[test] + fn native_histogram_resets_when_limit_is_hit_after_min_reset_duration() { + let h = Histogram::new_native( + NativeHistogramConfig::with_schema(8) + .max_buckets(1) + .min_reset_duration(Duration::from_secs(1)), + ); + h.observe(1.0); + { + let mut inner = h.inner.lock(); + inner.native.as_mut().unwrap().created = SystemTime::now() - Duration::from_secs(2); + } + h.observe(2.0); + + let inner = h.inner.lock(); + let native = inner.native.as_ref().unwrap(); + assert_eq!(1, inner.count); + assert_eq!(2.0, inner.sum); + assert_eq!(8, native.schema); + assert_eq!( + 1, + native.positive.iter().map(|(_, count)| *count).sum::() + ); + } + + #[test] + fn native_histogram_zero_min_reset_duration_does_not_reset() { + let h = Histogram::new_native( + NativeHistogramConfig::with_schema(8) + .max_buckets(1) + .min_reset_duration(Duration::ZERO), + ); + h.observe(1.0); + h.observe(1.1); + + let inner = h.inner.lock(); + let native = inner.native.as_ref().unwrap(); + assert_eq!(2, inner.count); + assert!(native.schema < 8); + assert!(native.scheduled_reset.is_none()); + } + + #[test] + fn native_histogram_supports_bucket_factor_constructor() { + let h = Histogram::new_native(NativeHistogramConfig::new(1.1)); + let inner = h.inner.lock(); + assert_eq!(3, inner.native.as_ref().unwrap().schema); + } + + #[test] + fn native_histogram_default_uses_recommended_bucket_factor() { + let h = Histogram::new_native(NativeHistogramConfig::default()); + let inner = h.inner.lock(); + assert_eq!(3, inner.native.as_ref().unwrap().schema); + } + + // Copying tests from Go https://github.com/prometheus/client_golang/blob/ef80579ce2dff4ae60710045fd1d38e366adaed0/prometheus/histogram_test.go#L472 + // 1:1 to check compatibility + #[derive(Debug)] + struct NativeHistogramParityCase { + name: &'static str, + observations: &'static [f64], + bucket_factor: f64, + zero_threshold: Option, + max_buckets: usize, + max_zero_threshold: f64, + sample_count: u64, + sample_sum: f64, + schema: i32, + expected_zero_threshold: f64, + zero_count: u64, + negative_spans: &'static [(i32, u32)], + negative_deltas: &'static [i64], + positive_spans: &'static [(i32, u32)], + positive_deltas: &'static [i64], + } + + #[test] + fn native_histogram_matches_client_golang_scenarios() { + const DEFAULT_ZERO_THRESHOLD: f64 = 2.938735877055719e-39; + const ZERO_WIDTH_ZERO_BUCKET: f64 = NATIVE_HISTOGRAM_ZERO_THRESHOLD_ZERO; + const NAN: f64 = f64::NAN; + const INF: f64 = f64::INFINITY; + const NEG_INF: f64 = f64::NEG_INFINITY; + + let cases = [ + NativeHistogramParityCase { + name: "no observations", + observations: &[], + bucket_factor: 1.1, + zero_threshold: None, + max_buckets: 0, + max_zero_threshold: 0.0, + sample_count: 0, + sample_sum: 0.0, + schema: 3, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 0, + negative_spans: &[], + negative_deltas: &[], + positive_spans: &[], + positive_deltas: &[], + }, + NativeHistogramParityCase { + name: "no observations and zero threshold of zero resulting in no-op span", + observations: &[], + bucket_factor: 1.1, + zero_threshold: Some(ZERO_WIDTH_ZERO_BUCKET), + max_buckets: 0, + max_zero_threshold: 0.0, + sample_count: 0, + sample_sum: 0.0, + schema: 3, + expected_zero_threshold: 0.0, + zero_count: 0, + negative_spans: &[], + negative_deltas: &[], + positive_spans: &[(0, 0)], + positive_deltas: &[], + }, + NativeHistogramParityCase { + name: "factor 1.1 results in schema 3", + observations: &[0.0, 1.0, 2.0, 3.0], + bucket_factor: 1.1, + zero_threshold: None, + max_buckets: 0, + max_zero_threshold: 0.0, + sample_count: 4, + sample_sum: 6.0, + schema: 3, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 1, + negative_spans: &[], + negative_deltas: &[], + positive_spans: &[(0, 1), (7, 1), (4, 1)], + positive_deltas: &[1, 0, 0], + }, + NativeHistogramParityCase { + name: "factor 1.2 results in schema 2", + observations: &[0.0, 1.0, 1.2, 1.4, 1.8, 2.0], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 0, + max_zero_threshold: 0.0, + sample_count: 6, + sample_sum: 7.4, + schema: 2, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 1, + negative_spans: &[], + negative_deltas: &[], + positive_spans: &[(0, 5)], + positive_deltas: &[1, -1, 2, -2, 2], + }, + NativeHistogramParityCase { + name: "factor 4 results in schema -1", + observations: &[ + 0.0156251, 0.0625, 0.1, 0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 3.5, 5.0, 6.0, 7.0, + 33.33, + ], + bucket_factor: 4.0, + zero_threshold: None, + max_buckets: 0, + max_zero_threshold: 0.0, + sample_count: 14, + sample_sum: 63.2581251, + schema: -1, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 0, + negative_spans: &[], + negative_deltas: &[], + positive_spans: &[(-2, 6)], + positive_deltas: &[2, 0, 0, 2, -1, -2], + }, + NativeHistogramParityCase { + name: "factor 17 results in schema -2", + observations: &[ + 0.0156251, 0.0625, 0.1, 0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 3.5, 5.0, 6.0, 7.0, + 33.33, + ], + bucket_factor: 17.0, + zero_threshold: None, + max_buckets: 0, + max_zero_threshold: 0.0, + sample_count: 14, + sample_sum: 63.2581251, + schema: -2, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 0, + negative_spans: &[], + negative_deltas: &[], + positive_spans: &[(-1, 4)], + positive_deltas: &[2, 2, 3, -6], + }, + NativeHistogramParityCase { + name: "negative buckets", + observations: &[0.0, -1.0, -1.2, -1.4, -1.8, -2.0], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 0, + max_zero_threshold: 0.0, + sample_count: 6, + sample_sum: -7.4, + schema: 2, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 1, + negative_spans: &[(0, 5)], + negative_deltas: &[1, -1, 2, -2, 2], + positive_spans: &[], + positive_deltas: &[], + }, + NativeHistogramParityCase { + name: "negative and positive buckets", + observations: &[0.0, -1.0, -1.2, -1.4, -1.8, -2.0, 1.0, 1.2, 1.4, 1.8, 2.0], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 0, + max_zero_threshold: 0.0, + sample_count: 11, + sample_sum: 0.0, + schema: 2, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 1, + negative_spans: &[(0, 5)], + negative_deltas: &[1, -1, 2, -2, 2], + positive_spans: &[(0, 5)], + positive_deltas: &[1, -1, 2, -2, 2], + }, + NativeHistogramParityCase { + name: "wide zero bucket", + observations: &[0.0, -1.0, -1.2, -1.4, -1.8, -2.0, 1.0, 1.2, 1.4, 1.8, 2.0], + bucket_factor: 1.2, + zero_threshold: Some(1.4), + max_buckets: 0, + max_zero_threshold: 0.0, + sample_count: 11, + sample_sum: 0.0, + schema: 2, + expected_zero_threshold: 1.4, + zero_count: 7, + negative_spans: &[(4, 1)], + negative_deltas: &[2], + positive_spans: &[(4, 1)], + positive_deltas: &[2], + }, + NativeHistogramParityCase { + name: "NaN observation", + observations: &[0.0, 1.0, 1.2, 1.4, 1.8, 2.0, NAN], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 0, + max_zero_threshold: 0.0, + sample_count: 7, + sample_sum: NAN, + schema: 2, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 1, + negative_spans: &[], + negative_deltas: &[], + positive_spans: &[(0, 5)], + positive_deltas: &[1, -1, 2, -2, 2], + }, + NativeHistogramParityCase { + name: "+Inf observation", + observations: &[0.0, 1.0, 1.2, 1.4, 1.8, 2.0, INF], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 0, + max_zero_threshold: 0.0, + sample_count: 7, + sample_sum: INF, + schema: 2, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 1, + negative_spans: &[], + negative_deltas: &[], + positive_spans: &[(0, 5), (4092, 1)], + positive_deltas: &[1, -1, 2, -2, 2, -1], + }, + NativeHistogramParityCase { + name: "-Inf observation", + observations: &[0.0, 1.0, 1.2, 1.4, 1.8, 2.0, NEG_INF], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 0, + max_zero_threshold: 0.0, + sample_count: 7, + sample_sum: NEG_INF, + schema: 2, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 1, + negative_spans: &[(4097, 1)], + negative_deltas: &[1], + positive_spans: &[(0, 5)], + positive_deltas: &[1, -1, 2, -2, 2], + }, + NativeHistogramParityCase { + name: "limited buckets but nothing triggered", + observations: &[0.0, 1.0, 1.2, 1.4, 1.8, 2.0], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 4, + max_zero_threshold: 0.0, + sample_count: 6, + sample_sum: 7.4, + schema: 2, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 1, + negative_spans: &[], + negative_deltas: &[], + positive_spans: &[(0, 5)], + positive_deltas: &[1, -1, 2, -2, 2], + }, + NativeHistogramParityCase { + name: "buckets limited by halving resolution", + observations: &[0.0, 1.0, 1.1, 1.2, 1.4, 1.8, 2.0, 3.0], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 4, + max_zero_threshold: 0.0, + sample_count: 8, + sample_sum: 11.5, + schema: 1, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 1, + negative_spans: &[], + negative_deltas: &[], + positive_spans: &[(0, 5)], + positive_deltas: &[1, 2, -1, -2, 1], + }, + NativeHistogramParityCase { + name: "buckets limited by widening the zero bucket", + observations: &[0.0, 1.0, 1.1, 1.2, 1.4, 1.8, 2.0, 3.0], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 4, + max_zero_threshold: 1.2, + sample_count: 8, + sample_sum: 11.5, + schema: 2, + expected_zero_threshold: 1.0, + zero_count: 2, + negative_spans: &[], + negative_deltas: &[], + positive_spans: &[(1, 7)], + positive_deltas: &[1, 1, -2, 2, -2, 0, 1], + }, + NativeHistogramParityCase { + name: "buckets limited by widening the zero bucket twice", + observations: &[0.0, 1.0, 1.1, 1.2, 1.4, 1.8, 2.0, 3.0, 4.0], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 4, + max_zero_threshold: 1.2, + sample_count: 9, + sample_sum: 15.5, + schema: 2, + expected_zero_threshold: 1.189207115002721, + zero_count: 3, + negative_spans: &[], + negative_deltas: &[], + positive_spans: &[(2, 7)], + positive_deltas: &[2, -2, 2, -2, 0, 1, 0], + }, + NativeHistogramParityCase { + name: "limited buckets but nothing triggered, negative observations", + observations: &[0.0, -1.0, -1.2, -1.4, -1.8, -2.0], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 4, + max_zero_threshold: 0.0, + sample_count: 6, + sample_sum: -7.4, + schema: 2, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 1, + negative_spans: &[(0, 5)], + negative_deltas: &[1, -1, 2, -2, 2], + positive_spans: &[], + positive_deltas: &[], + }, + NativeHistogramParityCase { + name: "buckets limited by halving resolution, negative observations", + observations: &[0.0, -1.0, -1.1, -1.2, -1.4, -1.8, -2.0, -3.0], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 4, + max_zero_threshold: 0.0, + sample_count: 8, + sample_sum: -11.5, + schema: 1, + expected_zero_threshold: DEFAULT_ZERO_THRESHOLD, + zero_count: 1, + negative_spans: &[(0, 5)], + negative_deltas: &[1, 2, -1, -2, 1], + positive_spans: &[], + positive_deltas: &[], + }, + NativeHistogramParityCase { + name: "buckets limited by widening the zero bucket, negative observations", + observations: &[0.0, -1.0, -1.1, -1.2, -1.4, -1.8, -2.0, -3.0], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 4, + max_zero_threshold: 1.2, + sample_count: 8, + sample_sum: -11.5, + schema: 2, + expected_zero_threshold: 1.0, + zero_count: 2, + negative_spans: &[(1, 7)], + negative_deltas: &[1, 1, -2, 2, -2, 0, 1], + positive_spans: &[], + positive_deltas: &[], + }, + NativeHistogramParityCase { + name: "buckets limited by widening the zero bucket twice, negative observations", + observations: &[0.0, -1.0, -1.1, -1.2, -1.4, -1.8, -2.0, -3.0, -4.0], + bucket_factor: 1.2, + zero_threshold: None, + max_buckets: 4, + max_zero_threshold: 1.2, + sample_count: 9, + sample_sum: -15.5, + schema: 2, + expected_zero_threshold: 1.189207115002721, + zero_count: 3, + negative_spans: &[(2, 7)], + negative_deltas: &[2, -2, 2, -2, 0, 1, 0], + positive_spans: &[], + positive_deltas: &[], + }, + ]; + + for case in cases { + let mut config = NativeHistogramConfig::new(case.bucket_factor) + .max_buckets(case.max_buckets) + .max_zero_threshold(case.max_zero_threshold); + if let Some(zero_threshold) = case.zero_threshold { + config = config.zero_threshold(zero_threshold); + } + let h = Histogram::new_native(config); + for observation in case.observations { + h.observe(*observation); + } + + let snapshot = h.snapshot().unwrap(); + let native = snapshot.native.unwrap(); + + assert_eq!(case.sample_count, snapshot.count, "{}", case.name); + if case.sample_sum.is_nan() { + assert!(snapshot.sum.is_nan(), "{}", case.name); + } else { + assert_eq!(case.sample_sum, snapshot.sum, "{}", case.name); + } + assert_eq!(case.schema, native.schema, "{}", case.name); + assert_eq!( + case.expected_zero_threshold, native.zero_threshold, + "{}", + case.name + ); + assert_eq!(case.zero_count, native.zero_count, "{}", case.name); + assert_eq!(case.negative_spans, native.negative.spans, "{}", case.name); + assert_eq!( + case.negative_deltas, native.negative.deltas, + "{}", + case.name + ); + assert_eq!(case.positive_spans, native.positive.spans, "{}", case.name); + assert_eq!( + case.positive_deltas, native.positive.deltas, + "{}", + case.name + ); + } + } + + #[test] + fn native_histogram_maps_positive_infinity_into_sparse_bucket() { + let h = Histogram::new_native(NativeHistogramConfig::with_schema(4)); + h.observe(f64::INFINITY); + let inner = h.inner.lock(); + let native = inner.native.as_ref().unwrap(); + assert_eq!( + Some(1), + native + .positive + .iter() + .find(|(index, _)| *index == 16385) + .map(|(_, count)| *count) + ); + assert!(!native.positive.iter().any(|(index, _)| *index == i32::MAX)); + } + + #[test] + fn native_histogram_widens_zero_bucket_at_min_schema_when_limited() { + let mut inner = NativeHistogramState { + initial_zero_threshold: 0.0, + initial_schema: SCHEMA_MIN, + zero_threshold: 0.0, + zero_count: 0, + schema: SCHEMA_MIN, + max_buckets: 1, + min_reset_duration: None, + max_zero_threshold: 1.0, + created: SystemTime::now(), + scheduled_reset: None, + positive: vec![(-10, 2), (0, 1)], + negative: Vec::new(), + }; + + assert!(widen_zero_bucket(&mut inner)); + assert!(inner.zero_threshold > 0.0); + assert!(inner.zero_count >= 2); + } + + #[test] + fn native_histogram_encodes_spans_and_deltas() { + let buckets = vec![(-10, 1), (-7, 7), (-5, 7), (2, 8)]; + + let encoding = encode_spans_and_deltas(&buckets).unwrap(); + + assert_eq!(encoding.spans, vec![(-10, 6), (6, 1)]); + assert_eq!(encoding.deltas, vec![1, -1, 0, 7, -7, 7, 1]); + } + + #[test] + fn native_histogram_downsamples_like_go_client() { + let buckets = vec![(-2, 1), (-1, 2), (0, 4), (1, 8)]; + + let downsampled = downsample_buckets(&buckets); + + assert_eq!(downsampled, vec![(-1, 1), (0, 6), (1, 8)]); + } + + #[test] + fn native_histogram_positive_upper_bound_uses_bucket_index() { + assert_eq!(1.0, positive_upper_bound(0, 0)); + assert_eq!(2.0, positive_upper_bound(0, 1)); + assert!((positive_upper_bound(3, 8) - 2.0).abs() <= 4.0 * f64::EPSILON); + assert_eq!(f64::MAX, positive_upper_bound(0, 1024)); + assert_eq!(f64::INFINITY, positive_upper_bound(0, 1025)); + assert_eq!(f64::MAX, positive_upper_bound(8, 262144)); + assert_eq!(f64::INFINITY, positive_upper_bound(8, 262145)); + } + + #[test] + fn native_histogram_bucket_index_matches_standard_boundaries() { + assert_eq!(0, bucket_index(0, 1.0, false)); + assert_eq!( + 1, + bucket_index(0, f64::from_bits(1.0f64.to_bits() + 1), false) + ); + assert_eq!(8, bucket_index(3, 2.0, false)); + assert_eq!( + -1074, + bucket_index(0, f64::MIN_POSITIVE / 2f64.powi(52), false) + ); + assert_eq!(1024, bucket_index(0, f64::MAX, false)); + assert_eq!(1025, bucket_index(0, f64::INFINITY, true)); + assert_eq!(262144, bucket_index(8, f64::MAX, false)); + assert_eq!(262145, bucket_index(8, f64::INFINITY, true)); + } + + #[test] + fn native_histogram_bounds_match_client_golang_constants() { + let bounds = native_histogram_bounds(); + assert_eq!(1, bounds[0].len()); + assert_eq!(256, bounds[8].len()); + assert_eq!(0.5013556375251013, bounds[8][1]); + assert_eq!(0.7071067811865475, bounds[8][128]); + assert_eq!(0.9972960560854698, bounds[8][255]); + } + + #[test] + fn native_histogram_no_op_span_marks_zero_width_nan_only_histogram() { + let h = Histogram::new_native( + NativeHistogramConfig::with_schema(0) + .zero_threshold(NATIVE_HISTOGRAM_ZERO_THRESHOLD_ZERO), + ); + h.observe(f64::NAN); + + let snapshot = h.snapshot().unwrap(); + let native = snapshot.native.unwrap(); + + assert_eq!(1, snapshot.count); + assert_eq!(0.0, native.zero_threshold); + assert_eq!(0, native.zero_count); + assert_eq!(vec![(0, 0)], native.positive.spans); + assert!(native.positive.deltas.is_empty()); + assert!(native.negative.spans.is_empty()); + } + + #[test] + fn native_histogram_fails_for_counts_not_fitting_delta_wire_type() { + let buckets = vec![(0, i64::MAX as u64 + 1)]; + + assert!(encode_spans_and_deltas(&buckets).is_err()); + } + + #[test] + fn classic_and_native_histogram_updates_both_representations() { + let h = + Histogram::new_classic_and_native([1.0, 2.0], NativeHistogramConfig::with_schema(0)); + h.observe(1.0); + h.observe(4.0); + + let inner = h.inner.lock(); + let native = inner.native.as_ref().unwrap(); + assert_eq!(2, inner.count); + assert_eq!(5.0, inner.sum); + assert_eq!(1, inner.buckets[0].1); + assert_eq!(1, inner.buckets[2].1); + assert_eq!( + 2, + native.positive.iter().map(|(_, count)| *count).sum::() + ); + } }