Skip to main content

celers_beat/
alert.rs

1//! Alert management and monitoring
2//!
3//! Provides alert types, conditions, and the `AlertManager` for tracking
4//! and deduplicating alerts. Also includes webhook configuration for
5//! alert delivery.
6
7use chrono::{DateTime, Duration, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::sync::Arc;
11
12/// Alert severity level
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Hash)]
14pub enum AlertLevel {
15    /// Informational alert
16    Info,
17    /// Warning alert - requires attention
18    Warning,
19    /// Critical alert - requires immediate action
20    Critical,
21}
22
23impl std::fmt::Display for AlertLevel {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            AlertLevel::Info => write!(f, "INFO"),
27            AlertLevel::Warning => write!(f, "WARNING"),
28            AlertLevel::Critical => write!(f, "CRITICAL"),
29        }
30    }
31}
32
33/// Alert condition that triggered the alert
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Hash, Eq)]
35#[serde(tag = "type")]
36pub enum AlertCondition {
37    /// Schedule was missed (task didn't execute when expected)
38    MissedSchedule {
39        /// Expected run time
40        expected_at: DateTime<Utc>,
41        /// Current time when missed was detected
42        detected_at: DateTime<Utc>,
43    },
44    /// Multiple consecutive failures
45    ConsecutiveFailures {
46        /// Number of consecutive failures
47        count: u32,
48        /// Failure threshold that triggered the alert
49        threshold: u32,
50    },
51    /// High failure rate detected
52    HighFailureRate {
53        /// Current failure rate (0.0 to 1.0)
54        rate: String, // String to make it hashable
55        /// Threshold that was exceeded
56        threshold: String,
57    },
58    /// Slow execution detected
59    SlowExecution {
60        /// Actual duration in milliseconds
61        duration_ms: u64,
62        /// Expected/threshold duration in milliseconds
63        threshold_ms: u64,
64    },
65    /// Task is stuck (not executing for extended period)
66    TaskStuck {
67        /// Time since last execution
68        idle_duration_seconds: i64,
69        /// Expected interval in seconds
70        expected_interval_seconds: u64,
71    },
72    /// Task has become unhealthy
73    TaskUnhealthy {
74        /// Health issues detected
75        issues: Vec<String>,
76    },
77}
78
79impl std::fmt::Display for AlertCondition {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            AlertCondition::MissedSchedule {
83                expected_at,
84                detected_at,
85            } => {
86                let delay = detected_at
87                    .signed_duration_since(*expected_at)
88                    .num_seconds();
89                write!(
90                    f,
91                    "Missed schedule ({}s late, expected at {})",
92                    delay,
93                    expected_at.format("%Y-%m-%d %H:%M:%S UTC")
94                )
95            }
96            AlertCondition::ConsecutiveFailures { count, threshold } => {
97                write!(
98                    f,
99                    "Consecutive failures ({} failures, threshold: {})",
100                    count, threshold
101                )
102            }
103            AlertCondition::HighFailureRate { rate, threshold } => {
104                write!(
105                    f,
106                    "High failure rate (rate: {}, threshold: {})",
107                    rate, threshold
108                )
109            }
110            AlertCondition::SlowExecution {
111                duration_ms,
112                threshold_ms,
113            } => {
114                write!(
115                    f,
116                    "Slow execution ({}ms, threshold: {}ms)",
117                    duration_ms, threshold_ms
118                )
119            }
120            AlertCondition::TaskStuck {
121                idle_duration_seconds,
122                expected_interval_seconds,
123            } => {
124                write!(
125                    f,
126                    "Task stuck (idle: {}s, expected interval: {}s)",
127                    idle_duration_seconds, expected_interval_seconds
128                )
129            }
130            AlertCondition::TaskUnhealthy { issues } => {
131                write!(f, "Task unhealthy: {}", issues.join(", "))
132            }
133        }
134    }
135}
136
137/// Alert record
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Alert {
140    /// Alert timestamp
141    pub timestamp: DateTime<Utc>,
142    /// Task name that triggered the alert
143    pub task_name: String,
144    /// Alert severity level
145    pub level: AlertLevel,
146    /// Condition that triggered the alert
147    pub condition: AlertCondition,
148    /// Human-readable message
149    pub message: String,
150    /// Additional metadata (optional)
151    #[serde(default)]
152    pub metadata: HashMap<String, String>,
153}
154
155impl Alert {
156    /// Create a new alert
157    pub fn new(
158        task_name: String,
159        level: AlertLevel,
160        condition: AlertCondition,
161        message: String,
162    ) -> Self {
163        Self {
164            timestamp: Utc::now(),
165            task_name,
166            level,
167            condition,
168            message,
169            metadata: HashMap::new(),
170        }
171    }
172
173    /// Add metadata to the alert
174    pub fn with_metadata(mut self, key: String, value: String) -> Self {
175        self.metadata.insert(key, value);
176        self
177    }
178
179    /// Check if this is a critical alert
180    pub fn is_critical(&self) -> bool {
181        self.level == AlertLevel::Critical
182    }
183
184    /// Check if this is a warning alert
185    pub fn is_warning(&self) -> bool {
186        self.level == AlertLevel::Warning
187    }
188
189    /// Check if this is an info alert
190    pub fn is_info(&self) -> bool {
191        self.level == AlertLevel::Info
192    }
193
194    /// Get a unique key for deduplication
195    fn dedup_key(&self) -> String {
196        format!("{}::{:?}", self.task_name, self.condition)
197    }
198}
199
200impl std::fmt::Display for Alert {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        write!(
203            f,
204            "[{}] {} - {} - {}",
205            self.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
206            self.level,
207            self.task_name,
208            self.message
209        )
210    }
211}
212
213/// Alert callback type
214///
215/// Called when an alert is triggered. Receives the alert details.
216pub type AlertCallback = Arc<dyn Fn(&Alert) + Send + Sync>;
217
218/// Alert manager for tracking and deduplicating alerts
219#[derive(Clone, Serialize, Deserialize)]
220pub struct AlertManager {
221    /// Recent alerts (limited size)
222    alerts: Vec<Alert>,
223    /// Maximum number of alerts to keep in history
224    max_history: usize,
225    /// Deduplication window in seconds
226    dedup_window_seconds: i64,
227    /// Last alert time by dedup key
228    last_alert_time: HashMap<String, DateTime<Utc>>,
229    /// Alert callbacks (not serialized)
230    #[serde(skip)]
231    callbacks: Vec<AlertCallback>,
232}
233
234impl AlertManager {
235    /// Create a new alert manager
236    ///
237    /// # Arguments
238    /// * `max_history` - Maximum number of alerts to keep in history
239    /// * `dedup_window_seconds` - Time window for deduplicating alerts (e.g., 300 = 5 minutes)
240    pub fn new(max_history: usize, dedup_window_seconds: i64) -> Self {
241        Self {
242            alerts: Vec::new(),
243            max_history,
244            dedup_window_seconds,
245            last_alert_time: HashMap::new(),
246            callbacks: Vec::new(),
247        }
248    }
249
250    /// Add an alert callback
251    pub fn add_callback(&mut self, callback: AlertCallback) {
252        self.callbacks.push(callback);
253    }
254
255    /// Record an alert (with deduplication)
256    ///
257    /// # Arguments
258    /// * `alert` - Alert to record
259    ///
260    /// # Returns
261    /// * `true` if alert was recorded (not deduplicated)
262    /// * `false` if alert was suppressed due to deduplication
263    pub fn record_alert(&mut self, alert: Alert) -> bool {
264        let dedup_key = alert.dedup_key();
265        let now = Utc::now();
266
267        // Check if we should deduplicate this alert
268        if let Some(last_time) = self.last_alert_time.get(&dedup_key) {
269            let elapsed = now.signed_duration_since(*last_time).num_seconds();
270            if elapsed < self.dedup_window_seconds {
271                // Suppress duplicate alert
272                return false;
273            }
274        }
275
276        // Record the alert
277        self.last_alert_time.insert(dedup_key, now);
278
279        // Trigger callbacks
280        for callback in &self.callbacks {
281            callback(&alert);
282        }
283
284        // Add to history
285        self.alerts.push(alert);
286
287        // Trim history if needed
288        if self.alerts.len() > self.max_history {
289            self.alerts.drain(0..self.alerts.len() - self.max_history);
290        }
291
292        // Cleanup old dedup entries (older than window)
293        self.last_alert_time.retain(|_, last_time| {
294            now.signed_duration_since(*last_time).num_seconds() < self.dedup_window_seconds * 2
295        });
296
297        true
298    }
299
300    /// Get all alerts
301    pub fn get_alerts(&self) -> &[Alert] {
302        &self.alerts
303    }
304
305    /// Get critical alerts
306    pub fn get_critical_alerts(&self) -> Vec<&Alert> {
307        self.alerts.iter().filter(|a| a.is_critical()).collect()
308    }
309
310    /// Get warning alerts
311    pub fn get_warning_alerts(&self) -> Vec<&Alert> {
312        self.alerts.iter().filter(|a| a.is_warning()).collect()
313    }
314
315    /// Get alerts for a specific task
316    pub fn get_task_alerts(&self, task_name: &str) -> Vec<&Alert> {
317        self.alerts
318            .iter()
319            .filter(|a| a.task_name == task_name)
320            .collect()
321    }
322
323    /// Get recent alerts (within specified seconds)
324    pub fn get_recent_alerts(&self, seconds: i64) -> Vec<&Alert> {
325        let cutoff = Utc::now() - Duration::seconds(seconds);
326        self.alerts
327            .iter()
328            .filter(|a| a.timestamp > cutoff)
329            .collect()
330    }
331
332    /// Clear all alerts
333    pub fn clear(&mut self) {
334        self.alerts.clear();
335        self.last_alert_time.clear();
336    }
337
338    /// Clear alerts for a specific task
339    pub fn clear_task_alerts(&mut self, task_name: &str) {
340        self.alerts.retain(|a| a.task_name != task_name);
341        self.last_alert_time
342            .retain(|k, _| !k.starts_with(&format!("{}::", task_name)));
343    }
344
345    /// Get alert count
346    pub fn alert_count(&self) -> usize {
347        self.alerts.len()
348    }
349
350    /// Get critical alert count
351    pub fn critical_alert_count(&self) -> usize {
352        self.alerts.iter().filter(|a| a.is_critical()).count()
353    }
354
355    /// Get warning alert count
356    pub fn warning_alert_count(&self) -> usize {
357        self.alerts.iter().filter(|a| a.is_warning()).count()
358    }
359}
360
361impl Default for AlertManager {
362    fn default() -> Self {
363        Self::new(1000, 300) // Keep 1000 alerts, 5-minute dedup window
364    }
365}
366
367impl std::fmt::Debug for AlertManager {
368    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369        f.debug_struct("AlertManager")
370            .field("alerts_count", &self.alerts.len())
371            .field("max_history", &self.max_history)
372            .field("dedup_window_seconds", &self.dedup_window_seconds)
373            .field("callbacks_count", &self.callbacks.len())
374            .finish()
375    }
376}
377
378/// Alert configuration for task monitoring
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct AlertConfig {
381    /// Enable alerting for this task
382    #[serde(default = "default_true")]
383    pub enabled: bool,
384    /// Threshold for consecutive failures before alerting
385    #[serde(default = "default_consecutive_failures_threshold")]
386    pub consecutive_failures_threshold: u32,
387    /// Threshold for failure rate (0.0 to 1.0) before alerting
388    #[serde(default = "default_failure_rate_threshold")]
389    pub failure_rate_threshold: f64,
390    /// Threshold for slow execution (milliseconds)
391    pub slow_execution_threshold_ms: Option<u64>,
392    /// Enable alerts for missed schedules
393    #[serde(default = "default_true")]
394    pub alert_on_missed_schedule: bool,
395    /// Enable alerts for task stuck
396    #[serde(default = "default_true")]
397    pub alert_on_stuck: bool,
398}
399
400#[allow(dead_code)]
401fn default_true() -> bool {
402    true
403}
404
405#[allow(dead_code)]
406fn default_consecutive_failures_threshold() -> u32 {
407    3
408}
409
410#[allow(dead_code)]
411fn default_failure_rate_threshold() -> f64 {
412    0.5
413}
414
415impl Default for AlertConfig {
416    fn default() -> Self {
417        Self {
418            enabled: true,
419            consecutive_failures_threshold: 3,
420            failure_rate_threshold: 0.5,
421            slow_execution_threshold_ms: None,
422            alert_on_missed_schedule: true,
423            alert_on_stuck: true,
424        }
425    }
426}
427
428impl AlertConfig {
429    /// Create a new alert configuration
430    pub fn new() -> Self {
431        Self::default()
432    }
433
434    /// Disable all alerts for this task
435    pub fn disabled() -> Self {
436        Self {
437            enabled: false,
438            ..Default::default()
439        }
440    }
441
442    /// Set consecutive failures threshold
443    pub fn with_consecutive_failures_threshold(mut self, threshold: u32) -> Self {
444        self.consecutive_failures_threshold = threshold;
445        self
446    }
447
448    /// Set failure rate threshold
449    pub fn with_failure_rate_threshold(mut self, threshold: f64) -> Self {
450        self.failure_rate_threshold = threshold;
451        self
452    }
453
454    /// Set slow execution threshold
455    pub fn with_slow_execution_threshold_ms(mut self, threshold_ms: u64) -> Self {
456        self.slow_execution_threshold_ms = Some(threshold_ms);
457        self
458    }
459
460    /// Disable missed schedule alerts
461    pub fn without_missed_schedule_alerts(mut self) -> Self {
462        self.alert_on_missed_schedule = false;
463        self
464    }
465
466    /// Disable stuck task alerts
467    pub fn without_stuck_alerts(mut self) -> Self {
468        self.alert_on_stuck = false;
469        self
470    }
471}
472
473// ============================================================================
474// Webhook Alert Delivery
475// ============================================================================
476
477/// Webhook configuration for alert delivery
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct WebhookConfig {
480    /// Webhook endpoint URL
481    pub url: String,
482    /// HTTP headers to include in webhook requests
483    #[serde(default)]
484    pub headers: HashMap<String, String>,
485    /// Timeout for webhook requests in seconds
486    #[serde(default = "default_webhook_timeout")]
487    pub timeout_seconds: u64,
488    /// Filter: only send alerts matching these levels (empty = all levels)
489    #[serde(default)]
490    pub alert_levels: Vec<AlertLevel>,
491}
492
493#[allow(dead_code)]
494fn default_webhook_timeout() -> u64 {
495    30
496}
497
498impl WebhookConfig {
499    /// Create a new webhook configuration
500    ///
501    /// # Arguments
502    /// * `url` - Webhook endpoint URL
503    ///
504    /// # Examples
505    /// ```
506    /// use celers_beat::WebhookConfig;
507    ///
508    /// let webhook = WebhookConfig::new("https://example.com/alerts");
509    /// ```
510    pub fn new(url: impl Into<String>) -> Self {
511        Self {
512            url: url.into(),
513            headers: HashMap::new(),
514            timeout_seconds: default_webhook_timeout(),
515            alert_levels: Vec::new(),
516        }
517    }
518
519    /// Add a custom HTTP header
520    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
521        self.headers.insert(key.into(), value.into());
522        self
523    }
524
525    /// Set the timeout for webhook requests
526    pub fn with_timeout(mut self, timeout_seconds: u64) -> Self {
527        self.timeout_seconds = timeout_seconds;
528        self
529    }
530
531    /// Filter alerts by level (empty = send all alerts)
532    pub fn with_alert_levels(mut self, levels: Vec<AlertLevel>) -> Self {
533        self.alert_levels = levels;
534        self
535    }
536
537    /// Check if this webhook should receive the given alert
538    pub fn should_send(&self, alert: &Alert) -> bool {
539        if self.alert_levels.is_empty() {
540            return true;
541        }
542        self.alert_levels.contains(&alert.level)
543    }
544
545    /// Create a JSON payload for the webhook
546    pub fn create_payload(&self, alert: &Alert) -> serde_json::Value {
547        serde_json::json!({
548            "timestamp": alert.timestamp.to_rfc3339(),
549            "task_name": alert.task_name,
550            "level": format!("{:?}", alert.level),
551            "condition": format!("{:?}", alert.condition),
552            "message": alert.message,
553            "metadata": alert.metadata,
554        })
555    }
556}