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}