//! Non-cumulative gauge histogram for Grafana heatmap visualization. //! //! Unlike Prometheus Histogram which uses cumulative `le` buckets, this emits //! non-cumulative bucket counts with `(gt, le]` ranges suitable for heatmaps. //! //! # Design: True Zero-Allocation Hot Path //! //! The key insight is that `gauge!` returns a `Gauge` handle that can be stored. //! By pre-registering all gauge handles at startup, the hot path becomes just //! N+1 atomic `gauge.set()` calls with zero allocations. //! //! # Performance Characteristics //! //! Setup (once per label combination): //! - `register()`: N+1 gauge registrations, N+1 String allocations for gt/le //! //! Hot path (`set_counts()`): //! - **Zero heap allocations** //! - **Zero key lookups** (handles are pre-registered) //! - N+1 atomic `gauge.set()` calls //! //! # Example //! //! ```ignore //! use crate::observability::gauge_histogram::{BucketBounds, GaugeHistogramVec}; //! //! // Define at module level //! static BOUNDS: BucketBounds<10> = BucketBounds::new([1, 2, 3, 5, 7, 10, 20, 50, 100, 200]); //! static HISTOGRAM: GaugeHistogramVec<10> = GaugeHistogramVec::new("smg_request_dist", &BOUNDS); //! //! // At startup: register for each label combination //! let handle = HISTOGRAM.register(&[("router", "round_robin"), ("model", "llama")]); //! //! // Pre-allocate counts buffer //! let mut counts = vec![0usize; BOUNDS.bucket_count()]; //! //! // Hot path: TRUE zero allocation //! fn update(handle: &GaugeHistogramHandle, counts: &mut [usize], observations: &[u64]) { //! BOUNDS.compute_counts_into(counts, observations); //! handle.set_counts(counts); // Just N+1 atomic gauge.set() calls! //! } //! ``` use std::sync::Arc; use dashmap::DashMap; use metrics::{gauge, Label}; // ============================================================================= // BUCKET BOUNDS // ============================================================================= /// Static bucket boundary configuration. /// /// Uses const generics to define bucket bounds at compile time with validation. /// The bounds define `N` upper limits, creating `N + 1` buckets: /// `(0, b[0]], (b[0], b[1]], ..., (b[N-1], +Inf]`. #[derive(Debug)] pub struct BucketBounds { bounds: [u64; N], } impl BucketBounds { /// Create new bucket bounds from a sorted array of upper limits. /// /// # Panics /// /// Panics at compile time (in const context) or runtime if bounds are not /// strictly ascending. #[must_use] pub const fn new(bounds: [u64; N]) -> Self { let mut i = 1; while i < N { assert!( bounds[i] > bounds[i - 1], "bucket bounds must be strictly ascending" ); i += 1; } Self { bounds } } /// Returns the number of buckets (one more than the number of bounds). #[inline] #[must_use] pub const fn bucket_count(&self) -> usize { N + 1 } /// Returns the number of bounds. #[inline] #[must_use] pub const fn bound_count(&self) -> usize { N } /// Get the bounds array. #[inline] #[must_use] pub const fn bounds(&self) -> &[u64; N] { &self.bounds } /// Find the bucket index for a value. O(log N). #[inline] #[must_use] pub fn bucket_index(&self, value: u64) -> usize { self.bounds.partition_point(|&bound| bound < value) } /// Get the upper bound for a bucket index, or None for the +Inf bucket. #[inline] #[must_use] pub const fn upper_bound(&self, idx: usize) -> Option { if idx < N { Some(self.bounds[idx]) } else { None } } /// Get the lower bound for a bucket index (0 for the first bucket). #[inline] #[must_use] pub const fn lower_bound(&self, idx: usize) -> u64 { if idx == 0 { 0 } else { self.bounds[idx - 1] } } /// Compute bucket counts into a pre-allocated buffer. **Zero allocation.** /// /// # Panics /// /// Panics if `counts.len() < bucket_count()`. #[inline] pub fn compute_counts_into(&self, counts: &mut [usize], observations: &[u64]) { debug_assert!( counts.len() >= self.bucket_count(), "counts buffer too small" ); counts[..self.bucket_count()].fill(0); for &value in observations { let idx = self.bucket_index(value); counts[idx] += 1; } } /// Compute bucket counts, allocating a new Vec. /// /// Prefer `compute_counts_into` in hot paths to avoid allocation. #[must_use] pub fn compute_counts(&self, observations: &[u64]) -> Vec { let mut counts = vec![0usize; self.bucket_count()]; self.compute_counts_into(&mut counts, observations); counts } } // ============================================================================= // GAUGE HISTOGRAM HANDLE (pre-registered, zero-alloc hot path) // ============================================================================= /// Pre-registered gauge handles for a histogram with specific labels. /// /// This is what you use in the hot path. Calling `set_counts()` does only /// N+1 atomic `gauge.set()` operations - zero allocations, zero lookups. #[derive(Clone)] pub struct GaugeHistogramHandle { gauges: Vec, } impl GaugeHistogramHandle { /// Set bucket counts. **TRUE zero allocation.** /// /// Just N+1 atomic `gauge.set()` calls - no key lookup, no allocation. #[inline] pub fn set_counts(&self, counts: &[usize]) { debug_assert_eq!( counts.len(), self.gauges.len(), "counts length must match bucket count" ); for (gauge, &count) in self.gauges.iter().zip(counts.iter()) { gauge.set(count as f64); } } /// Number of buckets. #[inline] pub fn bucket_count(&self) -> usize { self.gauges.len() } /// Zero out all gauges. **Zero allocation.** #[inline] pub fn zero_counts(&self) { for gauge in &self.gauges { gauge.set(0.0); } } } // ============================================================================= // GAUGE HISTOGRAM VEC (factory for registering handles) // ============================================================================= /// Factory for creating pre-registered histogram handles. /// /// Define as a static constant, then call `register()` for each label combination /// you need. The returned `GaugeHistogramHandle` provides zero-allocation updates. #[derive(Debug)] pub struct GaugeHistogramVec { name: &'static str, bounds: &'static BucketBounds, } impl GaugeHistogramVec { /// Create a new gauge histogram factory. /// /// This just stores the name and bounds - no allocation or registration yet. #[must_use] pub const fn new(name: &'static str, bounds: &'static BucketBounds) -> Self { Self { name, bounds } } #[inline] pub const fn name(&self) -> &'static str { self.name } #[inline] pub const fn bounds(&self) -> &BucketBounds { self.bounds } /// Register gauges for a specific label combination. /// /// Call this once per unique label combination (at startup or when first seen). /// The returned handle can be cloned cheaply (just Arc clones internally). /// /// # Arguments /// /// - `labels`: Static key-value label pairs for this histogram instance /// /// # Example /// /// ```ignore /// let handle = HISTOGRAM.register(&[("router", "round_robin"), ("model", "llama")]); /// ``` pub fn register(&self, labels: &[(&'static str, &str)]) -> GaugeHistogramHandle { let bucket_count = self.bounds.bucket_count(); let mut gauges = Vec::with_capacity(bucket_count); for i in 0..bucket_count { // Build gt/le labels for this bucket let gt_str = if i == 0 { "0".to_string() } else { self.bounds.bounds[i - 1].to_string() }; let le_str = if i < N { self.bounds.bounds[i].to_string() } else { "+Inf".to_string() }; // Build complete label set let mut all_labels: Vec