use super::spec::{EventKind, Severity};
use serde_json::{Map, Value};
#[derive(Debug, Clone)]
pub struct NotifyEvent {
pub kind: EventKind,
pub severity: Severity,
pub pipeline: String,
pub row: String,
pub title: String,
pub message: String,
pub details: Map<String, Value>,
}
impl NotifyEvent {
fn base(
kind: EventKind,
severity: Severity,
pipeline: impl Into<String>,
row: impl Into<String>,
title: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
kind,
severity,
pipeline: pipeline.into(),
row: row.into(),
title: title.into(),
message: message.into(),
details: Map::new(),
}
}
fn with(mut self, key: &str, value: Value) -> Self {
self.details.insert(key.to_string(), value);
self
}
pub fn incident_key(&self) -> String {
format!("{}:{}", self.pipeline, self.row)
}
pub fn dedupe_key(&self) -> String {
format!("{}:{}:{}", self.kind.as_str(), self.pipeline, self.row)
}
pub fn opens_incident(&self) -> bool {
matches!(
self.kind,
EventKind::RunFailure | EventKind::CircuitOpen | EventKind::ContractAbort
)
}
pub fn closes_incident(&self) -> bool {
matches!(self.kind, EventKind::RunSuccess)
}
pub fn run_failure(
pipeline: impl Into<String>,
row: impl Into<String>,
error_kind: &str,
message: impl Into<String>,
) -> Self {
let p = pipeline.into();
Self::base(
EventKind::RunFailure,
Severity::Error,
p.clone(),
row,
format!("Pipeline `{p}` failed"),
message,
)
.with("error_kind", Value::String(error_kind.to_string()))
}
pub fn run_success(
pipeline: impl Into<String>,
row: impl Into<String>,
rows_written: u64,
) -> Self {
let p = pipeline.into();
Self::base(
EventKind::RunSuccess,
Severity::Info,
p.clone(),
row,
format!("Pipeline `{p}` succeeded"),
format!("Run completed, {rows_written} records written."),
)
.with("records_written", Value::from(rows_written))
}
pub fn sla_breach(
pipeline: impl Into<String>,
row: impl Into<String>,
sla_kind: &str,
message: impl Into<String>,
) -> Self {
let p = pipeline.into();
Self::base(
EventKind::SlaBreach,
Severity::Warning,
p.clone(),
row,
format!("SLA breach ({sla_kind}) on `{p}`"),
message,
)
.with("sla_kind", Value::String(sla_kind.to_string()))
}
pub fn circuit_open(
pipeline: impl Into<String>,
row: impl Into<String>,
failures: u32,
cooldown_secs: u64,
) -> Self {
let p = pipeline.into();
Self::base(
EventKind::CircuitOpen,
Severity::Critical,
p.clone(),
row,
format!("Circuit breaker open on `{p}`"),
format!(
"Tripped after {failures} consecutive failures; cooling down {cooldown_secs}s."
),
)
.with("failures", Value::from(failures))
.with("cooldown_secs", Value::from(cooldown_secs))
}
pub fn contract_abort(
pipeline: impl Into<String>,
row: impl Into<String>,
message: impl Into<String>,
) -> Self {
let p = pipeline.into();
Self::base(
EventKind::ContractAbort,
Severity::Error,
p.clone(),
row,
format!("Data contract breach aborted `{p}`"),
message,
)
}
pub fn dlq_threshold(
pipeline: impl Into<String>,
row: impl Into<String>,
records_dlq: u64,
) -> Self {
let p = pipeline.into();
Self::base(
EventKind::DlqThreshold,
Severity::Warning,
p.clone(),
row,
format!("DLQ threshold reached on `{p}`"),
format!("{records_dlq} records were routed to the dead-letter queue."),
)
.with("records_dlq", Value::from(records_dlq))
}
pub fn scheduler_stuck(pipeline: impl Into<String>, message: impl Into<String>) -> Self {
let p = pipeline.into();
Self::base(
EventKind::SchedulerStuck,
Severity::Critical,
p.clone(),
String::new(),
format!("Scheduler stuck for `{p}`"),
message,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructors_fix_severity_and_kind() {
assert_eq!(
NotifyEvent::run_failure("p", "", "sink", "boom").severity,
Severity::Error
);
assert_eq!(
NotifyEvent::circuit_open("p", "", 5, 30).severity,
Severity::Critical
);
assert_eq!(
NotifyEvent::run_success("p", "", 10).severity,
Severity::Info
);
assert_eq!(
NotifyEvent::sla_breach("p", "", "staleness", "old").severity,
Severity::Warning
);
assert_eq!(
NotifyEvent::scheduler_stuck("p", "no beat").kind,
EventKind::SchedulerStuck
);
}
#[test]
fn incident_and_dedupe_keys() {
let f = NotifyEvent::run_failure("p", "r1", "sink", "boom");
assert_eq!(f.incident_key(), "p:r1");
assert_eq!(f.dedupe_key(), "run_failure:p:r1");
assert!(f.opens_incident());
assert!(!f.closes_incident());
let s = NotifyEvent::run_success("p", "r1", 3);
assert_eq!(s.incident_key(), "p:r1"); assert!(s.closes_incident());
assert!(!s.opens_incident());
}
#[test]
fn details_carry_structured_context() {
let e = NotifyEvent::dlq_threshold("p", "", 42);
assert_eq!(e.details.get("records_dlq").unwrap(), &Value::from(42u64));
}
}