mod collector;
mod cpu;
mod memory;
mod process;
mod psi;
mod storage;
use core::fmt;
use crate::history::{ContributorMetric, ContributorSet};
use crate::model::{Confidence, Severity, SystemSnapshot};
use crate::units::Percent;
use super::{DiagnosticRule, Finding, HistoryWindow, Thresholds};
pub use collector::{
COLLECTOR_BEHIND, CollectorBehindRule, SELF_OVERHEAD, SNAPSHOT_STALE, SelfOverheadRule,
SnapshotStaleRule, budget_share,
};
pub use cpu::{CPU_SATURATION, LOAD_HIGH, LoadHighRule, SustainedCpuSaturationRule};
pub use memory::{
MEMORY_AVAILABILITY_LOW, MemoryAvailabilityLowRule, SWAP_ACTIVITY, SwapActivityRule,
};
pub use process::{
PROCESS_CPU_SPIKE, PROCESS_RSS_GROWTH, ProcessCpuSpikeRule, ProcessRssGrowthRule,
ZOMBIE_PRESENT, ZombieProcessRule,
};
pub use psi::{IoPsiElevatedRule, MemoryPsiElevatedRule, PSI_IO_ELEVATED, PSI_MEMORY_ELEVATED};
pub use storage::{DISK_SUSTAINED_BUSY, DiskBusyRule, disk_signal_ready};
const SUMMARY_CONTRIBUTORS: usize = 3;
pub struct RuleSet {
rules: Vec<Box<dyn DiagnosticRule>>,
enabled: bool,
}
impl RuleSet {
#[must_use]
pub fn new(thresholds: Thresholds) -> Self {
let thresholds = thresholds.sanitized();
Self {
enabled: thresholds.enabled,
rules: vec![
Box::new(SustainedCpuSaturationRule::new(thresholds)),
Box::new(LoadHighRule::new(thresholds)),
Box::new(MemoryAvailabilityLowRule::new(thresholds)),
Box::new(SwapActivityRule::new(thresholds)),
Box::new(MemoryPsiElevatedRule::new(thresholds)),
Box::new(IoPsiElevatedRule::new(thresholds)),
Box::new(DiskBusyRule::new(thresholds)),
Box::new(ProcessRssGrowthRule::new(thresholds)),
Box::new(ZombieProcessRule::new(thresholds)),
Box::new(ProcessCpuSpikeRule::new(thresholds)),
Box::new(CollectorBehindRule::new(thresholds)),
Box::new(SnapshotStaleRule::new(thresholds)),
Box::new(SelfOverheadRule::new(thresholds)),
],
}
}
#[must_use]
pub fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Vec<Finding> {
if !self.enabled {
return Vec::new();
}
let mut findings: Vec<Finding> = self
.rules
.iter()
.filter_map(|rule| rule.evaluate(current, history))
.collect();
findings.sort_by(|left, right| {
right
.severity
.cmp(&left.severity)
.then_with(|| left.rule_id.cmp(right.rule_id))
});
findings
}
#[must_use]
pub fn rules(&self) -> &[Box<dyn DiagnosticRule>] {
&self.rules
}
#[must_use]
pub fn ids(&self) -> Vec<&'static str> {
self.rules.iter().map(|rule| rule.id()).collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.rules.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}
impl Default for RuleSet {
fn default() -> Self {
Self::new(Thresholds::default())
}
}
impl fmt::Debug for RuleSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RuleSet")
.field("enabled", &self.enabled)
.field("rules", &self.ids())
.finish()
}
}
pub(crate) fn as_percent(value: f32) -> Percent {
Percent::new(value).unwrap_or(Percent::ZERO)
}
pub(crate) fn as_count(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
pub(crate) fn ratio(value: f64, reference: f64) -> Option<f64> {
if reference <= 0.0 {
return None;
}
let ratio = value / reference;
ratio.is_finite().then_some(ratio)
}
pub(crate) fn percent_contributors(
contributors: &ContributorSet,
metric: ContributorMetric,
) -> Option<String> {
let rendered: Vec<String> = contributors
.metric(metric)
.entries()
.iter()
.take(SUMMARY_CONTRIBUTORS)
.filter_map(|entry| match entry.value {
crate::model::MeasuredValue::Percent(percent) => {
Some(format!("{} {percent}", entry.name))
}
_ => None,
})
.collect();
(!rendered.is_empty()).then(|| rendered.join(", "))
}
pub(crate) fn share_contributors(
contributors: &ContributorSet,
metric: ContributorMetric,
whole: u64,
) -> Option<String> {
let rendered: Vec<String> = contributors
.metric(metric)
.entries()
.iter()
.take(SUMMARY_CONTRIBUTORS)
.filter_map(|entry| match entry.value {
crate::model::MeasuredValue::Bytes(bytes) => {
Percent::ratio(bytes, whole).map(|share| format!("{} {share}", entry.name))
}
_ => None,
})
.collect();
(!rendered.is_empty()).then(|| rendered.join(", "))
}
pub(crate) fn coverage_sentence(
contributors: &ContributorSet,
metric: ContributorMetric,
noun: &str,
) -> Option<String> {
contributors
.metric(metric)
.coverage()
.fresh()
.map(|coverage| {
format!(" Retained top processes account for {coverage} of observed {noun}.")
})
}
pub(crate) fn escalate(watch: bool, critical: bool) -> Option<Severity> {
match (critical, watch) {
(true, _) => Some(Severity::Critical),
(false, true) => Some(Severity::Watch),
(false, false) => None,
}
}
pub(crate) const SUSTAINED_CONFIDENCE: Confidence = Confidence::Medium;
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostics::fixtures::{Timeline, set_cpu};
use core::time::Duration;
#[test]
fn the_set_registers_every_rule_named_in_section_eleven_two() {
let set = RuleSet::default();
assert_eq!(set.len(), 13, "§11.2 lists thirteen rules");
assert!(!set.is_empty());
assert_eq!(set.rules().len(), set.len());
let mut ids = set.ids();
let count = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), count, "rule ids must be unique");
}
#[test]
fn rule_ids_are_stable_lower_case_dotted_names() {
for id in RuleSet::default().ids() {
assert!(id.is_ascii(), "{id} is not ASCII");
assert!(id.contains('.'), "{id} is not namespaced");
assert_eq!(id.to_lowercase(), id, "{id} is not lower case");
}
}
#[test]
fn a_healthy_system_produces_no_findings() {
let mut timeline = Timeline::new(Duration::from_secs(1));
let current = timeline.push_many(20, |snapshot| set_cpu(snapshot, 12.0));
let findings = RuleSet::default().evaluate(¤t, &timeline.window());
assert!(findings.is_empty(), "{findings:#?}");
}
#[test]
fn disabling_diagnostics_produces_no_findings_at_all() {
let mut timeline = Timeline::new(Duration::from_secs(1));
let current = timeline.push_many(20, |snapshot| set_cpu(snapshot, 99.0));
let set = RuleSet::new(Thresholds {
enabled: false,
..Thresholds::default()
});
assert!(set.evaluate(¤t, &timeline.window()).is_empty());
}
#[test]
fn findings_are_ordered_most_severe_first_and_deterministically() {
let mut timeline = Timeline::new(Duration::from_secs(1));
let current = timeline.push_many(20, |snapshot| {
set_cpu(snapshot, 99.0);
crate::diagnostics::fixtures::set_load(snapshot, 24.0);
});
let set = RuleSet::default();
let first = set.evaluate(¤t, &timeline.window());
let second = set.evaluate(¤t, &timeline.window());
assert_eq!(first, second, "evaluation must be deterministic");
assert!(first.len() >= 2, "{first:#?}");
for pair in first.windows(2) {
let [left, right] = pair else { continue };
assert!(
left.severity >= right.severity,
"{} before {}",
left.rule_id,
right.rule_id
);
}
}
#[test]
fn a_ratio_is_none_when_it_would_be_undefined() {
assert!(ratio(1.0, 0.0).is_none());
assert!(ratio(f64::NAN, 1.0).is_none());
assert!(ratio(4.0, 2.0).is_some_and(|value| (value - 2.0).abs() < f64::EPSILON));
}
#[test]
fn escalation_prefers_the_more_severe_outcome() {
assert_eq!(escalate(false, false), None);
assert_eq!(escalate(true, false), Some(Severity::Watch));
assert_eq!(escalate(true, true), Some(Severity::Critical));
assert_eq!(
escalate(false, true),
Some(Severity::Critical),
"a critical condition is critical even if watch was not counted"
);
}
#[test]
fn the_debug_form_names_the_registered_rules() {
let printed = format!("{:?}", RuleSet::default());
assert!(printed.contains(CPU_SATURATION), "{printed}");
assert!(printed.contains("enabled: true"), "{printed}");
}
}