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                metrics::record_sent(channel, event.kind.as_str(), false);
115                metrics::record_dropped(channel, "channel_error");
116                tracing::warn!(rule = %rule.name, channel, error = %e, "notification delivery failed");
117            }
118        }
119    }
120
121    /// Send a `resolve` for every PagerDuty rule with an open incident on this
122    /// event's key.
123    async fn resolve_incidents(&self, event: &NotifyEvent) {
124        let open: Vec<usize> = {
125            let inc = self.incidents.lock().unwrap();
126            self.rules
127                .iter()
128                .enumerate()
129                .filter(|(_, r)| matches!(r.channel, ChannelSpec::Pagerduty(_)))
130                .filter(|(_, r)| inc.contains(&incident_id(&r.name, event)))
131                .map(|(i, _)| i)
132                .collect()
133        };
134        for idx in open {
135            let rule = &self.rules[idx];
136            let res = self
137                .send_with_retry(rule, event, PdAction::Resolve, &event.incident_key())
138                .await;
139            match res {
140                Ok(()) => {
141                    self.incidents
142                        .lock()
143                        .unwrap()
144                        .remove(&incident_id(&rule.name, event));
145                    metrics::record_sent(rule.channel.kind(), "resolve", true);
146                }
147                Err(e) => {
148                    metrics::record_sent(rule.channel.kind(), "resolve", false);
149                    tracing::warn!(rule = %rule.name, error = %e, "notification resolve failed");
150                }
151            }
152        }
153    }
154
155    /// Bounded-retry, per-attempt-timeout delivery of one message.
156    async fn send_with_retry(
157        &self,
158        rule: &NotificationSpec,
159        event: &NotifyEvent,
160        action: PdAction,
161        dedup_key: &str,
162    ) -> Result<(), String> {
163        let mut last = String::from("no attempt made");
164        for attempt in 0..MAX_ATTEMPTS {
165            if attempt > 0 {
166                tokio::time::sleep(RETRY_BACKOFF).await;
167            }
168            let fut = self.dispatch_once(rule, event, action, dedup_key);
169            match tokio::time::timeout(self.timeout, fut).await {
170                Ok(Ok(())) => return Ok(()),
171                Ok(Err(e)) => last = e,
172                Err(_) => last = format!("timed out after {:?}", self.timeout),
173            }
174        }
175        Err(last)
176    }
177
178    /// One HTTP request to the rule's channel.
179    async fn dispatch_once(
180        &self,
181        rule: &NotificationSpec,
182        event: &NotifyEvent,
183        action: PdAction,
184        dedup_key: &str,
185    ) -> Result<(), String> {
186        match &rule.channel {
187            ChannelSpec::Slack(c) => channels::send_slack(&self.client, c, event).await,
188            ChannelSpec::Webhook(c) => channels::send_webhook(&self.client, c, event).await,
189            ChannelSpec::Pagerduty(c) => {
190                channels::send_pagerduty(&self.client, c, event, action, dedup_key).await
191            }
192        }
193    }
194
195    /// Leading-edge coalesce decision. Returns `true` when the event should be
196    /// dropped (an identical one fired within the rule's window). Records the
197    /// send instant when it does NOT coalesce.
198    fn coalesce(&self, rule: &NotificationSpec, event: &NotifyEvent) -> bool {
199        let Some(window) = rule.dedupe_window_secs.filter(|w| *w > 0) else {
200            return false;
201        };
202        let key = format!("{}::{}", rule.name, event.dedupe_key());
203        let now = Instant::now();
204        let mut map = self.dedupe.lock().unwrap();
205        if let Some(last) = map.get(&key)
206            && now.duration_since(*last) < Duration::from_secs(window)
207        {
208            return true;
209        }
210        map.insert(key, now);
211        false
212    }
213
214    /// Test-only view of open incidents.
215    #[cfg(test)]
216    fn open_incident_count(&self) -> usize {
217        self.incidents.lock().unwrap().len()
218    }
219}
220
221fn incident_id(rule_name: &str, event: &NotifyEvent) -> String {
222    format!("{rule_name}::{}", event.incident_key())
223}
224
225/// Pure rule/event match: kind selector + severity floor + DLQ threshold.
226fn rule_matches(rule: &NotificationSpec, event: &NotifyEvent) -> bool {
227    if !rule.on.is_empty() && !rule.on.contains(&event.kind) {
228        return false;
229    }
230    if event.severity < rule.min_severity {
231        return false;
232    }
233    if event.kind == EventKind::DlqThreshold {
234        let count = event
235            .details
236            .get("records_dlq")
237            .and_then(|v| v.as_u64())
238            .unwrap_or(0);
239        if count < rule.dlq_threshold.unwrap_or(1) {
240            return false;
241        }
242    }
243    true
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::notify::spec::{ChannelSpec, PagerdutyConfig, Severity, SlackConfig, WebhookConfig};
250
251    fn rule(name: &str, on: Vec<EventKind>, channel: ChannelSpec) -> NotificationSpec {
252        NotificationSpec {
253            name: name.into(),
254            on,
255            min_severity: Severity::Info,
256            dedupe_window_secs: None,
257            dlq_threshold: None,
258            channel,
259        }
260    }
261
262    fn slack() -> ChannelSpec {
263        ChannelSpec::Slack(SlackConfig {
264            webhook_url: "http://x".into(),
265            channel: None,
266            username: None,
267        })
268    }
269
270    #[test]
271    fn matches_on_kind_selector() {
272        let r = rule("a", vec![EventKind::RunFailure], slack());
273        assert!(rule_matches(
274            &r,
275            &NotifyEvent::run_failure("p", "", "s", "m")
276        ));
277        assert!(!rule_matches(&r, &NotifyEvent::run_success("p", "", 1)));
278    }
279
280    #[test]
281    fn empty_selector_matches_all() {
282        let r = rule("a", vec![], slack());
283        assert!(rule_matches(&r, &NotifyEvent::run_success("p", "", 1)));
284        assert!(rule_matches(&r, &NotifyEvent::circuit_open("p", "", 3, 5)));
285    }
286
287    #[test]
288    fn severity_floor_gates() {
289        let mut r = rule("a", vec![], slack());
290        r.min_severity = Severity::Error;
291        // run_success is Info < Error → gated
292        assert!(!rule_matches(&r, &NotifyEvent::run_success("p", "", 1)));
293        // circuit_open is Critical ≥ Error → passes
294        assert!(rule_matches(&r, &NotifyEvent::circuit_open("p", "", 3, 5)));
295        // sla_breach is Warning < Error → gated
296        assert!(!rule_matches(
297            &r,
298            &NotifyEvent::sla_breach("p", "", "staleness", "x")
299        ));
300    }
301
302    #[test]
303    fn dlq_threshold_gates_below_count() {
304        let mut r = rule("a", vec![EventKind::DlqThreshold], slack());
305        r.dlq_threshold = Some(100);
306        assert!(!rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 50)));
307        assert!(rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 100)));
308        assert!(rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 250)));
309    }
310
311    #[test]
312    fn dlq_threshold_defaults_to_one() {
313        let r = rule("a", vec![EventKind::DlqThreshold], slack());
314        assert!(rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 1)));
315        assert!(!rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 0)));
316    }
317
318    #[test]
319    fn from_specs_empty_is_none() {
320        assert!(Notifier::from_specs(&[]).unwrap().is_none());
321    }
322
323    #[test]
324    fn from_specs_rejects_duplicate_names() {
325        let list = vec![rule("dup", vec![], slack()), rule("dup", vec![], slack())];
326        assert!(Notifier::from_specs(&list).is_err());
327    }
328
329    #[test]
330    fn coalesce_drops_within_window() {
331        let mut r = rule("a", vec![], slack());
332        r.dedupe_window_secs = Some(3600);
333        let n = Notifier::from_specs(&[r.clone()]).unwrap().unwrap();
334        let e = NotifyEvent::run_failure("p", "", "s", "m");
335        // first: not coalesced (records instant); second: coalesced
336        assert!(!n.coalesce(&r, &e));
337        assert!(n.coalesce(&r, &e));
338    }
339
340    #[test]
341    fn coalesce_disabled_never_drops() {
342        let r = rule("a", vec![], slack()); // no window
343        let n = Notifier::from_specs(std::slice::from_ref(&r))
344            .unwrap()
345            .unwrap();
346        let e = NotifyEvent::run_failure("p", "", "s", "m");
347        assert!(!n.coalesce(&r, &e));
348        assert!(!n.coalesce(&r, &e));
349    }
350
351    #[test]
352    fn incident_id_is_stable_per_rule_and_key() {
353        let f = NotifyEvent::run_failure("p", "r1", "s", "m");
354        assert_eq!(incident_id("pd", &f), "pd::p:r1");
355    }
356
357    #[tokio::test]
358    async fn resolve_only_touches_open_incidents() {
359        // A notifier with a PD rule; manually mark an incident open, then emit a
360        // success and confirm the resolve path tries to clear it. We can't hit a
361        // real PD endpoint, so point at an unroutable endpoint: the resolve send
362        // fails, so the incident stays open (delivery failure must not silently
363        // drop the incident).
364        let pd = ChannelSpec::Pagerduty(PagerdutyConfig {
365            routing_key: "rk".into(),
366            source: None,
367            endpoint: Some("http://127.0.0.1:0/enqueue".into()),
368        });
369        let mut r = rule("pd", vec![EventKind::RunFailure], pd);
370        r.min_severity = Severity::Info;
371        let n = Notifier::from_specs(&[r]).unwrap().unwrap();
372        n.incidents.lock().unwrap().insert("pd::p:r1".to_string());
373        assert_eq!(n.open_incident_count(), 1);
374        n.emit(NotifyEvent::run_success("p", "r1", 1)).await;
375        // resolve delivery failed (bad endpoint) → incident intentionally kept.
376        assert_eq!(n.open_incident_count(), 1);
377    }
378
379    #[test]
380    fn webhook_channel_variant_builds() {
381        let wh = ChannelSpec::Webhook(WebhookConfig {
382            url: "http://x".into(),
383            method: "POST".into(),
384            headers: Default::default(),
385            hmac_secret: Some("s".into()),
386            signature_header: "X-Faucet-Signature".into(),
387        });
388        assert!(
389            Notifier::from_specs(&[rule("w", vec![], wh)])
390                .unwrap()
391                .is_some()
392        );
393    }
394}