Skip to main content

elasticctl_api/compose/
rule_health.rs

1//! Health of one rule: its last execution, alert volume in a window, and the
2//! exception lists that narrow it.
3use super::{
4    AlertActivity, ExceptionCoverage, Part, RECENT_ALERTS, RuleSnapshot, alert_activity, degrade,
5    exception_coverage, snapshot_by_selector, validate_since,
6};
7use elasticctl_core::{ErrorKind, Result, Transport};
8use serde_json::Value;
9
10#[derive(Debug)]
11pub struct RuleHealth {
12    pub rule: RuleSnapshot,
13    pub alerts: Part<AlertActivity>,
14    pub exceptions: Part<ExceptionCoverage>,
15}
16
17/// Static finding codes, in the order a report lists them.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum HealthFinding {
20    RuleDisabled,
21    NeverExecuted,
22    LastExecutionFailed,
23    LastExecutionPartialFailure,
24    ExecutionGap,
25    NoAlertsInWindow,
26    ExceptionListMissing,
27}
28
29/// Read the rule by selector, then its alert activity with the five newest
30/// alerts, then its exception coverage. The two parts do not depend on each
31/// other; a fatal error kind in either fails the call.
32pub async fn rule_health(t: &Transport, selector: &str, since: &str) -> Result<RuleHealth> {
33    validate_since(since)?;
34    let rule = snapshot_by_selector(t, selector).await?;
35    let rule_id = rule.rule.rule_id()?.to_owned();
36    let alerts = degrade(alert_activity(t, &rule_id, since, RECENT_ALERTS).await)?;
37    let exceptions = degrade(exception_coverage(t, &rule.rule).await)?;
38    Ok(RuleHealth {
39        rule,
40        alerts,
41        exceptions,
42    })
43}
44
45impl RuleHealth {
46    /// Findings from typed comparisons. Status strings are compared exactly,
47    /// so an unknown status yields no execution finding.
48    pub fn findings(&self) -> Vec<HealthFinding> {
49        let mut out = Vec::new();
50        let enabled = self
51            .rule
52            .rule
53            .as_map()
54            .get("enabled")
55            .and_then(Value::as_bool);
56        let execution = self.rule.execution.as_ref();
57        if enabled == Some(false) {
58            out.push(HealthFinding::RuleDisabled);
59        }
60        if enabled == Some(true) && execution.is_none() {
61            out.push(HealthFinding::NeverExecuted);
62        }
63        if let Some(e) = execution {
64            match e.status.as_str() {
65                "failed" => out.push(HealthFinding::LastExecutionFailed),
66                "partial failure" => out.push(HealthFinding::LastExecutionPartialFailure),
67                _ => {}
68            }
69            if e.metrics
70                .execution_gap_duration_s
71                .is_some_and(|gap| gap > 0.0)
72            {
73                out.push(HealthFinding::ExecutionGap);
74            }
75        }
76        if let Part::Ok(alerts) = &self.alerts
77            && alerts.total == 0
78        {
79            out.push(HealthFinding::NoAlertsInWindow);
80        }
81        if let Part::Ok(coverage) = &self.exceptions
82            && coverage
83                .lists
84                .iter()
85                .any(|row| matches!(&row.facts, Err(e) if e.kind == ErrorKind::NotFound))
86        {
87            out.push(HealthFinding::ExceptionListMissing);
88        }
89        out
90    }
91
92    /// True when a part or an exception list row is unavailable.
93    pub fn partial(&self) -> bool {
94        matches!(self.alerts, Part::Unavailable(_))
95            || match &self.exceptions {
96                Part::Ok(coverage) => coverage.lists.iter().any(|row| row.facts.is_err()),
97                Part::Unavailable(_) => true,
98                Part::Skipped(_) => false,
99            }
100    }
101}