use super::channels;
use super::event::NotifyEvent;
use super::metrics;
use super::render::PdAction;
use super::spec::{ChannelSpec, EventKind, NotificationSpec, validate_all};
use crate::error::CliResult;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::Mutex;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_ATTEMPTS: u32 = 2;
const RETRY_BACKOFF: Duration = Duration::from_millis(250);
pub struct Notifier {
rules: Vec<NotificationSpec>,
client: reqwest::Client,
timeout: Duration,
dedupe: Mutex<HashMap<String, Instant>>,
incidents: Mutex<HashSet<String>>,
}
impl Notifier {
pub fn from_specs(specs: &[NotificationSpec]) -> CliResult<Option<Arc<Notifier>>> {
if specs.is_empty() {
return Ok(None);
}
validate_all(specs)?;
let client = reqwest::Client::builder()
.timeout(DEFAULT_TIMEOUT * MAX_ATTEMPTS + Duration::from_secs(5))
.build()
.map_err(|e| crate::error::CliError::Internal(format!("notify http client: {e}")))?;
Ok(Some(Arc::new(Notifier {
rules: specs.to_vec(),
client,
timeout: DEFAULT_TIMEOUT,
dedupe: Mutex::new(HashMap::new()),
incidents: Mutex::new(HashSet::new()),
})))
}
pub async fn emit(&self, event: NotifyEvent) {
if event.closes_incident() {
self.resolve_incidents(&event).await;
}
for idx in 0..self.rules.len() {
let rule = &self.rules[idx];
if !rule_matches(rule, &event) {
continue;
}
let channel = rule.channel.kind();
if matches!(rule.channel, ChannelSpec::Pagerduty(_)) && event.closes_incident() {
continue;
}
if self.coalesce(rule, &event) {
metrics::record_dropped(channel, "coalesced");
tracing::debug!(rule = %rule.name, kind = event.kind.as_str(), "notification coalesced");
continue;
}
self.deliver(rule, &event).await;
}
}
async fn deliver(&self, rule: &NotificationSpec, event: &NotifyEvent) {
let channel = rule.channel.kind();
let start = Instant::now();
let res = self
.send_with_retry(rule, event, PdAction::Trigger, &event.incident_key())
.await;
metrics::record_duration(channel, start.elapsed().as_secs_f64());
match res {
Ok(()) => {
metrics::record_sent(channel, event.kind.as_str(), true);
if matches!(rule.channel, ChannelSpec::Pagerduty(_)) && event.opens_incident() {
self.incidents
.lock()
.unwrap()
.insert(incident_id(&rule.name, event));
}
}
Err(e) => {
metrics::record_sent(channel, event.kind.as_str(), false);
metrics::record_dropped(channel, "channel_error");
tracing::warn!(rule = %rule.name, channel, error = %e, "notification delivery failed");
}
}
}
async fn resolve_incidents(&self, event: &NotifyEvent) {
let open: Vec<usize> = {
let inc = self.incidents.lock().unwrap();
self.rules
.iter()
.enumerate()
.filter(|(_, r)| matches!(r.channel, ChannelSpec::Pagerduty(_)))
.filter(|(_, r)| inc.contains(&incident_id(&r.name, event)))
.map(|(i, _)| i)
.collect()
};
for idx in open {
let rule = &self.rules[idx];
let res = self
.send_with_retry(rule, event, PdAction::Resolve, &event.incident_key())
.await;
match res {
Ok(()) => {
self.incidents
.lock()
.unwrap()
.remove(&incident_id(&rule.name, event));
metrics::record_sent(rule.channel.kind(), "resolve", true);
}
Err(e) => {
metrics::record_sent(rule.channel.kind(), "resolve", false);
tracing::warn!(rule = %rule.name, error = %e, "notification resolve failed");
}
}
}
}
async fn send_with_retry(
&self,
rule: &NotificationSpec,
event: &NotifyEvent,
action: PdAction,
dedup_key: &str,
) -> Result<(), String> {
let mut last = String::from("no attempt made");
for attempt in 0..MAX_ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(RETRY_BACKOFF).await;
}
let fut = self.dispatch_once(rule, event, action, dedup_key);
match tokio::time::timeout(self.timeout, fut).await {
Ok(Ok(())) => return Ok(()),
Ok(Err(e)) => last = e,
Err(_) => last = format!("timed out after {:?}", self.timeout),
}
}
Err(last)
}
async fn dispatch_once(
&self,
rule: &NotificationSpec,
event: &NotifyEvent,
action: PdAction,
dedup_key: &str,
) -> Result<(), String> {
match &rule.channel {
ChannelSpec::Slack(c) => channels::send_slack(&self.client, c, event).await,
ChannelSpec::Webhook(c) => channels::send_webhook(&self.client, c, event).await,
ChannelSpec::Pagerduty(c) => {
channels::send_pagerduty(&self.client, c, event, action, dedup_key).await
}
}
}
fn coalesce(&self, rule: &NotificationSpec, event: &NotifyEvent) -> bool {
let Some(window) = rule.dedupe_window_secs.filter(|w| *w > 0) else {
return false;
};
let key = format!("{}::{}", rule.name, event.dedupe_key());
let now = Instant::now();
let mut map = self.dedupe.lock().unwrap();
if let Some(last) = map.get(&key)
&& now.duration_since(*last) < Duration::from_secs(window)
{
return true;
}
map.insert(key, now);
false
}
#[cfg(test)]
fn open_incident_count(&self) -> usize {
self.incidents.lock().unwrap().len()
}
}
fn incident_id(rule_name: &str, event: &NotifyEvent) -> String {
format!("{rule_name}::{}", event.incident_key())
}
fn rule_matches(rule: &NotificationSpec, event: &NotifyEvent) -> bool {
if !rule.on.is_empty() && !rule.on.contains(&event.kind) {
return false;
}
if event.severity < rule.min_severity {
return false;
}
if event.kind == EventKind::DlqThreshold {
let count = event
.details
.get("records_dlq")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if count < rule.dlq_threshold.unwrap_or(1) {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::notify::spec::{ChannelSpec, PagerdutyConfig, Severity, SlackConfig, WebhookConfig};
fn rule(name: &str, on: Vec<EventKind>, channel: ChannelSpec) -> NotificationSpec {
NotificationSpec {
name: name.into(),
on,
min_severity: Severity::Info,
dedupe_window_secs: None,
dlq_threshold: None,
channel,
}
}
fn slack() -> ChannelSpec {
ChannelSpec::Slack(SlackConfig {
webhook_url: "http://x".into(),
channel: None,
username: None,
})
}
#[test]
fn matches_on_kind_selector() {
let r = rule("a", vec![EventKind::RunFailure], slack());
assert!(rule_matches(
&r,
&NotifyEvent::run_failure("p", "", "s", "m")
));
assert!(!rule_matches(&r, &NotifyEvent::run_success("p", "", 1)));
}
#[test]
fn empty_selector_matches_all() {
let r = rule("a", vec![], slack());
assert!(rule_matches(&r, &NotifyEvent::run_success("p", "", 1)));
assert!(rule_matches(&r, &NotifyEvent::circuit_open("p", "", 3, 5)));
}
#[test]
fn severity_floor_gates() {
let mut r = rule("a", vec![], slack());
r.min_severity = Severity::Error;
assert!(!rule_matches(&r, &NotifyEvent::run_success("p", "", 1)));
assert!(rule_matches(&r, &NotifyEvent::circuit_open("p", "", 3, 5)));
assert!(!rule_matches(
&r,
&NotifyEvent::sla_breach("p", "", "staleness", "x")
));
}
#[test]
fn dlq_threshold_gates_below_count() {
let mut r = rule("a", vec![EventKind::DlqThreshold], slack());
r.dlq_threshold = Some(100);
assert!(!rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 50)));
assert!(rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 100)));
assert!(rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 250)));
}
#[test]
fn dlq_threshold_defaults_to_one() {
let r = rule("a", vec![EventKind::DlqThreshold], slack());
assert!(rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 1)));
assert!(!rule_matches(&r, &NotifyEvent::dlq_threshold("p", "", 0)));
}
#[test]
fn from_specs_empty_is_none() {
assert!(Notifier::from_specs(&[]).unwrap().is_none());
}
#[test]
fn from_specs_rejects_duplicate_names() {
let list = vec![rule("dup", vec![], slack()), rule("dup", vec![], slack())];
assert!(Notifier::from_specs(&list).is_err());
}
#[test]
fn coalesce_drops_within_window() {
let mut r = rule("a", vec![], slack());
r.dedupe_window_secs = Some(3600);
let n = Notifier::from_specs(&[r.clone()]).unwrap().unwrap();
let e = NotifyEvent::run_failure("p", "", "s", "m");
assert!(!n.coalesce(&r, &e));
assert!(n.coalesce(&r, &e));
}
#[test]
fn coalesce_disabled_never_drops() {
let r = rule("a", vec![], slack()); let n = Notifier::from_specs(std::slice::from_ref(&r))
.unwrap()
.unwrap();
let e = NotifyEvent::run_failure("p", "", "s", "m");
assert!(!n.coalesce(&r, &e));
assert!(!n.coalesce(&r, &e));
}
#[test]
fn incident_id_is_stable_per_rule_and_key() {
let f = NotifyEvent::run_failure("p", "r1", "s", "m");
assert_eq!(incident_id("pd", &f), "pd::p:r1");
}
#[tokio::test]
async fn resolve_only_touches_open_incidents() {
let pd = ChannelSpec::Pagerduty(PagerdutyConfig {
routing_key: "rk".into(),
source: None,
endpoint: Some("http://127.0.0.1:0/enqueue".into()),
});
let mut r = rule("pd", vec![EventKind::RunFailure], pd);
r.min_severity = Severity::Info;
let n = Notifier::from_specs(&[r]).unwrap().unwrap();
n.incidents.lock().unwrap().insert("pd::p:r1".to_string());
assert_eq!(n.open_incident_count(), 1);
n.emit(NotifyEvent::run_success("p", "r1", 1)).await;
assert_eq!(n.open_incident_count(), 1);
}
#[test]
fn webhook_channel_variant_builds() {
let wh = ChannelSpec::Webhook(WebhookConfig {
url: "http://x".into(),
method: "POST".into(),
headers: Default::default(),
hmac_secret: Some("s".into()),
signature_header: "X-Faucet-Signature".into(),
});
assert!(
Notifier::from_specs(&[rule("w", vec![], wh)])
.unwrap()
.is_some()
);
}
}