1#![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
2use crate::event::{AlertSeverity, Event, TaskEvent, WorkerEvent};
47use chrono::{DateTime, Utc};
48use serde::{Deserialize, Serialize};
49use std::collections::HashMap;
50use std::collections::VecDeque;
51use std::time::Duration;
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct EventSnapshot {
60 pub timestamp: DateTime<Utc>,
62 pub pending: u64,
64 pub active: u64,
66 pub succeeded: u64,
68 pub failed: u64,
70 pub retried: u64,
72 pub revoked: u64,
74 pub queue_depth: u64,
76 pub worker_count: u64,
78 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
80 pub queue_depths: HashMap<String, u64>,
81}
82
83impl EventSnapshot {
84 #[inline]
86 #[must_use]
87 pub const fn completed(&self) -> u64 {
88 self.succeeded + self.failed
89 }
90
91 #[inline]
95 #[must_use]
96 pub fn failure_rate(&self) -> f64 {
97 let total = self.completed();
98 if total == 0 {
99 0.0
100 } else {
101 self.failed as f64 / total as f64
102 }
103 }
104}
105
106#[derive(Debug, Clone, Default)]
111pub struct EventSnapshotBuilder {
112 timestamp: Option<DateTime<Utc>>,
113 pending: u64,
114 active: u64,
115 succeeded: u64,
116 failed: u64,
117 retried: u64,
118 revoked: u64,
119 queue_depth: u64,
120 queue_depths: HashMap<String, u64>,
121 workers: std::collections::HashSet<String>,
122 worker_count_override: Option<u64>,
123}
124
125impl EventSnapshotBuilder {
126 #[must_use]
128 pub fn new() -> Self {
129 Self::default()
130 }
131
132 #[must_use]
134 pub fn timestamp(mut self, ts: DateTime<Utc>) -> Self {
135 self.timestamp = Some(ts);
136 self
137 }
138
139 #[must_use]
141 pub fn pending(mut self, n: u64) -> Self {
142 self.pending = n;
143 self
144 }
145
146 #[must_use]
148 pub fn active(mut self, n: u64) -> Self {
149 self.active = n;
150 self
151 }
152
153 #[must_use]
155 pub fn succeeded(mut self, n: u64) -> Self {
156 self.succeeded = n;
157 self
158 }
159
160 #[must_use]
162 pub fn failed(mut self, n: u64) -> Self {
163 self.failed = n;
164 self
165 }
166
167 #[must_use]
169 pub fn retried(mut self, n: u64) -> Self {
170 self.retried = n;
171 self
172 }
173
174 #[must_use]
176 pub fn revoked(mut self, n: u64) -> Self {
177 self.revoked = n;
178 self
179 }
180
181 #[must_use]
183 pub fn worker_count(mut self, n: u64) -> Self {
184 self.worker_count_override = Some(n);
185 self
186 }
187
188 #[must_use]
190 pub fn queue_depth(mut self, queue: impl Into<String>, depth: u64) -> Self {
191 let q = queue.into();
192 let prev = self.queue_depths.insert(q, depth).unwrap_or(0);
193 self.queue_depth = self.queue_depth + depth - prev;
194 self
195 }
196
197 #[must_use]
199 pub fn total_queue_depth(mut self, depth: u64) -> Self {
200 self.queue_depth = depth;
201 self
202 }
203
204 pub fn observe(&mut self, event: &Event) -> &mut Self {
210 match event {
211 Event::Task(task) => match task {
212 TaskEvent::Sent { .. } => self.pending += 1,
213 TaskEvent::Started { .. } => self.active += 1,
214 TaskEvent::Succeeded { .. } => self.succeeded += 1,
215 TaskEvent::Failed { .. } => self.failed += 1,
216 TaskEvent::Retried { .. } => self.retried += 1,
217 TaskEvent::Revoked { .. } => self.revoked += 1,
218 TaskEvent::Received { .. } | TaskEvent::Rejected { .. } => {}
219 },
220 Event::Worker(worker) => match worker {
221 WorkerEvent::Online { hostname, .. } | WorkerEvent::Heartbeat { hostname, .. } => {
222 self.workers.insert(hostname.clone());
223 }
224 WorkerEvent::Offline { hostname, .. } => {
225 self.workers.remove(hostname);
226 }
227 },
228 }
229 self
230 }
231
232 pub fn observe_all<'a, I>(&mut self, events: I) -> &mut Self
234 where
235 I: IntoIterator<Item = &'a Event>,
236 {
237 for event in events {
238 self.observe(event);
239 }
240 self
241 }
242
243 #[must_use]
245 pub fn build(self) -> EventSnapshot {
246 let worker_count = self
247 .worker_count_override
248 .unwrap_or(self.workers.len() as u64);
249 EventSnapshot {
250 timestamp: self.timestamp.unwrap_or_else(Utc::now),
251 pending: self.pending,
252 active: self.active,
253 succeeded: self.succeeded,
254 failed: self.failed,
255 retried: self.retried,
256 revoked: self.revoked,
257 queue_depth: self.queue_depth,
258 worker_count,
259 queue_depths: self.queue_depths,
260 }
261 }
262}
263
264#[derive(Debug, Clone)]
266pub enum AlertRuleKind {
267 FailureRate {
271 trip_threshold: f64,
273 clear_threshold: f64,
275 window: Duration,
277 min_samples: u64,
279 },
280 QueueDepth {
282 trip_depth: u64,
284 clear_depth: u64,
286 queue: Option<String>,
288 },
289 NoHeartbeat {
292 timeout: Duration,
294 },
295}
296
297#[derive(Debug, Clone)]
303pub struct AlertRule {
304 pub name: String,
306 pub severity: AlertSeverity,
308 pub kind: AlertRuleKind,
310 pub cooldown: Duration,
312}
313
314impl AlertRule {
315 #[must_use]
319 pub fn failure_rate(
320 name: impl Into<String>,
321 severity: AlertSeverity,
322 trip_threshold: f64,
323 ) -> Self {
324 Self {
325 name: name.into(),
326 severity,
327 kind: AlertRuleKind::FailureRate {
328 trip_threshold,
329 clear_threshold: trip_threshold,
330 window: Duration::from_secs(60),
331 min_samples: 1,
332 },
333 cooldown: Duration::from_secs(60),
334 }
335 }
336
337 #[must_use]
339 pub fn queue_depth(name: impl Into<String>, severity: AlertSeverity, trip_depth: u64) -> Self {
340 Self {
341 name: name.into(),
342 severity,
343 kind: AlertRuleKind::QueueDepth {
344 trip_depth,
345 clear_depth: trip_depth,
346 queue: None,
347 },
348 cooldown: Duration::from_secs(60),
349 }
350 }
351
352 #[must_use]
354 pub fn no_heartbeat(
355 name: impl Into<String>,
356 severity: AlertSeverity,
357 timeout: Duration,
358 ) -> Self {
359 Self {
360 name: name.into(),
361 severity,
362 kind: AlertRuleKind::NoHeartbeat { timeout },
363 cooldown: Duration::from_secs(60),
364 }
365 }
366
367 #[must_use]
369 pub fn with_cooldown(mut self, cooldown: Duration) -> Self {
370 self.cooldown = cooldown;
371 self
372 }
373
374 #[must_use]
376 pub fn with_clear_threshold(mut self, clear: f64) -> Self {
377 if let AlertRuleKind::FailureRate {
378 clear_threshold, ..
379 } = &mut self.kind
380 {
381 *clear_threshold = clear;
382 }
383 self
384 }
385
386 #[must_use]
388 pub fn with_window(mut self, window: Duration) -> Self {
389 if let AlertRuleKind::FailureRate { window: w, .. } = &mut self.kind {
390 *w = window;
391 }
392 self
393 }
394
395 #[must_use]
397 pub fn with_min_samples(mut self, min_samples: u64) -> Self {
398 if let AlertRuleKind::FailureRate { min_samples: m, .. } = &mut self.kind {
399 *m = min_samples;
400 }
401 self
402 }
403
404 #[must_use]
406 pub fn with_clear_depth(mut self, clear: u64) -> Self {
407 if let AlertRuleKind::QueueDepth { clear_depth, .. } = &mut self.kind {
408 *clear_depth = clear;
409 }
410 self
411 }
412
413 #[must_use]
415 pub fn for_queue(mut self, queue: impl Into<String>) -> Self {
416 if let AlertRuleKind::QueueDepth { queue: q, .. } = &mut self.kind {
417 *q = Some(queue.into());
418 }
419 self
420 }
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
425pub enum AlertStatus {
426 Firing,
428 Resolved,
430}
431
432#[derive(Debug, Clone)]
438pub struct RuleAlert {
439 pub rule_name: String,
441 pub severity: AlertSeverity,
443 pub status: AlertStatus,
445 pub message: String,
447 pub observed_value: f64,
450 pub timestamp: DateTime<Utc>,
452}
453
454impl RuleAlert {
455 #[inline]
457 #[must_use]
458 pub fn is_firing(&self) -> bool {
459 self.status == AlertStatus::Firing
460 }
461}
462
463#[derive(Debug)]
465struct RuleState {
466 firing: bool,
468 last_fired: Option<DateTime<Utc>>,
470 completions: VecDeque<(DateTime<Utc>, bool)>,
472}
473
474impl RuleState {
475 fn new() -> Self {
476 Self {
477 firing: false,
478 last_fired: None,
479 completions: VecDeque::new(),
480 }
481 }
482}
483
484#[derive(Debug)]
492pub struct AlertEvaluator {
493 rules: Vec<AlertRule>,
494 states: Vec<RuleState>,
495 last_heartbeat: Option<DateTime<Utc>>,
497}
498
499impl Default for AlertEvaluator {
500 fn default() -> Self {
501 Self::new()
502 }
503}
504
505impl AlertEvaluator {
506 #[must_use]
508 pub fn new() -> Self {
509 Self {
510 rules: Vec::new(),
511 states: Vec::new(),
512 last_heartbeat: None,
513 }
514 }
515
516 #[must_use]
518 pub fn with_rule(mut self, rule: AlertRule) -> Self {
519 self.add_rule(rule);
520 self
521 }
522
523 pub fn add_rule(&mut self, rule: AlertRule) {
525 self.rules.push(rule);
526 self.states.push(RuleState::new());
527 }
528
529 #[inline]
531 #[must_use]
532 pub fn rule_count(&self) -> usize {
533 self.rules.len()
534 }
535
536 #[must_use]
538 pub fn is_firing(&self, name: &str) -> bool {
539 self.rules
540 .iter()
541 .position(|r| r.name == name)
542 .and_then(|i| self.states.get(i))
543 .is_some_and(|s| s.firing)
544 }
545
546 pub fn observe_event(&mut self, event: &Event) -> Vec<RuleAlert> {
550 let now = event.timestamp();
551
552 if matches!(
554 event,
555 Event::Worker(WorkerEvent::Heartbeat { .. } | WorkerEvent::Online { .. })
556 ) {
557 self.last_heartbeat = Some(now);
558 }
559
560 let completion = match event {
562 Event::Task(TaskEvent::Succeeded { .. }) => Some(false),
563 Event::Task(TaskEvent::Failed { .. }) => Some(true),
564 _ => None,
565 };
566
567 let mut alerts = Vec::new();
568 for idx in 0..self.rules.len() {
569 let kind = self.rules[idx].kind.clone();
570 match kind {
571 AlertRuleKind::FailureRate {
572 trip_threshold,
573 clear_threshold,
574 window,
575 min_samples,
576 } => {
577 let rule = self.rules[idx].clone();
578 let state = &mut self.states[idx];
579 if let Some(is_failure) = completion {
580 state.completions.push_back((now, is_failure));
581 }
582 Self::prune_completions(&mut state.completions, now, window);
583 let total = state.completions.len() as u64;
584 let failed = state.completions.iter().filter(|&&(_, f)| f).count() as u64;
585 let rate = if total == 0 {
586 0.0
587 } else {
588 failed as f64 / total as f64
589 };
590 let can_fire = total >= min_samples;
591 if let Some(alert) = Self::evaluate_threshold(
592 &rule,
593 state,
594 now,
595 rate,
596 trip_threshold,
597 clear_threshold,
598 can_fire,
599 true,
600 format!(
601 "failure rate {:.0}% ({failed}/{total}) over window",
602 rate * 100.0
603 ),
604 ) {
605 alerts.push(alert);
606 }
607 }
608 AlertRuleKind::NoHeartbeat { timeout } => {
609 let rule = self.rules[idx].clone();
610 if let Some(alert) = self.no_heartbeat_alert(idx, now, timeout, rule) {
611 alerts.push(alert);
612 }
613 }
614 AlertRuleKind::QueueDepth { .. } => {
615 }
617 }
618 }
619 alerts
620 }
621
622 pub fn observe_snapshot(&mut self, snapshot: &EventSnapshot) -> Vec<RuleAlert> {
628 let now = snapshot.timestamp;
629 if snapshot.worker_count > 0 {
630 self.last_heartbeat = Some(now);
631 }
632
633 let mut alerts = Vec::new();
634 for idx in 0..self.rules.len() {
635 let kind = self.rules[idx].kind.clone();
636 match kind {
637 AlertRuleKind::QueueDepth {
638 trip_depth,
639 clear_depth,
640 queue,
641 } => {
642 let depth = queue.as_ref().map_or(snapshot.queue_depth, |q| {
643 snapshot.queue_depths.get(q).copied().unwrap_or(0)
644 });
645 let rule = self.rules[idx].clone();
646 let state = &mut self.states[idx];
647 if let Some(alert) = Self::evaluate_threshold(
648 &rule,
649 state,
650 now,
651 depth as f64,
652 trip_depth as f64,
653 clear_depth as f64,
654 true,
655 false,
656 format!("queue depth {depth}"),
657 ) {
658 alerts.push(alert);
659 }
660 }
661 AlertRuleKind::NoHeartbeat { timeout } => {
662 let rule = self.rules[idx].clone();
663 if let Some(alert) = self.no_heartbeat_alert(idx, now, timeout, rule) {
664 alerts.push(alert);
665 }
666 }
667 AlertRuleKind::FailureRate { .. } => {
668 }
670 }
671 }
672 alerts
673 }
674
675 pub fn tick(&mut self, now: DateTime<Utc>) -> Vec<RuleAlert> {
680 let mut alerts = Vec::new();
681 for idx in 0..self.rules.len() {
682 if let AlertRuleKind::NoHeartbeat { timeout } = self.rules[idx].kind {
683 let rule = self.rules[idx].clone();
684 if let Some(alert) = self.no_heartbeat_alert(idx, now, timeout, rule) {
685 alerts.push(alert);
686 }
687 }
688 }
689 alerts
690 }
691
692 fn no_heartbeat_alert(
695 &mut self,
696 idx: usize,
697 now: DateTime<Utc>,
698 timeout: Duration,
699 rule: AlertRule,
700 ) -> Option<RuleAlert> {
701 let secs_silent = match self.last_heartbeat {
702 Some(last) => (now - last).num_milliseconds().max(0) as f64 / 1000.0,
703 None => f64::INFINITY,
704 };
705 let timeout_secs = timeout.as_secs_f64();
706 let tripped = secs_silent > timeout_secs;
707 let state = &mut self.states[idx];
708 let display = if secs_silent.is_finite() {
709 format!("no heartbeat for {secs_silent:.1}s")
710 } else {
711 "no heartbeat observed yet".to_string()
712 };
713 Self::transition(&rule, state, now, tripped, secs_silent, display)
714 }
715
716 #[allow(clippy::too_many_arguments)]
722 fn evaluate_threshold(
723 rule: &AlertRule,
724 state: &mut RuleState,
725 now: DateTime<Utc>,
726 value: f64,
727 trip: f64,
728 clear: f64,
729 can_fire: bool,
730 _trip_when_ge: bool,
731 display: String,
732 ) -> Option<RuleAlert> {
733 let tripped = if state.firing {
734 value >= clear
736 } else {
737 can_fire && value >= trip
738 };
739 Self::transition(rule, state, now, tripped, value, display)
740 }
741
742 fn transition(
745 rule: &AlertRule,
746 state: &mut RuleState,
747 now: DateTime<Utc>,
748 tripped: bool,
749 observed_value: f64,
750 display: String,
751 ) -> Option<RuleAlert> {
752 if tripped {
753 let was_firing = state.firing;
754 state.firing = true;
755 let cooldown_ok = match state.last_fired {
756 None => true,
757 Some(last) => {
758 let elapsed = (now - last).num_milliseconds().max(0) as u128;
759 elapsed >= rule.cooldown.as_millis()
760 }
761 };
762 if !was_firing || cooldown_ok {
763 state.last_fired = Some(now);
764 return Some(RuleAlert {
765 rule_name: rule.name.clone(),
766 severity: rule.severity,
767 status: AlertStatus::Firing,
768 message: format!("{}: {display}", rule.name),
769 observed_value,
770 timestamp: now,
771 });
772 }
773 None
774 } else {
775 if state.firing {
776 state.firing = false;
777 state.last_fired = None;
778 return Some(RuleAlert {
779 rule_name: rule.name.clone(),
780 severity: rule.severity,
781 status: AlertStatus::Resolved,
782 message: format!("{} resolved: {display}", rule.name),
783 observed_value,
784 timestamp: now,
785 });
786 }
787 None
788 }
789 }
790
791 fn prune_completions(
793 completions: &mut VecDeque<(DateTime<Utc>, bool)>,
794 now: DateTime<Utc>,
795 window: Duration,
796 ) {
797 let cutoff = now - chrono::Duration::milliseconds(window.as_millis() as i64);
798 while let Some(&(ts, _)) = completions.front() {
799 if ts < cutoff {
800 completions.pop_front();
801 } else {
802 break;
803 }
804 }
805 }
806}
807
808#[cfg(test)]
809mod tests {
810 use super::*;
811 use crate::event::{TaskEventBuilder, WorkerEventBuilder};
812 use uuid::Uuid;
813
814 fn failed_event(at: DateTime<Utc>) -> Event {
815 Event::Task(TaskEvent::Failed {
817 task_id: Uuid::new_v4(),
818 task_name: "t".to_string(),
819 hostname: "w1".to_string(),
820 timestamp: at,
821 exception: "Boom".to_string(),
822 traceback: None,
823 })
824 }
825
826 fn succeeded_event(at: DateTime<Utc>) -> Event {
827 Event::Task(TaskEvent::Succeeded {
828 task_id: Uuid::new_v4(),
829 task_name: "t".to_string(),
830 hostname: "w1".to_string(),
831 timestamp: at,
832 runtime: 0.1,
833 result: None,
834 })
835 }
836
837 fn heartbeat_event(at: DateTime<Utc>) -> Event {
838 Event::Worker(WorkerEvent::Heartbeat {
839 hostname: "w1".to_string(),
840 timestamp: at,
841 active: 1,
842 processed: 1,
843 loadavg: None,
844 freq: 1.0,
845 })
846 }
847
848 #[test]
849 fn test_snapshot_builder_from_events() {
850 let now = Utc::now();
851 let events = vec![
852 TaskEventBuilder::new(Uuid::new_v4(), "t").sent("celery"),
853 succeeded_event(now),
854 failed_event(now),
855 WorkerEventBuilder::new("w1").online(),
856 WorkerEventBuilder::new("w2").online(),
857 ];
858 let mut b = EventSnapshotBuilder::new();
859 b.observe_all(&events);
860 let snap = b.queue_depth("celery", 7).build();
861 assert_eq!(snap.pending, 1);
862 assert_eq!(snap.succeeded, 1);
863 assert_eq!(snap.failed, 1);
864 assert_eq!(snap.worker_count, 2);
865 assert_eq!(snap.queue_depth, 7);
866 assert!((snap.failure_rate() - 0.5).abs() < 1e-9);
867 }
868
869 #[test]
870 fn test_snapshot_serialization_roundtrip() {
871 let snap = EventSnapshotBuilder::new()
872 .pending(3)
873 .active(2)
874 .succeeded(10)
875 .failed(2)
876 .worker_count(4)
877 .queue_depth("q", 5)
878 .build();
879 let json = serde_json::to_string(&snap).unwrap();
880 let parsed: EventSnapshot = serde_json::from_str(&json).unwrap();
881 assert_eq!(snap, parsed);
882 }
883
884 #[test]
885 fn test_failure_rate_trips_and_recovers() {
886 let rule = AlertRule::failure_rate("fail", AlertSeverity::Error, 0.5)
887 .with_clear_threshold(0.2)
888 .with_min_samples(4)
889 .with_window(Duration::from_secs(3600))
890 .with_cooldown(Duration::from_secs(0));
891 let mut eval = AlertEvaluator::new().with_rule(rule);
892
893 let base = Utc::now();
894 let mut fired = false;
896 for i in 0..4 {
897 let at = base + chrono::Duration::seconds(i);
898 let alerts = eval.observe_event(&failed_event(at));
899 if alerts.iter().any(|a| a.is_firing()) {
900 fired = true;
901 }
902 }
903 assert!(fired, "rule should fire on high failure rate");
904 assert!(eval.is_firing("fail"));
905
906 let mut resolved = false;
908 for i in 4..40 {
909 let at = base + chrono::Duration::seconds(i);
910 let alerts = eval.observe_event(&succeeded_event(at));
911 if alerts.iter().any(|a| a.status == AlertStatus::Resolved) {
912 resolved = true;
913 }
914 }
915 assert!(resolved, "rule should resolve once failure rate drops");
916 assert!(!eval.is_firing("fail"));
917 }
918
919 #[test]
920 fn test_failure_rate_respects_min_samples() {
921 let rule = AlertRule::failure_rate("fail", AlertSeverity::Warning, 0.5)
922 .with_min_samples(10)
923 .with_cooldown(Duration::from_secs(0));
924 let mut eval = AlertEvaluator::new().with_rule(rule);
925 let base = Utc::now();
926 for i in 0..3 {
928 let at = base + chrono::Duration::seconds(i);
929 let alerts = eval.observe_event(&failed_event(at));
930 assert!(alerts.is_empty());
931 }
932 assert!(!eval.is_firing("fail"));
933 }
934
935 #[test]
936 fn test_queue_depth_hysteresis() {
937 let rule = AlertRule::queue_depth("depth", AlertSeverity::Warning, 100)
938 .with_clear_depth(50)
939 .with_cooldown(Duration::from_secs(0));
940 let mut eval = AlertEvaluator::new().with_rule(rule);
941 let now = Utc::now();
942
943 let low = EventSnapshotBuilder::new()
944 .timestamp(now)
945 .total_queue_depth(40)
946 .build();
947 assert!(eval.observe_snapshot(&low).is_empty());
948
949 let high = EventSnapshotBuilder::new()
950 .timestamp(now)
951 .total_queue_depth(150)
952 .build();
953 let alerts = eval.observe_snapshot(&high);
954 assert!(alerts.iter().any(|a| a.is_firing()));
955 assert!(eval.is_firing("depth"));
956
957 let mid = EventSnapshotBuilder::new()
960 .timestamp(now)
961 .total_queue_depth(60)
962 .build();
963 let _ = eval.observe_snapshot(&mid);
964 assert!(eval.is_firing("depth"), "60 > clear threshold 50");
965
966 let cleared = EventSnapshotBuilder::new()
968 .timestamp(now)
969 .total_queue_depth(40)
970 .build();
971 let alerts = eval.observe_snapshot(&cleared);
972 assert!(alerts.iter().any(|a| a.status == AlertStatus::Resolved));
973 assert!(!eval.is_firing("depth"));
974 }
975
976 #[test]
977 fn test_queue_depth_specific_queue() {
978 let rule = AlertRule::queue_depth("q-depth", AlertSeverity::Error, 10).for_queue("urgent");
979 let mut eval = AlertEvaluator::new().with_rule(rule);
980 let now = Utc::now();
981 let snap = EventSnapshotBuilder::new()
982 .timestamp(now)
983 .queue_depth("urgent", 20)
984 .queue_depth("bulk", 5)
985 .build();
986 let alerts = eval.observe_snapshot(&snap);
987 assert!(alerts.iter().any(|a| a.is_firing()));
988 }
989
990 #[test]
991 fn test_no_heartbeat_trips_via_tick() {
992 let rule = AlertRule::no_heartbeat("hb", AlertSeverity::Critical, Duration::from_secs(30));
993 let mut eval = AlertEvaluator::new().with_rule(rule);
994 let base = Utc::now();
995
996 let alerts = eval.observe_event(&heartbeat_event(base));
998 assert!(alerts.is_empty());
999
1000 let _ = eval.tick(base + chrono::Duration::seconds(10));
1002 assert!(!eval.is_firing("hb"));
1003
1004 let alerts = eval.tick(base + chrono::Duration::seconds(45));
1006 assert!(alerts.iter().any(|a| a.is_firing()));
1007 assert!(eval.is_firing("hb"));
1008
1009 let alerts = eval.observe_event(&heartbeat_event(base + chrono::Duration::seconds(46)));
1011 assert!(alerts.iter().any(|a| a.status == AlertStatus::Resolved));
1012 assert!(!eval.is_firing("hb"));
1013 }
1014
1015 #[test]
1016 fn test_cooldown_suppresses_repeat_firing() {
1017 let rule = AlertRule::queue_depth("depth", AlertSeverity::Warning, 10)
1018 .with_cooldown(Duration::from_secs(300));
1019 let mut eval = AlertEvaluator::new().with_rule(rule);
1020 let now = Utc::now();
1021
1022 let high = EventSnapshotBuilder::new()
1023 .timestamp(now)
1024 .total_queue_depth(50)
1025 .build();
1026 let first = eval.observe_snapshot(&high);
1027 assert_eq!(first.iter().filter(|a| a.is_firing()).count(), 1);
1028
1029 let again = EventSnapshotBuilder::new()
1031 .timestamp(now + chrono::Duration::seconds(5))
1032 .total_queue_depth(50)
1033 .build();
1034 let second = eval.observe_snapshot(&again);
1035 assert!(second.iter().all(|a| !a.is_firing()));
1036
1037 let later = EventSnapshotBuilder::new()
1039 .timestamp(now + chrono::Duration::seconds(400))
1040 .total_queue_depth(50)
1041 .build();
1042 let third = eval.observe_snapshot(&later);
1043 assert_eq!(third.iter().filter(|a| a.is_firing()).count(), 1);
1044 }
1045
1046 #[test]
1047 fn test_multiple_rules_evaluated() {
1048 let mut eval = AlertEvaluator::new()
1049 .with_rule(AlertRule::queue_depth("d", AlertSeverity::Warning, 10))
1050 .with_rule(AlertRule::no_heartbeat(
1051 "hb",
1052 AlertSeverity::Critical,
1053 Duration::from_secs(5),
1054 ));
1055 assert_eq!(eval.rule_count(), 2);
1056 let now = Utc::now();
1057 let snap = EventSnapshotBuilder::new()
1059 .timestamp(now)
1060 .total_queue_depth(99)
1061 .worker_count(0)
1062 .build();
1063 let alerts = eval.observe_snapshot(&snap);
1064 assert!(alerts.iter().any(|a| a.rule_name == "d" && a.is_firing()));
1065 assert!(alerts.iter().any(|a| a.rule_name == "hb" && a.is_firing()));
1066 }
1067
1068 #[test]
1069 fn test_failure_rate_window_pruning() {
1070 let rule = AlertRule::failure_rate("fail", AlertSeverity::Error, 0.5)
1071 .with_min_samples(2)
1072 .with_window(Duration::from_secs(10))
1073 .with_cooldown(Duration::from_secs(0));
1074 let mut eval = AlertEvaluator::new().with_rule(rule);
1075 let base = Utc::now();
1076
1077 eval.observe_event(&failed_event(base));
1079 eval.observe_event(&failed_event(base + chrono::Duration::seconds(1)));
1080 assert!(eval.is_firing("fail"));
1081
1082 let mut resolved = false;
1084 for i in 0..3 {
1085 let at = base + chrono::Duration::seconds(100 + i);
1086 let alerts = eval.observe_event(&succeeded_event(at));
1087 if alerts.iter().any(|a| a.status == AlertStatus::Resolved) {
1088 resolved = true;
1089 }
1090 }
1091 assert!(resolved);
1092 assert!(!eval.is_firing("fail"));
1093 }
1094}