Skip to main content

trustformers_debug/model_diagnostics/
alerts.rs

1//! Alert system and diagnostic notifications.
2//!
3//! This module provides comprehensive alert management for model diagnostics,
4//! including threshold-based monitoring, alert prioritization, notification
5//! systems, and automated response recommendations.
6
7use anyhow::Result;
8use chrono::{DateTime, Duration, Utc};
9use std::collections::VecDeque;
10
11use super::types::{
12    ConvergenceStatus, LayerActivationStats, ModelDiagnosticAlert, ModelPerformanceMetrics,
13    TrainingDynamics, TrainingStability,
14};
15
16/// Alert manager for monitoring and managing diagnostic alerts.
17#[derive(Debug)]
18pub struct AlertManager {
19    /// Active alerts
20    active_alerts: Vec<ActiveAlert>,
21    /// Alert history
22    alert_history: VecDeque<HistoricalAlert>,
23    /// Alert configuration
24    config: AlertConfig,
25    /// Alert thresholds
26    thresholds: AlertThresholds,
27    /// Performance baseline for comparison
28    performance_baseline: Option<PerformanceBaseline>,
29}
30
31/// Configuration for the alert system.
32#[derive(Debug, Clone)]
33pub struct AlertConfig {
34    /// Maximum number of alerts to keep in history
35    pub max_history_size: usize,
36    /// Minimum time between duplicate alerts
37    pub duplicate_alert_cooldown: Duration,
38    /// Alert severity levels to monitor
39    pub monitored_severities: Vec<AlertSeverity>,
40    /// Enable automatic alert resolution
41    pub auto_resolve_alerts: bool,
42    /// Alert notification settings
43    pub notification_settings: NotificationSettings,
44}
45
46/// Alert thresholds for various metrics.
47#[derive(Debug, Clone)]
48pub struct AlertThresholds {
49    /// Performance degradation threshold (percentage)
50    pub performance_degradation_percent: f64,
51    /// Memory usage threshold (MB)
52    pub memory_usage_threshold_mb: f64,
53    /// Memory leak detection threshold (MB per step)
54    pub memory_leak_threshold_mb_per_step: f64,
55    /// Training instability variance threshold
56    pub training_instability_variance: f64,
57    /// Dead neuron ratio threshold
58    pub dead_neuron_ratio_threshold: f64,
59    /// Saturated neuron ratio threshold
60    pub saturated_neuron_ratio_threshold: f64,
61    /// Convergence plateau duration threshold (steps)
62    pub plateau_duration_threshold: usize,
63    /// Learning rate adjustment threshold
64    pub learning_rate_adjustment_threshold: f64,
65}
66
67/// Performance baseline for comparison.
68#[derive(Debug, Clone)]
69pub struct PerformanceBaseline {
70    /// Baseline loss value
71    pub baseline_loss: f64,
72    /// Baseline throughput
73    pub baseline_throughput: f64,
74    /// Baseline memory usage
75    pub baseline_memory_mb: f64,
76    /// Baseline accuracy (if available)
77    pub baseline_accuracy: Option<f64>,
78    /// When baseline was established
79    pub established_at: DateTime<Utc>,
80}
81
82/// Active alert with current status.
83#[derive(Debug, Clone)]
84pub struct ActiveAlert {
85    /// Alert information
86    pub alert: ModelDiagnosticAlert,
87    /// Alert severity
88    pub severity: AlertSeverity,
89    /// When alert was first triggered
90    pub triggered_at: DateTime<Utc>,
91    /// Number of times alert has been triggered
92    pub trigger_count: usize,
93    /// Recommended actions
94    pub recommended_actions: Vec<String>,
95    /// Alert status
96    pub status: AlertStatus,
97}
98
99/// Historical alert record.
100#[derive(Debug, Clone)]
101pub struct HistoricalAlert {
102    /// Alert information
103    pub alert: ModelDiagnosticAlert,
104    /// Alert severity
105    pub severity: AlertSeverity,
106    /// When alert was triggered
107    pub triggered_at: DateTime<Utc>,
108    /// When alert was resolved
109    pub resolved_at: Option<DateTime<Utc>>,
110    /// How alert was resolved
111    pub resolution_method: Option<String>,
112    /// Duration alert was active
113    pub duration: Option<Duration>,
114}
115
116/// Alert severity levels.
117#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
118pub enum AlertSeverity {
119    /// Informational alerts
120    Info,
121    /// Warning alerts
122    Warning,
123    /// Critical alerts requiring immediate attention
124    Critical,
125    /// Emergency alerts indicating system failure
126    Emergency,
127}
128
129/// Alert status tracking.
130#[derive(Debug, Clone, PartialEq)]
131pub enum AlertStatus {
132    /// Alert is active and unresolved
133    Active,
134    /// Alert is acknowledged but not resolved
135    Acknowledged,
136    /// Alert is being investigated
137    InvestigationInProgress,
138    /// Alert has been resolved
139    Resolved,
140    /// Alert was a false positive
141    FalsePositive,
142}
143
144/// Notification settings for alerts.
145#[derive(Debug, Clone)]
146pub struct NotificationSettings {
147    /// Enable console notifications
148    pub console_notifications: bool,
149    /// Enable file logging
150    pub file_logging: bool,
151    /// Log file path for alerts
152    pub log_file_path: Option<String>,
153    /// Enable webhook notifications
154    pub webhook_notifications: bool,
155    /// Webhook URL for notifications
156    pub webhook_url: Option<String>,
157}
158
159impl Default for AlertConfig {
160    fn default() -> Self {
161        Self {
162            max_history_size: 1000,
163            duplicate_alert_cooldown: Duration::minutes(5),
164            monitored_severities: vec![
165                AlertSeverity::Warning,
166                AlertSeverity::Critical,
167                AlertSeverity::Emergency,
168            ],
169            auto_resolve_alerts: true,
170            notification_settings: NotificationSettings::default(),
171        }
172    }
173}
174
175impl Default for NotificationSettings {
176    fn default() -> Self {
177        Self {
178            console_notifications: true,
179            file_logging: false,
180            log_file_path: None,
181            webhook_notifications: false,
182            webhook_url: None,
183        }
184    }
185}
186
187impl Default for AlertThresholds {
188    fn default() -> Self {
189        Self {
190            performance_degradation_percent: 10.0,
191            memory_usage_threshold_mb: 8192.0, // 8GB
192            memory_leak_threshold_mb_per_step: 1.0,
193            training_instability_variance: 0.1,
194            dead_neuron_ratio_threshold: 0.1,
195            saturated_neuron_ratio_threshold: 0.05,
196            plateau_duration_threshold: 100,
197            learning_rate_adjustment_threshold: 0.01,
198        }
199    }
200}
201
202/// POST a JSON alert payload to `url`, real HTTP delivery.
203///
204/// [`AlertManager::send_notification`] is a plain synchronous method (see its
205/// doc comment: making it `async` would ripple into every caller of the
206/// public [`AlertManager::add_alert`] API across the crate), so this cannot
207/// reuse the `async` `post_json` helper in `cicd_integration`. The blocking
208/// `reqwest` client is run on a dedicated OS thread rather than the caller's
209/// thread directly: `reqwest::blocking` internally starts its own Tokio
210/// runtime and panics if constructed on a thread that is already driving one
211/// (plausible here, since `AlertManager` may be invoked from async training
212/// loops elsewhere in the workspace). A fresh `std::thread` has no runtime
213/// affiliation, so this is safe regardless of the caller's context.
214#[cfg(feature = "http-integrations")]
215fn post_json_blocking(url: &str, payload: &serde_json::Value) -> Result<()> {
216    let url = url.to_string();
217    let payload = payload.clone();
218    std::thread::spawn(move || -> Result<()> {
219        let client = reqwest::blocking::Client::new();
220        let response = client
221            .post(&url)
222            .json(&payload)
223            // Bound the whole request (connect + send + receive): without this,
224            // an unresponsive webhook endpoint would hang this spawned thread
225            // (and so the `.join()` below, and so `send_notification`'s caller)
226            // indefinitely instead of surfacing as a delivery error.
227            .timeout(std::time::Duration::from_secs(30))
228            .send()
229            .map_err(|e| anyhow::anyhow!("webhook delivery failed: {e}"))?;
230        let status = response.status();
231        if !status.is_success() {
232            let body = response.text().unwrap_or_default();
233            anyhow::bail!("webhook delivery failed: HTTP {status}: {body}");
234        }
235        Ok(())
236    })
237    .join()
238    .map_err(|_| anyhow::anyhow!("webhook delivery thread panicked"))?
239}
240
241/// Without `http-integrations`, no HTTP client exists in this build: fail
242/// honestly instead of pretending to deliver (mirrors
243/// `cicd_integration::post_json`'s disabled-feature branch).
244#[cfg(not(feature = "http-integrations"))]
245fn post_json_blocking(_url: &str, _payload: &serde_json::Value) -> Result<()> {
246    anyhow::bail!(
247        "HTTP notification delivery is not enabled: rebuild trustformers-debug with \
248         `--features http-integrations`"
249    )
250}
251
252impl AlertManager {
253    /// Create a new alert manager.
254    pub fn new() -> Self {
255        Self {
256            active_alerts: Vec::new(),
257            alert_history: VecDeque::new(),
258            config: AlertConfig::default(),
259            thresholds: AlertThresholds::default(),
260            performance_baseline: None,
261        }
262    }
263
264    /// Create alert manager with custom configuration.
265    pub fn with_config(config: AlertConfig, thresholds: AlertThresholds) -> Self {
266        Self {
267            active_alerts: Vec::new(),
268            alert_history: VecDeque::new(),
269            config,
270            thresholds,
271            performance_baseline: None,
272        }
273    }
274
275    /// Set performance baseline for comparison.
276    pub fn set_performance_baseline(&mut self, baseline: PerformanceBaseline) {
277        self.performance_baseline = Some(baseline);
278    }
279
280    /// Establish baseline from current metrics.
281    pub fn establish_baseline_from_metrics(&mut self, metrics: &ModelPerformanceMetrics) {
282        self.performance_baseline = Some(PerformanceBaseline {
283            baseline_loss: metrics.loss,
284            baseline_throughput: metrics.throughput_samples_per_sec,
285            baseline_memory_mb: metrics.memory_usage_mb,
286            baseline_accuracy: metrics.accuracy,
287            established_at: Utc::now(),
288        });
289    }
290
291    /// Process performance metrics and generate alerts.
292    pub fn process_performance_metrics(
293        &mut self,
294        metrics: &ModelPerformanceMetrics,
295    ) -> Result<Vec<ModelDiagnosticAlert>> {
296        let mut new_alerts = Vec::new();
297
298        // Check for performance degradation
299        if let Some(baseline) = &self.performance_baseline {
300            let loss_degradation =
301                ((metrics.loss - baseline.baseline_loss) / baseline.baseline_loss) * 100.0;
302            if loss_degradation > self.thresholds.performance_degradation_percent {
303                let alert = ModelDiagnosticAlert::PerformanceDegradation {
304                    metric: "loss".to_string(),
305                    current: metrics.loss,
306                    previous_avg: baseline.baseline_loss,
307                    degradation_percent: loss_degradation,
308                };
309                new_alerts.push(alert);
310            }
311
312            let throughput_degradation = ((baseline.baseline_throughput
313                - metrics.throughput_samples_per_sec)
314                / baseline.baseline_throughput)
315                * 100.0;
316            if throughput_degradation > self.thresholds.performance_degradation_percent {
317                let alert = ModelDiagnosticAlert::PerformanceDegradation {
318                    metric: "throughput".to_string(),
319                    current: metrics.throughput_samples_per_sec,
320                    previous_avg: baseline.baseline_throughput,
321                    degradation_percent: throughput_degradation,
322                };
323                new_alerts.push(alert);
324            }
325        }
326
327        // Check for memory issues
328        if metrics.memory_usage_mb > self.thresholds.memory_usage_threshold_mb {
329            let alert = ModelDiagnosticAlert::MemoryLeak {
330                current_usage_mb: metrics.memory_usage_mb,
331                growth_rate_mb_per_step: 0.0, // Would need historical data to calculate
332            };
333            new_alerts.push(alert);
334        }
335
336        // Process new alerts
337        for alert in &new_alerts {
338            self.add_alert(alert.clone(), self.determine_alert_severity(alert))?;
339        }
340
341        Ok(new_alerts)
342    }
343
344    /// Process training dynamics and generate alerts.
345    pub fn process_training_dynamics(
346        &mut self,
347        dynamics: &TrainingDynamics,
348    ) -> Result<Vec<ModelDiagnosticAlert>> {
349        let mut new_alerts = Vec::new();
350
351        // Check for training instability
352        if matches!(
353            dynamics.training_stability,
354            TrainingStability::Unstable | TrainingStability::HighVariance
355        ) {
356            let alert = ModelDiagnosticAlert::TrainingInstability {
357                variance: 0.0, // Would need to extract from dynamics
358                threshold: self.thresholds.training_instability_variance,
359            };
360            new_alerts.push(alert);
361        }
362
363        // Check for convergence issues
364        match dynamics.convergence_status {
365            ConvergenceStatus::Diverging => {
366                let alert = ModelDiagnosticAlert::ConvergenceIssue {
367                    issue_type: ConvergenceStatus::Diverging,
368                    duration_steps: 0, // Would need historical tracking
369                };
370                new_alerts.push(alert);
371            },
372            ConvergenceStatus::Plateau => {
373                if let Some(plateau_info) = &dynamics.plateau_detection {
374                    if plateau_info.duration_steps > self.thresholds.plateau_duration_threshold {
375                        let alert = ModelDiagnosticAlert::ConvergenceIssue {
376                            issue_type: ConvergenceStatus::Plateau,
377                            duration_steps: plateau_info.duration_steps,
378                        };
379                        new_alerts.push(alert);
380                    }
381                }
382            },
383            _ => {},
384        }
385
386        // Process new alerts
387        for alert in &new_alerts {
388            self.add_alert(alert.clone(), self.determine_alert_severity(alert))?;
389        }
390
391        Ok(new_alerts)
392    }
393
394    /// Process layer statistics and generate alerts.
395    pub fn process_layer_stats(
396        &mut self,
397        stats: &LayerActivationStats,
398    ) -> Result<Vec<ModelDiagnosticAlert>> {
399        let mut new_alerts = Vec::new();
400
401        // Check for dead neurons
402        if stats.dead_neurons_ratio > self.thresholds.dead_neuron_ratio_threshold {
403            let alert = ModelDiagnosticAlert::ArchitecturalConcern {
404                concern: format!(
405                    "High dead neuron ratio in layer {}: {:.2}%",
406                    stats.layer_name,
407                    stats.dead_neurons_ratio * 100.0
408                ),
409                recommendation: "Consider adjusting learning rate or initialization".to_string(),
410            };
411            new_alerts.push(alert);
412        }
413
414        // Check for saturated neurons
415        if stats.saturated_neurons_ratio > self.thresholds.saturated_neuron_ratio_threshold {
416            let alert = ModelDiagnosticAlert::ArchitecturalConcern {
417                concern: format!(
418                    "High saturated neuron ratio in layer {}: {:.2}%",
419                    stats.layer_name,
420                    stats.saturated_neurons_ratio * 100.0
421                ),
422                recommendation: "Consider adjusting activation function or scaling".to_string(),
423            };
424            new_alerts.push(alert);
425        }
426
427        // Process new alerts
428        for alert in &new_alerts {
429            self.add_alert(alert.clone(), self.determine_alert_severity(alert))?;
430        }
431
432        Ok(new_alerts)
433    }
434
435    /// Add a new alert to the system.
436    pub fn add_alert(
437        &mut self,
438        alert: ModelDiagnosticAlert,
439        severity: AlertSeverity,
440    ) -> Result<()> {
441        // Check for duplicate alerts within cooldown period
442        if self.is_duplicate_alert(&alert) {
443            return Ok(());
444        }
445
446        let active_alert = ActiveAlert {
447            alert: alert.clone(),
448            severity: severity.clone(),
449            triggered_at: Utc::now(),
450            trigger_count: 1,
451            recommended_actions: self.generate_recommended_actions(&alert),
452            status: AlertStatus::Active,
453        };
454
455        self.active_alerts.push(active_alert);
456
457        // Send notification
458        self.send_notification(&alert, &severity)?;
459
460        Ok(())
461    }
462
463    /// Resolve an alert.
464    pub fn resolve_alert(&mut self, alert_index: usize, resolution_method: String) -> Result<()> {
465        if alert_index >= self.active_alerts.len() {
466            return Err(anyhow::anyhow!("Invalid alert index"));
467        }
468
469        let mut active_alert = self.active_alerts.remove(alert_index);
470        active_alert.status = AlertStatus::Resolved;
471
472        let historical_alert = HistoricalAlert {
473            alert: active_alert.alert,
474            severity: active_alert.severity,
475            triggered_at: active_alert.triggered_at,
476            resolved_at: Some(Utc::now()),
477            resolution_method: Some(resolution_method),
478            duration: Some(Utc::now() - active_alert.triggered_at),
479        };
480
481        self.add_to_history(historical_alert);
482        Ok(())
483    }
484
485    /// Get all active alerts.
486    pub fn get_active_alerts(&self) -> &[ActiveAlert] {
487        &self.active_alerts
488    }
489
490    /// Get alerts by severity.
491    pub fn get_alerts_by_severity(&self, severity: AlertSeverity) -> Vec<&ActiveAlert> {
492        self.active_alerts.iter().filter(|alert| alert.severity == severity).collect()
493    }
494
495    /// Get alert statistics.
496    pub fn get_alert_statistics(&self) -> AlertStatistics {
497        let mut stats = AlertStatistics::default();
498
499        for alert in &self.active_alerts {
500            match alert.severity {
501                AlertSeverity::Info => stats.info_count += 1,
502                AlertSeverity::Warning => stats.warning_count += 1,
503                AlertSeverity::Critical => stats.critical_count += 1,
504                AlertSeverity::Emergency => stats.emergency_count += 1,
505            }
506        }
507
508        stats.total_active = self.active_alerts.len();
509        stats.total_historical = self.alert_history.len();
510
511        stats
512    }
513
514    /// Clear resolved alerts from active list.
515    pub fn clear_resolved_alerts(&mut self) {
516        let now = Utc::now();
517        let mut resolved_alerts = Vec::new();
518
519        self.active_alerts.retain(|alert| {
520            if matches!(alert.status, AlertStatus::Resolved) {
521                resolved_alerts.push(HistoricalAlert {
522                    alert: alert.alert.clone(),
523                    severity: alert.severity.clone(),
524                    triggered_at: alert.triggered_at,
525                    resolved_at: Some(now),
526                    resolution_method: Some("Auto-resolved".to_string()),
527                    duration: Some(now - alert.triggered_at),
528                });
529                false
530            } else {
531                true
532            }
533        });
534
535        for historical in resolved_alerts {
536            self.add_to_history(historical);
537        }
538    }
539
540    /// Determine alert severity based on alert type.
541    fn determine_alert_severity(&self, alert: &ModelDiagnosticAlert) -> AlertSeverity {
542        match alert {
543            ModelDiagnosticAlert::PerformanceDegradation {
544                degradation_percent,
545                ..
546            } => {
547                if *degradation_percent > 50.0 {
548                    AlertSeverity::Critical
549                } else if *degradation_percent > 25.0 {
550                    AlertSeverity::Warning
551                } else {
552                    AlertSeverity::Info
553                }
554            },
555            ModelDiagnosticAlert::MemoryLeak {
556                current_usage_mb, ..
557            } => {
558                if *current_usage_mb > 16384.0 {
559                    // 16GB
560                    AlertSeverity::Emergency
561                } else if *current_usage_mb > 8192.0 {
562                    // 8GB
563                    AlertSeverity::Critical
564                } else {
565                    AlertSeverity::Warning
566                }
567            },
568            ModelDiagnosticAlert::TrainingInstability { .. } => AlertSeverity::Warning,
569            ModelDiagnosticAlert::ConvergenceIssue { issue_type, .. } => match issue_type {
570                ConvergenceStatus::Diverging => AlertSeverity::Critical,
571                ConvergenceStatus::Plateau => AlertSeverity::Warning,
572                _ => AlertSeverity::Info,
573            },
574            ModelDiagnosticAlert::ArchitecturalConcern { .. } => AlertSeverity::Info,
575        }
576    }
577
578    /// Check if alert is a duplicate within cooldown period.
579    fn is_duplicate_alert(&self, alert: &ModelDiagnosticAlert) -> bool {
580        let now = Utc::now();
581        let cooldown_threshold = now - self.config.duplicate_alert_cooldown;
582
583        self.active_alerts.iter().any(|active| {
584            active.triggered_at > cooldown_threshold
585                && std::mem::discriminant(&active.alert) == std::mem::discriminant(alert)
586        })
587    }
588
589    /// Generate recommended actions for an alert.
590    fn generate_recommended_actions(&self, alert: &ModelDiagnosticAlert) -> Vec<String> {
591        match alert {
592            ModelDiagnosticAlert::PerformanceDegradation { metric, .. } => {
593                vec![
594                    format!("Investigate {} degradation causes", metric),
595                    "Check for data quality issues".to_string(),
596                    "Review recent configuration changes".to_string(),
597                    "Consider adjusting learning rate".to_string(),
598                ]
599            },
600            ModelDiagnosticAlert::MemoryLeak { .. } => {
601                vec![
602                    "Monitor memory usage patterns".to_string(),
603                    "Check for gradient accumulation issues".to_string(),
604                    "Review batch size configuration".to_string(),
605                    "Consider implementing memory cleanup".to_string(),
606                ]
607            },
608            ModelDiagnosticAlert::TrainingInstability { .. } => {
609                vec![
610                    "Reduce learning rate".to_string(),
611                    "Enable gradient clipping".to_string(),
612                    "Check data preprocessing".to_string(),
613                    "Consider using learning rate scheduling".to_string(),
614                ]
615            },
616            ModelDiagnosticAlert::ConvergenceIssue { issue_type, .. } => match issue_type {
617                ConvergenceStatus::Diverging => vec![
618                    "Immediately reduce learning rate".to_string(),
619                    "Check gradient magnitudes".to_string(),
620                    "Review loss function implementation".to_string(),
621                ],
622                ConvergenceStatus::Plateau => vec![
623                    "Consider learning rate annealing".to_string(),
624                    "Try different optimization algorithm".to_string(),
625                    "Evaluate model capacity".to_string(),
626                ],
627                _ => vec!["Monitor training progress".to_string()],
628            },
629            ModelDiagnosticAlert::ArchitecturalConcern { recommendation, .. } => {
630                vec![recommendation.clone()]
631            },
632        }
633    }
634
635    /// Send notification for an alert.
636    ///
637    /// Each of the three channels below is opt-in via [`NotificationSettings`]
638    /// (all default to disabled except `console_notifications`). A channel
639    /// that is enabled but cannot actually deliver -- missing config, or a
640    /// build without the `http-integrations` feature -- returns `Err` instead
641    /// of silently doing nothing: the alert itself was already recorded in
642    /// [`Self::add_alert`] before this is called, so a delivery failure here
643    /// only means the *notification* did not go out, which the caller must
644    /// be able to observe.
645    fn send_notification(
646        &self,
647        alert: &ModelDiagnosticAlert,
648        severity: &AlertSeverity,
649    ) -> Result<()> {
650        if self.config.notification_settings.console_notifications {
651            // A caller-opted-in delivery channel in its own right (see the
652            // `file_logging`/`webhook_notifications` channels below), not
653            // incidental print debugging -- stdout is this channel's actual
654            // destination, so `println!` is correct here rather than `tracing`.
655            println!("[{:?}] Alert: {:?}", severity, alert);
656        }
657
658        if self.config.notification_settings.file_logging {
659            let log_path =
660                self.config.notification_settings.log_file_path.as_ref().ok_or_else(|| {
661                    anyhow::anyhow!("file_logging is enabled but no log_file_path is configured")
662                })?;
663
664            use std::io::Write;
665            let line = format!("{} [{:?}] {:?}\n", Utc::now().to_rfc3339(), severity, alert);
666            let mut file =
667                std::fs::OpenOptions::new().create(true).append(true).open(log_path).map_err(
668                    |e| anyhow::anyhow!("failed to open alert log file {log_path}: {e}"),
669                )?;
670            file.write_all(line.as_bytes())
671                .map_err(|e| anyhow::anyhow!("failed to write alert log file {log_path}: {e}"))?;
672        }
673
674        if self.config.notification_settings.webhook_notifications {
675            let webhook_url =
676                self.config.notification_settings.webhook_url.as_ref().ok_or_else(|| {
677                    anyhow::anyhow!(
678                        "webhook_notifications is enabled but no webhook_url is configured"
679                    )
680                })?;
681
682            let payload = serde_json::json!({
683                "severity": format!("{:?}", severity),
684                "alert": format!("{:?}", alert),
685                "timestamp": Utc::now().to_rfc3339(),
686            });
687            post_json_blocking(webhook_url, &payload)?;
688        }
689
690        Ok(())
691    }
692
693    /// Add alert to history with size management.
694    fn add_to_history(&mut self, historical_alert: HistoricalAlert) {
695        self.alert_history.push_back(historical_alert);
696
697        while self.alert_history.len() > self.config.max_history_size {
698            self.alert_history.pop_front();
699        }
700    }
701}
702
703/// Alert system statistics.
704#[derive(Debug, Default)]
705pub struct AlertStatistics {
706    /// Number of active info alerts
707    pub info_count: usize,
708    /// Number of active warning alerts
709    pub warning_count: usize,
710    /// Number of active critical alerts
711    pub critical_count: usize,
712    /// Number of active emergency alerts
713    pub emergency_count: usize,
714    /// Total active alerts
715    pub total_active: usize,
716    /// Total historical alerts
717    pub total_historical: usize,
718}
719
720impl Default for AlertManager {
721    fn default() -> Self {
722        Self::new()
723    }
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729
730    #[test]
731    fn test_alert_manager_creation() {
732        let manager = AlertManager::new();
733        assert_eq!(manager.active_alerts.len(), 0);
734        assert_eq!(manager.alert_history.len(), 0);
735    }
736
737    #[test]
738    fn test_add_alert() {
739        let mut manager = AlertManager::new();
740        let alert = ModelDiagnosticAlert::PerformanceDegradation {
741            metric: "loss".to_string(),
742            current: 1.5,
743            previous_avg: 1.0,
744            degradation_percent: 50.0,
745        };
746
747        manager.add_alert(alert, AlertSeverity::Warning).expect("add operation failed");
748        assert_eq!(manager.active_alerts.len(), 1);
749    }
750
751    #[test]
752    fn test_alert_severity_determination() {
753        let manager = AlertManager::new();
754
755        let high_degradation = ModelDiagnosticAlert::PerformanceDegradation {
756            metric: "loss".to_string(),
757            current: 2.0,
758            previous_avg: 1.0,
759            degradation_percent: 60.0,
760        };
761
762        let severity = manager.determine_alert_severity(&high_degradation);
763        assert_eq!(severity, AlertSeverity::Critical);
764    }
765
766    #[test]
767    fn test_duplicate_alert_detection() {
768        let mut manager = AlertManager::new();
769        let alert = ModelDiagnosticAlert::TrainingInstability {
770            variance: 0.2,
771            threshold: 0.1,
772        };
773
774        // Add first alert
775        manager
776            .add_alert(alert.clone(), AlertSeverity::Warning)
777            .expect("add operation failed");
778        assert_eq!(manager.active_alerts.len(), 1);
779
780        // Try to add duplicate - should be filtered out
781        manager.add_alert(alert, AlertSeverity::Warning).expect("add operation failed");
782        assert_eq!(manager.active_alerts.len(), 1);
783    }
784
785    fn sample_alert() -> ModelDiagnosticAlert {
786        ModelDiagnosticAlert::PerformanceDegradation {
787            metric: "loss".to_string(),
788            current: 1.5,
789            previous_avg: 1.0,
790            degradation_percent: 50.0,
791        }
792    }
793
794    /// Regression: `file_logging: true` used to silently discard
795    /// `log_file_path` (`let _ = log_path;`) and write nothing at all, while
796    /// `add_alert` still returned `Ok(())` as if the log had been written.
797    /// The configured path must now contain a real, readable line per alert.
798    #[test]
799    fn test_file_logging_writes_a_real_line_to_the_configured_path() {
800        let log_path = std::env::temp_dir().join(format!(
801            "trustformers_debug_alert_log_{}.txt",
802            std::process::id()
803        ));
804        let _ = std::fs::remove_file(&log_path); // start clean; ignore "did not exist"
805
806        let mut settings = NotificationSettings::default();
807        settings.console_notifications = false;
808        settings.file_logging = true;
809        settings.log_file_path = Some(log_path.to_string_lossy().into_owned());
810
811        let mut manager = AlertManager::with_config(
812            AlertConfig {
813                notification_settings: settings,
814                ..AlertConfig::default()
815            },
816            AlertThresholds::default(),
817        );
818
819        manager
820            .add_alert(sample_alert(), AlertSeverity::Critical)
821            .expect("file logging must succeed when a valid path is configured");
822
823        let written = std::fs::read_to_string(&log_path).expect("log file must have been created");
824        assert!(
825            written.contains("Critical") && written.contains("PerformanceDegradation"),
826            "log file must contain a real record of the alert, got: {written:?}"
827        );
828
829        let _ = std::fs::remove_file(&log_path);
830    }
831
832    /// Regression: `file_logging: true` with `log_file_path: None` used to
833    /// silently do nothing and return `Ok(())`. A caller that opted into
834    /// file logging but forgot to configure a path deserves a configuration
835    /// error, not silent success.
836    #[test]
837    fn test_file_logging_without_a_path_is_a_configuration_error() {
838        let mut settings = NotificationSettings::default();
839        settings.console_notifications = false;
840        settings.file_logging = true;
841        settings.log_file_path = None;
842
843        let mut manager = AlertManager::with_config(
844            AlertConfig {
845                notification_settings: settings,
846                ..AlertConfig::default()
847            },
848            AlertThresholds::default(),
849        );
850
851        let err = manager
852            .add_alert(sample_alert(), AlertSeverity::Warning)
853            .expect_err("file_logging without a configured path must not silently succeed");
854        assert!(err.to_string().contains("log_file_path"));
855    }
856
857    /// Regression: `webhook_notifications: true` used to silently discard
858    /// `webhook_url` (`let _ = webhook_url;`) and return `Ok(())` without
859    /// attempting any delivery. Without the `http-integrations` feature this
860    /// crate has no HTTP client at all, so the honest outcome is an error
861    /// naming the feature, not a false "delivered" signal.
862    #[cfg(not(feature = "http-integrations"))]
863    #[test]
864    fn test_webhook_without_the_http_feature_is_an_honest_error() {
865        let mut settings = NotificationSettings::default();
866        settings.console_notifications = false;
867        settings.webhook_notifications = true;
868        settings.webhook_url = Some("http://127.0.0.1:1/webhook".to_string());
869
870        let mut manager = AlertManager::with_config(
871            AlertConfig {
872                notification_settings: settings,
873                ..AlertConfig::default()
874            },
875            AlertThresholds::default(),
876        );
877
878        let err = manager
879            .add_alert(sample_alert(), AlertSeverity::Emergency)
880            .expect_err("webhook delivery must not silently pretend to have sent anything");
881        assert!(err.to_string().contains("http-integrations"));
882    }
883
884    /// Regression: `webhook_notifications: true` with `webhook_url: None`
885    /// used to silently do nothing and return `Ok(())`.
886    #[test]
887    fn test_webhook_without_a_url_is_a_configuration_error() {
888        let mut settings = NotificationSettings::default();
889        settings.console_notifications = false;
890        settings.webhook_notifications = true;
891        settings.webhook_url = None;
892
893        let mut manager = AlertManager::with_config(
894            AlertConfig {
895                notification_settings: settings,
896                ..AlertConfig::default()
897            },
898            AlertThresholds::default(),
899        );
900
901        let err = manager
902            .add_alert(sample_alert(), AlertSeverity::Warning)
903            .expect_err("webhook_notifications without a configured URL must not silently succeed");
904        assert!(err.to_string().contains("webhook_url"));
905    }
906}