Skip to main content

fast_telemetry/metric/dynamic/
histogram.rs

1//! Runtime-labeled histogram for dynamic dimensions.
2
3use super::cache::{CacheableSeries, LabelCache, SERIES_CACHE_SIZE};
4#[cfg(feature = "eviction")]
5use super::current_cycle;
6use super::{DynamicIndexMap, DynamicLabelSet, HISTOGRAM_IDS, dynamic_index_map, thread_id};
7use crossbeam_utils::CachePadded;
8use parking_lot::RwLock;
9use std::cell::RefCell;
10#[cfg(feature = "eviction")]
11use std::sync::atomic::AtomicU32;
12use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU64, AtomicUsize, Ordering};
13use std::sync::{Arc, Weak};
14
15const DEFAULT_MAX_SERIES: usize = 2000;
16const OVERFLOW_LABEL_KEY: &str = "__ft_overflow";
17const OVERFLOW_LABEL_VALUE: &str = "true";
18
19type HistogramIndexShard = CachePadded<RwLock<DynamicIndexMap<Arc<HistogramSeries>>>>;
20type HistogramSnapshotEntry = (DynamicLabelSet, Vec<(u64, u64)>, u64, u64);
21
22struct ShardedCounter {
23    cells: Vec<CachePadded<AtomicIsize>>,
24}
25
26impl ShardedCounter {
27    fn new(shard_count: usize) -> Self {
28        Self {
29            cells: (0..shard_count)
30                .map(|_| CachePadded::new(AtomicIsize::new(0)))
31                .collect(),
32        }
33    }
34
35    #[inline]
36    fn add_at(&self, shard_idx: usize, value: isize) {
37        self.cells[shard_idx].fetch_add(value, Ordering::Relaxed);
38    }
39
40    #[inline]
41    fn inc_at(&self, shard_idx: usize) {
42        self.add_at(shard_idx, 1);
43    }
44
45    #[inline]
46    fn sum(&self) -> isize {
47        self.cells
48            .iter()
49            .map(|cell| cell.load(Ordering::Relaxed))
50            .sum()
51    }
52}
53
54struct HistogramSeries {
55    bounds: Arc<Vec<u64>>,
56    buckets: Vec<ShardedCounter>,
57    sum: ShardedCounter,
58    count: ShardedCounter,
59    /// Tombstone flag set by exporter before removing from map.
60    evicted: AtomicBool,
61    /// Last export cycle when this series was accessed.
62    #[cfg(feature = "eviction")]
63    last_accessed_cycle: AtomicU32,
64}
65
66impl HistogramSeries {
67    #[cfg(feature = "eviction")]
68    fn new(bounds: Arc<Vec<u64>>, shard_count: usize, cycle: u32) -> Self {
69        let buckets = (0..=bounds.len())
70            .map(|_| ShardedCounter::new(shard_count))
71            .collect();
72        Self {
73            bounds,
74            buckets,
75            sum: ShardedCounter::new(shard_count),
76            count: ShardedCounter::new(shard_count),
77            evicted: AtomicBool::new(false),
78            last_accessed_cycle: AtomicU32::new(cycle),
79        }
80    }
81
82    #[cfg(not(feature = "eviction"))]
83    fn new(bounds: Arc<Vec<u64>>, shard_count: usize) -> Self {
84        let buckets = (0..=bounds.len())
85            .map(|_| ShardedCounter::new(shard_count))
86            .collect();
87        Self {
88            bounds,
89            buckets,
90            sum: ShardedCounter::new(shard_count),
91            count: ShardedCounter::new(shard_count),
92            evicted: AtomicBool::new(false),
93        }
94    }
95
96    #[inline]
97    fn is_evicted(&self) -> bool {
98        self.evicted.load(Ordering::Relaxed)
99    }
100
101    #[cfg(feature = "eviction")]
102    fn mark_evicted(&self) {
103        self.evicted.store(true, Ordering::Relaxed);
104    }
105
106    #[inline]
107    fn record_at(&self, shard_idx: usize, value: u64) {
108        let bucket_idx = self
109            .bounds
110            .iter()
111            .position(|&bound| value <= bound)
112            .unwrap_or(self.bounds.len());
113        self.buckets[bucket_idx].inc_at(shard_idx);
114        self.sum.add_at(shard_idx, value as isize);
115        self.count.inc_at(shard_idx);
116        // Note: timestamp updated on slow path (lookup/cache miss) to avoid
117        // global atomic read on every record.
118    }
119
120    /// Touch the series timestamp. Called on slow path only.
121    #[cfg(feature = "eviction")]
122    #[inline]
123    fn touch(&self, cycle: u32) {
124        self.last_accessed_cycle.store(cycle, Ordering::Relaxed);
125    }
126
127    fn buckets_cumulative(&self) -> Vec<(u64, u64)> {
128        let mut result = Vec::with_capacity(self.buckets.len());
129        for (bound, cumulative) in self.buckets_cumulative_iter() {
130            result.push((bound, cumulative));
131        }
132        result
133    }
134
135    fn buckets_cumulative_iter(&self) -> impl Iterator<Item = (u64, u64)> + '_ {
136        let mut cumulative = 0i64;
137        self.buckets.iter().enumerate().map(move |(i, counter)| {
138            cumulative += counter.sum() as i64;
139            let bound = if i < self.bounds.len() {
140                self.bounds[i]
141            } else {
142                u64::MAX
143            };
144            (bound, cumulative as u64)
145        })
146    }
147
148    fn sum(&self) -> u64 {
149        self.sum.sum() as u64
150    }
151
152    fn count(&self) -> u64 {
153        self.count.sum() as u64
154    }
155}
156
157impl CacheableSeries for HistogramSeries {
158    fn is_evicted(&self) -> bool {
159        self.is_evicted()
160    }
161}
162
163/// A reusable handle to a dynamic-label histogram series.
164///
165/// Use this for hot paths to avoid per-update label canonicalization and map
166/// lookups. Resolve once with `DynamicHistogram::series(...)`, then call
167/// `record()` on the handle.
168#[derive(Clone)]
169pub struct DynamicHistogramSeries {
170    series: Arc<HistogramSeries>,
171    shard_mask: usize,
172}
173
174/// Borrowed read-only view of a dynamic histogram series.
175#[doc(hidden)]
176pub struct DynamicHistogramSeriesView<'a> {
177    series: &'a HistogramSeries,
178}
179
180impl<'a> DynamicHistogramSeriesView<'a> {
181    /// Iterate cumulative `(bound, count)` buckets without allocating.
182    #[doc(hidden)]
183    pub fn buckets_cumulative_iter(&self) -> impl Iterator<Item = (u64, u64)> + '_ {
184        self.series.buckets_cumulative_iter()
185    }
186
187    #[doc(hidden)]
188    pub fn sum(&self) -> u64 {
189        self.series.sum()
190    }
191
192    #[doc(hidden)]
193    pub fn count(&self) -> u64 {
194        self.series.count()
195    }
196}
197
198impl DynamicHistogramSeries {
199    /// Record a value in this histogram series.
200    #[inline]
201    pub fn record(&self, value: u64) {
202        let shard_idx = thread_id() & self.shard_mask;
203        self.series.record_at(shard_idx, value);
204    }
205
206    /// Get cumulative bucket counts.
207    pub fn buckets_cumulative(&self) -> Vec<(u64, u64)> {
208        self.series.buckets_cumulative()
209    }
210
211    /// Get the sum of all recorded values.
212    pub fn sum(&self) -> u64 {
213        self.series.sum()
214    }
215
216    /// Get the count of all recorded values.
217    pub fn count(&self) -> u64 {
218        self.series.count()
219    }
220
221    /// Check if this series handle has been evicted.
222    #[inline]
223    pub fn is_evicted(&self) -> bool {
224        self.series.is_evicted()
225    }
226}
227
228thread_local! {
229    static SERIES_CACHE: RefCell<LabelCache<Weak<HistogramSeries>, SERIES_CACHE_SIZE>> =
230        RefCell::new(LabelCache::new());
231}
232
233/// Histogram keyed by runtime label sets.
234///
235/// Uses sharded index for key->series lookup and per-series sharded counters
236/// for fast updates.
237pub struct DynamicHistogram {
238    id: usize,
239    bounds: Arc<Vec<u64>>,
240    shard_count: usize,
241    max_series: usize,
242    shard_mask: usize,
243    index_shards: Vec<HistogramIndexShard>,
244    /// Approximate number of live series (incremented on insert, decremented on evict).
245    series_count: AtomicUsize,
246    /// Count of records routed to overflow bucket due to cardinality cap.
247    overflow_count: AtomicU64,
248}
249
250impl DynamicHistogram {
251    /// Creates a new runtime-labeled histogram with given bucket boundaries.
252    pub fn new(bounds: &[u64], shard_count: usize) -> Self {
253        Self::with_limits(bounds, shard_count, DEFAULT_MAX_SERIES)
254    }
255
256    /// Creates a new runtime-labeled histogram with a series cardinality cap.
257    ///
258    /// When the number of unique label sets approximately reaches `max_series`,
259    /// new label sets are redirected into a single overflow series
260    /// (`__ft_overflow=true`). The cap is checked via a lock-free atomic counter,
261    /// so concurrent inserts may briefly overshoot by the number of in-flight
262    /// writers before the overflow kicks in.
263    pub fn with_limits(bounds: &[u64], shard_count: usize, max_series: usize) -> Self {
264        let shard_count = shard_count.next_power_of_two();
265        let id = HISTOGRAM_IDS.fetch_add(1, Ordering::Relaxed);
266        Self {
267            id,
268            bounds: Arc::new(bounds.to_vec()),
269            shard_count,
270            max_series,
271            shard_mask: shard_count - 1,
272            index_shards: (0..shard_count)
273                .map(|_| CachePadded::new(RwLock::new(dynamic_index_map())))
274                .collect(),
275            series_count: AtomicUsize::new(0),
276            overflow_count: AtomicU64::new(0),
277        }
278    }
279
280    /// Creates a histogram with default latency buckets (in microseconds).
281    pub fn with_latency_buckets(shard_count: usize) -> Self {
282        Self::with_limits(
283            &[
284                10,         // 10µs
285                50,         // 50µs
286                100,        // 100µs
287                500,        // 500µs
288                1_000,      // 1ms
289                5_000,      // 5ms
290                10_000,     // 10ms
291                50_000,     // 50ms
292                100_000,    // 100ms
293                500_000,    // 500ms
294                1_000_000,  // 1s
295                5_000_000,  // 5s
296                10_000_000, // 10s
297            ],
298            shard_count,
299            DEFAULT_MAX_SERIES,
300        )
301    }
302
303    /// Resolve a reusable series handle for `labels`.
304    ///
305    /// Preferred for hot paths when labels come from a finite active set.
306    pub fn series(&self, labels: &[(&str, &str)]) -> DynamicHistogramSeries {
307        if let Some(series) = self.cached_series(labels) {
308            return DynamicHistogramSeries {
309                series,
310                shard_mask: self.shard_mask,
311            };
312        }
313        let series = self.lookup_or_create(labels);
314        self.update_cache(labels, &series);
315        DynamicHistogramSeries {
316            series,
317            shard_mask: self.shard_mask,
318        }
319    }
320
321    /// Record a value for the series identified by `labels`.
322    #[inline]
323    pub fn record(&self, labels: &[(&str, &str)], value: u64) {
324        if let Some(series) = self.cached_series(labels) {
325            let shard_idx = thread_id() & self.shard_mask;
326            series.record_at(shard_idx, value);
327            return;
328        }
329
330        let series = self.lookup_or_create(labels);
331        self.update_cache(labels, &series);
332        let shard_idx = thread_id() & self.shard_mask;
333        series.record_at(shard_idx, value);
334    }
335
336    /// Get cumulative bucket counts for the series identified by `labels`.
337    pub fn buckets_cumulative(&self, labels: &[(&str, &str)]) -> Vec<(u64, u64)> {
338        let key = DynamicLabelSet::from_pairs(labels);
339        let index_shard = self.index_shard_for(&key);
340        self.index_shards[index_shard]
341            .read()
342            .get(&key)
343            .map(|series| series.buckets_cumulative())
344            .unwrap_or_default()
345    }
346
347    /// Get sum for the series identified by `labels`.
348    pub fn sum(&self, labels: &[(&str, &str)]) -> u64 {
349        let key = DynamicLabelSet::from_pairs(labels);
350        let index_shard = self.index_shard_for(&key);
351        self.index_shards[index_shard]
352            .read()
353            .get(&key)
354            .map(|series| series.sum())
355            .unwrap_or(0)
356    }
357
358    /// Get count for the series identified by `labels`.
359    pub fn count(&self, labels: &[(&str, &str)]) -> u64 {
360        let key = DynamicLabelSet::from_pairs(labels);
361        let index_shard = self.index_shard_for(&key);
362        self.index_shards[index_shard]
363            .read()
364            .get(&key)
365            .map(|series| series.count())
366            .unwrap_or(0)
367    }
368
369    /// Returns a snapshot of all label-set with their histogram data.
370    pub fn snapshot(&self) -> Vec<HistogramSnapshotEntry> {
371        let mut out = Vec::new();
372        for shard in &self.index_shards {
373            let guard = shard.read();
374            for (labels, series) in guard.iter() {
375                out.push((
376                    labels.clone(),
377                    series.buckets_cumulative(),
378                    series.sum(),
379                    series.count(),
380                ));
381            }
382        }
383        out
384    }
385
386    /// Returns the current number of distinct label sets.
387    pub fn cardinality(&self) -> usize {
388        self.index_shards
389            .iter()
390            .map(|shard| shard.read().len())
391            .sum()
392    }
393
394    /// Returns the number of records routed to the overflow bucket.
395    ///
396    /// A non-zero value indicates the cardinality cap was hit and label
397    /// fidelity is being lost. Use this to alert on cardinality pressure.
398    pub fn overflow_count(&self) -> u64 {
399        self.overflow_count.load(Ordering::Relaxed)
400    }
401
402    /// Iterate all series without cloning label sets.
403    ///
404    /// Calls `f` with borrowed label pairs and a borrowed series view.
405    /// Used by exporters/macros to avoid `snapshot()` and bucket vec allocations.
406    /// The callback runs while a shard read lock is held; it must be fast and
407    /// must not call back into this metric.
408    #[doc(hidden)]
409    pub fn visit_series<F>(&self, mut f: F)
410    where
411        F: for<'a> FnMut(&'a [(String, String)], DynamicHistogramSeriesView<'a>),
412    {
413        for shard in &self.index_shards {
414            let guard = shard.read();
415            for (labels, series) in guard.iter() {
416                f(labels.pairs(), DynamicHistogramSeriesView { series });
417            }
418        }
419    }
420
421    /// Evict series that haven't been accessed for `max_staleness` cycles.
422    ///
423    /// Call this after `advance_cycle()` in your exporter task.
424    /// Series are marked as evicted (so cached handles see the tombstone),
425    /// then removed from the index.
426    ///
427    /// Protected series (Arc::strong_count > 1) are never evicted - someone
428    /// holds a DynamicHistogramSeries handle to them.
429    ///
430    /// Returns the number of series evicted.
431    #[cfg(feature = "eviction")]
432    pub fn evict_stale(&self, max_staleness: u32) -> usize {
433        let cycle = current_cycle();
434        let mut removed = 0;
435
436        for shard in &self.index_shards {
437            let mut guard = shard.write();
438            guard.retain(|_labels, series| {
439                // Protected if someone holds a handle (strong_count > 1 means
440                // both the map and at least one DynamicHistogramSeries hold refs)
441                if Arc::strong_count(series) > 1 {
442                    return true;
443                }
444                // Otherwise check timestamp staleness
445                let last = series.last_accessed_cycle.load(Ordering::Relaxed);
446                let stale = cycle.saturating_sub(last) > max_staleness;
447                if stale {
448                    series.mark_evicted();
449                    removed += 1;
450                    self.series_count.fetch_sub(1, Ordering::Relaxed);
451                }
452                !stale
453            });
454        }
455
456        removed
457    }
458
459    fn lookup_or_create(&self, labels: &[(&str, &str)]) -> Arc<HistogramSeries> {
460        let requested_key = DynamicLabelSet::from_pairs(labels);
461        let requested_shard = self.index_shard_for(&requested_key);
462        #[cfg(feature = "eviction")]
463        let cycle = current_cycle();
464
465        // Fast path: read lock only.
466        if let Some(series) = self.index_shards[requested_shard]
467            .read()
468            .get(&requested_key)
469        {
470            #[cfg(feature = "eviction")]
471            series.touch(cycle);
472            return Arc::clone(series);
473        }
474
475        // Check cardinality cap BEFORE taking any write lock (lock-free).
476        let key = if self.max_series > 0
477            && self.series_count.load(Ordering::Relaxed) >= self.max_series
478        {
479            self.overflow_count.fetch_add(1, Ordering::Relaxed);
480            DynamicLabelSet::from_pairs(&[(OVERFLOW_LABEL_KEY, OVERFLOW_LABEL_VALUE)])
481        } else {
482            requested_key
483        };
484        let shard = self.index_shard_for(&key);
485
486        if let Some(series) = self.index_shards[shard].read().get(&key) {
487            #[cfg(feature = "eviction")]
488            series.touch(cycle);
489            return Arc::clone(series);
490        }
491
492        let mut guard = self.index_shards[shard].write();
493        if let Some(series) = guard.get(&key) {
494            #[cfg(feature = "eviction")]
495            series.touch(cycle);
496            return Arc::clone(series);
497        }
498        #[cfg(feature = "eviction")]
499        let series = Arc::new(HistogramSeries::new(
500            Arc::clone(&self.bounds),
501            self.shard_count,
502            cycle,
503        ));
504        #[cfg(not(feature = "eviction"))]
505        let series = Arc::new(HistogramSeries::new(
506            Arc::clone(&self.bounds),
507            self.shard_count,
508        ));
509        guard.insert(key, Arc::clone(&series));
510        self.series_count.fetch_add(1, Ordering::Relaxed);
511        series
512    }
513
514    fn index_shard_for(&self, key: &DynamicLabelSet) -> usize {
515        key.shard_index(self.shard_mask)
516    }
517
518    fn cached_series(&self, labels: &[(&str, &str)]) -> Option<Arc<HistogramSeries>> {
519        SERIES_CACHE.with(|cache| {
520            let series = cache.borrow_mut().get(self.id, labels)?;
521            #[cfg(feature = "eviction")]
522            series.touch(current_cycle());
523            Some(series)
524        })
525    }
526
527    fn update_cache(&self, labels: &[(&str, &str)], series: &Arc<HistogramSeries>) {
528        SERIES_CACHE.with(|cache| {
529            cache
530                .borrow_mut()
531                .insert(self.id, labels, Arc::downgrade(series));
532        });
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn test_basic_recording() {
542        let h = DynamicHistogram::new(&[10, 100, 1000], 4);
543        let labels = &[("org_id", "42")];
544
545        h.record(labels, 5); // bucket 0 (≤10)
546        h.record(labels, 50); // bucket 1 (≤100)
547        h.record(labels, 500); // bucket 2 (≤1000)
548        h.record(labels, 5000); // bucket 3 (+Inf)
549
550        let buckets = h.buckets_cumulative(labels);
551        assert_eq!(buckets.len(), 4);
552        assert_eq!(buckets[0], (10, 1));
553        assert_eq!(buckets[1], (100, 2));
554        assert_eq!(buckets[2], (1000, 3));
555        assert_eq!(buckets[3], (u64::MAX, 4));
556
557        assert_eq!(h.count(labels), 4);
558        assert_eq!(h.sum(labels), 5 + 50 + 500 + 5000);
559    }
560
561    #[test]
562    fn test_label_order_is_canonicalized() {
563        let h = DynamicHistogram::new(&[10, 100], 4);
564
565        h.record(&[("org_id", "42"), ("endpoint", "abc")], 5);
566
567        assert_eq!(h.count(&[("endpoint", "abc"), ("org_id", "42")]), 1);
568    }
569
570    #[test]
571    fn test_series_handle() {
572        let h = DynamicHistogram::new(&[10, 100, 1000], 4);
573        let series = h.series(&[("org_id", "42")]);
574
575        series.record(5);
576        series.record(50);
577        series.record(500);
578
579        assert_eq!(series.count(), 3);
580        assert_eq!(series.sum(), 555);
581        assert_eq!(h.count(&[("org_id", "42")]), 3);
582    }
583
584    #[test]
585    fn test_multiple_label_sets() {
586        let h = DynamicHistogram::new(&[100], 4);
587
588        h.record(&[("org_id", "1")], 50);
589        h.record(&[("org_id", "2")], 150);
590
591        assert_eq!(h.count(&[("org_id", "1")]), 1);
592        assert_eq!(h.count(&[("org_id", "2")]), 1);
593
594        let snap = h.snapshot();
595        assert_eq!(snap.len(), 2);
596    }
597
598    #[test]
599    fn test_overflow_bucket_routes_new_series_at_capacity() {
600        let h = DynamicHistogram::with_limits(&[100], 4, 1);
601
602        h.record(&[("org_id", "1")], 50);
603        h.record(&[("org_id", "2")], 150);
604
605        assert_eq!(h.cardinality(), 2);
606        assert_eq!(h.count(&[(OVERFLOW_LABEL_KEY, OVERFLOW_LABEL_VALUE)]), 1);
607        assert_eq!(h.sum(&[(OVERFLOW_LABEL_KEY, OVERFLOW_LABEL_VALUE)]), 150);
608    }
609
610    #[test]
611    fn test_concurrent_cap_bounded_overshoot() {
612        use std::sync::{Arc, Barrier};
613        use std::thread;
614
615        let cap = 10;
616        let threads = 16;
617        let h = Arc::new(DynamicHistogram::with_limits(&[100, 1000], 4, cap));
618        let barrier = Arc::new(Barrier::new(threads));
619
620        let handles: Vec<_> = (0..threads)
621            .map(|t| {
622                let h = Arc::clone(&h);
623                let barrier = Arc::clone(&barrier);
624                thread::spawn(move || {
625                    barrier.wait();
626                    for i in 0..5 {
627                        let label = format!("t{t}_s{i}");
628                        h.record(&[("key", &label)], 42);
629                    }
630                })
631            })
632            .collect();
633
634        for handle in handles {
635            handle.join().unwrap();
636        }
637
638        let card = h.cardinality();
639        assert!(
640            card <= cap + threads + 1,
641            "cardinality {card} exceeded bounded overshoot (cap={cap}, threads={threads})"
642        );
643        assert!(h.overflow_count() > 0, "overflow should have triggered");
644    }
645}