use super::event::NotifyEvent;
use super::spec::{PagerdutyConfig, SlackConfig, WebhookConfig};
use serde_json::{Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PdAction {
Trigger,
Resolve,
}
impl PdAction {
fn as_str(self) -> &'static str {
match self {
PdAction::Trigger => "trigger",
PdAction::Resolve => "resolve",
}
}
}
pub fn slack(cfg: &SlackConfig, event: &NotifyEvent) -> Value {
let emoji = match event.severity {
super::spec::Severity::Critical => "🚨",
super::spec::Severity::Error => "❌",
super::spec::Severity::Warning => "⚠️",
super::spec::Severity::Info => "✅",
};
let mut context_fields = vec![
format!("*Pipeline:* {}", event.pipeline),
format!("*Severity:* {}", event.severity.as_str()),
];
if !event.row.is_empty() {
context_fields.push(format!("*Row:* {}", event.row));
}
for (k, v) in &event.details {
context_fields.push(format!("*{k}:* {}", scalar(v)));
}
let mut body = json!({
"text": format!("{emoji} {}", event.title),
"blocks": [
{
"type": "section",
"text": { "type": "mrkdwn", "text": format!("{emoji} *{}*\n{}", event.title, event.message) }
},
{
"type": "context",
"elements": [ { "type": "mrkdwn", "text": context_fields.join(" • ") } ]
}
]
});
if let Some(ch) = &cfg.channel {
body["channel"] = Value::String(ch.clone());
}
if let Some(user) = &cfg.username {
body["username"] = Value::String(user.clone());
}
body
}
pub fn pagerduty(
cfg: &PagerdutyConfig,
event: &NotifyEvent,
action: PdAction,
dedup_key: &str,
) -> Value {
let source = cfg.source.clone().unwrap_or_else(|| event.pipeline.clone());
let mut body = json!({
"routing_key": cfg.routing_key,
"event_action": action.as_str(),
"dedup_key": dedup_key,
});
if action == PdAction::Trigger {
body["payload"] = json!({
"summary": event.title,
"source": source,
"severity": event.severity.as_pagerduty(),
"custom_details": {
"message": event.message,
"pipeline": event.pipeline,
"row": event.row,
"details": Value::Object(event.details.clone()),
}
});
}
body
}
pub fn webhook(cfg: &WebhookConfig, event: &NotifyEvent) -> Value {
let run = event.run.as_ref();
let mut body = json!({
"event": event.kind.as_str(),
"severity": event.severity.as_str(),
"pipeline": event.pipeline,
"row": event.row,
"title": event.title,
"message": event.message,
"details": Value::Object(event.details.clone()),
"run_id": run.and_then(|r| r.run_id.clone()).map_or(Value::Null, Value::String),
"invocation_id": run
.and_then(|r| r.invocation_id.clone())
.map_or(Value::Null, Value::String),
"started_at": run
.and_then(|r| r.started_at)
.map_or(Value::Null, |t| Value::String(t.to_rfc3339())),
"finished_at": run
.and_then(|r| r.finished_at)
.map_or(Value::Null, |t| Value::String(t.to_rfc3339())),
"duration_secs": run
.and_then(|r| r.duration)
.and_then(|d| serde_json::Number::from_f64(d.as_secs_f64()))
.map_or(Value::Null, Value::Number),
});
if !cfg.extra_fields.is_empty()
&& let Some(map) = body.as_object_mut()
{
for (k, v) in &cfg.extra_fields {
map.insert(k.clone(), v.clone());
}
}
body
}
fn scalar(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::notify::spec::Severity;
fn slack_cfg() -> SlackConfig {
SlackConfig {
webhook_url: "http://x".into(),
channel: Some("#alerts".into()),
username: Some("faucet".into()),
}
}
#[test]
fn slack_body_has_blocks_and_overrides() {
let e = NotifyEvent::run_failure("orders", "row1", "sink", "connection refused");
let body = slack(&slack_cfg(), &e);
assert_eq!(body["channel"], "#alerts");
assert_eq!(body["username"], "faucet");
let text = body["text"].as_str().unwrap();
assert!(text.contains("failed"));
let ctx = body["blocks"][1]["elements"][0]["text"].as_str().unwrap();
assert!(ctx.contains("row1"));
assert!(ctx.contains("error_kind"));
}
#[test]
fn slack_omits_row_when_empty() {
let e = NotifyEvent::scheduler_stuck("p", "no heartbeat");
let body = slack(
&SlackConfig {
webhook_url: "u".into(),
channel: None,
username: None,
},
&e,
);
let ctx = body["blocks"][1]["elements"][0]["text"].as_str().unwrap();
assert!(!ctx.contains("*Row:*"));
assert!(body.get("channel").is_none());
}
#[test]
fn pagerduty_trigger_carries_payload() {
let cfg = PagerdutyConfig {
routing_key: "rk".into(),
source: None,
endpoint: None,
};
let e = NotifyEvent::circuit_open("p", "", 5, 30);
let body = pagerduty(&cfg, &e, PdAction::Trigger, "p:");
assert_eq!(body["event_action"], "trigger");
assert_eq!(body["routing_key"], "rk");
assert_eq!(body["dedup_key"], "p:");
assert_eq!(body["payload"]["severity"], "critical");
assert_eq!(body["payload"]["source"], "p"); assert_eq!(body["payload"]["custom_details"]["details"]["failures"], 5);
}
#[test]
fn pagerduty_resolve_is_minimal() {
let cfg = PagerdutyConfig {
routing_key: "rk".into(),
source: Some("svc".into()),
endpoint: None,
};
let e = NotifyEvent::run_success("p", "", 1);
let body = pagerduty(&cfg, &e, PdAction::Resolve, "p:");
assert_eq!(body["event_action"], "resolve");
assert_eq!(body["dedup_key"], "p:");
assert!(body.get("payload").is_none());
}
#[test]
fn webhook_envelope_is_stable() {
let cfg = WebhookConfig {
url: "u".into(),
method: "POST".into(),
headers: Default::default(),
hmac_secret: None,
signature_header: "X-Faucet-Signature".into(),
extra_fields: Default::default(),
};
let e = NotifyEvent::dlq_threshold("p", "r", 42);
let body = webhook(&cfg, &e);
assert_eq!(body["event"], "dlq_threshold");
assert_eq!(body["severity"], Severity::Warning.as_str());
assert_eq!(body["details"]["records_dlq"], 42);
}
fn webhook_cfg() -> WebhookConfig {
WebhookConfig {
url: "u".into(),
method: "POST".into(),
headers: Default::default(),
hmac_secret: None,
signature_header: "X-Faucet-Signature".into(),
extra_fields: Default::default(),
}
}
#[test]
fn webhook_carries_run_identity_and_timing() {
let run =
crate::notify::RunContext::start(Some("run-abc".into()), Some("invocation-1".into()))
.finish(std::time::Instant::now());
let e = NotifyEvent::run_success("p", "r", 7).with_run(run);
let body = webhook(&webhook_cfg(), &e);
assert_eq!(body["run_id"], "run-abc");
assert_eq!(body["invocation_id"], "invocation-1");
assert!(body["started_at"].is_string(), "started_at must be RFC3339");
assert!(body["finished_at"].is_string());
assert!(
body["duration_secs"].as_f64().unwrap() >= 0.0,
"monotonic duration is never negative"
);
}
#[test]
fn webhook_emits_explicit_nulls_when_no_run_context() {
let e = NotifyEvent::scheduler_stuck("p", "no heartbeat");
let body = webhook(&webhook_cfg(), &e);
for k in [
"run_id",
"invocation_id",
"started_at",
"finished_at",
"duration_secs",
] {
assert!(body.get(k).is_some(), "{k} key must be present");
assert!(body[k].is_null(), "{k} must be null, not omitted");
}
}
#[test]
fn webhook_merges_extra_fields() {
let mut cfg = webhook_cfg();
cfg.extra_fields
.insert("tenant".into(), Value::String("acme".into()));
cfg.extra_fields.insert("attempt".into(), Value::from(2));
let e = NotifyEvent::run_success("p", "r", 1);
let body = webhook(&cfg, &e);
assert_eq!(body["tenant"], "acme");
assert_eq!(body["attempt"], 2);
assert_eq!(body["event"], "run_success");
}
#[test]
fn two_runs_of_one_pipeline_are_distinguishable() {
let mk = |id: &str| {
NotifyEvent::run_success("p", "r", 1).with_run(
crate::notify::RunContext::start(Some(id.into()), Some(id.into()))
.finish(std::time::Instant::now()),
)
};
let a = webhook(&webhook_cfg(), &mk("run-a"));
let b = webhook(&webhook_cfg(), &mk("run-b"));
assert_eq!(a["pipeline"], b["pipeline"]);
assert_eq!(a["row"], b["row"]);
assert_ne!(a["run_id"], b["run_id"]);
}
}