use crate::{MetricsError, Result};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
pub const DEFAULT_SECONDS_BUCKETS: &[f64] = &[
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
];
#[repr(align(64))]
pub struct Histogram {
bucket_bounds: Box<[f64]>,
bucket_counts: Box<[AtomicU64]>,
sum_bits: AtomicU64,
total: AtomicU64,
created_at: Instant,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct HistogramSnapshot {
pub buckets: Vec<HistogramBucket>,
pub sum: f64,
pub count: u64,
pub age: Duration,
}
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct HistogramBucket {
pub upper_bound: f64,
pub count: u64,
}
impl Histogram {
pub fn with_buckets(bounds: impl IntoIterator<Item = f64>) -> Self {
let mut cleaned: Vec<f64> = bounds
.into_iter()
.filter(|b| b.is_finite())
.collect::<Vec<_>>();
cleaned.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
cleaned.dedup_by(|a, b| (*a - *b).abs() < f64::EPSILON);
if cleaned.is_empty() {
debug_assert!(
false,
"Histogram::with_buckets requires at least one bucket"
);
cleaned.push(1.0);
}
let bucket_counts: Box<[AtomicU64]> = cleaned.iter().map(|_| AtomicU64::new(0)).collect();
Self {
bucket_bounds: cleaned.into_boxed_slice(),
bucket_counts,
sum_bits: AtomicU64::new(0.0_f64.to_bits()),
total: AtomicU64::new(0),
created_at: Instant::now(),
}
}
pub fn default_seconds() -> Self {
Self::with_buckets(DEFAULT_SECONDS_BUCKETS.iter().copied())
}
pub fn linear(start: f64, width: f64, count: usize) -> Self {
if count == 0 || !width.is_finite() || width <= 0.0 || !start.is_finite() {
return Self::with_buckets(std::iter::once(1.0));
}
let bounds = (0..count).map(|i| start + width * (i as f64));
Self::with_buckets(bounds)
}
pub fn exponential(start: f64, factor: f64, count: usize) -> Self {
if count == 0 || !start.is_finite() || !factor.is_finite() || start <= 0.0 || factor <= 1.0
{
return Self::with_buckets(std::iter::once(1.0));
}
let mut cur = start;
let bounds = (0..count).map(|i| {
let v = cur;
if i + 1 < count {
cur *= factor;
}
v
});
Self::with_buckets(bounds)
}
#[inline(always)]
pub fn observe(&self, value: f64) {
if !value.is_finite() {
return;
}
self.observe_finite(value);
}
#[inline]
pub fn try_observe(&self, value: f64) -> Result<()> {
if !value.is_finite() {
return Err(MetricsError::InvalidValue {
reason: "value is not finite",
});
}
self.observe_finite(value);
Ok(())
}
#[inline(always)]
fn observe_finite(&self, value: f64) {
let idx = self.bucket_bounds.partition_point(|&b| b < value);
if let Some(slot) = self.bucket_counts.get(idx) {
slot.fetch_add(1, Ordering::Relaxed);
}
self.total.fetch_add(1, Ordering::Relaxed);
loop {
let prev_bits = self.sum_bits.load(Ordering::Relaxed);
let new = f64::from_bits(prev_bits) + value;
if !new.is_finite() {
return;
}
if self
.sum_bits
.compare_exchange_weak(
prev_bits,
new.to_bits(),
Ordering::Relaxed,
Ordering::Relaxed,
)
.is_ok()
{
return;
}
}
}
#[must_use]
#[inline]
pub fn count(&self) -> u64 {
self.total.load(Ordering::Relaxed)
}
#[must_use]
#[inline]
pub fn sum(&self) -> f64 {
f64::from_bits(self.sum_bits.load(Ordering::Relaxed))
}
#[must_use]
#[inline]
pub fn mean(&self) -> f64 {
let c = self.count();
if c == 0 {
0.0
} else {
self.sum() / (c as f64)
}
}
#[must_use]
#[inline]
pub fn age(&self) -> Duration {
self.created_at.elapsed()
}
pub fn reset(&self) {
for b in self.bucket_counts.iter() {
b.store(0, Ordering::SeqCst);
}
self.sum_bits.store(0.0_f64.to_bits(), Ordering::SeqCst);
self.total.store(0, Ordering::SeqCst);
}
pub fn snapshot(&self) -> HistogramSnapshot {
let count = self.count();
let sum = self.sum();
let mut buckets = Vec::with_capacity(self.bucket_bounds.len() + 1);
let mut cumulative: u64 = 0;
for (bound, counter) in self.bucket_bounds.iter().zip(self.bucket_counts.iter()) {
cumulative = cumulative.saturating_add(counter.load(Ordering::Relaxed));
buckets.push(HistogramBucket {
upper_bound: *bound,
count: cumulative,
});
}
buckets.push(HistogramBucket {
upper_bound: f64::INFINITY,
count,
});
HistogramSnapshot {
buckets,
sum,
count,
age: self.age(),
}
}
#[must_use]
pub fn quantile(&self, q: f64) -> f64 {
let q = q.clamp(0.0, 1.0);
let count = self.count();
if count == 0 {
return 0.0;
}
let target = (q * count as f64).ceil() as u64;
let mut cumulative: u64 = 0;
let mut prev_bound = 0.0;
for (i, (bound, counter)) in self
.bucket_bounds
.iter()
.zip(self.bucket_counts.iter())
.enumerate()
{
let bucket_count = counter.load(Ordering::Relaxed);
let next_cum = cumulative.saturating_add(bucket_count);
if next_cum >= target {
if bucket_count == 0 {
return *bound;
}
let lower = if i == 0 { 0.0 } else { prev_bound };
let within = (target - cumulative) as f64 / bucket_count as f64;
return lower + (bound - lower) * within;
}
cumulative = next_cum;
prev_bound = *bound;
}
self.bucket_bounds.last().copied().unwrap_or(f64::INFINITY)
}
}
impl Default for Histogram {
fn default() -> Self {
Self::default_seconds()
}
}
impl std::fmt::Debug for Histogram {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let snap = self.snapshot();
f.debug_struct("Histogram")
.field("buckets", &snap.buckets.len())
.field("count", &snap.count)
.field("sum", &snap.sum)
.field(
"mean",
&(if snap.count > 0 {
snap.sum / snap.count as f64
} else {
0.0
}),
)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn observe_increments_count_and_sum() {
let h = Histogram::with_buckets([0.1, 1.0, 10.0]);
h.observe(0.5);
h.observe(0.5);
h.observe(5.0);
assert_eq!(h.count(), 3);
assert!((h.sum() - 6.0).abs() < 1e-9);
assert!((h.mean() - 2.0).abs() < 1e-9);
}
#[test]
fn cumulative_buckets_reported_correctly() {
let h = Histogram::with_buckets([1.0, 2.0, 3.0]);
h.observe(0.5); h.observe(1.5); h.observe(2.5); h.observe(10.0); let snap = h.snapshot();
assert_eq!(snap.buckets.len(), 4);
assert_eq!(snap.buckets[0].upper_bound, 1.0);
assert_eq!(snap.buckets[0].count, 1); assert_eq!(snap.buckets[1].count, 2); assert_eq!(snap.buckets[2].count, 3); assert!(snap.buckets[3].upper_bound.is_infinite());
assert_eq!(snap.buckets[3].count, 4); assert_eq!(snap.count, 4);
}
#[test]
fn try_observe_rejects_non_finite() {
let h = Histogram::with_buckets([1.0]);
assert!(matches!(
h.try_observe(f64::NAN),
Err(MetricsError::InvalidValue { .. })
));
assert!(matches!(
h.try_observe(f64::INFINITY),
Err(MetricsError::InvalidValue { .. })
));
assert_eq!(h.count(), 0);
}
#[test]
fn observe_silently_drops_non_finite() {
let h = Histogram::with_buckets([1.0]);
h.observe(f64::NAN);
h.observe(f64::INFINITY);
h.observe(2.0);
assert_eq!(h.count(), 1);
}
#[test]
fn quantile_zero_on_empty() {
let h = Histogram::with_buckets([1.0]);
assert_eq!(h.quantile(0.5), 0.0);
}
#[test]
fn quantile_estimates_within_bucket() {
let h = Histogram::with_buckets([1.0, 2.0]);
for _ in 0..10 {
h.observe(0.5); }
for _ in 0..10 {
h.observe(1.5); }
let p50 = h.quantile(0.5);
assert!(p50 > 0.0 && p50 <= 1.0);
let p100 = h.quantile(1.0);
assert!((p100 - 2.0).abs() < 1e-9);
}
#[test]
fn linear_and_exponential_constructors() {
let l = Histogram::linear(0.0, 0.5, 5);
let snap = l.snapshot();
assert_eq!(snap.buckets.len(), 6);
assert_eq!(snap.buckets[0].upper_bound, 0.0);
assert_eq!(snap.buckets[1].upper_bound, 0.5);
let e = Histogram::exponential(0.1, 2.0, 4);
let snap = e.snapshot();
assert_eq!(snap.buckets.len(), 5);
assert!((snap.buckets[0].upper_bound - 0.1).abs() < 1e-9);
assert!((snap.buckets[1].upper_bound - 0.2).abs() < 1e-9);
assert!((snap.buckets[2].upper_bound - 0.4).abs() < 1e-9);
assert!((snap.buckets[3].upper_bound - 0.8).abs() < 1e-9);
}
#[test]
fn invalid_constructor_inputs_fall_back_to_single_bucket() {
let h = Histogram::linear(0.0, -1.0, 5);
assert_eq!(h.bucket_bounds.len(), 1);
let h = Histogram::exponential(0.0, 2.0, 5);
assert_eq!(h.bucket_bounds.len(), 1);
let h = Histogram::exponential(1.0, 1.0, 5);
assert_eq!(h.bucket_bounds.len(), 1);
}
#[test]
fn reset_clears_state() {
let h = Histogram::with_buckets([1.0]);
h.observe(0.5);
h.observe(0.5);
assert_eq!(h.count(), 2);
h.reset();
assert_eq!(h.count(), 0);
assert_eq!(h.sum(), 0.0);
}
#[test]
fn debug_impl_does_not_panic() {
let h = Histogram::default_seconds();
h.observe(0.5);
let _ = format!("{h:?}");
}
}