Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions foundations-macros/src/metrics/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,8 @@ mod tests {
bucket_factor: 1.1,
zero_threshold: 0.0,
max_buckets: 0,
min_reset_duration: ::std::time::Duration::from_secs(3600),
max_zero_threshold: 0.0,
}
};

Expand Down
131 changes: 127 additions & 4 deletions foundations-metrics/src/metrics/native_histogram.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::time::SystemTime;
use std::time::{Duration, SystemTime};

use foundations_metrics_registry::proto::{self, Bucket, BucketSpan, LabelPair, MetricType};
use prometheus_client::encoding::prometheus_protobuf::{
Expand Down Expand Up @@ -242,17 +242,44 @@ pub struct NativeHistogramBuilder {
///
/// `0` leaves the bucket count unbounded.
pub max_buckets: usize,

/// How long a degraded histogram must run before the bucket limit is
/// enforced by resetting it instead of permanently reducing its resolution.
///
/// When [`Self::max_buckets`] is exceeded and at least this long has elapsed
/// since the histogram was created or last reset, the histogram is reset:
/// its schema returns to the value implied by [`Self::bucket_factor`], and
/// its counts, sum, and classic buckets are cleared. Otherwise the zero
/// threshold may be widened up to [`Self::max_zero_threshold`], and if that
/// is not enough the schema is reduced by one step, which is not recovered
/// until a later reset.
///
/// [`Duration::ZERO`] disables resets, so resolution loss becomes permanent
/// for the lifetime of the histogram.
pub min_reset_duration: Duration,

/// Upper bound on how far the zero bucket may be widened while enforcing
/// [`Self::max_buckets`].
///
/// When the bucket limit is exceeded and a reset is not yet due, the
/// histogram first tries to absorb sparse buckets near zero into a wider
/// zero bucket. Widening stops at this value. `0.0` (the default) disables
/// widening, so the next step is schema reduction.
pub max_zero_threshold: f64,
}

impl NativeHistogramBuilder {
/// Creates a builder with the given bucket growth `factor`, the default zero
/// threshold, and an unbounded number of buckets.
/// threshold, an unbounded number of buckets, a one hour minimum reset
/// duration, and zero-bucket widening disabled.
pub fn new(factor: f64) -> Self {
Self {
classic_buckets: None,
bucket_factor: factor,
zero_threshold: 0.0,
max_buckets: 0,
min_reset_duration: Duration::from_secs(3600),
max_zero_threshold: 0.0,
}
}

Expand Down Expand Up @@ -284,12 +311,31 @@ impl NativeHistogramBuilder {
self
}

/// Sets how long a degraded histogram must run before the bucket limit is
/// enforced by resetting it rather than reducing its resolution.
///
/// [`Duration::ZERO`] disables resets, making resolution loss permanent.
pub fn with_min_reset_duration(mut self, min_reset_duration: Duration) -> Self {
self.min_reset_duration = min_reset_duration;
self
}

/// Sets how far the zero bucket may be widened while enforcing the bucket
/// limit.
///
/// `0.0` disables widening. Must be finite and non-negative.
pub fn with_max_zero_threshold(mut self, max_zero_threshold: f64) -> Self {
self.max_zero_threshold = max_zero_threshold;
self
}

/// Translates this builder into the wrapped crate's configuration.
///
/// # Panics
///
/// Panics if `bucket_factor` is not greater than `1.0` or if
/// `zero_threshold` is not finite.
/// Panics if `bucket_factor` is not greater than `1.0`, if
/// `zero_threshold` is not finite, or if `max_zero_threshold` is not finite
/// and non-negative.
#[track_caller]
fn config(&self) -> NativeHistogramConfig {
// Validated here rather than left to `prometheus-client`: its assertions
Expand All @@ -305,10 +351,17 @@ impl NativeHistogramBuilder {
"native histogram zero threshold must be finite, but was {}",
self.zero_threshold
);
assert!(
self.max_zero_threshold.is_finite() && self.max_zero_threshold >= 0.0,
"native histogram max zero threshold must be finite and non-negative, but was {}",
self.max_zero_threshold
);

NativeHistogramConfig::new(self.bucket_factor)
.zero_threshold(self.zero_threshold)
.max_buckets(self.max_buckets)
.min_reset_duration(self.min_reset_duration)
.max_zero_threshold(self.max_zero_threshold)
}
}

Expand Down Expand Up @@ -542,6 +595,7 @@ mod tests {
let histogram: NativeHistogram = NativeHistogramBuilder::new(1.5)
.with_zero_threshold(0.001)
.with_max_buckets(160)
.with_max_zero_threshold(1.0)
.new_metric();
histogram.observe(0.5);

Expand Down Expand Up @@ -804,4 +858,73 @@ mod tests {
assert_eq!(encoded.sample_count, Some(1));
assert_eq!(encoded.zero_threshold, Some(0.001));
}

#[test]
fn elapsed_min_reset_duration_resets_instead_of_degrading_resolution() {
let histogram: NativeHistogram = NativeHistogramBuilder::new(1.1)
.with_max_buckets(1)
.with_min_reset_duration(Duration::from_nanos(1))
.new_metric();

let schema_before = encoded_histogram(&histogram.encode_metric_value())
.schema
.expect("schema is present");

histogram.observe(1.0);
histogram.observe(1.5);

let families = histogram.encode_metric_value();
let encoded = encoded_histogram(&families);

assert_eq!(encoded.schema, Some(schema_before));
assert_eq!(encoded.sample_count, Some(1));
}

#[test]
fn zero_min_reset_duration_degrades_resolution() {
let histogram: NativeHistogram = NativeHistogramBuilder::new(1.1)
.with_max_buckets(1)
.with_min_reset_duration(Duration::ZERO)
.new_metric();

let schema_before = encoded_histogram(&histogram.encode_metric_value())
.schema
.expect("schema is present");

histogram.observe(1.0);
histogram.observe(1.5);

let families = histogram.encode_metric_value();
let encoded = encoded_histogram(&families);

assert!(encoded.schema.expect("schema is present") < schema_before);
assert_eq!(encoded.sample_count, Some(2));
}

#[test]
fn max_zero_threshold_widens_zero_bucket_before_reducing_resolution() {
// Factor 2^(2^-8) ≈ 1.0027 selects schema 8, matching the upstream
// prometheus-client case that exercises zero-bucket widening.
let histogram: NativeHistogram = NativeHistogramBuilder::new(2f64.powf(2f64.powi(-8)))
.with_max_buckets(1)
.with_min_reset_duration(Duration::ZERO)
.with_max_zero_threshold(1.0)
.new_metric();

let schema_before = encoded_histogram(&histogram.encode_metric_value())
.schema
.expect("schema is present");

histogram.observe(2f64.powi(-100));
histogram.observe(1.0);

let families = histogram.encode_metric_value();
let encoded = encoded_histogram(&families);

assert_eq!(encoded.schema, Some(schema_before));
assert_eq!(encoded.sample_count, Some(2));
assert_eq!(encoded.zero_count, Some(1));
assert!(encoded.zero_threshold.expect("zero threshold") > 0.0);
assert_eq!(encoded.positive_delta.len(), 1);
}
}
Loading