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 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 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 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 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 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 #[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
225fn 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 assert!(!rule_matches(&r, &NotifyEvent::run_success("p", "", 1)));
293 assert!(rule_matches(&r, &NotifyEvent::circuit_open("p", "", 3, 5)));
295 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 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()); 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 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 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}