Skip to main content

monitrs_core/diagnostics/rules/
cpu.rs

1//! Sustained CPU saturation and high load (§11.2).
2
3use crate::history::{ContributorMetric, HistoryMetric};
4use crate::model::{MeasuredValue, Measurement, Severity, SystemSnapshot};
5use crate::units::format_duration;
6
7use super::super::{DiagnosticRule, Evidence, Finding, HistoryWindow, Thresholds};
8use super::{
9    SUSTAINED_CONFIDENCE, as_count, as_percent, coverage_sentence, escalate, percent_contributors,
10};
11
12/// Rule id for sustained CPU saturation.
13pub const CPU_SATURATION: &str = "cpu.sustained_saturation";
14/// Rule id for load high relative to the logical CPU count.
15pub const LOAD_HIGH: &str = "load.high_per_cpu";
16
17/// Aggregate CPU utilization sustained above its threshold (§11.2).
18///
19/// Counts *history*, not the current sample: one busy tick is a compile finishing,
20/// and §11.3 requires a minimum number of samples before a sustained claim.
21#[derive(Clone, Copy, Debug)]
22pub struct SustainedCpuSaturationRule {
23    thresholds: Thresholds,
24}
25
26impl SustainedCpuSaturationRule {
27    /// Builds the rule from sanitized thresholds.
28    #[must_use]
29    pub const fn new(thresholds: Thresholds) -> Self {
30        Self { thresholds }
31    }
32}
33
34impl DiagnosticRule for SustainedCpuSaturationRule {
35    fn id(&self) -> &'static str {
36        CPU_SATURATION
37    }
38
39    fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Option<Finding> {
40        let thresholds = &self.thresholds;
41        let span = thresholds.sustained_window;
42        let required = thresholds.sustained_samples;
43        let minimum = thresholds.minimum_samples();
44
45        let watch = history.count_at_least(
46            HistoryMetric::CpuBusy,
47            span,
48            f64::from(thresholds.cpu_watch_percent),
49        );
50        let critical = history.count_at_least(
51            HistoryMetric::CpuBusy,
52            span,
53            f64::from(thresholds.cpu_critical_percent),
54        );
55        let severity = escalate(
56            watch.sustained(required, minimum),
57            critical.sustained(required, minimum),
58        )?;
59        let (counted, threshold) = if severity == Severity::Critical {
60            (critical, thresholds.cpu_critical_percent)
61        } else {
62            (watch, thresholds.cpu_watch_percent)
63        };
64
65        let mut evidence = vec![
66            Evidence::new(
67                Measurement::new(
68                    "samples at or above threshold",
69                    MeasuredValue::Count(as_count(counted.matched)),
70                ),
71                counted.window(),
72            ),
73            Evidence::current(Measurement::new(
74                "threshold",
75                MeasuredValue::Percent(as_percent(threshold)),
76            )),
77        ];
78        if let Some(usage) = current.cpu.total.fresh() {
79            evidence.push(Evidence::current(Measurement::new(
80                "cpu busy",
81                MeasuredValue::Percent(usage.busy),
82            )));
83        }
84        if let Some(load) = current.load.fresh() {
85            evidence.push(Evidence::current(Measurement::new(
86                "load1",
87                MeasuredValue::Load(load.one),
88            )));
89        }
90        if let Some(total) = current.total_process_cpu() {
91            evidence.push(Evidence::current(Measurement::new(
92                "observed process cpu",
93                MeasuredValue::Percent(total),
94            )));
95        }
96
97        let mut summary = format!(
98            "CPU busy at or above {} in {} of the last {} samples ({}).",
99            as_percent(threshold),
100            counted.matched,
101            counted.considered,
102            format_duration(counted.span),
103        );
104        if let Some(sample) = history.selected() {
105            if let Some(contributors) =
106                percent_contributors(&sample.contributors, ContributorMetric::Cpu)
107            {
108                summary.push_str(&format!(" Top observed contributors: {contributors}."));
109            }
110            if let Some(coverage) =
111                coverage_sentence(&sample.contributors, ContributorMetric::Cpu, "cpu")
112            {
113                summary.push_str(&coverage);
114            }
115        }
116
117        Some(
118            Finding::new(
119                CPU_SATURATION,
120                severity,
121                "Sustained CPU saturation",
122                summary,
123                SUSTAINED_CONFIDENCE,
124            )
125            .with_evidence(evidence),
126        )
127    }
128}
129
130/// One-minute load sustained high relative to the logical CPU count (§11.2).
131///
132/// Normalized per CPU, because a load of eleven is unremarkable on 64 cores and
133/// severe on two. The summary states what load actually counts, since on Linux it
134/// includes tasks blocked in uninterruptible I/O and is therefore not a CPU
135/// utilization figure.
136#[derive(Clone, Copy, Debug)]
137pub struct LoadHighRule {
138    thresholds: Thresholds,
139}
140
141impl LoadHighRule {
142    /// Builds the rule from sanitized thresholds.
143    #[must_use]
144    pub const fn new(thresholds: Thresholds) -> Self {
145        Self { thresholds }
146    }
147}
148
149impl DiagnosticRule for LoadHighRule {
150    fn id(&self) -> &'static str {
151        LOAD_HIGH
152    }
153
154    fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Option<Finding> {
155        let thresholds = &self.thresholds;
156        let logical = current.cpu.logical_count;
157        if logical == 0 {
158            // Without a CPU count there is nothing to normalize against, and an
159            // absolute load average is not comparable to any threshold (§8.3).
160            return None;
161        }
162        let cpus = f64::from(logical);
163        let span = thresholds.sustained_window;
164        let required = thresholds.sustained_samples;
165        let minimum = thresholds.minimum_samples();
166
167        let watch = history.count_at_least(
168            HistoryMetric::LoadOne,
169            span,
170            f64::from(thresholds.load_watch_per_cpu) * cpus,
171        );
172        let critical = history.count_at_least(
173            HistoryMetric::LoadOne,
174            span,
175            f64::from(thresholds.load_critical_per_cpu) * cpus,
176        );
177        let severity = escalate(
178            watch.sustained(required, minimum),
179            critical.sustained(required, minimum),
180        )?;
181        let (counted, per_cpu_threshold) = if severity == Severity::Critical {
182            (critical, thresholds.load_critical_per_cpu)
183        } else {
184            (watch, thresholds.load_watch_per_cpu)
185        };
186
187        let mut evidence = vec![
188            Evidence::new(
189                Measurement::new(
190                    "samples at or above threshold",
191                    MeasuredValue::Count(as_count(counted.matched)),
192                ),
193                counted.window(),
194            ),
195            Evidence::current(Measurement::new(
196                "logical cpus",
197                MeasuredValue::Count(u64::from(logical)),
198            )),
199            Evidence::current(Measurement::new(
200                "threshold per cpu",
201                MeasuredValue::Load(per_cpu_threshold),
202            )),
203        ];
204        let mut current_per_cpu = None;
205        if let Some(load) = current.load.fresh() {
206            evidence.push(Evidence::current(Measurement::new(
207                "load1",
208                MeasuredValue::Load(load.one),
209            )));
210            if let Some(per_cpu) = load.per_cpu(logical) {
211                current_per_cpu = Some(per_cpu);
212                evidence.push(Evidence::current(Measurement::new(
213                    "load1 per cpu",
214                    MeasuredValue::Load(per_cpu),
215                )));
216            }
217        }
218
219        let observed = current_per_cpu
220            .map(|per_cpu| format!("{per_cpu:.2} per logical cpu"))
221            // The current reading may be unavailable even though the window was
222            // sustained; the counts below still carry the finding.
223            .unwrap_or_else(|| "elevated".to_owned());
224        let summary = format!(
225            "One-minute load is {observed} on {logical} logical cpus, at or above {per_cpu_threshold:.2} \
226             per cpu in {} of the last {} samples ({}). Load counts runnable tasks and, on Linux, \
227             tasks blocked in uninterruptible i/o, so it is not a cpu utilization figure.",
228            counted.matched,
229            counted.considered,
230            format_duration(counted.span),
231        );
232
233        Some(
234            Finding::new(
235                LOAD_HIGH,
236                severity,
237                "Load high relative to logical CPU count",
238                summary,
239                SUSTAINED_CONFIDENCE,
240            )
241            .with_evidence(evidence),
242        )
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::diagnostics::fixtures::{
250        Timeline, add_process, set_cpu, set_load, set_memory, snapshot,
251    };
252    use crate::model::{MetricState, ProcessState, UnavailableReason};
253    use core::time::Duration;
254
255    fn rule() -> SustainedCpuSaturationRule {
256        SustainedCpuSaturationRule::new(Thresholds::default().sanitized())
257    }
258
259    fn load_rule() -> LoadHighRule {
260        LoadHighRule::new(Thresholds::default().sanitized())
261    }
262
263    fn timeline() -> Timeline {
264        Timeline::new(Duration::from_secs(1))
265    }
266
267    #[test]
268    fn an_idle_system_produces_no_cpu_finding() {
269        let mut timeline = timeline();
270        let current = timeline.push_many(20, |snapshot| set_cpu(snapshot, 4.0));
271        assert!(rule().evaluate(&current, &timeline.window()).is_none());
272    }
273
274    #[test]
275    fn a_single_busy_sample_is_not_a_sustained_finding() {
276        let mut timeline = timeline();
277        timeline.push_many(19, |snapshot| set_cpu(snapshot, 3.0));
278        let current = timeline.push(|snapshot| set_cpu(snapshot, 99.0));
279        assert!(
280            rule().evaluate(&current, &timeline.window()).is_none(),
281            "§11.3 requires a minimum number of samples"
282        );
283    }
284
285    #[test]
286    fn nothing_fires_before_the_minimum_sample_count_is_reached() {
287        let mut timeline = timeline();
288        let current = timeline.push_many(9, |snapshot| set_cpu(snapshot, 99.0));
289        assert!(
290            rule().evaluate(&current, &timeline.window()).is_none(),
291            "nine samples cannot support a ten-sample claim"
292        );
293    }
294
295    #[test]
296    fn ten_of_the_last_fifteen_samples_above_the_watch_threshold_is_a_watch() {
297        let mut timeline = timeline();
298        timeline.push_many(5, |snapshot| set_cpu(snapshot, 10.0));
299        let current = timeline.push_many(10, |snapshot| set_cpu(snapshot, 85.0));
300
301        let finding = rule()
302            .evaluate(&current, &timeline.window())
303            .expect("ten of fifteen above 80% is sustained");
304        assert_eq!(finding.severity, Severity::Watch);
305        assert_eq!(finding.rule_id, CPU_SATURATION);
306        assert_eq!(finding.confidence, crate::model::Confidence::Medium);
307        assert!(
308            finding.summary.contains("10 of the last 15 samples"),
309            "{}",
310            finding.summary
311        );
312    }
313
314    #[test]
315    fn sustained_saturation_escalates_to_critical() {
316        let mut timeline = timeline();
317        let current = timeline.push_many(20, |snapshot| set_cpu(snapshot, 97.0));
318        let finding = rule()
319            .evaluate(&current, &timeline.window())
320            .expect("sustained above 95%");
321        assert_eq!(finding.severity, Severity::Critical);
322        assert_eq!(finding.symbol(), 'X');
323    }
324
325    #[test]
326    fn the_finding_carries_raw_evidence_and_a_time_window() {
327        let mut timeline = timeline();
328        let current = timeline.push_many(20, |snapshot| {
329            set_cpu(snapshot, 97.0);
330            set_load(snapshot, 11.4);
331        });
332        let finding = rule()
333            .evaluate(&current, &timeline.window())
334            .expect("sustained saturation");
335
336        let labels: Vec<&str> = finding
337            .evidence
338            .iter()
339            .map(|item| item.measurement.label)
340            .collect();
341        assert!(labels.contains(&"cpu busy"), "{labels:?}");
342        assert!(labels.contains(&"load1"), "{labels:?}");
343        assert!(
344            labels.contains(&"samples at or above threshold"),
345            "{labels:?}"
346        );
347
348        let counted = finding
349            .evidence
350            .iter()
351            .find(|item| item.measurement.label == "samples at or above threshold")
352            .expect("the count is evidence");
353        assert_eq!(counted.window.samples, 15);
354        assert_eq!(counted.window.span, Duration::from_secs(14));
355    }
356
357    #[test]
358    fn the_summary_names_top_contributors_without_claiming_causation() {
359        let mut timeline = timeline();
360        let current = timeline.push_many(20, |snapshot| {
361            set_cpu(snapshot, 97.0);
362            set_memory(snapshot, 32 * 1024 * 1024 * 1024, 8 * 1024 * 1024 * 1024);
363            add_process(
364                snapshot,
365                31_842,
366                "rustc",
367                Some(287.0),
368                None,
369                ProcessState::Running,
370            );
371            add_process(
372                snapshot,
373                1_221,
374                "postgres",
375                Some(54.0),
376                None,
377                ProcessState::Sleeping,
378            );
379        });
380        let finding = rule()
381            .evaluate(&current, &timeline.window())
382            .expect("sustained saturation");
383
384        assert!(
385            finding
386                .summary
387                .contains("Top observed contributors: rustc 287%"),
388            "{}",
389            finding.summary
390        );
391        assert!(
392            finding.summary.contains("account for"),
393            "the coverage sentence is evidence, not proof: {}",
394            finding.summary
395        );
396        for forbidden in ["caused", "because of", "responsible for"] {
397            assert!(
398                !finding.summary.contains(forbidden),
399                "§2.2 forbids claiming causation: {}",
400                finding.summary
401            );
402        }
403    }
404
405    #[test]
406    fn unavailable_samples_do_not_count_towards_saturation() {
407        let mut timeline = timeline();
408        timeline.push_many(10, |snapshot| set_cpu(snapshot, 99.0));
409        let current = timeline.push_many(10, |snapshot| {
410            snapshot.cpu.total =
411                MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset);
412        });
413        assert!(
414            rule().evaluate(&current, &timeline.window()).is_none(),
415            "a counter reset is not a saturated sample"
416        );
417    }
418
419    #[test]
420    fn an_empty_history_produces_no_finding() {
421        let timeline = timeline();
422        assert!(rule().evaluate(&snapshot(), &timeline.window()).is_none());
423        assert!(
424            load_rule()
425                .evaluate(&snapshot(), &timeline.window())
426                .is_none()
427        );
428    }
429
430    #[test]
431    fn load_is_judged_per_logical_cpu() {
432        let mut timeline = timeline();
433        // 7.9 on eight cpus is below one per cpu.
434        let quiet = timeline.push_many(20, |snapshot| set_load(snapshot, 7.9));
435        assert!(load_rule().evaluate(&quiet, &timeline.window()).is_none());
436
437        let mut busy_timeline = Timeline::new(Duration::from_secs(1));
438        let busy = busy_timeline.push_many(20, |snapshot| set_load(snapshot, 11.4));
439        let finding = load_rule()
440            .evaluate(&busy, &busy_timeline.window())
441            .expect("1.4 per cpu is above the watch threshold");
442        assert_eq!(finding.severity, Severity::Watch);
443        assert!(
444            finding.summary.contains("8 logical cpus"),
445            "{}",
446            finding.summary
447        );
448        assert!(
449            finding.summary.contains("uninterruptible"),
450            "the summary must say what load actually counts: {}",
451            finding.summary
452        );
453    }
454
455    #[test]
456    fn load_escalates_to_critical_at_two_per_cpu() {
457        let mut timeline = timeline();
458        let current = timeline.push_many(20, |snapshot| set_load(snapshot, 24.0));
459        let finding = load_rule()
460            .evaluate(&current, &timeline.window())
461            .expect("3.0 per cpu is critical");
462        assert_eq!(finding.severity, Severity::Critical);
463        let labels: Vec<&str> = finding
464            .evidence
465            .iter()
466            .map(|item| item.measurement.label)
467            .collect();
468        assert!(labels.contains(&"load1 per cpu"), "{labels:?}");
469        assert!(labels.contains(&"logical cpus"), "{labels:?}");
470    }
471
472    #[test]
473    fn load_without_a_cpu_count_produces_nothing_rather_than_a_guess() {
474        let mut timeline = timeline();
475        let mut current = timeline.push_many(20, |snapshot| set_load(snapshot, 99.0));
476        current.cpu.logical_count = 0;
477        assert!(load_rule().evaluate(&current, &timeline.window()).is_none());
478    }
479
480    #[test]
481    fn a_missing_load_average_produces_no_load_finding() {
482        let mut timeline = timeline();
483        let current = timeline.push_many(20, |snapshot| {
484            snapshot.load = MetricState::Unsupported;
485        });
486        assert!(load_rule().evaluate(&current, &timeline.window()).is_none());
487    }
488}