1use 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
17pub const COLLECTOR_BEHIND: &str = "collector.falling_behind";
19pub const SNAPSHOT_STALE: &str = "collector.snapshot_stale";
21pub const SELF_OVERHEAD: &str = "self.overhead_above_budget";
23
24const CRITICAL_BUDGET_MULTIPLE: f64 = 2.0;
26
27#[derive(Clone, Copy, Debug)]
30pub struct CollectorBehindRule {
31 thresholds: Thresholds,
32}
33
34impl CollectorBehindRule {
35 #[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#[derive(Clone, Copy, Debug)]
116pub struct SnapshotStaleRule {
117 thresholds: Thresholds,
118}
119
120impl SnapshotStaleRule {
121 #[must_use]
123 pub const fn new(thresholds: Thresholds) -> Self {
124 Self { thresholds }
125 }
126}
127
128fn 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
159fn 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 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#[derive(Clone, Copy, Debug)]
256pub struct SelfOverheadRule {
257 thresholds: Thresholds,
258}
259
260impl SelfOverheadRule {
261 #[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#[must_use]
376pub fn budget_share(measured: f64, budget: f64) -> Option<Percent> {
377 let share = ratio(measured, budget)?;
378 #[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(¤t, &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(¤t, &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(¤t, &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 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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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}