Skip to main content

monitrs_core/rates/
counter.rs

1//! One cumulative OS counter turned into a validated rate.
2//!
3//! Everything in this file exists to keep a single promise from §8.2: an
4//! invalid delta yields a typed state, never a huge or negative number.
5
6use core::time::Duration;
7use std::time::Instant;
8
9use crate::model::{MetricState, UnavailableReason};
10use crate::rates::keyed::DeltaTracker;
11use crate::units::Rate;
12
13/// The bit width of a cumulative OS counter (§8.2).
14///
15/// The width is what separates a *wraparound* from a *reset*. A 32-bit
16/// interface counter read on a 64-bit host jumps back to a small value roughly
17/// every 34 seconds on a saturated gigabit link; reporting each of those as a
18/// reset would blank the interface row almost continuously. When the width is
19/// genuinely unknown there is no evidence to tell wrap from reset apart, so the
20/// conservative answer applies: report a reset and re-baseline.
21#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
24pub enum CounterWidth {
25    /// The width is not known, so every backwards move is treated as a reset.
26    ///
27    /// The default, because guessing a width and guessing wrong fabricates
28    /// traffic that never happened.
29    #[default]
30    Unknown,
31    /// A 32-bit counter, such as a legacy `ifTable`-style interface counter or a
32    /// 32-bit device register widened to `u64` by the caller.
33    Bits32,
34    /// A 64-bit counter, such as Linux `/proc/net/dev` on a 64-bit kernel.
35    ///
36    /// Included for completeness: at 100 Gbit/s a 64-bit byte counter takes
37    /// over forty years to wrap, so in practice this behaves like
38    /// [`CounterWidth::Unknown`] apart from rejecting absurd backwards jumps.
39    Bits64,
40}
41
42impl CounterWidth {
43    /// The number of significant bits, or `None` when the width is unknown.
44    #[must_use]
45    pub const fn bits(self) -> Option<u32> {
46        match self {
47            Self::Unknown => None,
48            Self::Bits32 => Some(32),
49            Self::Bits64 => Some(64),
50        }
51    }
52
53    /// The largest value the counter can hold, or `None` when unknown.
54    #[must_use]
55    pub const fn max_value(self) -> Option<u64> {
56        match self {
57            Self::Unknown => None,
58            // Widening casts: a 32-bit counter's ceiling always fits a `u64`.
59            Self::Bits32 => Some(u32::MAX as u64),
60            Self::Bits64 => Some(u64::MAX),
61        }
62    }
63
64    /// The counter's modulus, `2^bits`.
65    ///
66    /// `u128` because a 64-bit counter's modulus does not fit in a `u64`.
67    const fn modulus(self) -> Option<u128> {
68        match self {
69            Self::Unknown => None,
70            Self::Bits32 => Some(1u128 << 32),
71            Self::Bits64 => Some(1u128 << 64),
72        }
73    }
74}
75
76/// The forward distance the counter travelled, or `None` when the movement
77/// cannot be explained without inventing data.
78///
79/// A backwards move is only read as a wrap when the width is known *and* going
80/// forward through the ceiling is a shorter journey than the counter having
81/// fallen back — that is, when the apparent drop exceeds half the counter
82/// range. Choosing the shorter modular arc has two useful consequences: it is
83/// the interpretation that assumes the least, and it bounds a wrapped delta to
84/// below half the range, so even a misjudged reset cannot produce an unbounded
85/// rate (§8.2). An exactly-half drop is treated as a reset, because it is not
86/// evidence of anything.
87fn forward_delta(previous: u64, current: u64, width: CounterWidth) -> Option<u64> {
88    if current >= previous {
89        return Some(current - previous);
90    }
91    let modulus = width.modulus()?;
92    // A reading outside the declared width means the declaration is wrong, and
93    // a wrap computed from the wrong modulus is pure fiction.
94    if u128::from(previous) >= modulus || u128::from(current) >= modulus {
95        return None;
96    }
97    let backwards = u128::from(previous) - u128::from(current);
98    let wrapped = modulus - backwards;
99    if wrapped < backwards {
100        u64::try_from(wrapped).ok()
101    } else {
102        None
103    }
104}
105
106/// What one counter reading means relative to the previous one.
107///
108/// Returned by [`CounterTracker::observe`] for callers that need the raw
109/// movement — running totals such as `NetworkSnapshot::since_launch` accumulate
110/// deltas rather than rates. Callers that only want a rate use
111/// [`CounterTracker::rate`].
112#[derive(Clone, Copy, Debug, Eq, PartialEq)]
113pub enum CounterDelta {
114    /// There was no previous reading, so no delta exists yet.
115    ///
116    /// §8.2 and §26: the first sample of delta-based data is warming up, and it
117    /// is emphatically not zero.
118    FirstSample,
119    /// A validated, non-negative movement over a measured monotonic interval.
120    Advanced {
121        /// How far the counter moved forward.
122        delta: u64,
123        /// The monotonic interval the movement happened in.
124        ///
125        /// May be [`Duration::ZERO`] when two readings share a timestamp. The
126        /// `delta` is still valid as a total in that case, but no rate can be
127        /// derived from it (§8.1).
128        elapsed: Duration,
129        /// Whether the movement was reconstructed from a known-width wrap.
130        ///
131        /// Exposed so collectors can record a wrap as a health issue instead of
132        /// silently trusting a reconstructed delta.
133        wrapped: bool,
134    },
135    /// The counter moved backwards in a way no known width explains.
136    ///
137    /// The tracker has already re-baselined on the offending reading, so the
138    /// *next* reading produces a valid delta rather than a second reset (§8.2).
139    Reset,
140}
141
142impl CounterDelta {
143    /// The rate this delta represents, as a publishable metric state.
144    #[must_use]
145    pub fn rate(self) -> MetricState<Rate> {
146        match self {
147            Self::FirstSample => MetricState::WarmingUp,
148            Self::Advanced { delta, elapsed, .. } => match Rate::from_delta(delta, elapsed) {
149                Some(rate) => MetricState::Available(rate),
150                // A zero-length interval carries no rate information at all.
151                // §8.2 prefers warming up over a fabricated number, and it is
152                // also the honest forecast: the next sample has a real
153                // interval.
154                None => MetricState::WarmingUp,
155            },
156            Self::Reset => MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset),
157        }
158    }
159
160    /// How far the counter moved, if this reading produced a usable movement.
161    ///
162    /// Returns `None` for the first sample and for a reset, so a caller
163    /// accumulating a running total cannot add an invalid delta to it.
164    #[must_use]
165    pub const fn advanced_by(self) -> Option<u64> {
166        match self {
167            Self::Advanced { delta, .. } => Some(delta),
168            Self::FirstSample | Self::Reset => None,
169        }
170    }
171
172    /// Whether this reading was reconstructed from a known-width wraparound.
173    #[must_use]
174    pub const fn wrapped(self) -> bool {
175        matches!(self, Self::Advanced { wrapped: true, .. })
176    }
177}
178
179/// One cumulative counter's baseline and the rules for reading the next value.
180///
181/// # Monotonic time
182///
183/// The tracker stores the [`Instant`] of every reading and derives the interval
184/// with [`Instant::saturating_duration_since`]. A wall-clock jump therefore
185/// cannot shorten, lengthen, or negate an interval, which is exactly what §8.1
186/// requires. Callers **must** pass the snapshot's monotonic `captured_at` and
187/// never an `Instant` reconstructed from a `SystemTime`.
188///
189/// # Example
190///
191/// ```
192/// use core::time::Duration;
193/// use std::time::Instant;
194///
195/// use monitrs_core::rates::{CounterTracker, CounterWidth};
196///
197/// let mut rx = CounterTracker::new(CounterWidth::Bits64);
198/// let start = Instant::now();
199///
200/// // The first reading establishes a baseline; there is nothing to divide yet.
201/// assert!(rx.rate(1_000, start).is_warming_up());
202///
203/// // 2 000 bytes over half a second is 4 000 B/s, not 2 000 B/s.
204/// let state = rx.rate(3_000, start + Duration::from_millis(500));
205/// let rate = state.fresh().copied().expect("second sample is measurable");
206/// assert_eq!(rate.per_second(), 4_000.0);
207/// ```
208#[derive(Clone, Copy, Debug)]
209pub struct CounterTracker {
210    width: CounterWidth,
211    last: Option<Reading>,
212}
213
214/// One retained counter reading.
215#[derive(Clone, Copy, Debug)]
216struct Reading {
217    value: u64,
218    at: Instant,
219}
220
221impl CounterTracker {
222    /// Builds a tracker with no baseline, for a counter of the given width.
223    #[must_use]
224    pub const fn new(width: CounterWidth) -> Self {
225        Self { width, last: None }
226    }
227
228    /// The width this tracker was told to assume.
229    #[must_use]
230    pub const fn width(&self) -> CounterWidth {
231        self.width
232    }
233
234    /// Whether the next reading will be the first, and so warming up (§8.2).
235    #[must_use]
236    pub const fn is_warming_up(&self) -> bool {
237        self.last.is_none()
238    }
239
240    /// The last accepted reading, or `None` while warming up.
241    #[must_use]
242    pub const fn last_value(&self) -> Option<u64> {
243        match self.last {
244            Some(reading) => Some(reading.value),
245            None => None,
246        }
247    }
248
249    /// When the last reading was accepted, or `None` while warming up.
250    #[must_use]
251    pub const fn last_observed_at(&self) -> Option<Instant> {
252        match self.last {
253            Some(reading) => Some(reading.at),
254            None => None,
255        }
256    }
257
258    /// Drops the baseline so the next reading warms up again.
259    ///
260    /// Collectors call this when the thing behind the counter changed identity —
261    /// a renamed interface, a re-created device node, a reused PID — because a
262    /// delta across such a change describes two different counters (§8.2).
263    pub fn forget_baseline(&mut self) {
264        self.last = None;
265    }
266
267    /// Folds one cumulative reading in and classifies the movement.
268    ///
269    /// `at` must be monotonic; see the type-level note on time.
270    pub fn observe(&mut self, value: u64, at: Instant) -> CounterDelta {
271        let Some(previous) = self.last.replace(Reading { value, at }) else {
272            return CounterDelta::FirstSample;
273        };
274        // Saturating rather than checked: `Instant` is monotonic, so a reversed
275        // pair means the caller broke the contract. Yielding a zero-length
276        // interval degrades to `WarmingUp`, which is safe; panicking in a
277        // sampling loop is not (§14.3).
278        let elapsed = at.saturating_duration_since(previous.at);
279        match forward_delta(previous.value, value, self.width) {
280            Some(delta) => CounterDelta::Advanced {
281                delta,
282                elapsed,
283                // The wrap path is the only way a backwards reading survives
284                // `forward_delta`.
285                wrapped: value < previous.value,
286            },
287            // The baseline was already replaced above, which is what makes the
288            // sample *after* a reset valid (§8.2).
289            None => CounterDelta::Reset,
290        }
291    }
292
293    /// Folds one cumulative reading in and publishes the resulting rate.
294    pub fn rate(&mut self, value: u64, at: Instant) -> MetricState<Rate> {
295        self.observe(value, at).rate()
296    }
297}
298
299impl DeltaTracker for CounterTracker {
300    type Config = CounterWidth;
301    type Reading = u64;
302    type Value = Rate;
303
304    fn with_config(config: Self::Config) -> Self {
305        Self::new(config)
306    }
307
308    fn observe_reading(&mut self, reading: Self::Reading, at: Instant) -> MetricState<Self::Value> {
309        self.rate(reading, at)
310    }
311
312    // The bodies are written out rather than delegating to the identically named
313    // inherent methods, which would be an ambiguous path.
314    fn last_observed_at(&self) -> Option<Instant> {
315        self.last.map(|reading| reading.at)
316    }
317
318    fn forget_baseline(&mut self) {
319        self.last = None;
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    /// A fixed origin so every test can express times relative to it.
328    fn origin() -> Instant {
329        Instant::now()
330    }
331
332    /// Rates are calculated in `f64`, so compare within a tolerance: an exact
333    /// comparison is both denied by `clippy::float_cmp` and wrong in general,
334    /// because an interval such as 1.4 s has no exact binary representation.
335    fn assert_rate(state: &MetricState<Rate>, expected: f64) {
336        let actual = state
337            .fresh()
338            .expect("expected a measured rate")
339            .per_second();
340        assert!(
341            (actual - expected).abs() < 1e-6,
342            "expected {expected}/s, got {actual}/s"
343        );
344    }
345
346    #[test]
347    fn a_first_sample_is_warming_up_and_not_zero() {
348        let mut tracker = CounterTracker::new(CounterWidth::Bits64);
349        let state = tracker.rate(4_096, origin());
350        assert!(state.is_warming_up());
351        assert_eq!(state.fresh(), None);
352        assert_ne!(state, MetricState::Available(Rate::ZERO));
353    }
354
355    #[test]
356    fn a_second_sample_divides_by_the_real_interval() {
357        let t0 = origin();
358        let mut tracker = CounterTracker::new(CounterWidth::Bits64);
359        assert_eq!(tracker.observe(1_000, t0), CounterDelta::FirstSample);
360        let state = tracker.rate(3_000, t0 + Duration::from_secs(2));
361        assert_rate(&state, 1_000.0);
362    }
363
364    #[test]
365    fn the_same_delta_over_different_intervals_gives_different_rates() {
366        let t0 = origin();
367        let mut fast = CounterTracker::new(CounterWidth::Bits64);
368        let mut slow = CounterTracker::new(CounterWidth::Bits64);
369        fast.rate(0, t0);
370        slow.rate(0, t0);
371
372        let half = fast.rate(1_000, t0 + Duration::from_millis(500));
373        let double = slow.rate(1_000, t0 + Duration::from_secs(2));
374
375        assert_rate(&half, 2_000.0);
376        assert_rate(&double, 500.0);
377    }
378
379    #[test]
380    fn a_counter_that_does_not_move_is_a_real_zero_rate() {
381        // Distinct from unavailable: the counter was read and genuinely did not
382        // advance, which is information.
383        let t0 = origin();
384        let mut tracker = CounterTracker::new(CounterWidth::Bits64);
385        tracker.rate(7_777, t0);
386        let state = tracker.rate(7_777, t0 + Duration::from_secs(1));
387        assert_eq!(state, MetricState::Available(Rate::ZERO));
388    }
389
390    #[test]
391    fn zero_elapsed_is_warming_up_rather_than_a_division_by_zero() {
392        let t0 = origin();
393        let mut tracker = CounterTracker::new(CounterWidth::Bits64);
394        tracker.rate(100, t0);
395        let state = tracker.rate(900, t0);
396        assert!(state.is_warming_up());
397        // The movement itself is still recoverable for running totals.
398        let mut totals = CounterTracker::new(CounterWidth::Bits64);
399        totals.observe(100, t0);
400        assert_eq!(totals.observe(900, t0).advanced_by(), Some(800));
401    }
402
403    #[test]
404    fn a_reversed_instant_cannot_produce_a_negative_or_huge_rate() {
405        // The contract is monotonic time, but a broken caller must degrade
406        // safely rather than panic or overflow (§8.1).
407        let t0 = origin();
408        let mut tracker = CounterTracker::new(CounterWidth::Bits64);
409        tracker.rate(0, t0 + Duration::from_secs(10));
410        let state = tracker.rate(1_000_000, t0);
411        assert!(state.is_warming_up());
412    }
413
414    #[test]
415    fn a_backwards_counter_of_unknown_width_is_a_typed_reset() {
416        let t0 = origin();
417        let mut tracker = CounterTracker::new(CounterWidth::Unknown);
418        tracker.rate(9_000_000, t0);
419        let state = tracker.rate(12, t0 + Duration::from_secs(1));
420        assert_eq!(
421            state,
422            MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset)
423        );
424        assert_eq!(state.fresh(), None);
425    }
426
427    #[test]
428    fn the_sample_after_a_reset_is_valid_again() {
429        let t0 = origin();
430        let mut tracker = CounterTracker::new(CounterWidth::Unknown);
431        tracker.rate(9_000_000, t0);
432        let reset = tracker.rate(12, t0 + Duration::from_secs(1));
433        assert!(!reset.is_available());
434
435        // Re-baselined on the offending reading, so this is a normal delta.
436        let recovered = tracker.rate(1_012, t0 + Duration::from_secs(2));
437        assert_rate(&recovered, 1_000.0);
438    }
439
440    #[test]
441    fn a_reset_never_reports_a_rate_derived_from_the_new_value() {
442        // The failure this pins down: treating `current - 0` as the delta, which
443        // would announce nine megabytes of traffic that never happened.
444        let t0 = origin();
445        let mut tracker = CounterTracker::new(CounterWidth::Unknown);
446        tracker.rate(9_000_000, t0);
447        let delta = tracker.observe(12, t0 + Duration::from_secs(1));
448        assert_eq!(delta, CounterDelta::Reset);
449        assert_eq!(delta.advanced_by(), None);
450    }
451
452    #[test]
453    fn a_known_width_counter_wraps_instead_of_resetting() {
454        // A 32-bit byte counter 300 bytes below its ceiling, plus 1 000 bytes.
455        let t0 = origin();
456        let ceiling = u64::from(u32::MAX) + 1;
457        let previous = ceiling - 300;
458        let mut tracker = CounterTracker::new(CounterWidth::Bits32);
459        tracker.observe(previous, t0);
460
461        let delta = tracker.observe(700, t0 + Duration::from_secs(1));
462        assert_eq!(
463            delta,
464            CounterDelta::Advanced {
465                delta: 1_000,
466                elapsed: Duration::from_secs(1),
467                wrapped: true,
468            }
469        );
470        assert_rate(&delta.rate(), 1_000.0);
471        assert!(delta.wrapped());
472    }
473
474    #[test]
475    fn the_same_movement_is_a_reset_when_the_width_is_unknown() {
476        let t0 = origin();
477        let previous = u64::from(u32::MAX) + 1 - 300;
478        let mut tracker = CounterTracker::new(CounterWidth::Unknown);
479        tracker.observe(previous, t0);
480        assert_eq!(
481            tracker.observe(700, t0 + Duration::from_secs(1)),
482            CounterDelta::Reset
483        );
484    }
485
486    #[test]
487    fn a_wrap_at_the_exact_boundary_is_reconstructed_exactly() {
488        let t0 = origin();
489        let mut tracker = CounterTracker::new(CounterWidth::Bits32);
490        tracker.observe(u64::from(u32::MAX), t0);
491        assert_eq!(
492            tracker
493                .observe(0, t0 + Duration::from_secs(1))
494                .advanced_by(),
495            Some(1),
496            "u32::MAX -> 0 is a single step forward"
497        );
498    }
499
500    #[test]
501    fn a_small_backwards_move_is_a_reset_even_at_a_known_width() {
502        // Only a drop past half the counter range is evidence of a wrap; a
503        // device that re-initialised its counter to a mid-range value is not.
504        let t0 = origin();
505        let mut tracker = CounterTracker::new(CounterWidth::Bits32);
506        tracker.observe(3_000_000_000, t0);
507        assert_eq!(
508            tracker.observe(2_999_000_000, t0 + Duration::from_secs(1)),
509            CounterDelta::Reset
510        );
511    }
512
513    #[test]
514    fn a_reading_outside_the_declared_width_is_a_reset_not_a_wrap() {
515        // The declaration is wrong, so its modulus would fabricate the delta.
516        let t0 = origin();
517        let mut tracker = CounterTracker::new(CounterWidth::Bits32);
518        tracker.observe(u64::from(u32::MAX) + 5_000, t0);
519        assert_eq!(
520            tracker.observe(10, t0 + Duration::from_secs(1)),
521            CounterDelta::Reset
522        );
523    }
524
525    #[test]
526    fn a_wrapped_delta_can_never_exceed_half_the_counter_range() {
527        // The bound that makes a misjudged wrap harmless (§8.2).
528        let half = 1u64 << 31;
529        let t0 = origin();
530        for previous in [u64::from(u32::MAX), 3_000_000_000, half + 1] {
531            for current in [0, 1, 1_000, half - 1] {
532                let mut tracker = CounterTracker::new(CounterWidth::Bits32);
533                tracker.observe(previous, t0);
534                if let Some(delta) = tracker
535                    .observe(current, t0 + Duration::from_secs(1))
536                    .advanced_by()
537                    && current < previous
538                {
539                    assert!(delta < half, "{previous} -> {current} produced {delta}");
540                }
541            }
542        }
543    }
544
545    #[test]
546    fn a_sixty_four_bit_counter_still_rejects_an_absurd_backwards_jump() {
547        let t0 = origin();
548        let mut tracker = CounterTracker::new(CounterWidth::Bits64);
549        tracker.observe(1_000_000, t0);
550        assert_eq!(
551            tracker.observe(9, t0 + Duration::from_secs(1)),
552            CounterDelta::Reset
553        );
554    }
555
556    #[test]
557    fn forgetting_the_baseline_makes_the_next_reading_warm_up() {
558        let t0 = origin();
559        let mut tracker = CounterTracker::new(CounterWidth::Bits64);
560        tracker.rate(1_000, t0);
561        assert!(!tracker.is_warming_up());
562
563        tracker.forget_baseline();
564        assert!(tracker.is_warming_up());
565        assert_eq!(tracker.last_value(), None);
566        assert_eq!(tracker.last_observed_at(), None);
567        assert!(
568            tracker
569                .rate(500_000, t0 + Duration::from_secs(1))
570                .is_warming_up(),
571            "a dropped baseline must not be reconstructed from the old value"
572        );
573    }
574
575    #[test]
576    fn the_baseline_tracks_the_most_recent_reading() {
577        let t0 = origin();
578        let at = t0 + Duration::from_secs(3);
579        let mut tracker = CounterTracker::new(CounterWidth::Bits32);
580        tracker.observe(42, t0);
581        tracker.observe(84, at);
582        assert_eq!(tracker.last_value(), Some(84));
583        assert_eq!(tracker.last_observed_at(), Some(at));
584        assert_eq!(tracker.width(), CounterWidth::Bits32);
585    }
586
587    #[test]
588    fn widths_report_their_own_limits() {
589        assert_eq!(CounterWidth::Unknown.bits(), None);
590        assert_eq!(CounterWidth::Unknown.max_value(), None);
591        assert_eq!(CounterWidth::Bits32.bits(), Some(32));
592        assert_eq!(CounterWidth::Bits32.max_value(), Some(u64::from(u32::MAX)));
593        assert_eq!(CounterWidth::Bits64.bits(), Some(64));
594        assert_eq!(CounterWidth::Bits64.max_value(), Some(u64::MAX));
595        assert_eq!(CounterWidth::default(), CounterWidth::Unknown);
596    }
597
598    #[test]
599    fn a_forward_move_is_never_treated_as_a_wrap() {
600        let t0 = origin();
601        let mut tracker = CounterTracker::new(CounterWidth::Bits32);
602        tracker.observe(10, t0);
603        let delta = tracker.observe(4_000_000_000, t0 + Duration::from_secs(1));
604        assert_eq!(delta.advanced_by(), Some(3_999_999_990));
605        assert!(!delta.wrapped());
606    }
607}