1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum AlertAction {
17 Log,
19 Metric,
21 Webhook(String),
23 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#[derive(Debug, Clone)]
40pub struct AlertConfig {
41 pub id: String,
43 pub measurement: String,
45 pub threshold: f64,
47 pub cooldown: Duration,
49 pub actions: Vec<AlertAction>,
51 pub enabled: bool,
53}
54
55impl AlertConfig {
56 #[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 #[must_use]
71 pub fn with_cooldown(mut self, cooldown: Duration) -> Self {
72 self.cooldown = cooldown;
73 self
74 }
75
76 #[must_use]
78 pub fn with_actions(mut self, actions: Vec<AlertAction>) -> Self {
79 self.actions = actions;
80 self
81 }
82
83 #[must_use]
85 pub fn with_action(mut self, action: AlertAction) -> Self {
86 self.actions.push(action);
87 self
88 }
89}
90
91#[derive(Debug, Clone)]
93pub struct FiredAlert {
94 pub alert_id: String,
96 pub measurement: String,
98 pub tags: BTreeMap<String, String>,
100 pub score: f64,
102 pub threshold: f64,
104 pub timestamp: i64,
106 pub fired_at: Instant,
108}
109
110type CooldownKey = (String, String);
112
113pub struct AlertEngine {
115 configs: DashMap<String, AlertConfig>,
116 last_fired: DashMap<CooldownKey, Instant>,
118 history: RwLock<VecDeque<FiredAlert>>,
120 max_history: usize,
122 eval_count: AtomicU64,
133}
134
135impl AlertEngine {
136 #[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 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 pub fn unregister(&self, id: &str) {
157 self.configs.remove(id);
158 self.last_fired.retain(|k, _| k.0 != id);
159 }
160
161 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 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 if score < config.threshold {
189 continue;
190 }
191
192 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 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 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 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 #[must_use]
240 pub fn history(&self) -> Vec<FiredAlert> {
241 self.history.read().iter().cloned().collect()
242 }
243
244 #[must_use]
246 pub fn alert_count(&self) -> usize {
247 self.configs.len()
248 }
249
250 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 now.duration_since(*fired_at) < config.cooldown
260 } else {
261 false
263 }
264 });
265 }
266
267 #[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 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 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 let fired = engine.evaluate("cpu", &tags, 0.5, 1000);
353 assert!(fired.is_empty());
354
355 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 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 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 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 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 let fired = engine.evaluate("cpu", &tags, 0.9, 1000);
494 assert_eq!(fired.len(), 1);
495 assert_eq!(engine.cooldown_count(), 1);
496
497 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}