Skip to main content

chronix_analytics/
alerting.rs

1//! Alerting hooks — configurable actions triggered by anomaly detection.
2
3use std::collections::{BTreeMap, VecDeque};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{Duration, Instant};
6
7use dashmap::DashMap;
8use metrics::counter;
9use parking_lot::RwLock;
10use serde::{Deserialize, Serialize};
11use tracing::{debug, warn};
12
13/// Action to take when an alert fires.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum AlertAction {
17    /// Log a structured tracing event at WARN level.
18    Log,
19    /// Increment a Prometheus counter.
20    Metric,
21    /// HTTP POST to a webhook URL.
22    Webhook(String),
23    /// Emit a CDC event.
24    CdcEvent,
25}
26
27impl std::fmt::Display for AlertAction {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            Self::Log => write!(f, "log"),
31            Self::Metric => write!(f, "metric"),
32            Self::Webhook(url) => write!(f, "webhook({url})"),
33            Self::CdcEvent => write!(f, "cdc_event"),
34        }
35    }
36}
37
38/// Configuration for an alert.
39#[derive(Debug, Clone)]
40pub struct AlertConfig {
41    /// Unique alert ID.
42    pub id: String,
43    /// Measurement this alert applies to.
44    pub measurement: String,
45    /// Anomaly score threshold to fire the alert.
46    pub threshold: f64,
47    /// Cooldown period between firings for the same series.
48    pub cooldown: Duration,
49    /// Actions to take when the alert fires.
50    pub actions: Vec<AlertAction>,
51    /// Whether the alert is enabled.
52    pub enabled: bool,
53}
54
55impl AlertConfig {
56    /// Create a new alert config.
57    #[must_use]
58    pub fn new(id: impl Into<String>, measurement: impl Into<String>, threshold: f64) -> Self {
59        Self {
60            id: id.into(),
61            measurement: measurement.into(),
62            threshold,
63            cooldown: Duration::from_secs(300),
64            actions: vec![AlertAction::Log, AlertAction::Metric],
65            enabled: true,
66        }
67    }
68
69    /// Set the cooldown period.
70    #[must_use]
71    pub fn with_cooldown(mut self, cooldown: Duration) -> Self {
72        self.cooldown = cooldown;
73        self
74    }
75
76    /// Set the alert actions.
77    #[must_use]
78    pub fn with_actions(mut self, actions: Vec<AlertAction>) -> Self {
79        self.actions = actions;
80        self
81    }
82
83    /// Add an action.
84    #[must_use]
85    pub fn with_action(mut self, action: AlertAction) -> Self {
86        self.actions.push(action);
87        self
88    }
89}
90
91/// A fired alert record.
92#[derive(Debug, Clone)]
93pub struct FiredAlert {
94    /// Alert config ID.
95    pub alert_id: String,
96    /// Measurement.
97    pub measurement: String,
98    /// Series tags.
99    pub tags: BTreeMap<String, String>,
100    /// Anomaly score that triggered the alert.
101    pub score: f64,
102    /// Threshold that was exceeded.
103    pub threshold: f64,
104    /// Timestamp of the anomalous point.
105    pub timestamp: i64,
106    /// When the alert was fired.
107    pub fired_at: Instant,
108}
109
110/// Per-series cooldown key: `(alert_id, canonical_tags_key)`.
111type CooldownKey = (String, String);
112
113/// Alert engine — evaluates anomaly scores against configured alerts.
114pub struct AlertEngine {
115    configs: DashMap<String, AlertConfig>,
116    /// Per-alert×series cooldown tracking.
117    last_fired: DashMap<CooldownKey, Instant>,
118    /// Fired alert history.
119    history: RwLock<VecDeque<FiredAlert>>,
120    /// Maximum history size.
121    max_history: usize,
122    /// Evaluation counter for periodic stale-cooldown eviction.
123    ///
124    /// # Eviction strategy
125    ///
126    /// Stale cooldown entries are evicted every **1024 evaluations**
127    /// (count-based).  This is cheap and prevents unbounded growth for
128    /// most workloads.  A time-based eviction (e.g. background timer)
129    /// would be more predictable under variable evaluation rates but
130    /// adds complexity; the count-based approach is intentional for
131    /// simplicity.
132    eval_count: AtomicU64,
133}
134
135impl AlertEngine {
136    /// Create a new alert engine.
137    #[must_use]
138    pub fn new() -> Self {
139        Self {
140            configs: DashMap::new(),
141            last_fired: DashMap::new(),
142            history: RwLock::new(VecDeque::new()),
143            max_history: 100_000,
144            eval_count: AtomicU64::new(0),
145        }
146    }
147
148    /// Register an alert configuration.
149    pub fn register(&self, config: AlertConfig) {
150        let id = config.id.clone();
151        self.configs.insert(id.clone(), config);
152        debug!(alert_id = %id, "Alert registered");
153    }
154
155    /// Unregister an alert.
156    pub fn unregister(&self, id: &str) {
157        self.configs.remove(id);
158        self.last_fired.retain(|k, _| k.0 != id);
159    }
160
161    /// Evaluate an anomaly score against all configured alerts for a measurement.
162    ///
163    /// Returns the fired alert records (if any).
164    pub fn evaluate(
165        &self,
166        measurement: &str,
167        tags: &BTreeMap<String, String>,
168        score: f64,
169        timestamp: i64,
170    ) -> Vec<FiredAlert> {
171        let mut fired = Vec::new();
172        let series_key = compute_tags_key(tags);
173        let now = Instant::now();
174
175        // Periodically evict stale cooldowns to prevent unbounded growth.
176        let count = self.eval_count.fetch_add(1, Ordering::Relaxed);
177        if count.is_multiple_of(1024) && count > 0 {
178            self.evict_stale_cooldowns();
179        }
180
181        for entry in self.configs.iter() {
182            let config = entry.value();
183            if !config.enabled || config.measurement != measurement {
184                continue;
185            }
186
187            // Check threshold
188            if score < config.threshold {
189                continue;
190            }
191
192            // Check cooldown
193            let key = (config.id.clone(), series_key.clone());
194            if let Some(last) = self.last_fired.get(&key)
195                && now.duration_since(*last) < config.cooldown
196            {
197                continue;
198            }
199
200            // Fire!
201            self.last_fired.insert(key, now);
202
203            let alert = FiredAlert {
204                alert_id: config.id.clone(),
205                measurement: measurement.to_string(),
206                tags: tags.clone(),
207                score,
208                threshold: config.threshold,
209                timestamp,
210                fired_at: now,
211            };
212
213            // Execute actions
214            for action in &config.actions {
215                execute_action(action, &alert);
216            }
217
218            counter!(
219                "chronix_alert_fired_total",
220                "alert_id" => config.id.clone(),
221                "measurement" => measurement.to_string()
222            )
223            .increment(1);
224
225            // Store in history
226            let mut hist = self.history.write();
227            if hist.len() >= self.max_history {
228                hist.pop_front();
229            }
230            hist.push_back(alert.clone());
231
232            fired.push(alert);
233        }
234
235        fired
236    }
237
238    /// Get alert firing history.
239    #[must_use]
240    pub fn history(&self) -> Vec<FiredAlert> {
241        self.history.read().iter().cloned().collect()
242    }
243
244    /// Number of configured alerts.
245    #[must_use]
246    pub fn alert_count(&self) -> usize {
247        self.configs.len()
248    }
249
250    /// Evict expired cooldown entries to prevent unbounded growth.
251    ///
252    /// Removes entries whose cooldown period has elapsed according to the
253    /// alert config. Entries for unregistered alerts are always removed.
254    pub fn evict_stale_cooldowns(&self) {
255        let now = Instant::now();
256        self.last_fired.retain(|key, fired_at| {
257            if let Some(config) = self.configs.get(&key.0) {
258                // Keep if still within cooldown window
259                now.duration_since(*fired_at) < config.cooldown
260            } else {
261                // Alert was unregistered — remove stale entry
262                false
263            }
264        });
265    }
266
267    /// Number of active cooldown entries (for diagnostics).
268    #[must_use]
269    pub fn cooldown_count(&self) -> usize {
270        self.last_fired.len()
271    }
272}
273
274impl Default for AlertEngine {
275    fn default() -> Self {
276        Self::new()
277    }
278}
279
280fn execute_action(action: &AlertAction, alert: &FiredAlert) {
281    match action {
282        AlertAction::Log => {
283            warn!(
284                alert_id = %alert.alert_id,
285                measurement = %alert.measurement,
286                score = alert.score,
287                threshold = alert.threshold,
288                timestamp = alert.timestamp,
289                "Alert fired"
290            );
291        }
292        AlertAction::Metric => {
293            counter!(
294                "chronix_alert_action_metric_total",
295                "alert_id" => alert.alert_id.clone()
296            )
297            .increment(1);
298        }
299        AlertAction::Webhook(url) => {
300            // In embedded mode, webhook delivery is handled by the
301            // Pipeline's DeliveryRouter (FiredAlert → SignalEvent → channels).
302            // This action records the intent for observability.
303            counter!(
304                "chronix_alert_webhook_total",
305                "alert_id" => alert.alert_id.clone()
306            )
307            .increment(1);
308            debug!(
309                alert_id = %alert.alert_id,
310                url = %url,
311                "Webhook alert action recorded (delivery via Pipeline DeliveryRouter)"
312            );
313        }
314        AlertAction::CdcEvent => {
315            // In embedded mode, CDC event emission is handled by the
316            // Pipeline when it converts FiredAlert → SignalEvent.
317            // This action records the intent for observability.
318            counter!(
319                "chronix_alert_cdc_event_total",
320                "alert_id" => alert.alert_id.clone()
321            )
322            .increment(1);
323            debug!(
324                alert_id = %alert.alert_id,
325                "CDC event alert action recorded (delivery via Pipeline)"
326            );
327        }
328    }
329}
330
331use crate::util::compute_tags_key;
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    fn make_tags(host: &str) -> BTreeMap<String, String> {
338        let mut tags = BTreeMap::new();
339        tags.insert("host".to_string(), host.to_string());
340        tags
341    }
342
343    #[test]
344    fn alert_fires_on_threshold() {
345        let engine = AlertEngine::new();
346        engine.register(
347            AlertConfig::new("alert1", "cpu", 0.8).with_cooldown(Duration::from_millis(0)),
348        );
349
350        let tags = make_tags("host1");
351        // Below threshold — no alert
352        let fired = engine.evaluate("cpu", &tags, 0.5, 1000);
353        assert!(fired.is_empty());
354
355        // Above threshold — fires
356        let fired = engine.evaluate("cpu", &tags, 0.9, 2000);
357        assert_eq!(fired.len(), 1);
358        assert_eq!(fired[0].alert_id, "alert1");
359        assert!((fired[0].score - 0.9).abs() < f64::EPSILON);
360    }
361
362    #[test]
363    fn cooldown_suppresses() {
364        let engine = AlertEngine::new();
365        engine.register(
366            AlertConfig::new("alert1", "cpu", 0.5).with_cooldown(Duration::from_secs(3600)),
367        );
368
369        let tags = make_tags("host1");
370        let fired1 = engine.evaluate("cpu", &tags, 0.9, 1000);
371        assert_eq!(fired1.len(), 1);
372
373        // Second firing suppressed by cooldown
374        let fired2 = engine.evaluate("cpu", &tags, 0.9, 2000);
375        assert!(fired2.is_empty());
376    }
377
378    #[test]
379    fn different_series_independent_cooldown() {
380        let engine = AlertEngine::new();
381        engine.register(
382            AlertConfig::new("alert1", "cpu", 0.5).with_cooldown(Duration::from_secs(3600)),
383        );
384
385        let tags_a = make_tags("host1");
386        let tags_b = make_tags("host2");
387
388        let fired_a = engine.evaluate("cpu", &tags_a, 0.9, 1000);
389        assert_eq!(fired_a.len(), 1);
390
391        // Different series — fires independently
392        let fired_b = engine.evaluate("cpu", &tags_b, 0.9, 2000);
393        assert_eq!(fired_b.len(), 1);
394    }
395
396    #[test]
397    fn unregistered_measurement_ignored() {
398        let engine = AlertEngine::new();
399        engine.register(AlertConfig::new("alert1", "cpu", 0.5));
400
401        let tags = make_tags("host1");
402        let fired = engine.evaluate("memory", &tags, 0.9, 1000);
403        assert!(fired.is_empty());
404    }
405
406    #[test]
407    fn multiple_alerts_same_measurement() {
408        let engine = AlertEngine::new();
409        engine
410            .register(AlertConfig::new("low", "cpu", 0.3).with_cooldown(Duration::from_millis(0)));
411        engine
412            .register(AlertConfig::new("high", "cpu", 0.8).with_cooldown(Duration::from_millis(0)));
413
414        let tags = make_tags("host1");
415
416        // Score 0.5 — only "low" fires
417        let fired = engine.evaluate("cpu", &tags, 0.5, 1000);
418        assert_eq!(fired.len(), 1);
419        assert_eq!(fired[0].alert_id, "low");
420
421        // Score 0.9 — both fire
422        let fired = engine.evaluate("cpu", &tags, 0.9, 2000);
423        assert_eq!(fired.len(), 2);
424    }
425
426    #[test]
427    fn unregister_removes_alert() {
428        let engine = AlertEngine::new();
429        engine.register(AlertConfig::new("alert1", "cpu", 0.5));
430        assert_eq!(engine.alert_count(), 1);
431
432        engine.unregister("alert1");
433        assert_eq!(engine.alert_count(), 0);
434    }
435
436    #[test]
437    fn history_recorded() {
438        let engine = AlertEngine::new();
439        engine.register(
440            AlertConfig::new("alert1", "cpu", 0.5).with_cooldown(Duration::from_millis(0)),
441        );
442
443        let tags = make_tags("host1");
444        engine.evaluate("cpu", &tags, 0.9, 1000);
445        engine.evaluate("cpu", &tags, 0.8, 2000);
446
447        let hist = engine.history();
448        assert_eq!(hist.len(), 2);
449    }
450
451    #[test]
452    fn alert_action_display() {
453        assert_eq!(AlertAction::Log.to_string(), "log");
454        assert_eq!(AlertAction::Metric.to_string(), "metric");
455        assert_eq!(
456            AlertAction::Webhook("https://x.com".into()).to_string(),
457            "webhook(https://x.com)"
458        );
459        assert_eq!(AlertAction::CdcEvent.to_string(), "cdc_event");
460    }
461
462    #[test]
463    fn alert_config_builder() {
464        let config = AlertConfig::new("a1", "cpu", 0.5)
465            .with_cooldown(Duration::from_secs(60))
466            .with_actions(vec![AlertAction::Log])
467            .with_action(AlertAction::Metric);
468        assert_eq!(config.actions.len(), 2);
469        assert_eq!(config.cooldown, Duration::from_secs(60));
470    }
471
472    #[test]
473    fn disabled_alert_does_not_fire() {
474        let engine = AlertEngine::new();
475        let mut config = AlertConfig::new("alert1", "cpu", 0.5);
476        config.enabled = false;
477        engine.register(config);
478
479        let tags = make_tags("host1");
480        let fired = engine.evaluate("cpu", &tags, 0.9, 1000);
481        assert!(fired.is_empty());
482    }
483
484    #[test]
485    fn test_cooldown_eviction() {
486        let engine = AlertEngine::new();
487        engine.register(
488            AlertConfig::new("alert1", "cpu", 0.5).with_cooldown(Duration::from_millis(10)),
489        );
490
491        let tags = make_tags("host1");
492        // Fire the alert
493        let fired = engine.evaluate("cpu", &tags, 0.9, 1000);
494        assert_eq!(fired.len(), 1);
495        assert_eq!(engine.cooldown_count(), 1);
496
497        // Wait for cooldown to expire
498        std::thread::sleep(Duration::from_millis(20));
499
500        engine.evict_stale_cooldowns();
501        assert_eq!(engine.cooldown_count(), 0);
502    }
503
504    #[test]
505    fn test_cooldown_count_grows_with_series() {
506        let engine = AlertEngine::new();
507        engine.register(
508            AlertConfig::new("alert1", "cpu", 0.5).with_cooldown(Duration::from_secs(3600)),
509        );
510
511        let tags_a = make_tags("host1");
512        let tags_b = make_tags("host2");
513        let tags_c = make_tags("host3");
514
515        engine.evaluate("cpu", &tags_a, 0.9, 1000);
516        engine.evaluate("cpu", &tags_b, 0.9, 2000);
517        engine.evaluate("cpu", &tags_c, 0.9, 3000);
518
519        assert_eq!(engine.cooldown_count(), 3);
520    }
521}