Skip to main content

scouter_types/spc/
alert.rs

1use crate::error::TypeError;
2use crate::AlertMap;
3use crate::{
4    dispatch::AlertDispatchType, AlertDispatchConfig, CommonCrons, DispatchAlertDescription,
5    OpsGenieDispatchConfig, PyHelperFuncs, SlackDispatchConfig, ValidateAlertConfig,
6};
7use core::fmt::Debug;
8use pyo3::prelude::*;
9use pyo3::types::PyString;
10
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::collections::HashSet;
14use std::fmt::Display;
15use tracing::error;
16
17#[pyclass(eq)]
18#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, std::cmp::Eq, Hash, Default)]
19pub enum AlertZone {
20    Zone1,
21    Zone2,
22    Zone3,
23    Zone4,
24    #[default]
25    NotApplicable,
26}
27
28impl Display for AlertZone {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            AlertZone::Zone1 => write!(f, "Zone 1"),
32            AlertZone::Zone2 => write!(f, "Zone 2"),
33            AlertZone::Zone3 => write!(f, "Zone 3"),
34            AlertZone::Zone4 => write!(f, "Zone 4"),
35            AlertZone::NotApplicable => write!(f, "NA"),
36        }
37    }
38}
39
40#[pyclass]
41#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
42pub struct SpcAlertRule {
43    #[pyo3(get, set)]
44    pub rule: String,
45
46    #[pyo3(get, set)]
47    pub zones_to_monitor: Vec<AlertZone>,
48}
49
50#[pymethods]
51impl SpcAlertRule {
52    #[new]
53    #[pyo3(signature = (rule="8 16 4 8 2 4 1 1", zones_to_monitor=vec![
54        AlertZone::Zone1,
55        AlertZone::Zone2,
56        AlertZone::Zone3,
57        AlertZone::Zone4,
58    ]))]
59    pub fn new(rule: &str, zones_to_monitor: Vec<AlertZone>) -> Self {
60        Self {
61            rule: rule.to_string(),
62            zones_to_monitor,
63        }
64    }
65}
66
67impl Default for SpcAlertRule {
68    fn default() -> SpcAlertRule {
69        Self {
70            rule: "8 16 4 8 2 4 1 1".to_string(),
71            zones_to_monitor: vec![
72                AlertZone::Zone1,
73                AlertZone::Zone2,
74                AlertZone::Zone3,
75                AlertZone::Zone4,
76            ],
77        }
78    }
79}
80
81#[pyclass]
82#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
83pub struct SpcAlertConfig {
84    #[pyo3(get, set)]
85    pub rule: SpcAlertRule,
86
87    #[pyo3(get, set)]
88    pub schedule: String,
89
90    #[pyo3(get, set)]
91    pub features_to_monitor: Vec<String>,
92
93    pub dispatch_config: AlertDispatchConfig,
94}
95
96impl ValidateAlertConfig for SpcAlertConfig {}
97
98#[pymethods]
99impl SpcAlertConfig {
100    #[new]
101    #[pyo3(signature = (rule=SpcAlertRule::default(), schedule=None, features_to_monitor=vec![], dispatch_config=None))]
102    pub fn new(
103        rule: SpcAlertRule,
104        schedule: Option<&Bound<'_, PyAny>>,
105        features_to_monitor: Vec<String>,
106        dispatch_config: Option<&Bound<'_, PyAny>>,
107    ) -> Result<Self, TypeError> {
108        let alert_dispatch_config = match dispatch_config {
109            None => AlertDispatchConfig::default(),
110            Some(config) => {
111                if config.is_instance_of::<SlackDispatchConfig>() {
112                    AlertDispatchConfig::Slack(config.extract::<SlackDispatchConfig>()?)
113                } else if config.is_instance_of::<OpsGenieDispatchConfig>() {
114                    AlertDispatchConfig::OpsGenie(config.extract::<OpsGenieDispatchConfig>()?)
115                } else {
116                    AlertDispatchConfig::default()
117                }
118            }
119        };
120
121        // check if schedule is None, string or CommonCrons
122
123        let schedule = match schedule {
124            Some(schedule) => {
125                if schedule.is_instance_of::<PyString>() {
126                    schedule.to_string()
127                } else if schedule.is_instance_of::<CommonCrons>() {
128                    schedule.extract::<CommonCrons>().unwrap().cron()
129                } else {
130                    error!("Invalid schedule type");
131                    return Err(TypeError::InvalidScheduleError)?;
132                }
133            }
134            None => CommonCrons::EveryDay.cron(),
135        };
136
137        let schedule = Self::resolve_schedule(&schedule);
138
139        Ok(Self {
140            rule,
141            schedule,
142            features_to_monitor,
143            dispatch_config: alert_dispatch_config,
144        })
145    }
146
147    #[getter]
148    pub fn dispatch_type(&self) -> AlertDispatchType {
149        self.dispatch_config.dispatch_type()
150    }
151
152    #[getter]
153    pub fn dispatch_config<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
154        self.dispatch_config.config(py)
155    }
156}
157
158impl Default for SpcAlertConfig {
159    fn default() -> SpcAlertConfig {
160        Self {
161            rule: SpcAlertRule::default(),
162            dispatch_config: AlertDispatchConfig::default(),
163            schedule: CommonCrons::EveryDay.cron(),
164            features_to_monitor: Vec::new(),
165        }
166    }
167}
168
169#[pyclass(eq)]
170#[derive(Debug, Eq, Hash, PartialEq, Serialize, Deserialize, Default, Clone, Copy)]
171pub enum SpcAlertType {
172    #[default]
173    OutOfBounds,
174    Consecutive,
175    Alternating,
176    AllGood,
177    Trend,
178}
179
180impl Display for SpcAlertType {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        match self {
183            SpcAlertType::OutOfBounds => write!(f, "Out of bounds"),
184            SpcAlertType::Consecutive => write!(f, "Consecutive"),
185            SpcAlertType::Alternating => write!(f, "Alternating"),
186            SpcAlertType::AllGood => write!(f, "All good"),
187            SpcAlertType::Trend => write!(f, "Trend"),
188        }
189    }
190}
191
192#[pyclass]
193#[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, Hash, PartialEq)]
194pub struct SpcAlert {
195    #[pyo3(get)]
196    pub kind: SpcAlertType,
197
198    #[pyo3(get)]
199    pub zone: AlertZone,
200}
201
202#[pymethods]
203#[allow(clippy::new_without_default)]
204impl SpcAlert {
205    #[new]
206    pub fn new(kind: SpcAlertType, zone: AlertZone) -> Self {
207        Self { kind, zone }
208    }
209
210    pub fn __str__(&self) -> String {
211        // serialize the struct to a string
212        PyHelperFuncs::__str__(self)
213    }
214}
215
216#[derive(Serialize, Deserialize, Debug, Default, Clone)]
217pub struct SpcAlertEntry {
218    pub feature: String,
219    pub kind: SpcAlertType,
220    pub zone: AlertZone,
221}
222
223// Drift config to use when calculating drift on a new sample of data
224
225#[derive(Serialize, Deserialize, Debug, Default, Clone)]
226pub struct SpcFeatureAlert {
227    pub feature: String,
228    pub alerts: Vec<SpcAlert>,
229}
230
231impl SpcFeatureAlert {
232    pub fn new(feature: String, alerts: Vec<SpcAlert>) -> Self {
233        Self { feature, alerts }
234    }
235
236    pub fn to_alert_map(&self) -> Vec<AlertMap> {
237        self.alerts
238            .iter()
239            .map(|alert| {
240                AlertMap::Spc(SpcAlertEntry {
241                    feature: self.feature.clone(),
242                    kind: alert.kind,
243                    zone: alert.zone.clone(),
244                })
245            })
246            .collect()
247    }
248}
249
250#[derive(Debug, Serialize, Deserialize, Clone)]
251pub struct SpcFeatureAlerts {
252    pub features: HashMap<String, SpcFeatureAlert>,
253    pub has_alerts: bool,
254}
255
256impl SpcFeatureAlerts {
257    pub fn new(has_alerts: bool) -> Self {
258        Self {
259            features: HashMap::new(),
260            has_alerts,
261        }
262    }
263
264    pub fn insert_feature_alert(&mut self, feature: &str, alerts: HashSet<SpcAlert>) {
265        // convert the alerts to a vector
266        let alerts: Vec<SpcAlert> = alerts.into_iter().collect();
267
268        let feature_alert = SpcFeatureAlert::new(feature.to_string(), alerts);
269
270        self.features.insert(feature.to_string(), feature_alert);
271    }
272}
273
274impl DispatchAlertDescription for SpcFeatureAlerts {
275    fn create_alert_description(&self, dispatch_type: AlertDispatchType) -> String {
276        let mut alert_description = String::new();
277
278        for (i, (_, feature_alert)) in self.features.iter().enumerate() {
279            if feature_alert.alerts.is_empty() {
280                continue;
281            }
282            if i == 0 {
283                let header = match dispatch_type {
284                    AlertDispatchType::Console => "Features that have drifted: \n",
285                    AlertDispatchType::OpsGenie => {
286                        "Drift has been detected for the following features:\n"
287                    }
288                    AlertDispatchType::Slack => {
289                        "Drift has been detected for the following features:\n"
290                    }
291                };
292                alert_description.push_str(header);
293            }
294
295            let feature_name = match dispatch_type {
296                AlertDispatchType::Console | AlertDispatchType::OpsGenie => {
297                    format!("{:indent$}{}: \n", "", &feature_alert.feature, indent = 4)
298                }
299                AlertDispatchType::Slack => format!("{}: \n", &feature_alert.feature),
300            };
301
302            alert_description = format!("{alert_description}{feature_name}");
303            feature_alert.alerts.iter().for_each(|alert| {
304                let alert_details = match dispatch_type {
305                    AlertDispatchType::Console | AlertDispatchType::OpsGenie => {
306                        let kind = format!("{:indent$}Kind: {}\n", "", &alert.kind, indent = 8);
307                        let zone = format!("{:indent$}Zone: {}\n", "", &alert.zone, indent = 8);
308                        format!("{kind}{zone}")
309                    }
310                    AlertDispatchType::Slack => format!(
311                        "{:indent$}{} error in {}\n",
312                        "",
313                        &alert.kind,
314                        &alert.zone,
315                        indent = 4
316                    ),
317                };
318                alert_description = format!("{alert_description}{alert_details}");
319            });
320        }
321        alert_description
322    }
323}
324
325pub struct TaskAlerts {
326    pub alerts: SpcFeatureAlerts,
327}
328
329impl TaskAlerts {
330    pub fn new() -> Self {
331        Self {
332            alerts: SpcFeatureAlerts::new(false),
333        }
334    }
335}
336
337impl Default for TaskAlerts {
338    fn default() -> Self {
339        Self::new()
340    }
341}
342
343#[cfg(test)]
344mod tests {
345
346    use super::*;
347
348    #[test]
349    fn test_types() {
350        // write tests for all alerts
351        let control_alert = SpcAlertRule::default().rule;
352
353        assert_eq!(control_alert, "8 16 4 8 2 4 1 1");
354        assert_eq!(AlertZone::NotApplicable.to_string(), "NA");
355        assert_eq!(AlertZone::Zone1.to_string(), "Zone 1");
356        assert_eq!(AlertZone::Zone2.to_string(), "Zone 2");
357        assert_eq!(AlertZone::Zone3.to_string(), "Zone 3");
358        assert_eq!(AlertZone::Zone4.to_string(), "Zone 4");
359        assert_eq!(SpcAlertType::AllGood.to_string(), "All good");
360        assert_eq!(SpcAlertType::Consecutive.to_string(), "Consecutive");
361        assert_eq!(SpcAlertType::Alternating.to_string(), "Alternating");
362        assert_eq!(SpcAlertType::OutOfBounds.to_string(), "Out of bounds");
363    }
364
365    #[test]
366    fn test_alert_config() {
367        //test console alert config
368        let alert_config = SpcAlertConfig::default();
369        assert_eq!(alert_config.dispatch_config, AlertDispatchConfig::default());
370        assert_eq!(alert_config.dispatch_type(), AlertDispatchType::Console);
371
372        let slack_dispatch_config = SlackDispatchConfig {
373            channel: "test-channel".to_string(),
374        };
375        //test slack alert config
376        let alert_config = SpcAlertConfig {
377            dispatch_config: AlertDispatchConfig::Slack(slack_dispatch_config.clone()),
378            ..Default::default()
379        };
380        assert_eq!(
381            alert_config.dispatch_config,
382            AlertDispatchConfig::Slack(slack_dispatch_config)
383        );
384        assert_eq!(alert_config.dispatch_type(), AlertDispatchType::Slack);
385        assert_eq!(
386            match &alert_config.dispatch_config {
387                AlertDispatchConfig::Slack(config) => &config.channel,
388                _ => panic!("Expected Slack dispatch config"),
389            },
390            "test-channel"
391        );
392
393        //test opsgenie alert config
394        let opsgenie_dispatch_config = OpsGenieDispatchConfig {
395            team: "test-team".to_string(),
396            priority: "P5".to_string(),
397        };
398
399        let alert_config = SpcAlertConfig {
400            dispatch_config: AlertDispatchConfig::OpsGenie(opsgenie_dispatch_config.clone()),
401            ..Default::default()
402        };
403        assert_eq!(
404            alert_config.dispatch_config,
405            AlertDispatchConfig::OpsGenie(opsgenie_dispatch_config.clone())
406        );
407        assert_eq!(alert_config.dispatch_type(), AlertDispatchType::OpsGenie);
408        assert_eq!(
409            match &alert_config.dispatch_config {
410                AlertDispatchConfig::OpsGenie(config) => &config.team,
411                _ => panic!("Expected OpsGenie dispatch config"),
412            },
413            "test-team"
414        );
415    }
416
417    #[test]
418    fn test_spc_feature_alerts() {
419        // Create a sample SpcFeatureAlert (assuming SpcFeatureAlert is defined elsewhere)
420        let sample_alert = SpcFeatureAlert {
421            feature: "feature1".to_string(),
422            alerts: vec![SpcAlert {
423                kind: SpcAlertType::OutOfBounds,
424                zone: AlertZone::Zone1,
425            }]
426            .into_iter()
427            .collect(),
428            // Initialize fields of SpcFeatureAlert
429        };
430
431        // Create a HashMap with sample data
432        let mut features = HashMap::new();
433        features.insert("feature1".to_string(), sample_alert.clone());
434
435        // Create an instance of SpcFeatureAlerts
436        let alerts = SpcFeatureAlerts {
437            features: features.clone(),
438            has_alerts: true,
439        };
440
441        // Assert the values
442        assert!(alerts.has_alerts);
443
444        // Assert the values of the features
445        assert_eq!(alerts.features["feature1"].feature, sample_alert.feature);
446
447        // Assert constructing alert description
448        let _ = alerts.create_alert_description(AlertDispatchType::Console);
449        let _ = alerts.create_alert_description(AlertDispatchType::OpsGenie);
450        let _ = alerts.create_alert_description(AlertDispatchType::Slack);
451    }
452}