Skip to main content

celers_core/
alerting.rs

1#![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
2//! Event snapshots and event-based alerting.
3//!
4//! This module builds on the real-time event types in [`crate::event`] to
5//! provide two cooperating pieces:
6//!
7//! 1. **Snapshots** ([`EventSnapshot`], [`EventSnapshotBuilder`]) — a periodic
8//!    aggregate of cluster state: counts of tasks by lifecycle state, queue
9//!    depth, worker count and a timestamp. Snapshots are cheap to produce, easy
10//!    to serialize, and are themselves a useful input to alerting.
11//!
12//! 2. **A rule engine** ([`AlertRule`], [`AlertRuleKind`], [`AlertEvaluator`])
13//!    that consumes a stream of [`Event`]s and/or [`EventSnapshot`]s and emits
14//!    [`RuleAlert`]s when a rule trips. Rules support *hysteresis* (separate
15//!    trip / clear thresholds) and a *cooldown* so a flapping condition does not
16//!    produce a storm of alerts. The engine also emits a recovery alert when a
17//!    previously-firing rule clears.
18//!
19//! It deliberately reuses the existing [`crate::event::AlertSeverity`] type
20//! rather than introducing a parallel severity enum.
21//!
22//! # Example
23//!
24//! ```rust
25//! use celers_core::alerting::{AlertEvaluator, AlertRule, EventSnapshotBuilder};
26//! use celers_core::event::{AlertSeverity, Event, TaskEventBuilder};
27//! use uuid::Uuid;
28//!
29//! // Fail if more than 50% of tasks fail over the recent window, recover under 20%.
30//! let rule = AlertRule::failure_rate("high-failures", AlertSeverity::Error, 0.5)
31//!     .with_clear_threshold(0.2)
32//!     .with_min_samples(4);
33//!
34//! let mut eval = AlertEvaluator::new().with_rule(rule);
35//!
36//! // Feed some failing task events; once the window has enough samples and the
37//! // failure rate crosses 0.5, an alert is produced.
38//! let mut last = Vec::new();
39//! for _ in 0..4 {
40//!     let ev = TaskEventBuilder::new(Uuid::new_v4(), "t").hostname("w1").failed("Boom");
41//!     last = eval.observe_event(&ev);
42//! }
43//! assert!(last.iter().any(|a| a.rule_name == "high-failures"));
44//! ```
45
46use 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/// Periodic aggregate snapshot of cluster state.
54///
55/// A snapshot is a point-in-time summary suitable for dashboards, persistence,
56/// and as an input to [`AlertEvaluator`]. All counts are plain integers so the
57/// type serializes compactly.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct EventSnapshot {
60    /// When the snapshot was taken.
61    pub timestamp: DateTime<Utc>,
62    /// Number of tasks currently pending (sent but not started).
63    pub pending: u64,
64    /// Number of tasks currently active/running.
65    pub active: u64,
66    /// Number of tasks that have succeeded (in the aggregation period).
67    pub succeeded: u64,
68    /// Number of tasks that have failed (in the aggregation period).
69    pub failed: u64,
70    /// Number of tasks retried (in the aggregation period).
71    pub retried: u64,
72    /// Number of tasks revoked (in the aggregation period).
73    pub revoked: u64,
74    /// Total depth across all queues.
75    pub queue_depth: u64,
76    /// Number of workers considered online.
77    pub worker_count: u64,
78    /// Optional per-queue depth breakdown.
79    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
80    pub queue_depths: HashMap<String, u64>,
81}
82
83impl EventSnapshot {
84    /// Total number of terminal task outcomes recorded (succeeded + failed).
85    #[inline]
86    #[must_use]
87    pub const fn completed(&self) -> u64 {
88        self.succeeded + self.failed
89    }
90
91    /// Failure rate over completed tasks (`failed / (succeeded + failed)`).
92    ///
93    /// Returns `0.0` when no tasks have completed.
94    #[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/// Builder for [`EventSnapshot`].
107///
108/// Either set fields explicitly, or fold a batch of [`Event`]s into the running
109/// counts via [`EventSnapshotBuilder::observe`] / [`EventSnapshotBuilder::observe_all`].
110#[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    /// Create a new, empty snapshot builder.
127    #[must_use]
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// Set the snapshot timestamp (defaults to [`Utc::now`] at build time).
133    #[must_use]
134    pub fn timestamp(mut self, ts: DateTime<Utc>) -> Self {
135        self.timestamp = Some(ts);
136        self
137    }
138
139    /// Set the pending count.
140    #[must_use]
141    pub fn pending(mut self, n: u64) -> Self {
142        self.pending = n;
143        self
144    }
145
146    /// Set the active count.
147    #[must_use]
148    pub fn active(mut self, n: u64) -> Self {
149        self.active = n;
150        self
151    }
152
153    /// Set the succeeded count.
154    #[must_use]
155    pub fn succeeded(mut self, n: u64) -> Self {
156        self.succeeded = n;
157        self
158    }
159
160    /// Set the failed count.
161    #[must_use]
162    pub fn failed(mut self, n: u64) -> Self {
163        self.failed = n;
164        self
165    }
166
167    /// Set the retried count.
168    #[must_use]
169    pub fn retried(mut self, n: u64) -> Self {
170        self.retried = n;
171        self
172    }
173
174    /// Set the revoked count.
175    #[must_use]
176    pub fn revoked(mut self, n: u64) -> Self {
177        self.revoked = n;
178        self
179    }
180
181    /// Set the worker count explicitly (overrides workers inferred from events).
182    #[must_use]
183    pub fn worker_count(mut self, n: u64) -> Self {
184        self.worker_count_override = Some(n);
185        self
186    }
187
188    /// Set the depth for a specific queue (also adds to the total).
189    #[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    /// Set the aggregate queue depth directly (when per-queue detail is absent).
198    #[must_use]
199    pub fn total_queue_depth(mut self, depth: u64) -> Self {
200        self.queue_depth = depth;
201        self
202    }
203
204    /// Fold a single event into the running aggregate.
205    ///
206    /// Task lifecycle events bump the corresponding counters; worker
207    /// online/heartbeat events register a worker as present, and worker offline
208    /// removes it.
209    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    /// Fold a batch of events into the running aggregate.
233    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    /// Finalize the snapshot.
244    #[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/// The condition a rule monitors.
265#[derive(Debug, Clone)]
266pub enum AlertRuleKind {
267    /// Trip when the failure rate over the recent event window meets/exceeds
268    /// `trip_threshold` (0.0..=1.0). Requires at least `min_samples` completed
269    /// tasks in the window before it can fire.
270    FailureRate {
271        /// Rate (0.0..=1.0) at or above which the rule trips.
272        trip_threshold: f64,
273        /// Rate strictly below which the rule clears (hysteresis).
274        clear_threshold: f64,
275        /// Sliding window over which completions are counted.
276        window: Duration,
277        /// Minimum completed samples in the window before the rule may fire.
278        min_samples: u64,
279    },
280    /// Trip when the observed queue depth meets/exceeds `trip_depth`.
281    QueueDepth {
282        /// Depth at or above which the rule trips.
283        trip_depth: u64,
284        /// Depth at or below which the rule clears (hysteresis).
285        clear_depth: u64,
286        /// Optional specific queue to watch; `None` uses total depth.
287        queue: Option<String>,
288    },
289    /// Trip when no worker heartbeat (or snapshot with workers) has been seen
290    /// within `timeout`.
291    NoHeartbeat {
292        /// Maximum allowed silence before tripping.
293        timeout: Duration,
294    },
295}
296
297/// A configured alerting rule.
298///
299/// Each rule carries a unique `name`, a [`AlertSeverity`], the monitored
300/// [`AlertRuleKind`], and a `cooldown` that suppresses repeat alerts while the
301/// rule remains continuously tripped.
302#[derive(Debug, Clone)]
303pub struct AlertRule {
304    /// Unique rule identifier (also used as the alert's `rule_name`).
305    pub name: String,
306    /// Severity attached to alerts produced by this rule.
307    pub severity: AlertSeverity,
308    /// The condition being monitored.
309    pub kind: AlertRuleKind,
310    /// Minimum time between successive *firing* alerts for this rule.
311    pub cooldown: Duration,
312}
313
314impl AlertRule {
315    /// Build a failure-rate rule. By default the clear threshold equals the trip
316    /// threshold (no hysteresis), the window is 60s, `min_samples` is 1 and the
317    /// cooldown is 60s; adjust with the builder methods.
318    #[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    /// Build a queue-depth rule. Clear depth defaults to the trip depth.
338    #[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    /// Build a no-heartbeat rule.
353    #[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    /// Set the cooldown between repeated firing alerts.
368    #[must_use]
369    pub fn with_cooldown(mut self, cooldown: Duration) -> Self {
370        self.cooldown = cooldown;
371        self
372    }
373
374    /// Set the clear threshold for a failure-rate rule (enables hysteresis).
375    #[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    /// Set the sliding window for a failure-rate rule.
387    #[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    /// Set the minimum samples for a failure-rate rule.
396    #[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    /// Set the clear depth for a queue-depth rule (enables hysteresis).
405    #[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    /// Watch a specific queue for a queue-depth rule.
414    #[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/// Whether an emitted alert signals a rule firing or recovering.
424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
425pub enum AlertStatus {
426    /// The rule's condition is currently met.
427    Firing,
428    /// The rule's condition cleared after previously firing.
429    Resolved,
430}
431
432/// An alert emitted by [`AlertEvaluator`] when a rule trips or recovers.
433///
434/// Named distinctly from [`crate::event::Alert`] so both can coexist; this one
435/// is rule-centric (carries the rule name, status, and observed value) whereas
436/// [`crate::event::Alert`] is event-centric.
437#[derive(Debug, Clone)]
438pub struct RuleAlert {
439    /// Name of the rule that produced this alert.
440    pub rule_name: String,
441    /// Severity inherited from the rule.
442    pub severity: AlertSeverity,
443    /// Whether the rule is firing or has resolved.
444    pub status: AlertStatus,
445    /// Human-readable description of why the alert fired.
446    pub message: String,
447    /// The observed value that drove the decision (rate, depth, or seconds of
448    /// silence depending on the rule kind).
449    pub observed_value: f64,
450    /// When the alert was generated.
451    pub timestamp: DateTime<Utc>,
452}
453
454impl RuleAlert {
455    /// Returns `true` if this alert indicates the rule is currently firing.
456    #[inline]
457    #[must_use]
458    pub fn is_firing(&self) -> bool {
459        self.status == AlertStatus::Firing
460    }
461}
462
463/// Per-rule mutable evaluation state held by the evaluator.
464#[derive(Debug)]
465struct RuleState {
466    /// Whether the rule is currently considered tripped.
467    firing: bool,
468    /// Last time a *firing* alert was emitted (for cooldown).
469    last_fired: Option<DateTime<Utc>>,
470    /// For failure-rate rules: recent completions as (timestamp, was_failure).
471    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/// Consumes events and snapshots, applying [`AlertRule`]s and emitting
485/// [`RuleAlert`]s with hysteresis and cooldown.
486///
487/// The evaluator is `!Sync`-free (it is plain `Send` state) and intended to be
488/// driven from a single monitoring task. Call [`observe_event`](AlertEvaluator::observe_event)
489/// for each incoming [`Event`] and [`observe_snapshot`](AlertEvaluator::observe_snapshot)
490/// for each periodic [`EventSnapshot`]; both return any alerts produced.
491#[derive(Debug)]
492pub struct AlertEvaluator {
493    rules: Vec<AlertRule>,
494    states: Vec<RuleState>,
495    /// Last time any worker heartbeat / presence was observed.
496    last_heartbeat: Option<DateTime<Utc>>,
497}
498
499impl Default for AlertEvaluator {
500    fn default() -> Self {
501        Self::new()
502    }
503}
504
505impl AlertEvaluator {
506    /// Create an evaluator with no rules.
507    #[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    /// Add a rule (builder style).
517    #[must_use]
518    pub fn with_rule(mut self, rule: AlertRule) -> Self {
519        self.add_rule(rule);
520        self
521    }
522
523    /// Add a rule in place.
524    pub fn add_rule(&mut self, rule: AlertRule) {
525        self.rules.push(rule);
526        self.states.push(RuleState::new());
527    }
528
529    /// Number of configured rules.
530    #[inline]
531    #[must_use]
532    pub fn rule_count(&self) -> usize {
533        self.rules.len()
534    }
535
536    /// Whether the rule with `name` is currently firing.
537    #[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    /// Observe a single event, updating internal windows and evaluating rules.
547    ///
548    /// Returns any alerts (firing or resolved) produced as a result.
549    pub fn observe_event(&mut self, event: &Event) -> Vec<RuleAlert> {
550        let now = event.timestamp();
551
552        // Track heartbeat/presence for no-heartbeat rules.
553        if matches!(
554            event,
555            Event::Worker(WorkerEvent::Heartbeat { .. } | WorkerEvent::Online { .. })
556        ) {
557            self.last_heartbeat = Some(now);
558        }
559
560        // Record completions for failure-rate rules.
561        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                    // Queue depth is snapshot-driven; events do not change it.
616                }
617            }
618        }
619        alerts
620    }
621
622    /// Observe a periodic snapshot, evaluating snapshot-driven rules.
623    ///
624    /// Returns any alerts produced. Queue-depth and no-heartbeat rules are
625    /// primarily evaluated here; failure-rate rules also incorporate the
626    /// snapshot's completed counts as additional samples.
627    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                    // Failure-rate is event-driven; snapshots leave it unchanged.
669                }
670            }
671        }
672        alerts
673    }
674
675    /// Explicitly evaluate time-based rules (e.g. no-heartbeat) at `now`.
676    ///
677    /// Useful to call on a timer even when no events arrive, so a stalled
678    /// cluster still trips the no-heartbeat rule.
679    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    /// Evaluate a no-heartbeat rule against `now`, returning an alert if state
693    /// transitions or a re-fire is warranted.
694    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    /// Generic threshold evaluation with hysteresis + cooldown.
717    ///
718    /// `trip_when_ge` selects the comparison direction: `true` trips when
719    /// `value >= trip` and clears when `value < clear` (used by both failure
720    /// rate and queue depth, which are "high is bad" metrics).
721    #[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            // Stay firing until value drops below the clear threshold.
735            value >= clear
736        } else {
737            can_fire && value >= trip
738        };
739        Self::transition(rule, state, now, tripped, value, display)
740    }
741
742    /// Apply a desired tripped/cleared decision to `state`, honouring cooldown,
743    /// and produce an alert if a transition or cooldown-permitted re-fire occurs.
744    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    /// Drop completions older than `window` from the front of the deque.
792    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        // Construct directly so we control the timestamp.
816        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        // 4 failures -> rate 1.0 >= 0.5, fires.
895        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        // Now feed many successes to push rate below clear threshold (0.2).
907        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        // 3 failures: rate 1.0 but only 3 samples (< 10) -> no fire.
927        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        // Drop to 60: above clear(50) -> still firing, no new alert (cooldown 0
958        // but no transition since stays firing... actually re-fire allowed).
959        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        // Drop to 40: below clear -> resolves.
967        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        // Record a heartbeat.
997        let alerts = eval.observe_event(&heartbeat_event(base));
998        assert!(alerts.is_empty());
999
1000        // Tick 10s later: still within timeout.
1001        let _ = eval.tick(base + chrono::Duration::seconds(10));
1002        assert!(!eval.is_firing("hb"));
1003
1004        // Tick 45s after last heartbeat: trips.
1005        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        // New heartbeat resolves it.
1010        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        // Same condition shortly after: within cooldown, suppressed.
1030        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        // After cooldown elapses, it re-fires.
1038        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        // High depth + no heartbeat ever -> both fire on snapshot with 0 workers.
1058        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        // Two old failures, then much later two successes: old ones pruned.
1078        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        // 100s later, successes only -> failures pruned, rate 0 -> resolves.
1083        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}