monitrs_core/diagnostics/rules/
mod.rs1mod collector;
31mod cpu;
32mod memory;
33mod process;
34mod psi;
35mod storage;
36
37use core::fmt;
38
39use crate::history::{ContributorMetric, ContributorSet};
40use crate::model::{Confidence, Severity, SystemSnapshot};
41use crate::units::Percent;
42
43use super::{DiagnosticRule, Finding, HistoryWindow, Thresholds};
44
45pub use collector::{
46 COLLECTOR_BEHIND, CollectorBehindRule, SELF_OVERHEAD, SNAPSHOT_STALE, SelfOverheadRule,
47 SnapshotStaleRule, budget_share,
48};
49pub use cpu::{CPU_SATURATION, LOAD_HIGH, LoadHighRule, SustainedCpuSaturationRule};
50pub use memory::{
51 MEMORY_AVAILABILITY_LOW, MemoryAvailabilityLowRule, SWAP_ACTIVITY, SwapActivityRule,
52};
53pub use process::{
54 PROCESS_CPU_SPIKE, PROCESS_RSS_GROWTH, ProcessCpuSpikeRule, ProcessRssGrowthRule,
55 ZOMBIE_PRESENT, ZombieProcessRule,
56};
57pub use psi::{IoPsiElevatedRule, MemoryPsiElevatedRule, PSI_IO_ELEVATED, PSI_MEMORY_ELEVATED};
58pub use storage::{DISK_SUSTAINED_BUSY, DiskBusyRule, disk_signal_ready};
59
60const SUMMARY_CONTRIBUTORS: usize = 3;
65
66pub struct RuleSet {
68 rules: Vec<Box<dyn DiagnosticRule>>,
69 enabled: bool,
70}
71
72impl RuleSet {
73 #[must_use]
75 pub fn new(thresholds: Thresholds) -> Self {
76 let thresholds = thresholds.sanitized();
77 Self {
78 enabled: thresholds.enabled,
79 rules: vec![
80 Box::new(SustainedCpuSaturationRule::new(thresholds)),
81 Box::new(LoadHighRule::new(thresholds)),
82 Box::new(MemoryAvailabilityLowRule::new(thresholds)),
83 Box::new(SwapActivityRule::new(thresholds)),
84 Box::new(MemoryPsiElevatedRule::new(thresholds)),
85 Box::new(IoPsiElevatedRule::new(thresholds)),
86 Box::new(DiskBusyRule::new(thresholds)),
87 Box::new(ProcessRssGrowthRule::new(thresholds)),
88 Box::new(ZombieProcessRule::new(thresholds)),
89 Box::new(ProcessCpuSpikeRule::new(thresholds)),
90 Box::new(CollectorBehindRule::new(thresholds)),
91 Box::new(SnapshotStaleRule::new(thresholds)),
92 Box::new(SelfOverheadRule::new(thresholds)),
93 ],
94 }
95 }
96
97 #[must_use]
103 pub fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Vec<Finding> {
104 if !self.enabled {
105 return Vec::new();
106 }
107 let mut findings: Vec<Finding> = self
108 .rules
109 .iter()
110 .filter_map(|rule| rule.evaluate(current, history))
111 .collect();
112 findings.sort_by(|left, right| {
113 right
114 .severity
115 .cmp(&left.severity)
116 .then_with(|| left.rule_id.cmp(right.rule_id))
117 });
118 findings
119 }
120
121 #[must_use]
123 pub fn rules(&self) -> &[Box<dyn DiagnosticRule>] {
124 &self.rules
125 }
126
127 #[must_use]
129 pub fn ids(&self) -> Vec<&'static str> {
130 self.rules.iter().map(|rule| rule.id()).collect()
131 }
132
133 #[must_use]
135 pub fn len(&self) -> usize {
136 self.rules.len()
137 }
138
139 #[must_use]
141 pub fn is_empty(&self) -> bool {
142 self.rules.is_empty()
143 }
144}
145
146impl Default for RuleSet {
147 fn default() -> Self {
149 Self::new(Thresholds::default())
150 }
151}
152
153impl fmt::Debug for RuleSet {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156 f.debug_struct("RuleSet")
157 .field("enabled", &self.enabled)
158 .field("rules", &self.ids())
159 .finish()
160 }
161}
162
163pub(crate) fn as_percent(value: f32) -> Percent {
168 Percent::new(value).unwrap_or(Percent::ZERO)
169}
170
171pub(crate) fn as_count(value: usize) -> u64 {
173 u64::try_from(value).unwrap_or(u64::MAX)
174}
175
176pub(crate) fn ratio(value: f64, reference: f64) -> Option<f64> {
180 if reference <= 0.0 {
181 return None;
182 }
183 let ratio = value / reference;
184 ratio.is_finite().then_some(ratio)
185}
186
187pub(crate) fn percent_contributors(
193 contributors: &ContributorSet,
194 metric: ContributorMetric,
195) -> Option<String> {
196 let rendered: Vec<String> = contributors
197 .metric(metric)
198 .entries()
199 .iter()
200 .take(SUMMARY_CONTRIBUTORS)
201 .filter_map(|entry| match entry.value {
202 crate::model::MeasuredValue::Percent(percent) => {
203 Some(format!("{} {percent}", entry.name))
204 }
205 _ => None,
206 })
207 .collect();
208 (!rendered.is_empty()).then(|| rendered.join(", "))
209}
210
211pub(crate) fn share_contributors(
216 contributors: &ContributorSet,
217 metric: ContributorMetric,
218 whole: u64,
219) -> Option<String> {
220 let rendered: Vec<String> = contributors
221 .metric(metric)
222 .entries()
223 .iter()
224 .take(SUMMARY_CONTRIBUTORS)
225 .filter_map(|entry| match entry.value {
226 crate::model::MeasuredValue::Bytes(bytes) => {
227 Percent::ratio(bytes, whole).map(|share| format!("{} {share}", entry.name))
228 }
229 _ => None,
230 })
231 .collect();
232 (!rendered.is_empty()).then(|| rendered.join(", "))
233}
234
235pub(crate) fn coverage_sentence(
240 contributors: &ContributorSet,
241 metric: ContributorMetric,
242 noun: &str,
243) -> Option<String> {
244 contributors
245 .metric(metric)
246 .coverage()
247 .fresh()
248 .map(|coverage| {
249 format!(" Retained top processes account for {coverage} of observed {noun}.")
250 })
251}
252
253pub(crate) fn escalate(watch: bool, critical: bool) -> Option<Severity> {
258 match (critical, watch) {
259 (true, _) => Some(Severity::Critical),
260 (false, true) => Some(Severity::Watch),
261 (false, false) => None,
262 }
263}
264
265pub(crate) const SUSTAINED_CONFIDENCE: Confidence = Confidence::Medium;
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::diagnostics::fixtures::{Timeline, set_cpu};
272 use core::time::Duration;
273
274 #[test]
275 fn the_set_registers_every_rule_named_in_section_eleven_two() {
276 let set = RuleSet::default();
277 assert_eq!(set.len(), 13, "§11.2 lists thirteen rules");
278 assert!(!set.is_empty());
279 assert_eq!(set.rules().len(), set.len());
280
281 let mut ids = set.ids();
282 let count = ids.len();
283 ids.sort_unstable();
284 ids.dedup();
285 assert_eq!(ids.len(), count, "rule ids must be unique");
286 }
287
288 #[test]
289 fn rule_ids_are_stable_lower_case_dotted_names() {
290 for id in RuleSet::default().ids() {
291 assert!(id.is_ascii(), "{id} is not ASCII");
292 assert!(id.contains('.'), "{id} is not namespaced");
293 assert_eq!(id.to_lowercase(), id, "{id} is not lower case");
294 }
295 }
296
297 #[test]
298 fn a_healthy_system_produces_no_findings() {
299 let mut timeline = Timeline::new(Duration::from_secs(1));
300 let current = timeline.push_many(20, |snapshot| set_cpu(snapshot, 12.0));
301 let findings = RuleSet::default().evaluate(¤t, &timeline.window());
302 assert!(findings.is_empty(), "{findings:#?}");
303 }
304
305 #[test]
306 fn disabling_diagnostics_produces_no_findings_at_all() {
307 let mut timeline = Timeline::new(Duration::from_secs(1));
308 let current = timeline.push_many(20, |snapshot| set_cpu(snapshot, 99.0));
309 let set = RuleSet::new(Thresholds {
310 enabled: false,
311 ..Thresholds::default()
312 });
313 assert!(set.evaluate(¤t, &timeline.window()).is_empty());
314 }
315
316 #[test]
317 fn findings_are_ordered_most_severe_first_and_deterministically() {
318 let mut timeline = Timeline::new(Duration::from_secs(1));
319 let current = timeline.push_many(20, |snapshot| {
320 set_cpu(snapshot, 99.0);
321 crate::diagnostics::fixtures::set_load(snapshot, 24.0);
322 });
323 let set = RuleSet::default();
324 let first = set.evaluate(¤t, &timeline.window());
325 let second = set.evaluate(¤t, &timeline.window());
326
327 assert_eq!(first, second, "evaluation must be deterministic");
328 assert!(first.len() >= 2, "{first:#?}");
329 for pair in first.windows(2) {
330 let [left, right] = pair else { continue };
331 assert!(
332 left.severity >= right.severity,
333 "{} before {}",
334 left.rule_id,
335 right.rule_id
336 );
337 }
338 }
339
340 #[test]
341 fn a_ratio_is_none_when_it_would_be_undefined() {
342 assert!(ratio(1.0, 0.0).is_none());
343 assert!(ratio(f64::NAN, 1.0).is_none());
344 assert!(ratio(4.0, 2.0).is_some_and(|value| (value - 2.0).abs() < f64::EPSILON));
345 }
346
347 #[test]
348 fn escalation_prefers_the_more_severe_outcome() {
349 assert_eq!(escalate(false, false), None);
350 assert_eq!(escalate(true, false), Some(Severity::Watch));
351 assert_eq!(escalate(true, true), Some(Severity::Critical));
352 assert_eq!(
353 escalate(false, true),
354 Some(Severity::Critical),
355 "a critical condition is critical even if watch was not counted"
356 );
357 }
358
359 #[test]
360 fn the_debug_form_names_the_registered_rules() {
361 let printed = format!("{:?}", RuleSet::default());
362 assert!(printed.contains(CPU_SATURATION), "{printed}");
363 assert!(printed.contains("enabled: true"), "{printed}");
364 }
365}