Skip to main content

ipfrs_storage/
metrics_collector.rs

1//! Storage metrics collector with sliding window aggregation and alerting.
2//!
3//! Collects time-series storage metrics (throughput, latency, error rates)
4//! with sliding window aggregation and configurable alert thresholds.
5
6use std::collections::HashMap;
7
8/// The kind of metric being recorded.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum MetricKind {
11    /// Read throughput in bytes per second.
12    ReadThroughput,
13    /// Write throughput in bytes per second.
14    WriteThroughput,
15    /// Read latency in milliseconds.
16    ReadLatencyMs,
17    /// Write latency in milliseconds.
18    WriteLatencyMs,
19    /// Error rate as a fraction [0.0, 1.0].
20    ErrorRate,
21    /// Cache hit rate as a fraction [0.0, 1.0].
22    CacheHitRate,
23}
24
25/// A single recorded metric sample.
26#[derive(Clone, Debug)]
27pub struct MetricSample {
28    /// The kind of metric this sample represents.
29    pub kind: MetricKind,
30    /// The numeric value of the sample.
31    pub value: f64,
32    /// The logical tick at which this sample was recorded.
33    pub tick: u64,
34}
35
36/// Aggregated statistics over the current sliding window for a metric kind.
37#[derive(Clone, Debug)]
38pub struct WindowStats {
39    /// The kind of metric.
40    pub kind: MetricKind,
41    /// Minimum value in the window.
42    pub min: f64,
43    /// Maximum value in the window.
44    pub max: f64,
45    /// Arithmetic mean of values in the window.
46    pub mean: f64,
47    /// Number of samples in the window.
48    pub count: usize,
49}
50
51impl WindowStats {
52    /// Returns `max - min` (the range of values in the window).
53    pub fn range(&self) -> f64 {
54        self.max - self.min
55    }
56}
57
58/// A threshold that triggers an alert when the window mean crosses a boundary.
59#[derive(Clone, Debug)]
60pub struct AlertThreshold {
61    /// The metric kind this threshold applies to.
62    pub kind: MetricKind,
63    /// Alert if the window mean exceeds this value.
64    pub max_value: Option<f64>,
65    /// Alert if the window mean falls below this value.
66    pub min_value: Option<f64>,
67}
68
69/// An alert emitted when a metric mean crosses a configured threshold.
70#[derive(Clone, Debug)]
71pub struct MetricAlert {
72    /// The metric kind that triggered the alert.
73    pub kind: MetricKind,
74    /// Human-readable description of the alert.
75    pub message: String,
76    /// The current window mean that triggered the alert.
77    pub current_value: f64,
78    /// The threshold value that was crossed.
79    pub threshold: f64,
80}
81
82/// Collects time-series storage metrics with sliding window aggregation.
83///
84/// Each `MetricKind` maintains an independent ring of up to `window_size`
85/// samples. When the window is full the oldest sample is evicted before the
86/// new one is inserted, keeping memory bounded. Configured
87/// [`AlertThreshold`]s are evaluated lazily via [`check_alerts`].
88///
89/// # Example
90///
91/// ```
92/// use ipfrs_storage::metrics_collector::{
93///     MetricKind, StorageMetricsCollector, AlertThreshold,
94/// };
95///
96/// let mut col = StorageMetricsCollector::new(50);
97/// col.record(MetricKind::ReadLatencyMs, 12.5);
98/// col.record(MetricKind::ReadLatencyMs, 15.0);
99/// let stats = col.window_stats(MetricKind::ReadLatencyMs).unwrap();
100/// assert_eq!(stats.count, 2);
101/// ```
102///
103/// [`check_alerts`]: StorageMetricsCollector::check_alerts
104pub struct StorageMetricsCollector {
105    /// Per-kind sample windows (bounded to `window_size`).
106    pub samples: HashMap<MetricKind, Vec<MetricSample>>,
107    /// Maximum number of samples retained per metric kind.
108    pub window_size: usize,
109    /// Alert thresholds evaluated by `check_alerts`.
110    pub thresholds: Vec<AlertThreshold>,
111    /// Monotonically increasing logical clock, advanced by [`advance_tick`].
112    ///
113    /// [`advance_tick`]: StorageMetricsCollector::advance_tick
114    pub current_tick: u64,
115}
116
117impl StorageMetricsCollector {
118    /// Creates a new collector with the given sliding-window size.
119    ///
120    /// `window_size` controls how many samples are kept per `MetricKind`.
121    /// A value of `0` is accepted but effectively means no samples are ever
122    /// retained (every sample is immediately evicted).
123    pub fn new(window_size: usize) -> Self {
124        Self {
125            samples: HashMap::new(),
126            window_size,
127            thresholds: Vec::new(),
128            current_tick: 0,
129        }
130    }
131
132    /// Records a sample for `kind` with the given `value` at `current_tick`.
133    ///
134    /// If the window for this kind already contains `window_size` samples the
135    /// oldest (index 0) is removed before the new sample is appended.
136    pub fn record(&mut self, kind: MetricKind, value: f64) {
137        let tick = self.current_tick;
138        let window = self.samples.entry(kind).or_default();
139        if window.len() >= self.window_size {
140            window.remove(0);
141        }
142        window.push(MetricSample { kind, value, tick });
143    }
144
145    /// Advances the logical clock by one tick.
146    pub fn advance_tick(&mut self) {
147        self.current_tick += 1;
148    }
149
150    /// Returns aggregated statistics for all samples of `kind` currently in
151    /// the window, or `None` if no samples have been recorded for that kind.
152    pub fn window_stats(&self, kind: MetricKind) -> Option<WindowStats> {
153        let window = self.samples.get(&kind)?;
154        if window.is_empty() {
155            return None;
156        }
157
158        let count = window.len();
159        let mut min = f64::INFINITY;
160        let mut max = f64::NEG_INFINITY;
161        let mut sum = 0.0_f64;
162
163        for sample in window {
164            if sample.value < min {
165                min = sample.value;
166            }
167            if sample.value > max {
168                max = sample.value;
169            }
170            sum += sample.value;
171        }
172
173        Some(WindowStats {
174            kind,
175            min,
176            max,
177            mean: sum / count as f64,
178            count,
179        })
180    }
181
182    /// Registers an alert threshold.
183    pub fn add_threshold(&mut self, threshold: AlertThreshold) {
184        self.thresholds.push(threshold);
185    }
186
187    /// Evaluates all registered thresholds against current window statistics
188    /// and returns any triggered alerts.
189    ///
190    /// An alert is emitted when:
191    /// - `threshold.max_value` is set and `mean > max_value`, or
192    /// - `threshold.min_value` is set and `mean < min_value`.
193    pub fn check_alerts(&self) -> Vec<MetricAlert> {
194        let mut alerts = Vec::new();
195
196        for threshold in &self.thresholds {
197            let stats = match self.window_stats(threshold.kind) {
198                Some(s) => s,
199                None => continue,
200            };
201
202            if let Some(max) = threshold.max_value {
203                if stats.mean > max {
204                    alerts.push(MetricAlert {
205                        kind: threshold.kind,
206                        message: format!(
207                            "{:?} mean {:.4} exceeds maximum threshold {:.4}",
208                            threshold.kind, stats.mean, max
209                        ),
210                        current_value: stats.mean,
211                        threshold: max,
212                    });
213                }
214            }
215
216            if let Some(min) = threshold.min_value {
217                if stats.mean < min {
218                    alerts.push(MetricAlert {
219                        kind: threshold.kind,
220                        message: format!(
221                            "{:?} mean {:.4} is below minimum threshold {:.4}",
222                            threshold.kind, stats.mean, min
223                        ),
224                        current_value: stats.mean,
225                        threshold: min,
226                    });
227                }
228            }
229        }
230
231        alerts
232    }
233
234    /// Returns window statistics for every metric kind that has at least one
235    /// recorded sample.
236    pub fn all_stats(&self) -> Vec<WindowStats> {
237        let mut result = Vec::new();
238        for &kind in self.samples.keys() {
239            if let Some(stats) = self.window_stats(kind) {
240                result.push(stats);
241            }
242        }
243        result
244    }
245}
246
247// ---------------------------------------------------------------------------
248// Tests
249// ---------------------------------------------------------------------------
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    // -----------------------------------------------------------------------
256    // 1. Basic record: single sample is stored correctly
257    // -----------------------------------------------------------------------
258    #[test]
259    fn test_record_single_sample() {
260        let mut col = StorageMetricsCollector::new(100);
261        col.record(MetricKind::ReadThroughput, 42.0);
262        let window = col.samples.get(&MetricKind::ReadThroughput).unwrap();
263        assert_eq!(window.len(), 1);
264        assert!((window[0].value - 42.0).abs() < f64::EPSILON);
265        assert_eq!(window[0].tick, 0);
266    }
267
268    // -----------------------------------------------------------------------
269    // 2. Multiple records accumulate until window_size is reached
270    // -----------------------------------------------------------------------
271    #[test]
272    fn test_record_multiple_samples_within_window() {
273        let mut col = StorageMetricsCollector::new(5);
274        for i in 0..5u64 {
275            col.record(MetricKind::WriteThroughput, i as f64);
276        }
277        let window = col.samples.get(&MetricKind::WriteThroughput).unwrap();
278        assert_eq!(window.len(), 5);
279    }
280
281    // -----------------------------------------------------------------------
282    // 3. window_size eviction: oldest sample is removed
283    // -----------------------------------------------------------------------
284    #[test]
285    fn test_window_size_eviction() {
286        let mut col = StorageMetricsCollector::new(3);
287        col.record(MetricKind::ReadLatencyMs, 1.0); // oldest
288        col.record(MetricKind::ReadLatencyMs, 2.0);
289        col.record(MetricKind::ReadLatencyMs, 3.0);
290        col.record(MetricKind::ReadLatencyMs, 4.0); // should evict 1.0
291
292        let window = col.samples.get(&MetricKind::ReadLatencyMs).unwrap();
293        assert_eq!(window.len(), 3);
294        assert!((window[0].value - 2.0).abs() < f64::EPSILON);
295        assert!((window[2].value - 4.0).abs() < f64::EPSILON);
296    }
297
298    // -----------------------------------------------------------------------
299    // 4. window_stats – min
300    // -----------------------------------------------------------------------
301    #[test]
302    fn test_window_stats_min() {
303        let mut col = StorageMetricsCollector::new(100);
304        for v in [10.0_f64, 3.0, 7.0, 1.0, 5.0] {
305            col.record(MetricKind::WriteLatencyMs, v);
306        }
307        let stats = col.window_stats(MetricKind::WriteLatencyMs).unwrap();
308        assert!((stats.min - 1.0).abs() < f64::EPSILON);
309    }
310
311    // -----------------------------------------------------------------------
312    // 5. window_stats – max
313    // -----------------------------------------------------------------------
314    #[test]
315    fn test_window_stats_max() {
316        let mut col = StorageMetricsCollector::new(100);
317        for v in [10.0_f64, 3.0, 7.0, 1.0, 5.0] {
318            col.record(MetricKind::WriteLatencyMs, v);
319        }
320        let stats = col.window_stats(MetricKind::WriteLatencyMs).unwrap();
321        assert!((stats.max - 10.0).abs() < f64::EPSILON);
322    }
323
324    // -----------------------------------------------------------------------
325    // 6. window_stats – mean
326    // -----------------------------------------------------------------------
327    #[test]
328    fn test_window_stats_mean() {
329        let mut col = StorageMetricsCollector::new(100);
330        for v in [2.0_f64, 4.0, 6.0] {
331            col.record(MetricKind::ErrorRate, v);
332        }
333        let stats = col.window_stats(MetricKind::ErrorRate).unwrap();
334        assert!((stats.mean - 4.0).abs() < 1e-10);
335    }
336
337    // -----------------------------------------------------------------------
338    // 7. window_stats – count
339    // -----------------------------------------------------------------------
340    #[test]
341    fn test_window_stats_count() {
342        let mut col = StorageMetricsCollector::new(100);
343        for _ in 0..7 {
344            col.record(MetricKind::CacheHitRate, 0.9);
345        }
346        let stats = col.window_stats(MetricKind::CacheHitRate).unwrap();
347        assert_eq!(stats.count, 7);
348    }
349
350    // -----------------------------------------------------------------------
351    // 8. WindowStats::range returns max – min
352    // -----------------------------------------------------------------------
353    #[test]
354    fn test_window_stats_range() {
355        let mut col = StorageMetricsCollector::new(100);
356        for v in [5.0_f64, 15.0, 10.0] {
357            col.record(MetricKind::ReadThroughput, v);
358        }
359        let stats = col.window_stats(MetricKind::ReadThroughput).unwrap();
360        assert!((stats.range() - 10.0).abs() < f64::EPSILON);
361    }
362
363    // -----------------------------------------------------------------------
364    // 9. add_threshold stores the threshold
365    // -----------------------------------------------------------------------
366    #[test]
367    fn test_add_threshold_stored() {
368        let mut col = StorageMetricsCollector::new(100);
369        col.add_threshold(AlertThreshold {
370            kind: MetricKind::ErrorRate,
371            max_value: Some(0.05),
372            min_value: None,
373        });
374        assert_eq!(col.thresholds.len(), 1);
375        assert_eq!(col.thresholds[0].kind, MetricKind::ErrorRate);
376    }
377
378    // -----------------------------------------------------------------------
379    // 10. check_alerts – max_value exceeded triggers alert
380    // -----------------------------------------------------------------------
381    #[test]
382    fn test_check_alerts_max_exceeded() {
383        let mut col = StorageMetricsCollector::new(100);
384        col.record(MetricKind::ErrorRate, 0.10);
385        col.record(MetricKind::ErrorRate, 0.12);
386        col.add_threshold(AlertThreshold {
387            kind: MetricKind::ErrorRate,
388            max_value: Some(0.05),
389            min_value: None,
390        });
391        let alerts = col.check_alerts();
392        assert_eq!(alerts.len(), 1);
393        assert_eq!(alerts[0].kind, MetricKind::ErrorRate);
394        assert!((alerts[0].threshold - 0.05).abs() < f64::EPSILON);
395        assert!(alerts[0].current_value > 0.05);
396    }
397
398    // -----------------------------------------------------------------------
399    // 11. check_alerts – min_value not met triggers alert
400    // -----------------------------------------------------------------------
401    #[test]
402    fn test_check_alerts_min_not_met() {
403        let mut col = StorageMetricsCollector::new(100);
404        col.record(MetricKind::CacheHitRate, 0.50);
405        col.record(MetricKind::CacheHitRate, 0.60);
406        col.add_threshold(AlertThreshold {
407            kind: MetricKind::CacheHitRate,
408            max_value: None,
409            min_value: Some(0.80),
410        });
411        let alerts = col.check_alerts();
412        assert_eq!(alerts.len(), 1);
413        assert_eq!(alerts[0].kind, MetricKind::CacheHitRate);
414        assert!((alerts[0].threshold - 0.80).abs() < f64::EPSILON);
415        assert!(alerts[0].current_value < 0.80);
416    }
417
418    // -----------------------------------------------------------------------
419    // 12. check_alerts – no alert when within threshold
420    // -----------------------------------------------------------------------
421    #[test]
422    fn test_check_alerts_no_alert_within_threshold() {
423        let mut col = StorageMetricsCollector::new(100);
424        col.record(MetricKind::ErrorRate, 0.01);
425        col.record(MetricKind::ErrorRate, 0.02);
426        col.add_threshold(AlertThreshold {
427            kind: MetricKind::ErrorRate,
428            max_value: Some(0.05),
429            min_value: Some(0.001),
430        });
431        let alerts = col.check_alerts();
432        assert!(
433            alerts.is_empty(),
434            "expected no alerts, got {:?}",
435            alerts.len()
436        );
437    }
438
439    // -----------------------------------------------------------------------
440    // 13. advance_tick increments current_tick
441    // -----------------------------------------------------------------------
442    #[test]
443    fn test_advance_tick() {
444        let mut col = StorageMetricsCollector::new(100);
445        assert_eq!(col.current_tick, 0);
446        col.advance_tick();
447        assert_eq!(col.current_tick, 1);
448        col.advance_tick();
449        assert_eq!(col.current_tick, 2);
450    }
451
452    // -----------------------------------------------------------------------
453    // 14. Tick is captured correctly in the sample
454    // -----------------------------------------------------------------------
455    #[test]
456    fn test_tick_stored_in_sample() {
457        let mut col = StorageMetricsCollector::new(100);
458        col.record(MetricKind::ReadLatencyMs, 5.0);
459        col.advance_tick();
460        col.record(MetricKind::ReadLatencyMs, 10.0);
461
462        let window = col.samples.get(&MetricKind::ReadLatencyMs).unwrap();
463        assert_eq!(window[0].tick, 0);
464        assert_eq!(window[1].tick, 1);
465    }
466
467    // -----------------------------------------------------------------------
468    // 15. all_stats – returns stats for all kinds with data
469    // -----------------------------------------------------------------------
470    #[test]
471    fn test_all_stats_count() {
472        let mut col = StorageMetricsCollector::new(100);
473        col.record(MetricKind::ReadThroughput, 1.0);
474        col.record(MetricKind::WriteThroughput, 2.0);
475        col.record(MetricKind::ReadLatencyMs, 3.0);
476        let all = col.all_stats();
477        assert_eq!(all.len(), 3);
478    }
479
480    // -----------------------------------------------------------------------
481    // 16. empty kind returns None from window_stats
482    // -----------------------------------------------------------------------
483    #[test]
484    fn test_empty_kind_returns_none() {
485        let col = StorageMetricsCollector::new(100);
486        assert!(col.window_stats(MetricKind::WriteLatencyMs).is_none());
487    }
488
489    // -----------------------------------------------------------------------
490    // 17. Multiple kinds are independent
491    // -----------------------------------------------------------------------
492    #[test]
493    fn test_multiple_kinds_independent() {
494        let mut col = StorageMetricsCollector::new(3);
495        // Fill read-throughput window completely
496        col.record(MetricKind::ReadThroughput, 10.0);
497        col.record(MetricKind::ReadThroughput, 20.0);
498        col.record(MetricKind::ReadThroughput, 30.0);
499        col.record(MetricKind::ReadThroughput, 40.0); // evicts 10.0
500
501        // Write-throughput window untouched
502        col.record(MetricKind::WriteThroughput, 100.0);
503
504        let read_stats = col.window_stats(MetricKind::ReadThroughput).unwrap();
505        let write_stats = col.window_stats(MetricKind::WriteThroughput).unwrap();
506
507        assert_eq!(read_stats.count, 3);
508        assert!((read_stats.min - 20.0).abs() < f64::EPSILON);
509
510        assert_eq!(write_stats.count, 1);
511        assert!((write_stats.mean - 100.0).abs() < f64::EPSILON);
512    }
513
514    // -----------------------------------------------------------------------
515    // 18. check_alerts – both max and min thresholds on the same kind
516    // -----------------------------------------------------------------------
517    #[test]
518    fn test_check_alerts_both_bounds_violated() {
519        // We set up two separate threshold structs to get two independent alerts.
520        let mut col = StorageMetricsCollector::new(100);
521        col.record(MetricKind::ReadLatencyMs, 200.0);
522        // mean = 200 → exceeds max of 100 AND exceeds min check irrelevant, so
523        // instead violate min with a different metric kind.
524        col.record(MetricKind::CacheHitRate, 0.1);
525
526        col.add_threshold(AlertThreshold {
527            kind: MetricKind::ReadLatencyMs,
528            max_value: Some(100.0),
529            min_value: None,
530        });
531        col.add_threshold(AlertThreshold {
532            kind: MetricKind::CacheHitRate,
533            max_value: None,
534            min_value: Some(0.5),
535        });
536
537        let alerts = col.check_alerts();
538        assert_eq!(alerts.len(), 2);
539    }
540
541    // -----------------------------------------------------------------------
542    // 19. window_stats after eviction reflects only retained samples
543    // -----------------------------------------------------------------------
544    #[test]
545    fn test_window_stats_after_eviction() {
546        let mut col = StorageMetricsCollector::new(2);
547        col.record(MetricKind::ErrorRate, 100.0); // will be evicted
548        col.record(MetricKind::ErrorRate, 200.0);
549        col.record(MetricKind::ErrorRate, 300.0); // evicts 100
550
551        let stats = col.window_stats(MetricKind::ErrorRate).unwrap();
552        assert_eq!(stats.count, 2);
553        assert!((stats.min - 200.0).abs() < f64::EPSILON);
554        assert!((stats.max - 300.0).abs() < f64::EPSILON);
555        assert!((stats.mean - 250.0).abs() < f64::EPSILON);
556    }
557
558    // -----------------------------------------------------------------------
559    // 20. all_stats – empty collector returns empty vec
560    // -----------------------------------------------------------------------
561    #[test]
562    fn test_all_stats_empty() {
563        let col = StorageMetricsCollector::new(100);
564        assert!(col.all_stats().is_empty());
565    }
566}