1use 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#[derive(Debug)]
18pub struct AlertManager {
19 active_alerts: Vec<ActiveAlert>,
21 alert_history: VecDeque<HistoricalAlert>,
23 config: AlertConfig,
25 thresholds: AlertThresholds,
27 performance_baseline: Option<PerformanceBaseline>,
29}
30
31#[derive(Debug, Clone)]
33pub struct AlertConfig {
34 pub max_history_size: usize,
36 pub duplicate_alert_cooldown: Duration,
38 pub monitored_severities: Vec<AlertSeverity>,
40 pub auto_resolve_alerts: bool,
42 pub notification_settings: NotificationSettings,
44}
45
46#[derive(Debug, Clone)]
48pub struct AlertThresholds {
49 pub performance_degradation_percent: f64,
51 pub memory_usage_threshold_mb: f64,
53 pub memory_leak_threshold_mb_per_step: f64,
55 pub training_instability_variance: f64,
57 pub dead_neuron_ratio_threshold: f64,
59 pub saturated_neuron_ratio_threshold: f64,
61 pub plateau_duration_threshold: usize,
63 pub learning_rate_adjustment_threshold: f64,
65}
66
67#[derive(Debug, Clone)]
69pub struct PerformanceBaseline {
70 pub baseline_loss: f64,
72 pub baseline_throughput: f64,
74 pub baseline_memory_mb: f64,
76 pub baseline_accuracy: Option<f64>,
78 pub established_at: DateTime<Utc>,
80}
81
82#[derive(Debug, Clone)]
84pub struct ActiveAlert {
85 pub alert: ModelDiagnosticAlert,
87 pub severity: AlertSeverity,
89 pub triggered_at: DateTime<Utc>,
91 pub trigger_count: usize,
93 pub recommended_actions: Vec<String>,
95 pub status: AlertStatus,
97}
98
99#[derive(Debug, Clone)]
101pub struct HistoricalAlert {
102 pub alert: ModelDiagnosticAlert,
104 pub severity: AlertSeverity,
106 pub triggered_at: DateTime<Utc>,
108 pub resolved_at: Option<DateTime<Utc>>,
110 pub resolution_method: Option<String>,
112 pub duration: Option<Duration>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
118pub enum AlertSeverity {
119 Info,
121 Warning,
123 Critical,
125 Emergency,
127}
128
129#[derive(Debug, Clone, PartialEq)]
131pub enum AlertStatus {
132 Active,
134 Acknowledged,
136 InvestigationInProgress,
138 Resolved,
140 FalsePositive,
142}
143
144#[derive(Debug, Clone)]
146pub struct NotificationSettings {
147 pub console_notifications: bool,
149 pub file_logging: bool,
151 pub log_file_path: Option<String>,
153 pub webhook_notifications: bool,
155 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, 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#[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 .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#[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 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 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 pub fn set_performance_baseline(&mut self, baseline: PerformanceBaseline) {
277 self.performance_baseline = Some(baseline);
278 }
279
280 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 pub fn process_performance_metrics(
293 &mut self,
294 metrics: &ModelPerformanceMetrics,
295 ) -> Result<Vec<ModelDiagnosticAlert>> {
296 let mut new_alerts = Vec::new();
297
298 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 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, };
333 new_alerts.push(alert);
334 }
335
336 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 pub fn process_training_dynamics(
346 &mut self,
347 dynamics: &TrainingDynamics,
348 ) -> Result<Vec<ModelDiagnosticAlert>> {
349 let mut new_alerts = Vec::new();
350
351 if matches!(
353 dynamics.training_stability,
354 TrainingStability::Unstable | TrainingStability::HighVariance
355 ) {
356 let alert = ModelDiagnosticAlert::TrainingInstability {
357 variance: 0.0, threshold: self.thresholds.training_instability_variance,
359 };
360 new_alerts.push(alert);
361 }
362
363 match dynamics.convergence_status {
365 ConvergenceStatus::Diverging => {
366 let alert = ModelDiagnosticAlert::ConvergenceIssue {
367 issue_type: ConvergenceStatus::Diverging,
368 duration_steps: 0, };
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 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 pub fn process_layer_stats(
396 &mut self,
397 stats: &LayerActivationStats,
398 ) -> Result<Vec<ModelDiagnosticAlert>> {
399 let mut new_alerts = Vec::new();
400
401 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 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 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 pub fn add_alert(
437 &mut self,
438 alert: ModelDiagnosticAlert,
439 severity: AlertSeverity,
440 ) -> Result<()> {
441 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 self.send_notification(&alert, &severity)?;
459
460 Ok(())
461 }
462
463 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 pub fn get_active_alerts(&self) -> &[ActiveAlert] {
487 &self.active_alerts
488 }
489
490 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 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 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 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 AlertSeverity::Emergency
561 } else if *current_usage_mb > 8192.0 {
562 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 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 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 fn send_notification(
646 &self,
647 alert: &ModelDiagnosticAlert,
648 severity: &AlertSeverity,
649 ) -> Result<()> {
650 if self.config.notification_settings.console_notifications {
651 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 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#[derive(Debug, Default)]
705pub struct AlertStatistics {
706 pub info_count: usize,
708 pub warning_count: usize,
710 pub critical_count: usize,
712 pub emergency_count: usize,
714 pub total_active: usize,
716 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 manager
776 .add_alert(alert.clone(), AlertSeverity::Warning)
777 .expect("add operation failed");
778 assert_eq!(manager.active_alerts.len(), 1);
779
780 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 #[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); 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 #[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 #[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 #[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}