1use std::collections::HashMap;
15use std::sync::Mutex;
16use std::time::Duration;
17
18use crate::config::AlertsCfg;
19
20pub struct AlertRuntime {
22 pub enabled: bool,
23 webhook_url: String,
24 budget_threshold: f64,
25 client: reqwest::Client,
26 alerting: Mutex<HashMap<String, bool>>,
28}
29
30impl AlertRuntime {
31 pub fn build(cfg: &AlertsCfg) -> Self {
34 let client = reqwest::Client::builder()
35 .timeout(Duration::from_millis(cfg.timeout_ms.max(1)))
36 .build()
37 .unwrap_or_default();
38 AlertRuntime {
39 enabled: cfg.enabled && !cfg.webhook_url.trim().is_empty(),
40 webhook_url: cfg.webhook_url.trim().to_string(),
41 budget_threshold: cfg.budget_consumed_threshold,
42 client,
43 alerting: Mutex::new(HashMap::new()),
44 }
45 }
46
47 pub fn disabled() -> Self {
49 Self::build(&AlertsCfg::default())
50 }
51
52 pub fn decide_budget(&self, budget: &str, ratio: f64) -> bool {
57 if !self.enabled || !ratio.is_finite() {
58 return false;
59 }
60 let over = ratio >= self.budget_threshold;
61 let mut state = self.alerting.lock().expect("alert state mutex poisoned");
62 let was_over = state.get(budget).copied().unwrap_or(false);
63 if over {
64 if was_over {
65 false } else {
67 state.insert(budget.to_string(), true);
68 true
69 }
70 } else {
71 state.insert(budget.to_string(), false); false
73 }
74 }
75
76 pub fn fire_budget_alert(&self, budget: &str, ratio: f64) {
79 if !self.decide_budget(budget, ratio) {
80 return;
81 }
82 let text = format!(
83 "⚠️ EdgeGuard: LLM budget \"{budget}\" at {:.0}% of its limit (alert threshold {:.0}%).",
84 ratio * 100.0,
85 self.budget_threshold * 100.0
86 );
87 let body = serde_json::json!({ "text": text });
88 let client = self.client.clone();
89 let url = self.webhook_url.clone();
90 tokio::spawn(async move {
91 match client.post(&url).json(&body).send().await {
92 Ok(resp) if resp.status().is_success() => {}
93 Ok(resp) => tracing::debug!(status = %resp.status(), "alert webhook rejected"),
94 Err(e) => tracing::debug!(error = %e, "alert webhook failed"),
95 }
96 });
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 fn rt(threshold: f64) -> AlertRuntime {
105 AlertRuntime::build(&AlertsCfg {
106 enabled: true,
107 webhook_url: "http://127.0.0.1:1/hook".into(),
108 budget_consumed_threshold: threshold,
109 ..AlertsCfg::default()
110 })
111 }
112
113 #[test]
114 fn budget_alert_is_edge_triggered_per_crossing() {
115 let a = rt(0.9);
116 assert!(a.decide_budget("monthly", 0.95));
118 assert!(!a.decide_budget("monthly", 0.96));
120 assert!(!a.decide_budget("monthly", 1.20));
121 assert!(!a.decide_budget("monthly", 0.50));
123 assert!(a.decide_budget("monthly", 0.91));
124 assert!(a.decide_budget("daily", 0.90)); }
127
128 #[test]
129 fn disabled_or_missing_url_never_fires() {
130 let off = AlertRuntime::disabled();
131 assert!(!off.decide_budget("b", 5.0));
132 let no_url = AlertRuntime::build(&AlertsCfg {
133 enabled: true,
134 webhook_url: " ".into(), ..AlertsCfg::default()
136 });
137 assert!(!no_url.enabled);
138 assert!(!no_url.decide_budget("b", 5.0));
139 }
140
141 #[test]
142 fn non_finite_ratio_never_fires() {
143 let a = rt(0.9);
144 assert!(!a.decide_budget("b", f64::NAN));
145 assert!(!a.decide_budget("b", f64::INFINITY)); }
147}