1use std::collections::HashMap;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use serde_json::json;
17use tracing::{info, warn};
18
19use crate::config::AlertsConfig;
20use crate::internal::storage::db::call_blocking;
21use crate::runtime::AppState;
22
23static BUDGET_REFUSALS: AtomicU64 = AtomicU64::new(0);
26
27pub fn note_budget_refusal() {
28 BUDGET_REFUSALS.fetch_add(1, Ordering::Relaxed);
29}
30
31pub fn budget_refusal_count() -> u64 {
32 BUDGET_REFUSALS.load(Ordering::Relaxed)
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Alert {
37 pub class: &'static str,
38 pub message: String,
39}
40
41#[derive(Debug, Clone, Default)]
43pub struct HealthSample {
44 pub dlq_pending: i64,
45 pub restarts_total: u64,
46 pub budget_refusals: u64,
47 pub provider_consecutive_failures: u64,
48 pub provider_breaker_open: bool,
49}
50
51#[derive(Default)]
54pub struct AlertLoopState {
55 prev: Option<HealthSample>,
56 last_sent: HashMap<&'static str, Instant>,
57}
58
59const PROVIDER_DOWN_FAILURES: u64 = 4;
62
63pub fn evaluate_alerts(
66 loop_state: &mut AlertLoopState,
67 sample: &HealthSample,
68 cfg: &AlertsConfig,
69 now: Instant,
70) -> Vec<Alert> {
71 let Some(prev) = loop_state.prev.replace(sample.clone()) else {
72 return Vec::new();
73 };
74
75 let mut candidates = Vec::new();
76 if sample.dlq_pending > prev.dlq_pending {
77 candidates.push(Alert {
78 class: "dlq_growth",
79 message: format!(
80 "Scheduler dead-letter queue grew from {} to {} unreplayed entries.",
81 prev.dlq_pending, sample.dlq_pending
82 ),
83 });
84 }
85 if sample.provider_breaker_open
86 || sample.provider_consecutive_failures >= PROVIDER_DOWN_FAILURES
87 {
88 candidates.push(Alert {
89 class: "provider_down",
90 message: format!(
91 "LLM provider is failing: {} consecutive failure(s){}.",
92 sample.provider_consecutive_failures,
93 if sample.provider_breaker_open {
94 ", circuit breaker open"
95 } else {
96 ""
97 }
98 ),
99 });
100 }
101 if sample.budget_refusals > prev.budget_refusals {
102 candidates.push(Alert {
103 class: "budget_exhaustion",
104 message: format!(
105 "{} turn(s) refused since the last poll because a chat's daily token budget \
106 is exhausted.",
107 sample.budget_refusals - prev.budget_refusals
108 ),
109 });
110 }
111 let restart_delta = sample.restarts_total.saturating_sub(prev.restarts_total);
112 if cfg.restart_storm_threshold > 0 && restart_delta >= cfg.restart_storm_threshold {
113 candidates.push(Alert {
114 class: "restart_storm",
115 message: format!(
116 "{restart_delta} supervised-loop restart(s) within one poll interval."
117 ),
118 });
119 }
120
121 let cooldown = Duration::from_secs(cfg.cooldown_secs);
122 candidates
126 .into_iter()
127 .filter(|alert| match loop_state.last_sent.get(alert.class) {
128 Some(sent) => now.duration_since(*sent) >= cooldown,
129 None => true,
130 })
131 .collect()
132}
133
134impl AlertLoopState {
135 fn mark_sent(&mut self, class: &'static str, now: Instant) {
138 self.last_sent.insert(class, now);
139 }
140}
141
142async fn gather_sample(state: &Arc<AppState>) -> HealthSample {
143 let dlq_pending = call_blocking(state.db.clone(), |db| db.count_scheduled_task_dlq(false))
144 .await
145 .unwrap_or_else(|err| {
146 warn!("alerts: failed to count scheduler DLQ: {err}");
147 0
148 });
149 let restarts_total = crate::supervision::restart_counts()
150 .into_iter()
151 .map(|(_, count)| count)
152 .sum();
153 let provider = crate::llm::provider_failover_snapshot();
154 HealthSample {
155 dlq_pending,
156 restarts_total,
157 budget_refusals: budget_refusal_count(),
158 provider_consecutive_failures: provider.consecutive_failures,
159 provider_breaker_open: provider.breaker_open,
160 }
161}
162
163async fn deliver_alert(
166 state: &Arc<AppState>,
167 client: &reqwest::Client,
168 url: &str,
169 alert: &Alert,
170) -> bool {
171 let body = json!({
172 "source": "microclaw",
173 "class": alert.class,
174 "message": alert.message,
175 "generated_at": chrono::Utc::now().to_rfc3339(),
176 });
177 let outcome = match client
178 .post(url)
179 .json(&body)
180 .timeout(Duration::from_secs(10))
181 .send()
182 .await
183 {
184 Ok(resp) if resp.status().is_success() => Ok(()),
185 Ok(resp) => Err(format!("webhook returned HTTP {}", resp.status())),
186 Err(err) => Err(format!("webhook POST failed: {err}")),
187 };
188 let (status, detail) = match &outcome {
189 Ok(()) => ("sent", alert.message.clone()),
190 Err(err) => {
191 warn!("alerts: {err} (class {})", alert.class);
192 ("error", format!("{}: {err}", alert.message))
193 }
194 };
195 let class = alert.class;
196 let status = status.to_string();
197 let _ = call_blocking(state.db.clone(), move |db| {
198 db.log_audit_event("alert", "alerts_loop", class, None, &status, Some(&detail))
199 })
200 .await;
201 outcome.is_ok()
202}
203
204pub async fn run_alerts_once(
207 state: &Arc<AppState>,
208 loop_state: &mut AlertLoopState,
209 client: &reqwest::Client,
210) -> usize {
211 let cfg = &state.config.alerts;
212 let sample = gather_sample(state).await;
213 let now = Instant::now();
214 let alerts = evaluate_alerts(loop_state, &sample, cfg, now);
215 let mut delivered = 0usize;
216 for alert in &alerts {
217 if deliver_alert(state, client, &cfg.webhook_url, alert).await {
220 loop_state.mark_sent(alert.class, now);
221 delivered += 1;
222 }
223 }
224 delivered
225}
226
227pub fn spawn_alerts(state: Arc<AppState>) {
230 let cfg = state.config.alerts.clone();
231 if !cfg.enabled || cfg.webhook_url.trim().is_empty() {
232 info!("alerts: disabled (alerts.enabled=false or no webhook_url)");
233 return;
234 }
235 crate::supervision::spawn_supervised("alerts", move || {
236 let state = state.clone();
237 let interval_secs = cfg.interval_secs.max(10);
238 async move {
239 let client = reqwest::Client::builder()
240 .user_agent(format!(
241 "{}/alerts",
242 crate::http_client::default_llm_user_agent()
243 ))
244 .redirect(crate::internal::tool_runtime::url_safety::ssrf_redirect_policy(3))
245 .build()
246 .unwrap_or_else(|_| reqwest::Client::new());
247 let mut loop_state = AlertLoopState::default();
248 let mut ticker = tokio::time::interval(Duration::from_secs(interval_secs));
249 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
250 info!("alerts: loop started (every {interval_secs}s)");
251 loop {
252 ticker.tick().await;
253 run_alerts_once(&state, &mut loop_state, &client).await;
254 }
255 }
256 });
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn cfg() -> AlertsConfig {
264 AlertsConfig {
265 enabled: true,
266 webhook_url: "https://alerts.example.com/hook".into(),
267 interval_secs: 60,
268 cooldown_secs: 900,
269 restart_storm_threshold: 5,
270 }
271 }
272
273 #[test]
274 fn first_poll_is_baseline_only() {
275 let mut state = AlertLoopState::default();
276 let sample = HealthSample {
277 dlq_pending: 10,
278 provider_breaker_open: true,
279 ..Default::default()
280 };
281 assert!(evaluate_alerts(&mut state, &sample, &cfg(), Instant::now()).is_empty());
282 }
283
284 #[test]
285 fn dlq_growth_and_budget_delta_alert_once_within_cooldown() {
286 let mut state = AlertLoopState::default();
287 let now = Instant::now();
288 let base = HealthSample::default();
289 evaluate_alerts(&mut state, &base, &cfg(), now);
290
291 let grown = HealthSample {
292 dlq_pending: 3,
293 budget_refusals: 2,
294 ..Default::default()
295 };
296 let alerts = evaluate_alerts(&mut state, &grown, &cfg(), now);
297 let classes: Vec<_> = alerts.iter().map(|a| a.class).collect();
298 assert_eq!(classes, vec!["dlq_growth", "budget_exhaustion"]);
299 for a in &alerts {
301 state.mark_sent(a.class, now);
302 }
303
304 let grown_again = HealthSample {
306 dlq_pending: 5,
307 budget_refusals: 4,
308 ..Default::default()
309 };
310 assert!(evaluate_alerts(&mut state, &grown_again, &cfg(), now).is_empty());
311 }
312
313 #[test]
314 fn unmarked_alert_is_not_suppressed_next_poll() {
315 let mut state = AlertLoopState::default();
318 let now = Instant::now();
319 evaluate_alerts(&mut state, &HealthSample::default(), &cfg(), now);
320
321 let grown = HealthSample {
322 dlq_pending: 2,
323 ..Default::default()
324 };
325 assert_eq!(evaluate_alerts(&mut state, &grown, &cfg(), now).len(), 1);
326 let grown_more = HealthSample {
328 dlq_pending: 3,
329 ..Default::default()
330 };
331 assert_eq!(
332 evaluate_alerts(&mut state, &grown_more, &cfg(), now).len(),
333 1
334 );
335 }
336
337 #[test]
338 fn provider_down_requires_breaker_or_threshold() {
339 let mut state = AlertLoopState::default();
340 let now = Instant::now();
341 evaluate_alerts(&mut state, &HealthSample::default(), &cfg(), now);
342
343 let flaky = HealthSample {
344 provider_consecutive_failures: 2,
345 ..Default::default()
346 };
347 assert!(evaluate_alerts(&mut state, &flaky, &cfg(), now).is_empty());
348
349 let down = HealthSample {
350 provider_consecutive_failures: PROVIDER_DOWN_FAILURES,
351 ..Default::default()
352 };
353 let alerts = evaluate_alerts(&mut state, &down, &cfg(), now);
354 assert_eq!(alerts.len(), 1);
355 assert_eq!(alerts[0].class, "provider_down");
356 }
357
358 #[test]
359 fn restart_storm_uses_delta_and_threshold() {
360 let mut state = AlertLoopState::default();
361 let now = Instant::now();
362 evaluate_alerts(
363 &mut state,
364 &HealthSample {
365 restarts_total: 100,
366 ..Default::default()
367 },
368 &cfg(),
369 now,
370 );
371
372 let calm = HealthSample {
373 restarts_total: 103,
374 ..Default::default()
375 };
376 assert!(evaluate_alerts(&mut state, &calm, &cfg(), now).is_empty());
377
378 let storm = HealthSample {
379 restarts_total: 109,
380 ..Default::default()
381 };
382 let alerts = evaluate_alerts(&mut state, &storm, &cfg(), now);
383 assert_eq!(alerts.len(), 1);
384 assert_eq!(alerts[0].class, "restart_storm");
385 }
386}