Skip to main content

microclaw_engine/
alerts.rs

1//! Opt-in operational webhook alerts.
2//!
3//! A supervised loop polls runtime health every `alerts.interval_secs` and
4//! POSTs a JSON alert to the configured webhook when a condition trips:
5//! scheduler DLQ growth, provider down, token-budget exhaustion, or a
6//! supervised-loop restart storm. Threshold/dedupe decisions live in pure
7//! functions over snapshots so they are testable without a runtime; every
8//! delivery attempt is recorded in the tamper-evident audit chain
9//! (`kind = "alert"`).
10
11use 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
23/// Turns refused because a chat exhausted its token budget, since process
24/// start. Recorded by the agent engine at the single enforcement point.
25static 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/// One poll's worth of health numbers, gathered from the shared sources.
42#[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/// Mutable state carried between polls: the previous sample (deltas) and
52/// per-class last-sent times (cooldown).
53#[derive(Default)]
54pub struct AlertLoopState {
55    prev: Option<HealthSample>,
56    last_sent: HashMap<&'static str, Instant>,
57}
58
59/// Consecutive provider failures that count as "provider down". Matches the
60/// circuit-breaker threshold in `llm.rs`.
61const PROVIDER_DOWN_FAILURES: u64 = 4;
62
63/// Pure alert policy: compare the current sample against the previous one
64/// and the per-class cooldowns. The first poll only establishes a baseline.
65pub 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    // Filter by cooldown only — do NOT record last_sent here. The caller
123    // records it after a successful delivery, so a failed webhook POST does
124    // not start the cooldown and silently drop the alert.
125    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    /// Record that an alert class was successfully delivered at `now`,
136    /// starting its cooldown window.
137    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
163/// Deliver one alert; returns true when the webhook accepted it. Every
164/// attempt (success or failure) is recorded in the audit chain.
165async 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
204/// One poll: gather, evaluate, deliver. Extracted from the loop for
205/// testability; returns how many alerts were delivered (or attempted).
206pub 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        // Start the cooldown only on a successful delivery: a webhook that
218        // is briefly down must not suppress the alert for a full cooldown.
219        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
227/// Spawn the supervised alerts loop; a no-op unless alerts are enabled
228/// with a webhook configured.
229pub 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        // Simulate successful delivery starting the cooldown.
300        for a in &alerts {
301            state.mark_sent(a.class, now);
302        }
303
304        // Still growing, but inside the cooldown window → suppressed.
305        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        // Models a failed delivery: evaluate returns the alert but the caller
316        // never calls mark_sent, so the next poll re-offers it.
317        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        // Delivery "failed" → no mark_sent. Growth continues; still offered.
327        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}