1use 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
24const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
26const MAX_ATTEMPTS: u32 = 2;
28const RETRY_BACKOFF: Duration = Duration::from_millis(250);
30
31pub struct Notifier {
33 rules: Vec<NotificationSpec>,
34 client: reqwest::Client,
35 timeout: Duration,
36 dedupe: Mutex<HashMap<String, Instant>>,
38 incidents: Mutex<HashSet<String>>,
40}
41
42impl Notifier {
43 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 .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 pub async fn emit(&self, event: NotifyEvent) {
68 if event.closes_incident() {
71 self.resolve_incidents(&event).await;
72 }
73
74 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 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 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 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 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 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 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 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 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 #[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
243fn 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 assert!(!rule_matches(&r, &NotifyEvent::run_success("p", "", 1)));
311 assert!(rule_matches(&r, &NotifyEvent::circuit_open("p", "", 3, 5)));
313 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 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()); 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 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 n.uncoalesce(&r, &e);
380 assert!(
382 !n.coalesce(&r, &e),
383 "after rollback the next event retries instead of being dropped"
384 );
385 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 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 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 });
426 assert!(
427 Notifier::from_specs(&[rule("w", vec![], wh)])
428 .unwrap()
429 .is_some()
430 );
431 }
432}