1use super::spec::{EventKind, Severity};
9use serde_json::{Map, Value};
10
11#[derive(Debug, Clone)]
13pub struct NotifyEvent {
14 pub kind: EventKind,
15 pub severity: Severity,
16 pub pipeline: String,
18 pub row: String,
20 pub title: String,
22 pub message: String,
24 pub details: Map<String, Value>,
27}
28
29impl NotifyEvent {
30 fn base(
31 kind: EventKind,
32 severity: Severity,
33 pipeline: impl Into<String>,
34 row: impl Into<String>,
35 title: impl Into<String>,
36 message: impl Into<String>,
37 ) -> Self {
38 Self {
39 kind,
40 severity,
41 pipeline: pipeline.into(),
42 row: row.into(),
43 title: title.into(),
44 message: message.into(),
45 details: Map::new(),
46 }
47 }
48
49 fn with(mut self, key: &str, value: Value) -> Self {
50 self.details.insert(key.to_string(), value);
51 self
52 }
53
54 pub fn incident_key(&self) -> String {
58 format!("{}:{}", self.pipeline, self.row)
59 }
60
61 pub fn dedupe_key(&self) -> String {
64 format!("{}:{}:{}", self.kind.as_str(), self.pipeline, self.row)
65 }
66
67 pub fn opens_incident(&self) -> bool {
70 matches!(
71 self.kind,
72 EventKind::RunFailure | EventKind::CircuitOpen | EventKind::ContractAbort
73 )
74 }
75
76 pub fn closes_incident(&self) -> bool {
78 matches!(self.kind, EventKind::RunSuccess)
79 }
80
81 pub fn run_failure(
84 pipeline: impl Into<String>,
85 row: impl Into<String>,
86 error_kind: &str,
87 message: impl Into<String>,
88 ) -> Self {
89 let p = pipeline.into();
90 Self::base(
91 EventKind::RunFailure,
92 Severity::Error,
93 p.clone(),
94 row,
95 format!("Pipeline `{p}` failed"),
96 message,
97 )
98 .with("error_kind", Value::String(error_kind.to_string()))
99 }
100
101 pub fn run_success(
102 pipeline: impl Into<String>,
103 row: impl Into<String>,
104 rows_written: u64,
105 ) -> Self {
106 let p = pipeline.into();
107 Self::base(
108 EventKind::RunSuccess,
109 Severity::Info,
110 p.clone(),
111 row,
112 format!("Pipeline `{p}` succeeded"),
113 format!("Run completed, {rows_written} records written."),
114 )
115 .with("records_written", Value::from(rows_written))
116 }
117
118 pub fn sla_breach(
119 pipeline: impl Into<String>,
120 row: impl Into<String>,
121 sla_kind: &str,
122 message: impl Into<String>,
123 ) -> Self {
124 let p = pipeline.into();
125 Self::base(
126 EventKind::SlaBreach,
127 Severity::Warning,
128 p.clone(),
129 row,
130 format!("SLA breach ({sla_kind}) on `{p}`"),
131 message,
132 )
133 .with("sla_kind", Value::String(sla_kind.to_string()))
134 }
135
136 pub fn circuit_open(
137 pipeline: impl Into<String>,
138 row: impl Into<String>,
139 failures: u32,
140 cooldown_secs: u64,
141 ) -> Self {
142 let p = pipeline.into();
143 Self::base(
144 EventKind::CircuitOpen,
145 Severity::Critical,
146 p.clone(),
147 row,
148 format!("Circuit breaker open on `{p}`"),
149 format!(
150 "Tripped after {failures} consecutive failures; cooling down {cooldown_secs}s."
151 ),
152 )
153 .with("failures", Value::from(failures))
154 .with("cooldown_secs", Value::from(cooldown_secs))
155 }
156
157 pub fn contract_abort(
158 pipeline: impl Into<String>,
159 row: impl Into<String>,
160 message: impl Into<String>,
161 ) -> Self {
162 let p = pipeline.into();
163 Self::base(
164 EventKind::ContractAbort,
165 Severity::Error,
166 p.clone(),
167 row,
168 format!("Data contract breach aborted `{p}`"),
169 message,
170 )
171 }
172
173 pub fn dlq_threshold(
174 pipeline: impl Into<String>,
175 row: impl Into<String>,
176 records_dlq: u64,
177 ) -> Self {
178 let p = pipeline.into();
179 Self::base(
180 EventKind::DlqThreshold,
181 Severity::Warning,
182 p.clone(),
183 row,
184 format!("DLQ threshold reached on `{p}`"),
185 format!("{records_dlq} records were routed to the dead-letter queue."),
186 )
187 .with("records_dlq", Value::from(records_dlq))
188 }
189
190 pub fn scheduler_stuck(pipeline: impl Into<String>, message: impl Into<String>) -> Self {
191 let p = pipeline.into();
192 Self::base(
193 EventKind::SchedulerStuck,
194 Severity::Critical,
195 p.clone(),
196 String::new(),
197 format!("Scheduler stuck for `{p}`"),
198 message,
199 )
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn constructors_fix_severity_and_kind() {
209 assert_eq!(
210 NotifyEvent::run_failure("p", "", "sink", "boom").severity,
211 Severity::Error
212 );
213 assert_eq!(
214 NotifyEvent::circuit_open("p", "", 5, 30).severity,
215 Severity::Critical
216 );
217 assert_eq!(
218 NotifyEvent::run_success("p", "", 10).severity,
219 Severity::Info
220 );
221 assert_eq!(
222 NotifyEvent::sla_breach("p", "", "staleness", "old").severity,
223 Severity::Warning
224 );
225 assert_eq!(
226 NotifyEvent::scheduler_stuck("p", "no beat").kind,
227 EventKind::SchedulerStuck
228 );
229 }
230
231 #[test]
232 fn incident_and_dedupe_keys() {
233 let f = NotifyEvent::run_failure("p", "r1", "sink", "boom");
234 assert_eq!(f.incident_key(), "p:r1");
235 assert_eq!(f.dedupe_key(), "run_failure:p:r1");
236 assert!(f.opens_incident());
237 assert!(!f.closes_incident());
238
239 let s = NotifyEvent::run_success("p", "r1", 3);
240 assert_eq!(s.incident_key(), "p:r1"); assert!(s.closes_incident());
242 assert!(!s.opens_incident());
243 }
244
245 #[test]
246 fn details_carry_structured_context() {
247 let e = NotifyEvent::dlq_threshold("p", "", 42);
248 assert_eq!(e.details.get("records_dlq").unwrap(), &Value::from(42u64));
249 }
250}