Skip to main content

faucet_cli/notify/
dispatch.rs

1//! The notifier: match events to rules, coalesce, deliver, and correlate
2//! PagerDuty resolves (#280).
3//!
4//! A [`Notifier`] is built once per process (`from_specs`) and shared via
5//! `Arc`. It holds the compiled rules, a reqwest client, and two pieces of
6//! in-process state: a leading-edge coalesce map and the set of currently-open
7//! PagerDuty incidents (so a later `run_success` sends a matching `resolve`).
8//!
9//! **`emit` never fails or blocks the pipeline.** Every delivery is bounded by
10//! a per-attempt timeout and a small retry; a channel outage is logged, counted
11//! (`faucet_notifications_dropped_total`), and swallowed.
12
13use super::channels;
14use super::event::NotifyEvent;
15use super::metrics;
16use super::render::PdAction;
17use super::spec::{ChannelSpec, EventKind, NotificationSpec, validate_all};
18use crate::error::CliResult;
19use std::collections::{HashMap, HashSet};
20use std::sync::Arc;
21use std::sync::Mutex;
22use std::time::{Duration, Instant};
23
24/// Per-attempt delivery timeout.
25const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
26/// Total attempts per delivery (1 try + retries).
27const MAX_ATTEMPTS: u32 = 2;
28/// Fixed backoff between delivery attempts.
29const RETRY_BACKOFF: Duration = Duration::from_millis(250);
30
31/// A compiled, ready-to-fire set of notification rules.
32pub struct Notifier {
33    rules: Vec<NotificationSpec>,
34    client: reqwest::Client,
35    timeout: Duration,
36    /// (rule.name + dedupe_key) → last leading-edge send instant.
37    dedupe: Mutex<HashMap<String, Instant>>,
38    /// (rule.name + incident_key) currently-open PagerDuty incidents.
39    incidents: Mutex<HashSet<String>>,
40}
41
42impl Notifier {
43    /// Build a notifier from the config `notifications:` list. Returns `Ok(None)`
44    /// when the list is empty (no overhead / no client). Validates the rules
45    /// fail-fast (duplicate names, empty channel fields).
46    pub fn from_specs(specs: &[NotificationSpec]) -> CliResult<Option<Arc<Notifier>>> {
47        if specs.is_empty() {
48            return Ok(None);
49        }
50        validate_all(specs)?;
51        let client = reqwest::Client::builder()
52            // A generous ceiling; the per-attempt `timeout` below is the real
53            // bound. Never let a hung notifier wedge the process on drop.
54            .timeout(DEFAULT_TIMEOUT * MAX_ATTEMPTS + Duration::from_secs(5))
55            .build()
56            .map_err(|e| crate::error::CliError::Internal(format!("notify http client: {e}")))?;
57        Ok(Some(Arc::new(Notifier {
58            rules: specs.to_vec(),
59            client,
60            timeout: DEFAULT_TIMEOUT,
61            dedupe: Mutex::new(HashMap::new()),
62            incidents: Mutex::new(HashSet::new()),
63        })))
64    }
65
66    /// Fire an event at every matching rule. Infallible.
67    pub async fn emit(&self, event: NotifyEvent) {
68        // 1) PagerDuty auto-resolve: a success closes any incident a prior
69        //    failure opened on the same (pipeline, row).
70        if event.closes_incident() {
71            self.resolve_incidents(&event).await;
72        }
73
74        // 2) Normal delivery.
75        for idx in 0..self.rules.len() {
76            let rule = &self.rules[idx];
77            if !rule_matches(rule, &event) {
78                continue;
79            }
80            let channel = rule.channel.kind();
81            // PagerDuty is incident-oriented: a `run_success` is expressed as a
82            // `resolve` (step 1), never as a `trigger`.
83            if matches!(rule.channel, ChannelSpec::Pagerduty(_)) && event.closes_incident() {
84                continue;
85            }
86            if self.coalesce(rule, &event) {
87                metrics::record_dropped(channel, "coalesced");
88                tracing::debug!(rule = %rule.name, kind = event.kind.as_str(), "notification coalesced");
89                continue;
90            }
91            self.deliver(rule, &event).await;
92        }
93    }
94
95    /// Deliver a single (rule, event) as a trigger, then record incident state.
96    async fn deliver(&self, rule: &NotificationSpec, event: &NotifyEvent) {
97        let channel = rule.channel.kind();
98        let start = Instant::now();
99        let res = self
100            .send_with_retry(rule, event, PdAction::Trigger, &event.incident_key())
101            .await;
102        metrics::record_duration(channel, start.elapsed().as_secs_f64());
103        match res {
104            Ok(()) => {
105                metrics::record_sent(channel, event.kind.as_str(), true);
106                if matches!(rule.channel, ChannelSpec::Pagerduty(_)) && event.opens_incident() {
107                    self.incidents
108                        .lock()
109                        .unwrap()
110                        .insert(incident_id(&rule.name, event));
111                }
112            }
113            Err(e) => {
114                // The leading-edge delivery failed: roll back the coalesce
115                // timestamp so a retry of an identical event within the window
116                // is NOT silently dropped — otherwise the operator gets zero
117                // notifications about an ongoing outage until the window elapses
118                // (audit #321 L4).
119                self.uncoalesce(rule, event);
120                metrics::record_sent(channel, event.kind.as_str(), false);
121                metrics::record_dropped(channel, "channel_error");
122                tracing::warn!(rule = %rule.name, channel, error = %e, "notification delivery failed");
123            }
124        }
125    }
126
127    /// Send a `resolve` for every PagerDuty rule with an open incident on this
128    /// event's key.
129    async fn resolve_incidents(&self, event: &NotifyEvent) {
130        let open: Vec<usize> = {
131            let inc = self.incidents.lock().unwrap();
132            self.rules
133                .iter()
134                .enumerate()
135                .filter(|(_, r)| matches!(r.channel, ChannelSpec::Pagerduty(_)))
136                .filter(|(_, r)| inc.contains(&incident_id(&r.name, event)))
137                .map(|(i, _)| i)
138                .collect()
139        };
140        for idx in open {
141            let rule = &self.rules[idx];
142            let res = self
143                .send_with_retry(rule, event, PdAction::Resolve, &event.incident_key())
144                .await;
145            match res {
146                Ok(()) => {
147                    self.incidents
148                        .lock()
149                        .unwrap()
150                        .remove(&incident_id(&rule.name, event));
151                    metrics::record_sent(rule.channel.kind(), "resolve", true);
152                }
153                Err(e) => {
154                    metrics::record_sent(rule.channel.kind(), "resolve", false);
155                    tracing::warn!(rule = %rule.name, error = %e, "notification resolve failed");
156                }
157            }
158        }
159    }
160
161    /// Bounded-retry, per-attempt-timeout delivery of one message.
162    async fn send_with_retry(
163        &self,
164        rule: &NotificationSpec,
165        event: &NotifyEvent,
166        action: PdAction,
167        dedup_key: &str,
168    ) -> Result<(), String> {
169        let mut last = String::from("no attempt made");
170        for attempt in 0..MAX_ATTEMPTS {
171            if attempt > 0 {
172                tokio::time::sleep(RETRY_BACKOFF).await;
173            }
174            let fut = self.dispatch_once(rule, event, action, dedup_key);
175            match tokio::time::timeout(self.timeout, fut).await {
176                Ok(Ok(())) => return Ok(()),
177                Ok(Err(e)) => last = e,
178                Err(_) => last = format!("timed out after {:?}", self.timeout),
179            }
180        }
181        Err(last)
182    }
183
184    /// One HTTP request to the rule's channel.
185    async fn dispatch_once(
186        &self,
187        rule: &NotificationSpec,
188        event: &NotifyEvent,
189        action: PdAction,
190        dedup_key: &str,
191    ) -> Result<(), String> {
192        match &rule.channel {
193            ChannelSpec::Slack(c) => channels::send_slack(&self.client, c, event).await,
194            ChannelSpec::Webhook(c) => channels::send_webhook(&self.client, c, event).await,
195            ChannelSpec::Pagerduty(c) => {
196                channels::send_pagerduty(&self.client, c, event, action, dedup_key).await
197            }
198        }
199    }
200
201    /// Leading-edge coalesce decision. Returns `true` when the event should be
202    /// dropped (an identical one fired within the rule's window). Records the
203    /// send instant when it does NOT coalesce.
204    fn coalesce(&self, rule: &NotificationSpec, event: &NotifyEvent) -> bool {
205        let Some(window) = rule.dedupe_window_secs.filter(|w| *w > 0) else {
206            return false;
207        };
208        let key = format!("{}::{}", rule.name, event.dedupe_key());
209        let now = Instant::now();
210        let mut map = self.dedupe.lock().unwrap();
211        if let Some(last) = map.get(&key)
212            && now.duration_since(*last) < Duration::from_secs(window)
213        {
214            return true;
215        }
216        map.insert(key, now);
217        false
218    }
219
220    /// Remove the coalesce timestamp recorded by [`Self::coalesce`] for this
221    /// (rule, event). Called when the leading-edge delivery fails so the next
222    /// identical event re-attempts delivery instead of being coalesced away
223    /// (audit #321 L4).
224    fn uncoalesce(&self, rule: &NotificationSpec, event: &NotifyEvent) {
225        if rule.dedupe_window_secs.filter(|w| *w > 0).is_none() {
226            return;
227        }
228        let key = format!("{}::{}", rule.name, event.dedupe_key());
229        self.dedupe.lock().unwrap().remove(&key);
230    }
231
232    /// Test-only view of open incidents.
233    #[cfg(test)]
234    fn open_incident_count(&self) -> usize {
235        self.incidents.lock().unwrap().len()
236    }
237}
238
239fn incident_id(rule_name: &str, event: &NotifyEvent) -> String {
240    format!("{rule_name}::{}", event.incident_key())
241}
242
243/// Pure rule/event match: kind selector + severity floor + DLQ threshold.
244fn rule_matches(rule: &NotificationSpec, event: &NotifyEvent) -> bool {
245    if !rule.on.is_empty() && !rule.on.contains(&event.kind) {
246        return false;
247    }
248    if event.severity < rule.min_severity {
249        return false;
250    }
251    if event.kind == EventKind::DlqThreshold {
252        let count = event
253            .details
254            .get("records_dlq")
255            .and_then(|v| v.as_u64())
256            .unwrap_or(0);
257        if count < rule.dlq_threshold.unwrap_or(1) {
258            return false;
259        }
260    }
261    true
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::notify::spec::{ChannelSpec, PagerdutyConfig, Severity, SlackConfig, WebhookConfig};
268
269    fn rule(name: &str, on: Vec<EventKind>, channel: ChannelSpec) -> NotificationSpec {
270        NotificationSpec {
271            name: name.into(),
272            on,
273            min_severity: Severity::Info,
274            dedupe_window_secs: None,
275            dlq_threshold: None,
276            channel,
277        }
278    }
279
280    fn slack() -> ChannelSpec {
281        ChannelSpec::Slack(SlackConfig {
282            webhook_url: "http://x".into(),
283            channel: None,
284            username: None,
285        })
286    }
287
288    #[test]
289    fn matches_on_kind_selector() {
290        let r = rule("a", vec![EventKind::RunFailure], slack());
291        assert!(rule_matches(
292            &r,
293            &NotifyEvent::run_failure("p", "", "s", "m")
294        ));
295        assert!(!rule_matches(&r, &NotifyEvent::run_success("p", "", 1)));
296    }
297
298    #[test]
299    fn empty_selector_matches_all() {
300        let r = rule("a", vec![], slack());
301        assert!(rule_matches(&r, &NotifyEvent::run_success("p", "", 1)));
302        assert!(rule_matches(&r, &NotifyEvent::circuit_open("p", "", 3, 5)));
303    }
304
305    #[test]
306    fn severity_floor_gates() {
307        let mut r = rule("a", vec![], slack());
308        r.min_severity = Severity::Error;
309        // run_success is Info < Error → gated
310        assert!(!rule_matches(&r, &NotifyEvent::run_success("p", "", 1)));
311        // circuit_open is Critical ≥ Error → passes
312        assert!(rule_matches(&r, &NotifyEvent::circuit_open("p", "", 3, 5)));
313        // sla_breach is Warning < Error → gated
314        assert!(!rule_matches(
315            &r,
316            &NotifyEvent::sla_breach("p", "", "staleness", "x")
317        ));
318    }
319
320    #[test]
321    fn dlq_threshold_gates_below_count() {
322        let mut r = rule("a", vec![EventKind::DlqThreshold], slack());
323        r.dlq_threshold = Some(100);
324        assert!(!rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 50)));
325        assert!(rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 100)));
326        assert!(rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 250)));
327    }
328
329    #[test]
330    fn dlq_threshold_defaults_to_one() {
331        let r = rule("a", vec![EventKind::DlqThreshold], slack());
332        assert!(rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 1)));
333        assert!(!rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 0)));
334    }
335
336    #[test]
337    fn from_specs_empty_is_none() {
338        assert!(Notifier::from_specs(&[]).unwrap().is_none());
339    }
340
341    #[test]
342    fn from_specs_rejects_duplicate_names() {
343        let list = vec![rule("dup", vec![], slack()), rule("dup", vec![], slack())];
344        assert!(Notifier::from_specs(&list).is_err());
345    }
346
347    #[test]
348    fn coalesce_drops_within_window() {
349        let mut r = rule("a", vec![], slack());
350        r.dedupe_window_secs = Some(3600);
351        let n = Notifier::from_specs(&[r.clone()]).unwrap().unwrap();
352        let e = NotifyEvent::run_failure("p", "", "s", "m");
353        // first: not coalesced (records instant); second: coalesced
354        assert!(!n.coalesce(&r, &e));
355        assert!(n.coalesce(&r, &e));
356    }
357
358    #[test]
359    fn coalesce_disabled_never_drops() {
360        let r = rule("a", vec![], slack()); // no window
361        let n = Notifier::from_specs(std::slice::from_ref(&r))
362            .unwrap()
363            .unwrap();
364        let e = NotifyEvent::run_failure("p", "", "s", "m");
365        assert!(!n.coalesce(&r, &e));
366        assert!(!n.coalesce(&r, &e));
367    }
368
369    #[test]
370    fn uncoalesce_lets_the_next_event_retry_after_failure() {
371        // #321 L4: a leading-edge failure rolls the coalesce timestamp back, so
372        // the next identical event is delivered rather than silently coalesced.
373        let mut r = rule("a", vec![], slack());
374        r.dedupe_window_secs = Some(3600);
375        let n = Notifier::from_specs(&[r.clone()]).unwrap().unwrap();
376        let e = NotifyEvent::run_failure("p", "", "s", "m");
377        assert!(!n.coalesce(&r, &e), "leading edge records the instant");
378        // Simulate the delivery having failed:
379        n.uncoalesce(&r, &e);
380        // The next identical event must NOT be coalesced (retry allowed).
381        assert!(
382            !n.coalesce(&r, &e),
383            "after rollback the next event retries instead of being dropped"
384        );
385        // And a subsequent one (whose leading edge succeeded) does coalesce.
386        assert!(n.coalesce(&r, &e));
387    }
388
389    #[test]
390    fn incident_id_is_stable_per_rule_and_key() {
391        let f = NotifyEvent::run_failure("p", "r1", "s", "m");
392        assert_eq!(incident_id("pd", &f), "pd::p:r1");
393    }
394
395    #[tokio::test]
396    async fn resolve_only_touches_open_incidents() {
397        // A notifier with a PD rule; manually mark an incident open, then emit a
398        // success and confirm the resolve path tries to clear it. We can't hit a
399        // real PD endpoint, so point at an unroutable endpoint: the resolve send
400        // fails, so the incident stays open (delivery failure must not silently
401        // drop the incident).
402        let pd = ChannelSpec::Pagerduty(PagerdutyConfig {
403            routing_key: "rk".into(),
404            source: None,
405            endpoint: Some("http://127.0.0.1:0/enqueue".into()),
406        });
407        let mut r = rule("pd", vec![EventKind::RunFailure], pd);
408        r.min_severity = Severity::Info;
409        let n = Notifier::from_specs(&[r]).unwrap().unwrap();
410        n.incidents.lock().unwrap().insert("pd::p:r1".to_string());
411        assert_eq!(n.open_incident_count(), 1);
412        n.emit(NotifyEvent::run_success("p", "r1", 1)).await;
413        // resolve delivery failed (bad endpoint) → incident intentionally kept.
414        assert_eq!(n.open_incident_count(), 1);
415    }
416
417    #[test]
418    fn webhook_channel_variant_builds() {
419        let wh = ChannelSpec::Webhook(WebhookConfig {
420            url: "http://x".into(),
421            method: "POST".into(),
422            headers: Default::default(),
423            hmac_secret: Some("s".into()),
424            signature_header: "X-Faucet-Signature".into(),
425            extra_fields: Default::default(),
426        });
427        assert!(
428            Notifier::from_specs(&[rule("w", vec![], wh)])
429                .unwrap()
430                .is_some()
431        );
432    }
433}