1use super::spec::{EventKind, Severity};
9use serde_json::{Map, Value};
10
11fn redact(s: String) -> String {
13 crate::secrets::registry::redact(&s).into_owned()
14}
15
16#[derive(Debug, Clone)]
18pub struct NotifyEvent {
19 pub kind: EventKind,
20 pub severity: Severity,
21 pub pipeline: String,
23 pub row: String,
25 pub title: String,
27 pub message: String,
29 pub details: Map<String, Value>,
32}
33
34impl NotifyEvent {
35 fn base(
47 kind: EventKind,
48 severity: Severity,
49 pipeline: impl Into<String>,
50 row: impl Into<String>,
51 title: impl Into<String>,
52 message: impl Into<String>,
53 ) -> Self {
54 Self {
55 kind,
56 severity,
57 pipeline: pipeline.into(),
58 row: row.into(),
59 title: redact(title.into()),
60 message: redact(message.into()),
61 details: Map::new(),
62 }
63 }
64
65 fn with(mut self, key: &str, value: Value) -> Self {
66 let value = match value {
69 Value::String(s) => Value::String(redact(s)),
70 other => other,
71 };
72 self.details.insert(key.to_string(), value);
73 self
74 }
75
76 pub fn incident_key(&self) -> String {
80 format!("{}:{}", self.pipeline, self.row)
81 }
82
83 pub fn dedupe_key(&self) -> String {
86 format!("{}:{}:{}", self.kind.as_str(), self.pipeline, self.row)
87 }
88
89 pub fn opens_incident(&self) -> bool {
92 matches!(
93 self.kind,
94 EventKind::RunFailure | EventKind::CircuitOpen | EventKind::ContractAbort
95 )
96 }
97
98 pub fn closes_incident(&self) -> bool {
100 matches!(self.kind, EventKind::RunSuccess)
101 }
102
103 pub fn run_failure(
106 pipeline: impl Into<String>,
107 row: impl Into<String>,
108 error_kind: &str,
109 message: impl Into<String>,
110 ) -> Self {
111 let p = pipeline.into();
112 Self::base(
113 EventKind::RunFailure,
114 Severity::Error,
115 p.clone(),
116 row,
117 format!("Pipeline `{p}` failed"),
118 message,
119 )
120 .with("error_kind", Value::String(error_kind.to_string()))
121 }
122
123 pub fn run_success(
124 pipeline: impl Into<String>,
125 row: impl Into<String>,
126 rows_written: u64,
127 ) -> Self {
128 let p = pipeline.into();
129 Self::base(
130 EventKind::RunSuccess,
131 Severity::Info,
132 p.clone(),
133 row,
134 format!("Pipeline `{p}` succeeded"),
135 format!("Run completed, {rows_written} records written."),
136 )
137 .with("records_written", Value::from(rows_written))
138 }
139
140 pub fn sla_breach(
141 pipeline: impl Into<String>,
142 row: impl Into<String>,
143 sla_kind: &str,
144 message: impl Into<String>,
145 ) -> Self {
146 let p = pipeline.into();
147 Self::base(
148 EventKind::SlaBreach,
149 Severity::Warning,
150 p.clone(),
151 row,
152 format!("SLA breach ({sla_kind}) on `{p}`"),
153 message,
154 )
155 .with("sla_kind", Value::String(sla_kind.to_string()))
156 }
157
158 pub fn circuit_open(
159 pipeline: impl Into<String>,
160 row: impl Into<String>,
161 failures: u32,
162 cooldown_secs: u64,
163 ) -> Self {
164 let p = pipeline.into();
165 Self::base(
166 EventKind::CircuitOpen,
167 Severity::Critical,
168 p.clone(),
169 row,
170 format!("Circuit breaker open on `{p}`"),
171 format!(
172 "Tripped after {failures} consecutive failures; cooling down {cooldown_secs}s."
173 ),
174 )
175 .with("failures", Value::from(failures))
176 .with("cooldown_secs", Value::from(cooldown_secs))
177 }
178
179 pub fn contract_abort(
180 pipeline: impl Into<String>,
181 row: impl Into<String>,
182 message: impl Into<String>,
183 ) -> Self {
184 let p = pipeline.into();
185 Self::base(
186 EventKind::ContractAbort,
187 Severity::Error,
188 p.clone(),
189 row,
190 format!("Data contract breach aborted `{p}`"),
191 message,
192 )
193 }
194
195 pub fn dlq_threshold(
196 pipeline: impl Into<String>,
197 row: impl Into<String>,
198 records_dlq: u64,
199 ) -> Self {
200 let p = pipeline.into();
201 Self::base(
202 EventKind::DlqThreshold,
203 Severity::Warning,
204 p.clone(),
205 row,
206 format!("DLQ threshold reached on `{p}`"),
207 format!("{records_dlq} records were routed to the dead-letter queue."),
208 )
209 .with("records_dlq", Value::from(records_dlq))
210 }
211
212 pub fn scheduler_stuck(pipeline: impl Into<String>, message: impl Into<String>) -> Self {
213 let p = pipeline.into();
214 Self::base(
215 EventKind::SchedulerStuck,
216 Severity::Critical,
217 p.clone(),
218 String::new(),
219 format!("Scheduler stuck for `{p}`"),
220 message,
221 )
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn constructors_fix_severity_and_kind() {
231 assert_eq!(
232 NotifyEvent::run_failure("p", "", "sink", "boom").severity,
233 Severity::Error
234 );
235 assert_eq!(
236 NotifyEvent::circuit_open("p", "", 5, 30).severity,
237 Severity::Critical
238 );
239 assert_eq!(
240 NotifyEvent::run_success("p", "", 10).severity,
241 Severity::Info
242 );
243 assert_eq!(
244 NotifyEvent::sla_breach("p", "", "staleness", "old").severity,
245 Severity::Warning
246 );
247 assert_eq!(
248 NotifyEvent::scheduler_stuck("p", "no beat").kind,
249 EventKind::SchedulerStuck
250 );
251 }
252
253 #[test]
254 fn incident_and_dedupe_keys() {
255 let f = NotifyEvent::run_failure("p", "r1", "sink", "boom");
256 assert_eq!(f.incident_key(), "p:r1");
257 assert_eq!(f.dedupe_key(), "run_failure:p:r1");
258 assert!(f.opens_incident());
259 assert!(!f.closes_incident());
260
261 let s = NotifyEvent::run_success("p", "r1", 3);
262 assert_eq!(s.incident_key(), "p:r1"); assert!(s.closes_incident());
264 assert!(!s.opens_incident());
265 }
266
267 #[test]
268 fn details_carry_structured_context() {
269 let e = NotifyEvent::dlq_threshold("p", "", 42);
270 assert_eq!(e.details.get("records_dlq").unwrap(), &Value::from(42u64));
271 }
272}
273
274#[cfg(test)]
275mod redaction_tests {
276 use super::*;
277
278 #[test]
282 fn secrets_are_scrubbed_from_every_outbound_field() {
283 let secret = "sk-live-456-audit-secret";
286 crate::secrets::registry::register(secret);
287
288 let ev = NotifyEvent::run_failure(
289 "p",
290 "row",
291 "http",
292 format!("HTTP error for url (https://api.example.com/v1?api_key={secret})"),
293 );
294 assert!(
295 !ev.message.contains(secret),
296 "message leaked: {}",
297 ev.message
298 );
299 assert!(ev.message.contains("***"), "{}", ev.message);
300
301 let ev = NotifyEvent::sla_breach("p", "row", "staleness", format!("token {secret} stale"));
303 assert!(!ev.message.contains(secret));
304
305 let ev = NotifyEvent::run_failure("p", "row", "cfg", "boom")
306 .with("detail", Value::String(format!("url={secret}")));
307 assert!(
308 !ev.details["detail"].as_str().unwrap().contains(secret),
309 "detail leaked: {:?}",
310 ev.details
311 );
312 let ev = NotifyEvent::run_success("p", "row", 7);
314 assert_eq!(ev.details["records_written"], Value::from(7u64));
315 }
316}