use super::spec::{EventKind, Severity};
use chrono::{DateTime, Utc};
use serde_json::{Map, Value};
use std::time::Instant;
fn redact(s: String) -> String {
crate::secrets::registry::redact(&s).into_owned()
}
#[derive(Debug, Clone, Default)]
pub struct RunContext {
pub run_id: Option<String>,
pub invocation_id: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
pub duration: Option<std::time::Duration>,
}
impl RunContext {
pub fn start(run_id: Option<String>, invocation_id: Option<String>) -> Self {
Self {
run_id,
invocation_id,
started_at: Some(Utc::now()),
finished_at: None,
duration: None,
}
}
pub fn finish(mut self, since: Instant) -> Self {
self.finished_at = Some(Utc::now());
self.duration = Some(since.elapsed());
self
}
}
#[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>,
pub run: Option<RunContext>,
}
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: redact(title.into()),
message: redact(message.into()),
details: Map::new(),
run: None,
}
}
pub fn with_run(mut self, run: RunContext) -> Self {
self.run = Some(run);
self
}
pub fn with_run_opt(self, run: Option<RunContext>) -> Self {
match run {
Some(r) => self.with_run(r),
None => self,
}
}
fn with(mut self, key: &str, value: Value) -> Self {
let value = match value {
Value::String(s) => Value::String(redact(s)),
other => other,
};
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));
}
}
#[cfg(test)]
mod redaction_tests {
use super::*;
#[test]
fn secrets_are_scrubbed_from_every_outbound_field() {
let secret = "sk-live-456-audit-secret";
crate::secrets::registry::register(secret);
let ev = NotifyEvent::run_failure(
"p",
"row",
"http",
format!("HTTP error for url (https://api.example.com/v1?api_key={secret})"),
);
assert!(
!ev.message.contains(secret),
"message leaked: {}",
ev.message
);
assert!(ev.message.contains("***"), "{}", ev.message);
let ev = NotifyEvent::sla_breach("p", "row", "staleness", format!("token {secret} stale"));
assert!(!ev.message.contains(secret));
let ev = NotifyEvent::run_failure("p", "row", "cfg", "boom")
.with("detail", Value::String(format!("url={secret}")));
assert!(
!ev.details["detail"].as_str().unwrap().contains(secret),
"detail leaked: {:?}",
ev.details
);
let ev = NotifyEvent::run_success("p", "row", 7);
assert_eq!(ev.details["records_written"], Value::from(7u64));
}
}