1use super::spec::{EventKind, Severity};
9use chrono::{DateTime, Utc};
10use serde_json::{Map, Value};
11use std::time::Instant;
12
13fn redact(s: String) -> String {
15 crate::secrets::registry::redact(&s).into_owned()
16}
17
18#[derive(Debug, Clone, Default)]
33pub struct RunContext {
34 pub run_id: Option<String>,
39 pub invocation_id: Option<String>,
41 pub started_at: Option<DateTime<Utc>>,
43 pub finished_at: Option<DateTime<Utc>>,
45 pub duration: Option<std::time::Duration>,
47}
48
49impl RunContext {
50 pub fn start(run_id: Option<String>, invocation_id: Option<String>) -> Self {
53 Self {
54 run_id,
55 invocation_id,
56 started_at: Some(Utc::now()),
57 finished_at: None,
58 duration: None,
59 }
60 }
61
62 pub fn finish(mut self, since: Instant) -> Self {
65 self.finished_at = Some(Utc::now());
66 self.duration = Some(since.elapsed());
67 self
68 }
69}
70
71#[derive(Debug, Clone)]
73pub struct NotifyEvent {
74 pub kind: EventKind,
75 pub severity: Severity,
76 pub pipeline: String,
78 pub row: String,
80 pub title: String,
82 pub message: String,
84 pub details: Map<String, Value>,
87 pub run: Option<RunContext>,
91}
92
93impl NotifyEvent {
94 fn base(
106 kind: EventKind,
107 severity: Severity,
108 pipeline: impl Into<String>,
109 row: impl Into<String>,
110 title: impl Into<String>,
111 message: impl Into<String>,
112 ) -> Self {
113 Self {
114 kind,
115 severity,
116 pipeline: pipeline.into(),
117 row: row.into(),
118 title: redact(title.into()),
119 message: redact(message.into()),
120 details: Map::new(),
121 run: None,
122 }
123 }
124
125 pub fn with_run(mut self, run: RunContext) -> Self {
128 self.run = Some(run);
129 self
130 }
131
132 pub fn with_run_opt(self, run: Option<RunContext>) -> Self {
136 match run {
137 Some(r) => self.with_run(r),
138 None => self,
139 }
140 }
141
142 fn with(mut self, key: &str, value: Value) -> Self {
143 let value = match value {
146 Value::String(s) => Value::String(redact(s)),
147 other => other,
148 };
149 self.details.insert(key.to_string(), value);
150 self
151 }
152
153 pub fn incident_key(&self) -> String {
157 format!("{}:{}", self.pipeline, self.row)
158 }
159
160 pub fn dedupe_key(&self) -> String {
163 format!("{}:{}:{}", self.kind.as_str(), self.pipeline, self.row)
164 }
165
166 pub fn opens_incident(&self) -> bool {
169 matches!(
170 self.kind,
171 EventKind::RunFailure | EventKind::CircuitOpen | EventKind::ContractAbort
172 )
173 }
174
175 pub fn closes_incident(&self) -> bool {
177 matches!(self.kind, EventKind::RunSuccess)
178 }
179
180 pub fn run_failure(
183 pipeline: impl Into<String>,
184 row: impl Into<String>,
185 error_kind: &str,
186 message: impl Into<String>,
187 ) -> Self {
188 let p = pipeline.into();
189 Self::base(
190 EventKind::RunFailure,
191 Severity::Error,
192 p.clone(),
193 row,
194 format!("Pipeline `{p}` failed"),
195 message,
196 )
197 .with("error_kind", Value::String(error_kind.to_string()))
198 }
199
200 pub fn run_success(
201 pipeline: impl Into<String>,
202 row: impl Into<String>,
203 rows_written: u64,
204 ) -> Self {
205 let p = pipeline.into();
206 Self::base(
207 EventKind::RunSuccess,
208 Severity::Info,
209 p.clone(),
210 row,
211 format!("Pipeline `{p}` succeeded"),
212 format!("Run completed, {rows_written} records written."),
213 )
214 .with("records_written", Value::from(rows_written))
215 }
216
217 pub fn sla_breach(
218 pipeline: impl Into<String>,
219 row: impl Into<String>,
220 sla_kind: &str,
221 message: impl Into<String>,
222 ) -> Self {
223 let p = pipeline.into();
224 Self::base(
225 EventKind::SlaBreach,
226 Severity::Warning,
227 p.clone(),
228 row,
229 format!("SLA breach ({sla_kind}) on `{p}`"),
230 message,
231 )
232 .with("sla_kind", Value::String(sla_kind.to_string()))
233 }
234
235 pub fn circuit_open(
236 pipeline: impl Into<String>,
237 row: impl Into<String>,
238 failures: u32,
239 cooldown_secs: u64,
240 ) -> Self {
241 let p = pipeline.into();
242 Self::base(
243 EventKind::CircuitOpen,
244 Severity::Critical,
245 p.clone(),
246 row,
247 format!("Circuit breaker open on `{p}`"),
248 format!(
249 "Tripped after {failures} consecutive failures; cooling down {cooldown_secs}s."
250 ),
251 )
252 .with("failures", Value::from(failures))
253 .with("cooldown_secs", Value::from(cooldown_secs))
254 }
255
256 pub fn contract_abort(
257 pipeline: impl Into<String>,
258 row: impl Into<String>,
259 message: impl Into<String>,
260 ) -> Self {
261 let p = pipeline.into();
262 Self::base(
263 EventKind::ContractAbort,
264 Severity::Error,
265 p.clone(),
266 row,
267 format!("Data contract breach aborted `{p}`"),
268 message,
269 )
270 }
271
272 pub fn dlq_threshold(
273 pipeline: impl Into<String>,
274 row: impl Into<String>,
275 records_dlq: u64,
276 ) -> Self {
277 let p = pipeline.into();
278 Self::base(
279 EventKind::DlqThreshold,
280 Severity::Warning,
281 p.clone(),
282 row,
283 format!("DLQ threshold reached on `{p}`"),
284 format!("{records_dlq} records were routed to the dead-letter queue."),
285 )
286 .with("records_dlq", Value::from(records_dlq))
287 }
288
289 pub fn scheduler_stuck(pipeline: impl Into<String>, message: impl Into<String>) -> Self {
290 let p = pipeline.into();
291 Self::base(
292 EventKind::SchedulerStuck,
293 Severity::Critical,
294 p.clone(),
295 String::new(),
296 format!("Scheduler stuck for `{p}`"),
297 message,
298 )
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn constructors_fix_severity_and_kind() {
308 assert_eq!(
309 NotifyEvent::run_failure("p", "", "sink", "boom").severity,
310 Severity::Error
311 );
312 assert_eq!(
313 NotifyEvent::circuit_open("p", "", 5, 30).severity,
314 Severity::Critical
315 );
316 assert_eq!(
317 NotifyEvent::run_success("p", "", 10).severity,
318 Severity::Info
319 );
320 assert_eq!(
321 NotifyEvent::sla_breach("p", "", "staleness", "old").severity,
322 Severity::Warning
323 );
324 assert_eq!(
325 NotifyEvent::scheduler_stuck("p", "no beat").kind,
326 EventKind::SchedulerStuck
327 );
328 }
329
330 #[test]
331 fn incident_and_dedupe_keys() {
332 let f = NotifyEvent::run_failure("p", "r1", "sink", "boom");
333 assert_eq!(f.incident_key(), "p:r1");
334 assert_eq!(f.dedupe_key(), "run_failure:p:r1");
335 assert!(f.opens_incident());
336 assert!(!f.closes_incident());
337
338 let s = NotifyEvent::run_success("p", "r1", 3);
339 assert_eq!(s.incident_key(), "p:r1"); assert!(s.closes_incident());
341 assert!(!s.opens_incident());
342 }
343
344 #[test]
345 fn details_carry_structured_context() {
346 let e = NotifyEvent::dlq_threshold("p", "", 42);
347 assert_eq!(e.details.get("records_dlq").unwrap(), &Value::from(42u64));
348 }
349}
350
351#[cfg(test)]
352mod redaction_tests {
353 use super::*;
354
355 #[test]
359 fn secrets_are_scrubbed_from_every_outbound_field() {
360 let secret = "sk-live-456-audit-secret";
363 crate::secrets::registry::register(secret);
364
365 let ev = NotifyEvent::run_failure(
366 "p",
367 "row",
368 "http",
369 format!("HTTP error for url (https://api.example.com/v1?api_key={secret})"),
370 );
371 assert!(
372 !ev.message.contains(secret),
373 "message leaked: {}",
374 ev.message
375 );
376 assert!(ev.message.contains("***"), "{}", ev.message);
377
378 let ev = NotifyEvent::sla_breach("p", "row", "staleness", format!("token {secret} stale"));
380 assert!(!ev.message.contains(secret));
381
382 let ev = NotifyEvent::run_failure("p", "row", "cfg", "boom")
383 .with("detail", Value::String(format!("url={secret}")));
384 assert!(
385 !ev.details["detail"].as_str().unwrap().contains(secret),
386 "detail leaked: {:?}",
387 ev.details
388 );
389 let ev = NotifyEvent::run_success("p", "row", 7);
391 assert_eq!(ev.details["records_written"], Value::from(7u64));
392 }
393}