Skip to main content

faucet_cli/notify/
event.rs

1//! The runtime event a pipeline emits toward the notifier (#280).
2//!
3//! [`NotifyEvent`] is the pure, channel-agnostic description of something that
4//! happened. Constructors fix the canonical severity per kind so callers at the
5//! emit sites (executor, SLA pass, scheduler) don't have to. Rendering into a
6//! Slack / PagerDuty / webhook body lives in [`crate::notify::render`].
7
8use super::spec::{EventKind, Severity};
9use chrono::{DateTime, Utc};
10use serde_json::{Map, Value};
11use std::time::Instant;
12
13/// Scrub every resolved secret from text bound for an external channel.
14fn redact(s: String) -> String {
15    crate::secrets::registry::redact(&s).into_owned()
16}
17
18/// Run identity + timing for the invocation an event belongs to (#480).
19///
20/// Built once per invocation at the emit site and stamped onto every event that
21/// invocation produces, so a receiver can correlate a notification back to the
22/// run it triggered. Before this existed the only correlation keys in the
23/// payload were `pipeline` + `row`, which are **not unique per run** — two
24/// overlapping runs of one pipeline (a `schedule` `overlap: queue`, a cluster
25/// with several workers, a backfill fanning out per window) produced
26/// indistinguishable callbacks and a receiver keying off `pipeline` would
27/// mis-attribute status.
28///
29/// `duration` is measured from a monotonic [`Instant`], never by subtracting
30/// the two wall-clock stamps — an NTP step between them would otherwise yield a
31/// negative duration.
32#[derive(Debug, Clone, Default)]
33pub struct RunContext {
34    /// Correlation id for the submitted run. Under `faucet serve` this is the
35    /// serve run id returned by `POST /v1/runs`; otherwise the invocation's own
36    /// generated id. A matrix run's rows all share one `run_id` and are told
37    /// apart by `row` (and by `invocation_id`).
38    pub run_id: Option<String>,
39    /// This single invocation's id. Distinct per matrix row within one run.
40    pub invocation_id: Option<String>,
41    /// When the invocation started (UTC).
42    pub started_at: Option<DateTime<Utc>>,
43    /// When the invocation reached its terminal state (UTC).
44    pub finished_at: Option<DateTime<Utc>>,
45    /// Monotonic elapsed wall-clock for the invocation.
46    pub duration: Option<std::time::Duration>,
47}
48
49impl RunContext {
50    /// Open a context at "now", carrying the run/invocation identity. Call
51    /// [`finish`](Self::finish) when the invocation reaches a terminal state.
52    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    /// Close the context, stamping `finished_at` and the monotonic duration
63    /// measured from `since`.
64    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/// A single thing worth notifying about.
72#[derive(Debug, Clone)]
73pub struct NotifyEvent {
74    pub kind: EventKind,
75    pub severity: Severity,
76    /// Pipeline name (metric-label identity).
77    pub pipeline: String,
78    /// Matrix row id (`""` for non-matrix runs).
79    pub row: String,
80    /// Short one-line summary.
81    pub title: String,
82    /// Human-readable detail.
83    pub message: String,
84    /// Structured context rendered into channel payloads (never contains
85    /// secrets — the emit sites pass only safe scalars).
86    pub details: Map<String, Value>,
87    /// Run identity + timing (#480). `None` for events with no owning
88    /// invocation (e.g. `scheduler_stuck`, which is emitted by the scheduler
89    /// loop itself rather than by a run).
90    pub run: Option<RunContext>,
91}
92
93impl NotifyEvent {
94    /// The one constructor every event goes through — and therefore the single
95    /// place to scrub secrets.
96    ///
97    /// A notification leaves the trust boundary entirely: Slack, PagerDuty, or a
98    /// customer webhook. `message` is frequently a raw `FaucetError`, whose
99    /// `Display` can carry the material that produced it — `reqwest` includes the
100    /// full request URL, so a REST source with its API key in a query parameter
101    /// would post that key to a third party, and connection-string leakage in a
102    /// CDC error has already been a filed bug (#84). Every other outbound-ish
103    /// surface already redacts (MCP tool errors, the serve log layer, doctor
104    /// probe output); notifications did not (#456 H5).
105    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    /// Stamp run identity + timing onto this event (#480). Emit sites build one
126    /// [`RunContext`] per invocation and apply it to every event they produce.
127    pub fn with_run(mut self, run: RunContext) -> Self {
128        self.run = Some(run);
129        self
130    }
131
132    /// Stamp run identity from an optional context — the shape emit sites
133    /// actually have, since the notifier is feature-gated and some callers have
134    /// no run to attribute.
135    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        // Detail values are rendered into the same outbound payload as `message`,
144        // so a string detail is scrubbed too.
145        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    /// Correlation key for PagerDuty incident open/resolve pairing and for
154    /// coalescing per (pipeline, row). A `run_success` resolves the incident a
155    /// prior `run_failure` opened on the same key.
156    pub fn incident_key(&self) -> String {
157        format!("{}:{}", self.pipeline, self.row)
158    }
159
160    /// Per-rule coalesce key: the same kind repeating for the same (pipeline,
161    /// row) coalesces within a rule's `dedupe_window_secs`.
162    pub fn dedupe_key(&self) -> String {
163        format!("{}:{}:{}", self.kind.as_str(), self.pipeline, self.row)
164    }
165
166    /// True when this event opens an incident (a failure-class event that a
167    /// later success should resolve).
168    pub fn opens_incident(&self) -> bool {
169        matches!(
170            self.kind,
171            EventKind::RunFailure | EventKind::CircuitOpen | EventKind::ContractAbort
172        )
173    }
174
175    /// True when this event closes any open incident for its `incident_key`.
176    pub fn closes_incident(&self) -> bool {
177        matches!(self.kind, EventKind::RunSuccess)
178    }
179
180    // ── Constructors (canonical severity per kind) ───────────────────────────
181
182    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"); // matches the failure it resolves
340        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    /// #456 H5: a notification is delivered to Slack / PagerDuty / a customer
356    /// webhook, i.e. outside the trust boundary, so a resolved secret that landed
357    /// in the error text must not ride along.
358    #[test]
359    fn secrets_are_scrubbed_from_every_outbound_field() {
360        // A realistic leak: the API key is a query parameter, and reqwest's error
361        // Display embeds the whole URL.
362        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        // Titles and string details go into the same payload.
379        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        // Non-string details pass through untouched.
390        let ev = NotifyEvent::run_success("p", "row", 7);
391        assert_eq!(ev.details["records_written"], Value::from(7u64));
392    }
393}