Skip to main content

monitrs_core/diagnostics/rules/
collector.rs

1//! Rules about the monitor itself: collector lag, stale data, and self-overhead
2//! (§11.2, §16.1).
3//!
4//! §26 is blunt about why these exist: *a system monitor must measure and expose its
5//! own overhead.* These three rules are the part of that promise that speaks up
6//! without being asked, and all three are directly measured rather than inferred,
7//! so they carry [`Confidence::High`].
8
9use core::time::Duration;
10
11use crate::model::{Confidence, MeasuredValue, Measurement, MetricState, Severity, SystemSnapshot};
12use crate::units::{Percent, format_age, format_duration};
13
14use super::super::{DiagnosticRule, Evidence, Finding, HistoryWindow, Thresholds};
15use super::{as_count, as_percent, ratio};
16
17/// Rule id for a collector that cannot keep up.
18pub const COLLECTOR_BEHIND: &str = "collector.falling_behind";
19/// Rule id for a snapshot showing data older than it should be.
20pub const SNAPSHOT_STALE: &str = "collector.snapshot_stale";
21/// Rule id for monitrs exceeding one of its own §16.1 budgets.
22pub const SELF_OVERHEAD: &str = "self.overhead_above_budget";
23
24/// The multiple of a budget at which exceeding it becomes critical.
25const CRITICAL_BUDGET_MULTIPLE: f64 = 2.0;
26
27/// The collector is delivering snapshots later than the configured interval
28/// (§11.2, §16.2).
29#[derive(Clone, Copy, Debug)]
30pub struct CollectorBehindRule {
31    thresholds: Thresholds,
32}
33
34impl CollectorBehindRule {
35    /// Builds the rule from sanitized thresholds.
36    #[must_use]
37    pub const fn new(thresholds: Thresholds) -> Self {
38        Self { thresholds }
39    }
40}
41
42impl DiagnosticRule for CollectorBehindRule {
43    fn id(&self) -> &'static str {
44        COLLECTOR_BEHIND
45    }
46
47    fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Option<Finding> {
48        let interval = history.expected_interval();
49        if interval.is_zero() {
50            return None;
51        }
52        let lag = current.health.lag.as_secs_f64();
53        let watch = Thresholds::intervals_as_seconds(
54            interval,
55            self.thresholds.collector_lag_watch_intervals,
56        );
57        let critical = Thresholds::intervals_as_seconds(
58            interval,
59            self.thresholds.collector_lag_critical_intervals,
60        );
61        let severity = super::escalate(lag >= watch, lag >= critical)?;
62
63        let evidence = vec![
64            Evidence::current(Measurement::new(
65                "lag",
66                MeasuredValue::Duration(current.health.lag),
67            )),
68            Evidence::current(Measurement::new(
69                "sample interval",
70                MeasuredValue::Duration(interval),
71            )),
72            Evidence::current(Measurement::new(
73                "fast collection p95",
74                MeasuredValue::Duration(current.health.fast.p95_duration),
75            )),
76            Evidence::current(Measurement::new(
77                "dropped samples",
78                MeasuredValue::Count(current.health.dropped_samples),
79            )),
80            Evidence::current(Measurement::new(
81                "coalesced samples",
82                MeasuredValue::Count(current.health.coalesced_samples),
83            )),
84        ];
85
86        let intervals = ratio(lag, interval.as_secs_f64()).map_or_else(String::new, |value| {
87            format!(" ({value:.1} sample intervals)")
88        });
89        let summary = format!(
90            "The newest snapshot is {} behind live{intervals}. Displayed values are real \
91             measurements taken later than intended, not predictions; expensive enrichment is \
92             reduced before samples are dropped.",
93            format_age(current.health.lag),
94        );
95
96        Some(
97            Finding::new(
98                COLLECTOR_BEHIND,
99                severity,
100                "Collector falling behind",
101                summary,
102                Confidence::High,
103            )
104            .with_evidence(evidence),
105        )
106    }
107}
108
109/// The snapshot is showing retained values, or follows an unexpectedly long gap
110/// (§11.2, §7.5).
111///
112/// Two different problems with the same consequence: what is on screen is older than
113/// one sample interval. §4 requires a retained value to be shown with its age, and
114/// this rule is the summary of that state for the diagnostics panel.
115#[derive(Clone, Copy, Debug)]
116pub struct SnapshotStaleRule {
117    thresholds: Thresholds,
118}
119
120impl SnapshotStaleRule {
121    /// Builds the rule from sanitized thresholds.
122    #[must_use]
123    pub const fn new(thresholds: Thresholds) -> Self {
124        Self { thresholds }
125    }
126}
127
128/// How many of a snapshot's headline metrics are showing retained values, and the
129/// oldest such age.
130///
131/// Deliberately a fixed list of the metrics the header and overview render (§5.5):
132/// a stale reading in a panel nobody is looking at is not what the warning is for.
133fn stale_headline_metrics(snapshot: &SystemSnapshot) -> (usize, Duration) {
134    let mut count = 0usize;
135    let mut oldest = Duration::ZERO;
136    let mut note = |age: Option<Duration>| {
137        if let Some(age) = age {
138            count = count.saturating_add(1);
139            oldest = oldest.max(age);
140        }
141    };
142
143    note(stale_age(&snapshot.cpu.total));
144    note(stale_age(&snapshot.memory.available));
145    note(stale_age(&snapshot.memory.used));
146    note(stale_age(&snapshot.memory.swap.used));
147    note(stale_age(&snapshot.load));
148    for disk in &snapshot.disks {
149        note(stale_age(&disk.read));
150        note(stale_age(&disk.write));
151    }
152    for interface in &snapshot.networks {
153        note(stale_age(&interface.rx));
154        note(stale_age(&interface.tx));
155    }
156    (count, oldest)
157}
158
159/// The age of a retained value, or `None` when the metric is not stale.
160fn stale_age<T>(state: &MetricState<T>) -> Option<Duration> {
161    match state {
162        MetricState::Stale { age, .. } => Some(*age),
163        _ => None,
164    }
165}
166
167impl DiagnosticRule for SnapshotStaleRule {
168    fn id(&self) -> &'static str {
169        SNAPSHOT_STALE
170    }
171
172    fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Option<Finding> {
173        let interval = history.expected_interval();
174        if interval.is_zero() {
175            return None;
176        }
177        let watch =
178            Thresholds::intervals_as_seconds(interval, self.thresholds.stale_watch_intervals);
179        let critical =
180            Thresholds::intervals_as_seconds(interval, self.thresholds.stale_critical_intervals);
181
182        let (stale_count, oldest) = stale_headline_metrics(current);
183        // A first snapshot has no interval at all, which is warming up rather than
184        // a gap (§8.2).
185        let gap = if current.has_valid_interval() {
186            current.elapsed
187        } else {
188            Duration::ZERO
189        };
190        let worst = oldest.max(gap).as_secs_f64();
191        let triggered = stale_count > 0 || gap.as_secs_f64() >= watch;
192        if !triggered || worst < watch {
193            return None;
194        }
195        let severity = if worst >= critical {
196            Severity::Critical
197        } else {
198            Severity::Watch
199        };
200
201        let mut evidence = vec![
202            Evidence::current(Measurement::new(
203                "sample interval",
204                MeasuredValue::Duration(interval),
205            )),
206            Evidence::current(Measurement::new(
207                "interval since previous sample",
208                MeasuredValue::Duration(gap),
209            )),
210            Evidence::current(Measurement::new(
211                "stale headline metrics",
212                MeasuredValue::Count(as_count(stale_count)),
213            )),
214        ];
215        if !oldest.is_zero() {
216            evidence.push(Evidence::current(Measurement::new(
217                "oldest retained value",
218                MeasuredValue::Duration(oldest),
219            )));
220        }
221
222        let mut summary = String::new();
223        if stale_count > 0 {
224            summary.push_str(&format!(
225                "{stale_count} headline metric(s) are showing retained values, the oldest {} old. ",
226                format_age(oldest)
227            ));
228        }
229        if gap.as_secs_f64() >= watch {
230            summary.push_str(&format!(
231                "The interval since the previous sample was {}, against a configured {}. ",
232                format_duration(gap),
233                format_duration(interval)
234            ));
235        }
236        summary.push_str(
237            "Readings either side of a gap are not comparable, and rates across it were not \
238             computed from an assumed interval.",
239        );
240
241        Some(
242            Finding::new(
243                SNAPSHOT_STALE,
244                severity,
245                "Snapshot data stale",
246                summary,
247                Confidence::High,
248            )
249            .with_evidence(evidence),
250        )
251    }
252}
253
254/// monitrs is over one of its own §16.1 budgets.
255#[derive(Clone, Copy, Debug)]
256pub struct SelfOverheadRule {
257    thresholds: Thresholds,
258}
259
260impl SelfOverheadRule {
261    /// Builds the rule from sanitized thresholds.
262    #[must_use]
263    pub const fn new(thresholds: Thresholds) -> Self {
264        Self { thresholds }
265    }
266}
267
268impl DiagnosticRule for SelfOverheadRule {
269    fn id(&self) -> &'static str {
270        SELF_OVERHEAD
271    }
272
273    fn evaluate(&self, current: &SystemSnapshot, _history: &HistoryWindow<'_>) -> Option<Finding> {
274        let overhead = current.health.self_overhead.as_ref()?;
275        let thresholds = &self.thresholds;
276
277        let cpu_budget = f64::from(thresholds.self_cpu_budget_percent);
278        let rss_budget = thresholds.self_rss_budget_bytes as f64;
279        let sample_budget = thresholds.self_sample_budget();
280
281        let cpu_over =
282            ratio(f64::from(overhead.cpu.value()), cpu_budget).filter(|over| *over >= 1.0);
283        let rss_over = ratio(overhead.rss_bytes as f64, rss_budget).filter(|over| *over >= 1.0);
284        let sample_over = ratio(
285            current.health.fast.p95_duration.as_secs_f64(),
286            sample_budget.as_secs_f64(),
287        )
288        .filter(|over| *over >= 1.0);
289
290        let exceeded: Vec<(&str, f64)> = [
291            ("cpu", cpu_over),
292            ("resident memory", rss_over),
293            ("sample duration", sample_over),
294        ]
295        .into_iter()
296        .filter_map(|(label, over)| over.map(|over| (label, over)))
297        .collect();
298        let worst = exceeded
299            .iter()
300            .map(|(_, over)| *over)
301            .fold(0.0f64, f64::max);
302        if exceeded.is_empty() {
303            return None;
304        }
305        let severity = if worst >= CRITICAL_BUDGET_MULTIPLE {
306            Severity::Critical
307        } else {
308            Severity::Watch
309        };
310
311        let mut evidence = vec![
312            Evidence::current(Measurement::new(
313                "self cpu",
314                MeasuredValue::Percent(overhead.cpu),
315            )),
316            Evidence::current(Measurement::new(
317                "self cpu budget",
318                MeasuredValue::Percent(as_percent(thresholds.self_cpu_budget_percent)),
319            )),
320            Evidence::current(Measurement::new(
321                "self resident memory",
322                MeasuredValue::Bytes(overhead.rss_bytes),
323            )),
324            Evidence::current(Measurement::new(
325                "self resident memory budget",
326                MeasuredValue::Bytes(thresholds.self_rss_budget_bytes),
327            )),
328            Evidence::current(Measurement::new(
329                "history bytes",
330                MeasuredValue::Bytes(overhead.history_bytes),
331            )),
332            Evidence::current(Measurement::new(
333                "fast collection p95",
334                MeasuredValue::Duration(current.health.fast.p95_duration),
335            )),
336            Evidence::current(Measurement::new(
337                "fast collection budget",
338                MeasuredValue::Duration(sample_budget),
339            )),
340        ];
341        if let Some(&open_files) = overhead.open_files.fresh() {
342            evidence.push(Evidence::current(Measurement::new(
343                "open files",
344                MeasuredValue::Count(u64::from(open_files)),
345            )));
346        }
347
348        let named: Vec<String> = exceeded
349            .iter()
350            .map(|(label, over)| format!("{label} {over:.1}x budget"))
351            .collect();
352        let summary = format!(
353            "monitrs is over its own budget: {}. A monitor that costs more than what it measures \
354             is a bug in the monitor, not a property of the system.",
355            named.join(", ")
356        );
357
358        Some(
359            Finding::new(
360                SELF_OVERHEAD,
361                severity,
362                "monitrs self-overhead above budget",
363                summary,
364                Confidence::High,
365            )
366            .with_evidence(evidence),
367        )
368    }
369}
370
371/// The share of a budget a measurement occupies, for the Inspect screen (§7.5).
372///
373/// Returns `None` when the budget is zero, because a share of nothing is undefined
374/// rather than infinite.
375#[must_use]
376pub fn budget_share(measured: f64, budget: f64) -> Option<Percent> {
377    let share = ratio(measured, budget)?;
378    // Narrowing a calculated percentage to f32 is what `Percent` stores; a value the
379    // narrowing could not represent is rejected by `Percent::new` rather than shown.
380    #[allow(clippy::cast_possible_truncation)]
381    let percent = (share * 100.0) as f32;
382    Percent::new(percent)
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::diagnostics::fixtures::{
389        Timeline, set_cpu, set_health, set_self_overhead, snapshot,
390    };
391
392    const MIB: u64 = 1024 * 1024;
393
394    fn behind_rule() -> CollectorBehindRule {
395        CollectorBehindRule::new(Thresholds::default().sanitized())
396    }
397
398    fn stale_rule() -> SnapshotStaleRule {
399        SnapshotStaleRule::new(Thresholds::default().sanitized())
400    }
401
402    fn overhead_rule() -> SelfOverheadRule {
403        SelfOverheadRule::new(Thresholds::default().sanitized())
404    }
405
406    fn timeline() -> Timeline {
407        Timeline::new(Duration::from_secs(1))
408    }
409
410    #[test]
411    fn a_collector_keeping_up_produces_no_finding() {
412        let mut timeline = timeline();
413        let current = timeline.push_many(5, |snapshot| {
414            set_cpu(snapshot, 10.0);
415            set_health(
416                snapshot,
417                Duration::from_millis(120),
418                Duration::from_millis(40),
419            );
420        });
421        assert!(
422            behind_rule()
423                .evaluate(&current, &timeline.window())
424                .is_none()
425        );
426    }
427
428    #[test]
429    fn lag_beyond_two_intervals_is_a_watch() {
430        let mut timeline = timeline();
431        let current = timeline.push_many(5, |snapshot| {
432            set_health(
433                snapshot,
434                Duration::from_millis(2_500),
435                Duration::from_millis(400),
436            );
437        });
438        let finding = behind_rule()
439            .evaluate(&current, &timeline.window())
440            .expect("2.5s of lag on a 1s interval");
441        assert_eq!(finding.severity, Severity::Watch);
442        assert_eq!(finding.confidence, Confidence::High);
443        assert!(
444            finding.summary.contains("2.5 sample intervals"),
445            "{}",
446            finding.summary
447        );
448    }
449
450    #[test]
451    fn lag_beyond_five_intervals_is_critical() {
452        let mut timeline = timeline();
453        let current = timeline.push_many(5, |snapshot| {
454            set_health(snapshot, Duration::from_secs(9), Duration::from_millis(900));
455        });
456        let finding = behind_rule()
457            .evaluate(&current, &timeline.window())
458            .expect("9s of lag on a 1s interval");
459        assert_eq!(finding.severity, Severity::Critical);
460        let labels: Vec<&str> = finding
461            .evidence
462            .iter()
463            .map(|item| item.measurement.label)
464            .collect();
465        assert!(labels.contains(&"lag"), "{labels:?}");
466        assert!(labels.contains(&"dropped samples"), "{labels:?}");
467        assert!(labels.contains(&"coalesced samples"), "{labels:?}");
468    }
469
470    #[test]
471    fn lag_is_judged_against_the_configured_interval_not_one_second() {
472        // A 2.5s lag is fine when samples are five seconds apart.
473        let mut slow = Timeline::new(Duration::from_secs(5));
474        let current = slow.push_many(3, |snapshot| {
475            set_health(
476                snapshot,
477                Duration::from_millis(2_500),
478                Duration::from_millis(400),
479            );
480        });
481        assert!(
482            behind_rule().evaluate(&current, &slow.window()).is_none(),
483            "§8.1 forbids assuming a one-second interval"
484        );
485    }
486
487    #[test]
488    fn a_fresh_snapshot_is_not_stale() {
489        let mut timeline = timeline();
490        let current = timeline.push_many(5, |snapshot| set_cpu(snapshot, 10.0));
491        assert!(
492            stale_rule()
493                .evaluate(&current, &timeline.window())
494                .is_none()
495        );
496    }
497
498    #[test]
499    fn retained_headline_values_are_reported_with_their_age() {
500        let mut timeline = timeline();
501        let mut current = timeline.push_many(5, |snapshot| set_cpu(snapshot, 10.0));
502        current.cpu.total = current.cpu.total.into_stale(Duration::from_secs(4));
503
504        let finding = stale_rule()
505            .evaluate(&current, &timeline.window())
506            .expect("a retained value four seconds old");
507        assert_eq!(finding.severity, Severity::Watch);
508        assert!(
509            finding.summary.contains("retained values"),
510            "{}",
511            finding.summary
512        );
513        let count = finding
514            .evidence
515            .iter()
516            .find(|item| item.measurement.label == "stale headline metrics")
517            .expect("the count is evidence");
518        assert_eq!(count.measurement.value, MeasuredValue::Count(1));
519    }
520
521    #[test]
522    fn a_briefly_retained_value_is_not_worth_a_warning() {
523        let mut timeline = timeline();
524        let mut current = timeline.push_many(5, |snapshot| set_cpu(snapshot, 10.0));
525        current.cpu.total = current.cpu.total.into_stale(Duration::from_millis(1_500));
526        assert!(
527            stale_rule()
528                .evaluate(&current, &timeline.window())
529                .is_none(),
530            "one and a half intervals is within tolerance"
531        );
532    }
533
534    #[test]
535    fn a_long_gap_between_samples_is_reported_as_stale_data() {
536        let mut timeline = timeline();
537        timeline.push_many(3, |snapshot| set_cpu(snapshot, 10.0));
538        let mut current = timeline.build(|snapshot| set_cpu(snapshot, 10.0));
539        current.elapsed = Duration::from_secs(30);
540
541        let finding = stale_rule()
542            .evaluate(&current, &timeline.window())
543            .expect("a thirty second gap on a one second interval");
544        assert_eq!(finding.severity, Severity::Critical);
545        assert!(finding.summary.contains("30s"), "{}", finding.summary);
546        assert!(
547            finding.summary.contains("not comparable"),
548            "{}",
549            finding.summary
550        );
551    }
552
553    #[test]
554    fn the_first_snapshot_is_warming_up_rather_than_stale() {
555        let timeline = timeline();
556        let current = snapshot();
557        assert!(!current.has_valid_interval());
558        assert!(
559            stale_rule()
560                .evaluate(&current, &timeline.window())
561                .is_none()
562        );
563    }
564
565    #[test]
566    fn no_measured_overhead_produces_no_finding() {
567        let mut timeline = timeline();
568        let current = timeline.push_many(3, |snapshot| set_cpu(snapshot, 10.0));
569        assert!(current.health.self_overhead.is_none());
570        assert!(
571            overhead_rule()
572                .evaluate(&current, &timeline.window())
573                .is_none()
574        );
575    }
576
577    #[test]
578    fn overhead_inside_budget_produces_no_finding() {
579        let mut timeline = timeline();
580        let current = timeline.push_many(3, |snapshot| {
581            set_health(snapshot, Duration::ZERO, Duration::from_millis(80));
582            set_self_overhead(snapshot, 0.8, 30 * MIB);
583        });
584        assert!(
585            overhead_rule()
586                .evaluate(&current, &timeline.window())
587                .is_none()
588        );
589    }
590
591    #[test]
592    fn our_own_cpu_above_budget_is_reported_against_the_budget() {
593        let mut timeline = timeline();
594        let current = timeline.push_many(3, |snapshot| {
595            set_health(snapshot, Duration::ZERO, Duration::from_millis(80));
596            set_self_overhead(snapshot, 3.0, 30 * MIB);
597        });
598        let finding = overhead_rule()
599            .evaluate(&current, &timeline.window())
600            .expect("3% against a 2% budget");
601        assert_eq!(finding.severity, Severity::Watch);
602        assert!(
603            finding.summary.contains("cpu 1.5x budget"),
604            "{}",
605            finding.summary
606        );
607
608        let labels: Vec<&str> = finding
609            .evidence
610            .iter()
611            .map(|item| item.measurement.label)
612            .collect();
613        assert!(labels.contains(&"self cpu budget"), "{labels:?}");
614        assert!(labels.contains(&"history bytes"), "{labels:?}");
615        assert!(labels.contains(&"open files"), "{labels:?}");
616    }
617
618    #[test]
619    fn double_the_budget_is_critical_and_names_every_breach() {
620        let mut timeline = timeline();
621        let current = timeline.push_many(3, |snapshot| {
622            set_health(snapshot, Duration::ZERO, Duration::from_millis(600));
623            set_self_overhead(snapshot, 9.0, 120 * MIB);
624        });
625        let finding = overhead_rule()
626            .evaluate(&current, &timeline.window())
627            .expect("every budget is exceeded");
628        assert_eq!(finding.severity, Severity::Critical);
629        for expected in ["cpu", "resident memory", "sample duration"] {
630            assert!(finding.summary.contains(expected), "{}", finding.summary);
631        }
632    }
633
634    #[test]
635    fn a_slow_collection_alone_is_enough_to_report_overhead() {
636        let mut timeline = timeline();
637        let current = timeline.push_many(3, |snapshot| {
638            set_health(snapshot, Duration::ZERO, Duration::from_millis(250));
639            set_self_overhead(snapshot, 0.5, 20 * MIB);
640        });
641        let finding = overhead_rule()
642            .evaluate(&current, &timeline.window())
643            .expect("250ms against a 200ms budget");
644        assert!(
645            finding.summary.contains("sample duration"),
646            "{}",
647            finding.summary
648        );
649    }
650
651    #[test]
652    fn a_budget_share_of_a_zero_budget_is_undefined_rather_than_infinite() {
653        assert!(budget_share(1.0, 0.0).is_none());
654        let share = budget_share(1.0, 4.0).expect("a quarter of the budget");
655        assert!((share.value() - 25.0).abs() < f32::EPSILON);
656    }
657}