1use 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
12pub const CPU_SATURATION: &str = "cpu.sustained_saturation";
14pub const LOAD_HIGH: &str = "load.high_per_cpu";
16
17#[derive(Clone, Copy, Debug)]
22pub struct SustainedCpuSaturationRule {
23 thresholds: Thresholds,
24}
25
26impl SustainedCpuSaturationRule {
27 #[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#[derive(Clone, Copy, Debug)]
137pub struct LoadHighRule {
138 thresholds: Thresholds,
139}
140
141impl LoadHighRule {
142 #[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 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 .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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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 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(¤t, &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(¤t, &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(¤t, &timeline.window()).is_none());
487 }
488}