Skip to main content

razeri_plugin_api/
alerting.rs

1// Copyright (C) 2026 Gabriel Shea <gshea@ideapivot.com> - All Rights Reserved
2
3//! Alerting and health summary model for the canonical event stream.
4//!
5//! This module generalizes alerting beyond storage-specific metrics,
6//! operating on [`RazeriEvent`] data with deterministic, pure evaluation
7//! functions. Alert rules support threshold, windowed, and absence
8//! conditions with optional deduplication.
9//!
10//! Health summaries aggregate event streams into operator-friendly status
11//! indicators for workflows, categories, sources, entities, plugins, and
12//! sinks.
13
14use crate::event::{EventCategory, EventSeverity, EventSubjectKind, RazeriEvent};
15use chrono::{DateTime, TimeDelta, Utc};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18
19// ── Alert types ────────────────────────────────────────────────────────
20
21/// Scope of events an alert rule monitors.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case", tag = "type")]
24#[non_exhaustive]
25pub enum AlertScope {
26    /// All events regardless of category.
27    Global,
28    /// Events matching a specific category.
29    Category {
30        /// The category to filter on.
31        category: EventCategory,
32    },
33}
34
35/// Comparison operator for alert condition evaluation.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ComparisonOp {
39    /// `>`
40    GreaterThan,
41    /// `>=`
42    GreaterThanOrEqual,
43    /// `<`
44    LessThan,
45    /// `<=`
46    LessThanOrEqual,
47    /// `==`
48    Equal,
49}
50
51/// What aspect of the event stream to measure.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case", tag = "type")]
54#[non_exhaustive]
55pub enum AlertMetric {
56    /// Total count of matching events.
57    EventCount,
58    /// Count of events with Error or Critical severity.
59    ErrorCount,
60    /// Count of events with a specific payload type name (e.g. `"materialization_failed"`).
61    PayloadCount {
62        /// Payload type name to count (matches `EventPayload::name()`).
63        payload_name: String,
64    },
65}
66
67/// How an alert rule evaluates the event stream.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case", tag = "type")]
70#[non_exhaustive]
71pub enum AlertCondition {
72    /// Fire when absolute event count matches the comparison.
73    Threshold {
74        /// What to measure.
75        metric: AlertMetric,
76        /// Comparison operator.
77        op: ComparisonOp,
78        /// Threshold value.
79        value: u64,
80    },
81    /// Fire when event count within a time window matches the comparison.
82    Rate {
83        /// What to measure.
84        metric: AlertMetric,
85        /// Rolling window size in seconds.
86        window_secs: u64,
87        /// Comparison operator.
88        op: ComparisonOp,
89        /// Threshold value.
90        value: u64,
91    },
92    /// Fire when no matching events appear within the time window.
93    Absence {
94        /// Silence window in seconds.
95        window_secs: u64,
96    },
97}
98
99/// A named alerting rule definition.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct AlertRule {
102    /// Unique rule name.
103    pub name: String,
104    /// Which events this rule monitors.
105    pub scope: AlertScope,
106    /// How the rule evaluates the event stream.
107    pub condition: AlertCondition,
108    /// Severity assigned when the rule fires.
109    pub severity: EventSeverity,
110    /// Human-readable message describing the alert.
111    pub message: String,
112    /// Whether this rule is active.
113    pub enabled: bool,
114    /// Deduplication window in seconds. If set, the rule will not fire again
115    /// if it already fired within this window.
116    pub dedup_window_secs: Option<u64>,
117}
118
119/// Result of evaluating a single alert rule against the event stream.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct AlertResult {
122    /// Name of the rule that was evaluated.
123    pub rule_name: String,
124    /// Whether the alert condition was met and not deduplicated.
125    pub fired: bool,
126    /// Severity from the rule definition.
127    pub severity: EventSeverity,
128    /// Message from the rule definition.
129    pub message: String,
130    /// The measured value from the event stream.
131    pub current_value: f64,
132    /// The threshold value from the rule condition.
133    pub threshold: f64,
134    /// When this evaluation occurred.
135    pub evaluated_at: DateTime<Utc>,
136    /// Key used for deduplication.
137    pub dedup_key: String,
138}
139
140// ── Health summary types ───────────────────────────────────────────────
141
142/// Domain for which health is being summarized.
143#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case", tag = "kind", content = "name")]
145#[non_exhaustive]
146pub enum HealthDomain {
147    /// Health across all events.
148    Overall,
149    /// Health for a specific event category.
150    Category(EventCategory),
151    /// Health for a specific workflow.
152    Workflow(String),
153    /// Health for a named source.
154    Source(String),
155    /// Health for a named entity.
156    Entity(String),
157    /// Health for a named plugin.
158    Plugin(String),
159    /// Health for a named event sink.
160    Sink(String),
161}
162
163/// Overall health status indicator.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum HealthStatus {
167    /// No errors or warnings detected.
168    Healthy,
169    /// Warnings detected but no errors.
170    Degraded,
171    /// Errors or critical events detected.
172    Unhealthy,
173    /// No events observed (insufficient data).
174    Unknown,
175}
176
177/// Aggregated health for a specific domain.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct HealthSummary {
180    /// Which domain this summary covers.
181    pub domain: HealthDomain,
182    /// Derived health status.
183    pub status: HealthStatus,
184    /// Total events counted.
185    pub total_events: usize,
186    /// Events with Error or Critical severity.
187    pub error_count: usize,
188    /// Events with Warning severity.
189    pub warning_count: usize,
190    /// Timestamp of the most recent event, if any.
191    pub last_event_at: Option<DateTime<Utc>>,
192    /// Names of active alerts relevant to this domain.
193    pub active_alerts: Vec<String>,
194}
195
196/// External sink status for inclusion in health summaries.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct SinkStatus {
199    /// Sink name.
200    pub name: String,
201    /// Number of errors encountered.
202    pub errors: usize,
203    /// Most recent error message, if any.
204    pub last_error: Option<String>,
205}
206
207// ── Pure evaluation functions ──────────────────────────────────────────
208
209/// Evaluate alert rules against a canonical event stream.
210///
211/// This is a pure, deterministic function: identical inputs always produce
212/// identical outputs. Deduplication uses `previously_fired` results to
213/// suppress repeated firings within the dedup window.
214#[must_use]
215pub fn evaluate_rules(
216    rules: &[AlertRule],
217    events: &[RazeriEvent],
218    now: DateTime<Utc>,
219    previously_fired: &[AlertResult],
220) -> Vec<AlertResult> {
221    let mut results = Vec::new();
222
223    for rule in rules {
224        if !rule.enabled {
225            continue;
226        }
227
228        let scoped: Vec<&RazeriEvent> =
229            events.iter().filter(|e| matches_scope(&rule.scope, e)).collect();
230
231        let (condition_met, current_value, threshold) =
232            evaluate_condition(&rule.condition, &scoped, now);
233
234        let dedup_key = rule.name.clone();
235        let is_deduped = is_deduplicated(&dedup_key, rule.dedup_window_secs, now, previously_fired);
236
237        results.push(AlertResult {
238            rule_name: rule.name.clone(),
239            fired: condition_met && !is_deduped,
240            severity: rule.severity,
241            message: rule.message.clone(),
242            current_value,
243            threshold,
244            evaluated_at: now,
245            dedup_key,
246        });
247    }
248
249    results
250}
251
252/// Compute a health summary for a specific domain from the event stream.
253#[must_use]
254pub fn health_for_domain(
255    domain: &HealthDomain,
256    events: &[RazeriEvent],
257    active_alerts: &[AlertResult],
258) -> HealthSummary {
259    let domain_events: Vec<&RazeriEvent> =
260        events.iter().filter(|e| event_matches_domain(domain, e)).collect();
261
262    let fired_names: Vec<String> =
263        active_alerts.iter().filter(|a| a.fired).map(|a| a.rule_name.clone()).collect();
264
265    summarize_events(domain.clone(), &domain_events, &fired_names)
266}
267
268/// Auto-discover health domains from the event stream and produce summaries.
269///
270/// Returns summaries for: Overall, each event category, each subject
271/// (Source, Entity, Plugin), each workflow, and each sink (from external status).
272/// Output order is deterministic for the same inputs.
273#[must_use]
274pub fn discover_health(
275    events: &[RazeriEvent],
276    active_alerts: &[AlertResult],
277    sink_statuses: &[SinkStatus],
278) -> Vec<HealthSummary> {
279    let mut summaries = vec![build_overall_summary(events, active_alerts)];
280    let fired_names = fired_alert_names(active_alerts);
281
282    summaries.extend(build_category_summaries(events, &fired_names));
283    summaries.extend(build_subject_summaries(events, &fired_names));
284    summaries.extend(build_workflow_summaries(events, &fired_names));
285    summaries.extend(build_sink_summaries(sink_statuses));
286
287    summaries
288}
289
290// ── Internal helpers ───────────────────────────────────────────────────
291
292/// Build the overall health summary from all events.
293/// Parameters:
294/// - `events`: Full canonical event stream.
295/// - `active_alerts`: Evaluated alert results.
296fn build_overall_summary(events: &[RazeriEvent], active_alerts: &[AlertResult]) -> HealthSummary {
297    let fired_names = fired_alert_names(active_alerts);
298    summarize_events(HealthDomain::Overall, &events.iter().collect::<Vec<_>>(), &fired_names)
299}
300
301/// Collect the names of currently firing alerts.
302/// Parameters:
303/// - `active_alerts`: Evaluated alert results.
304fn fired_alert_names(active_alerts: &[AlertResult]) -> Vec<String> {
305    active_alerts.iter().filter(|a| a.fired).map(|a| a.rule_name.clone()).collect()
306}
307
308/// Build per-category health summaries in deterministic category order.
309/// Parameters:
310/// - `events`: Full canonical event stream.
311/// - `fired_names`: Names of currently firing alerts.
312fn build_category_summaries(events: &[RazeriEvent], fired_names: &[String]) -> Vec<HealthSummary> {
313    let mut by_category: HashMap<EventCategory, Vec<&RazeriEvent>> = HashMap::new();
314    for event in events {
315        by_category.entry(event.category()).or_default().push(event);
316    }
317
318    let mut categories: Vec<EventCategory> = by_category.keys().copied().collect();
319    categories.sort_by_key(|c| format!("{c:?}"));
320
321    categories
322        .into_iter()
323        .filter_map(|cat| {
324            by_category.get(&cat).map(|cat_events| {
325                summarize_events(HealthDomain::Category(cat), cat_events, fired_names)
326            })
327        })
328        .collect()
329}
330
331/// Build Source/Entity/Plugin subject summaries in deterministic key order.
332/// Parameters:
333/// - `events`: Full canonical event stream.
334/// - `fired_names`: Names of currently firing alerts.
335fn build_subject_summaries(events: &[RazeriEvent], fired_names: &[String]) -> Vec<HealthSummary> {
336    let mut by_subject: HashMap<(EventSubjectKind, String), Vec<&RazeriEvent>> = HashMap::new();
337    for event in events {
338        let key = (event.subject().kind, event.subject().name.clone());
339        by_subject.entry(key).or_default().push(event);
340    }
341
342    let mut subject_keys: Vec<(EventSubjectKind, String)> = by_subject.keys().cloned().collect();
343    subject_keys
344        .sort_by(|a, b| format!("{:?}", a.0).cmp(&format!("{:?}", b.0)).then(a.1.cmp(&b.1)));
345
346    subject_keys
347        .into_iter()
348        .filter_map(|(kind, name)| {
349            let domain = match kind {
350                EventSubjectKind::Source => HealthDomain::Source(name.clone()),
351                EventSubjectKind::Entity => HealthDomain::Entity(name.clone()),
352                EventSubjectKind::Plugin => HealthDomain::Plugin(name.clone()),
353                _ => return None,
354            };
355            by_subject
356                .get(&(kind, name))
357                .map(|subj_events| summarize_events(domain, subj_events, fired_names))
358        })
359        .collect()
360}
361
362/// Build per-workflow summaries in deterministic workflow-id order.
363/// Parameters:
364/// - `events`: Full canonical event stream.
365/// - `fired_names`: Names of currently firing alerts.
366fn build_workflow_summaries(events: &[RazeriEvent], fired_names: &[String]) -> Vec<HealthSummary> {
367    let mut by_workflow: HashMap<String, Vec<&RazeriEvent>> = HashMap::new();
368    for event in events {
369        if let Some(wf) = event.workflow() {
370            by_workflow.entry(wf.workflow_id.clone()).or_default().push(event);
371        }
372    }
373
374    let mut workflow_ids: Vec<String> = by_workflow.keys().cloned().collect();
375    workflow_ids.sort();
376
377    workflow_ids
378        .into_iter()
379        .filter_map(|wf_id| {
380            by_workflow.get(&wf_id).map(|wf_events| {
381                summarize_events(HealthDomain::Workflow(wf_id), wf_events, fired_names)
382            })
383        })
384        .collect()
385}
386
387/// Convert external sink statuses into health summaries.
388/// Parameters:
389/// - `sink_statuses`: Sink health state emitted by adapters.
390fn build_sink_summaries(sink_statuses: &[SinkStatus]) -> Vec<HealthSummary> {
391    sink_statuses
392        .iter()
393        .map(|sink| {
394            let status =
395                if sink.errors > 0 { HealthStatus::Unhealthy } else { HealthStatus::Healthy };
396            HealthSummary {
397                domain: HealthDomain::Sink(sink.name.clone()),
398                status,
399                total_events: 0,
400                error_count: sink.errors,
401                warning_count: 0,
402                last_event_at: None,
403                active_alerts: Vec::new(),
404            }
405        })
406        .collect()
407}
408
409fn matches_scope(scope: &AlertScope, event: &RazeriEvent) -> bool {
410    match scope {
411        AlertScope::Global => true,
412        AlertScope::Category { category } => event.category() == *category,
413    }
414}
415
416#[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)]
417fn evaluate_condition(
418    condition: &AlertCondition,
419    events: &[&RazeriEvent],
420    now: DateTime<Utc>,
421) -> (bool, f64, f64) {
422    match condition {
423        AlertCondition::Threshold { metric, op, value } => {
424            let count = count_metric(metric, events) as f64;
425            let threshold = *value as f64;
426            (compare(*op, count, threshold), count, threshold)
427        }
428        AlertCondition::Rate { metric, window_secs, op, value } => {
429            let cutoff = now - TimeDelta::seconds(*window_secs as i64);
430            let windowed: Vec<&RazeriEvent> =
431                events.iter().filter(|e| e.timestamp() >= cutoff).copied().collect();
432            let count = count_metric(metric, &windowed) as f64;
433            let threshold = *value as f64;
434            (compare(*op, count, threshold), count, threshold)
435        }
436        AlertCondition::Absence { window_secs } => {
437            let cutoff = now - TimeDelta::seconds(*window_secs as i64);
438            let has_recent = events.iter().any(|e| e.timestamp() >= cutoff);
439            let last_seen_secs = events
440                .iter()
441                .map(|e| (now - e.timestamp()).num_seconds().max(0) as f64)
442                .fold(f64::INFINITY, f64::min);
443            // If no events at all, report window+1 as the gap
444            let gap = if last_seen_secs.is_infinite() {
445                *window_secs as f64 + 1.0
446            } else {
447                last_seen_secs
448            };
449            (!has_recent, gap, *window_secs as f64)
450        }
451    }
452}
453
454fn count_metric(metric: &AlertMetric, events: &[&RazeriEvent]) -> usize {
455    match metric {
456        AlertMetric::EventCount => events.len(),
457        AlertMetric::ErrorCount => {
458            events.iter().filter(|e| e.severity() >= EventSeverity::Error).count()
459        }
460        AlertMetric::PayloadCount { payload_name } => {
461            events.iter().filter(|e| e.payload().name() == payload_name.as_str()).count()
462        }
463    }
464}
465
466fn compare(op: ComparisonOp, left: f64, right: f64) -> bool {
467    match op {
468        ComparisonOp::GreaterThan => left > right,
469        ComparisonOp::GreaterThanOrEqual => left >= right,
470        ComparisonOp::LessThan => left < right,
471        ComparisonOp::LessThanOrEqual => left <= right,
472        ComparisonOp::Equal => (left - right).abs() < f64::EPSILON,
473    }
474}
475
476#[allow(clippy::cast_possible_wrap)]
477fn is_deduplicated(
478    dedup_key: &str,
479    dedup_window_secs: Option<u64>,
480    now: DateTime<Utc>,
481    previously_fired: &[AlertResult],
482) -> bool {
483    let Some(window_secs) = dedup_window_secs else {
484        return false;
485    };
486    let cutoff = now - TimeDelta::seconds(window_secs as i64);
487    previously_fired
488        .iter()
489        .any(|prev| prev.fired && prev.dedup_key == dedup_key && prev.evaluated_at >= cutoff)
490}
491
492fn event_matches_domain(domain: &HealthDomain, event: &RazeriEvent) -> bool {
493    match domain {
494        HealthDomain::Overall => true,
495        HealthDomain::Category(cat) => event.category() == *cat,
496        HealthDomain::Workflow(wf_id) => {
497            event.workflow().is_some_and(|wf| wf.workflow_id == *wf_id)
498        }
499        HealthDomain::Source(name) => {
500            event.subject().kind == EventSubjectKind::Source && event.subject().name == *name
501        }
502        HealthDomain::Entity(name) => {
503            event.subject().kind == EventSubjectKind::Entity && event.subject().name == *name
504        }
505        HealthDomain::Plugin(name) => {
506            event.subject().kind == EventSubjectKind::Plugin && event.subject().name == *name
507        }
508        HealthDomain::Sink(_) => false,
509    }
510}
511
512fn summarize_events(
513    domain: HealthDomain,
514    events: &[&RazeriEvent],
515    active_alert_names: &[String],
516) -> HealthSummary {
517    let error_count = events.iter().filter(|e| e.severity() >= EventSeverity::Error).count();
518    let warning_count = events.iter().filter(|e| e.severity() == EventSeverity::Warning).count();
519    let last_event_at = events.iter().map(|e| e.timestamp()).max();
520
521    let status = if events.is_empty() {
522        HealthStatus::Unknown
523    } else if error_count > 0 {
524        HealthStatus::Unhealthy
525    } else if warning_count > 0 {
526        HealthStatus::Degraded
527    } else {
528        HealthStatus::Healthy
529    };
530
531    HealthSummary {
532        domain,
533        status,
534        total_events: events.len(),
535        error_count,
536        warning_count,
537        last_event_at,
538        active_alerts: active_alert_names.to_vec(),
539    }
540}
541
542#[cfg(test)]
543#[allow(clippy::float_cmp)]
544mod tests {
545    use super::*;
546    use crate::event::WorkflowContext;
547
548    fn ts(offset_secs: i64) -> DateTime<Utc> {
549        let base = DateTime::parse_from_rfc3339("2026-06-01T12:00:00Z")
550            .expect("valid base timestamp")
551            .with_timezone(&Utc);
552        base + TimeDelta::seconds(offset_secs)
553    }
554
555    fn now() -> DateTime<Utc> {
556        ts(0)
557    }
558
559    fn wf(id: &str) -> WorkflowContext {
560        WorkflowContext::new(id).expect("valid workflow id")
561    }
562
563    fn info_event(source: &str, offset_secs: i64) -> RazeriEvent {
564        RazeriEvent::profiling_completed(
565            format!("evt-info-{source}-{offset_secs}"),
566            ts(offset_secs),
567            wf("wf-1"),
568            source,
569            10,
570            1000,
571            42,
572        )
573        .expect("valid info event")
574    }
575
576    fn error_event(design: &str, offset_secs: i64) -> RazeriEvent {
577        RazeriEvent::materialization_failed(
578            format!("evt-err-{design}-{offset_secs}"),
579            ts(offset_secs),
580            wf("wf-1"),
581            design,
582            "PostgreSQL",
583            "connection refused",
584        )
585        .expect("valid error event")
586    }
587
588    fn warning_event(design: &str, offset_secs: i64) -> RazeriEvent {
589        RazeriEvent::design_validated(
590            format!("evt-warn-{design}-{offset_secs}"),
591            ts(offset_secs),
592            wf("wf-1"),
593            design,
594            0,
595            3,
596        )
597        .expect("valid warning event")
598    }
599
600    fn plugin_event(name: &str, offset_secs: i64) -> RazeriEvent {
601        RazeriEvent::plugin_loaded(
602            format!("evt-plugin-{name}-{offset_secs}"),
603            ts(offset_secs),
604            name,
605            "dialect",
606            "builtin",
607        )
608        .expect("valid plugin event")
609    }
610
611    fn threshold_rule(name: &str, metric: AlertMetric, value: u64) -> AlertRule {
612        AlertRule {
613            name: name.to_string(),
614            scope: AlertScope::Global,
615            condition: AlertCondition::Threshold { metric, op: ComparisonOp::GreaterThan, value },
616            severity: EventSeverity::Error,
617            message: format!("{name} triggered"),
618            enabled: true,
619            dedup_window_secs: None,
620        }
621    }
622
623    fn rate_rule(name: &str, metric: AlertMetric, window_secs: u64, value: u64) -> AlertRule {
624        AlertRule {
625            name: name.to_string(),
626            scope: AlertScope::Global,
627            condition: AlertCondition::Rate {
628                metric,
629                window_secs,
630                op: ComparisonOp::GreaterThan,
631                value,
632            },
633            severity: EventSeverity::Warning,
634            message: format!("{name} triggered"),
635            enabled: true,
636            dedup_window_secs: None,
637        }
638    }
639
640    fn absence_rule(name: &str, window_secs: u64) -> AlertRule {
641        AlertRule {
642            name: name.to_string(),
643            scope: AlertScope::Global,
644            condition: AlertCondition::Absence { window_secs },
645            severity: EventSeverity::Critical,
646            message: format!("{name} triggered"),
647            enabled: true,
648            dedup_window_secs: None,
649        }
650    }
651
652    // ── Alert evaluation tests ─────────────────────────────────────────
653
654    #[test]
655    fn threshold_fires_for_error_count_above_value() {
656        let events = vec![
657            error_event("design-a", -10),
658            error_event("design-b", -20),
659            error_event("design-c", -30),
660        ];
661        let rules = [threshold_rule("high-errors", AlertMetric::ErrorCount, 2)];
662        let results = evaluate_rules(&rules, &events, now(), &[]);
663
664        assert_eq!(results.len(), 1);
665        assert!(results[0].fired);
666        assert_eq!(results[0].current_value, 3.0);
667        assert_eq!(results[0].threshold, 2.0);
668    }
669
670    #[test]
671    fn threshold_does_not_fire_below_value() {
672        let events = vec![error_event("design-a", -10)];
673        let rules = [threshold_rule("high-errors", AlertMetric::ErrorCount, 2)];
674        let results = evaluate_rules(&rules, &events, now(), &[]);
675
676        assert_eq!(results.len(), 1);
677        assert!(!results[0].fired);
678        assert_eq!(results[0].current_value, 1.0);
679    }
680
681    #[test]
682    fn rate_filters_events_by_window() {
683        let events = vec![
684            info_event("a.csv", -10),   // within 60s window
685            info_event("b.csv", -30),   // within 60s window
686            info_event("c.csv", -50),   // within 60s window
687            info_event("d.csv", -120),  // outside 60s window
688            info_event("e.csv", -3600), // far outside window
689        ];
690        let rules = [rate_rule("high-event-rate", AlertMetric::EventCount, 60, 2)];
691        let results = evaluate_rules(&rules, &events, now(), &[]);
692
693        assert_eq!(results.len(), 1);
694        assert!(results[0].fired);
695        assert_eq!(results[0].current_value, 3.0);
696    }
697
698    #[test]
699    fn rate_does_not_fire_below_threshold_in_window() {
700        let events = vec![
701            info_event("a.csv", -10),   // within 60s window
702            info_event("b.csv", -3600), // outside 60s window
703        ];
704        let rules = [rate_rule("high-event-rate", AlertMetric::EventCount, 60, 2)];
705        let results = evaluate_rules(&rules, &events, now(), &[]);
706
707        assert_eq!(results.len(), 1);
708        assert!(!results[0].fired);
709        assert_eq!(results[0].current_value, 1.0);
710    }
711
712    #[test]
713    fn absence_fires_when_no_recent_events() {
714        let events = vec![
715            info_event("a.csv", -7200), // 2 hours ago
716        ];
717        let rules = [absence_rule("heartbeat", 3600)]; // window = 1 hour
718        let results = evaluate_rules(&rules, &events, now(), &[]);
719
720        assert_eq!(results.len(), 1);
721        assert!(results[0].fired);
722    }
723
724    #[test]
725    fn absence_does_not_fire_with_recent_events() {
726        let events = vec![
727            info_event("a.csv", -300), // 5 minutes ago
728        ];
729        let rules = [absence_rule("heartbeat", 3600)]; // window = 1 hour
730        let results = evaluate_rules(&rules, &events, now(), &[]);
731
732        assert_eq!(results.len(), 1);
733        assert!(!results[0].fired);
734    }
735
736    #[test]
737    fn absence_fires_when_no_events_at_all() {
738        let rules = [absence_rule("heartbeat", 3600)];
739        let results = evaluate_rules(&rules, &[], now(), &[]);
740
741        assert_eq!(results.len(), 1);
742        assert!(results[0].fired);
743        assert!(results[0].current_value > 3600.0);
744    }
745
746    #[test]
747    fn dedup_suppresses_repeated_firing() {
748        let events = vec![
749            error_event("design-a", -10),
750            error_event("design-b", -20),
751            error_event("design-c", -30),
752        ];
753        let rules = [AlertRule {
754            dedup_window_secs: Some(300),
755            ..threshold_rule("high-errors", AlertMetric::ErrorCount, 2)
756        }];
757
758        let previous = vec![AlertResult {
759            rule_name: "high-errors".to_string(),
760            fired: true,
761            severity: EventSeverity::Error,
762            message: "high-errors triggered".to_string(),
763            current_value: 3.0,
764            threshold: 2.0,
765            evaluated_at: ts(-60), // fired 60s ago, within 300s dedup window
766            dedup_key: "high-errors".to_string(),
767        }];
768
769        let results = evaluate_rules(&rules, &events, now(), &previous);
770        assert_eq!(results.len(), 1);
771        assert!(!results[0].fired); // deduplicated
772    }
773
774    #[test]
775    fn dedup_allows_firing_after_window_expires() {
776        let events = vec![
777            error_event("design-a", -10),
778            error_event("design-b", -20),
779            error_event("design-c", -30),
780        ];
781        let rules = [AlertRule {
782            dedup_window_secs: Some(300),
783            ..threshold_rule("high-errors", AlertMetric::ErrorCount, 2)
784        }];
785
786        let previous = vec![AlertResult {
787            rule_name: "high-errors".to_string(),
788            fired: true,
789            severity: EventSeverity::Error,
790            message: "high-errors triggered".to_string(),
791            current_value: 3.0,
792            threshold: 2.0,
793            evaluated_at: ts(-600), // fired 600s ago, outside 300s dedup window
794            dedup_key: "high-errors".to_string(),
795        }];
796
797        let results = evaluate_rules(&rules, &events, now(), &previous);
798        assert_eq!(results.len(), 1);
799        assert!(results[0].fired); // not deduplicated
800    }
801
802    #[test]
803    fn disabled_rules_are_skipped() {
804        let events = vec![error_event("design-a", -10)];
805        let rules = [AlertRule {
806            enabled: false,
807            ..threshold_rule("disabled-rule", AlertMetric::ErrorCount, 0)
808        }];
809
810        let results = evaluate_rules(&rules, &events, now(), &[]);
811        assert!(results.is_empty());
812    }
813
814    #[test]
815    fn category_scope_filters_events() {
816        let events = vec![
817            error_event("design-a", -10),    // Materialization
818            info_event("source.csv", -20),   // Profiling
819            plugin_event("pg-dialect", -30), // Plugin
820        ];
821        let rules = [AlertRule {
822            scope: AlertScope::Category { category: EventCategory::Materialization },
823            ..threshold_rule("mat-errors", AlertMetric::ErrorCount, 0)
824        }];
825        let results = evaluate_rules(&rules, &events, now(), &[]);
826
827        assert_eq!(results.len(), 1);
828        assert!(results[0].fired);
829        assert_eq!(results[0].current_value, 1.0); // only the materialization error
830    }
831
832    #[test]
833    fn payload_count_metric_filters_by_name() {
834        let events = vec![
835            error_event("design-a", -10),  // materialization_failed
836            error_event("design-b", -20),  // materialization_failed
837            info_event("source.csv", -30), // profiling_completed
838        ];
839        let metric =
840            AlertMetric::PayloadCount { payload_name: "materialization_failed".to_string() };
841        let rules = [threshold_rule("mat-failures", metric, 1)];
842        let results = evaluate_rules(&rules, &events, now(), &[]);
843
844        assert_eq!(results.len(), 1);
845        assert!(results[0].fired);
846        assert_eq!(results[0].current_value, 2.0);
847    }
848
849    #[test]
850    fn deterministic_evaluation_produces_identical_output() {
851        let events = vec![
852            error_event("design-a", -10),
853            info_event("source.csv", -20),
854            warning_event("design-b", -30),
855        ];
856        let rules = [
857            threshold_rule("errors", AlertMetric::ErrorCount, 0),
858            rate_rule("events", AlertMetric::EventCount, 60, 1),
859        ];
860
861        let result1 = evaluate_rules(&rules, &events, now(), &[]);
862        let result2 = evaluate_rules(&rules, &events, now(), &[]);
863
864        assert_eq!(result1.len(), result2.len());
865        for (a, b) in result1.iter().zip(result2.iter()) {
866            assert_eq!(a.rule_name, b.rule_name);
867            assert_eq!(a.fired, b.fired);
868            assert_eq!(a.current_value, b.current_value);
869            assert_eq!(a.threshold, b.threshold);
870            assert_eq!(a.evaluated_at, b.evaluated_at);
871        }
872    }
873
874    #[test]
875    fn severity_mapping_is_stable() {
876        let events = [
877            error_event("design-a", -10), // Error severity from MaterializationFailed
878            warning_event("design-b", -20), // Warning from DesignValidated with warnings
879            info_event("source.csv", -30), // Info from ProfilingCompleted
880        ];
881
882        assert_eq!(events[0].severity(), EventSeverity::Error);
883        assert_eq!(events[1].severity(), EventSeverity::Warning);
884        assert_eq!(events[2].severity(), EventSeverity::Info);
885    }
886
887    // ── Health summary tests ───────────────────────────────────────────
888
889    #[test]
890    fn health_summary_healthy_for_info_events() {
891        let events = vec![info_event("a.csv", -10), info_event("b.csv", -20)];
892        let summary = health_for_domain(&HealthDomain::Overall, &events, &[]);
893
894        assert_eq!(summary.status, HealthStatus::Healthy);
895        assert_eq!(summary.total_events, 2);
896        assert_eq!(summary.error_count, 0);
897        assert_eq!(summary.warning_count, 0);
898    }
899
900    #[test]
901    fn health_summary_degraded_for_warnings() {
902        let events = vec![info_event("a.csv", -10), warning_event("design-a", -20)];
903        let summary = health_for_domain(&HealthDomain::Overall, &events, &[]);
904
905        assert_eq!(summary.status, HealthStatus::Degraded);
906        assert_eq!(summary.warning_count, 1);
907        assert_eq!(summary.error_count, 0);
908    }
909
910    #[test]
911    fn health_summary_unhealthy_for_errors() {
912        let events = vec![info_event("a.csv", -10), error_event("design-a", -20)];
913        let summary = health_for_domain(&HealthDomain::Overall, &events, &[]);
914
915        assert_eq!(summary.status, HealthStatus::Unhealthy);
916        assert_eq!(summary.error_count, 1);
917    }
918
919    #[test]
920    fn health_summary_unknown_for_no_events() {
921        let summary = health_for_domain(&HealthDomain::Overall, &[], &[]);
922
923        assert_eq!(summary.status, HealthStatus::Unknown);
924        assert_eq!(summary.total_events, 0);
925    }
926
927    #[test]
928    fn health_summary_includes_last_event_timestamp() {
929        let events = vec![info_event("a.csv", -100), info_event("b.csv", -10)];
930        let summary = health_for_domain(&HealthDomain::Overall, &events, &[]);
931
932        assert_eq!(summary.last_event_at, Some(ts(-10)));
933    }
934
935    #[test]
936    fn health_summary_includes_active_alert_names() {
937        let events = vec![error_event("design-a", -10)];
938        let alerts = vec![AlertResult {
939            rule_name: "high-errors".to_string(),
940            fired: true,
941            severity: EventSeverity::Error,
942            message: "too many errors".to_string(),
943            current_value: 1.0,
944            threshold: 0.0,
945            evaluated_at: now(),
946            dedup_key: "high-errors".to_string(),
947        }];
948        let summary = health_for_domain(&HealthDomain::Overall, &events, &alerts);
949
950        assert_eq!(summary.active_alerts, vec!["high-errors"]);
951    }
952
953    #[test]
954    fn health_for_domain_filters_by_category() {
955        let events = vec![
956            info_event("a.csv", -10),     // Profiling
957            error_event("design-a", -20), // Materialization
958        ];
959        let summary =
960            health_for_domain(&HealthDomain::Category(EventCategory::Profiling), &events, &[]);
961
962        assert_eq!(summary.status, HealthStatus::Healthy);
963        assert_eq!(summary.total_events, 1);
964        assert_eq!(summary.error_count, 0);
965    }
966
967    #[test]
968    fn health_for_domain_filters_by_source() {
969        let events =
970            vec![info_event("a.csv", -10), info_event("b.csv", -20), error_event("design-a", -30)];
971        let summary = health_for_domain(&HealthDomain::Source("a.csv".to_string()), &events, &[]);
972
973        assert_eq!(summary.total_events, 1);
974        assert_eq!(summary.status, HealthStatus::Healthy);
975    }
976
977    #[test]
978    fn sink_failures_visible_in_health_summaries() {
979        let sinks = vec![SinkStatus {
980            name: "webhook-prod".to_string(),
981            errors: 5,
982            last_error: Some("connection timeout".to_string()),
983        }];
984        let summaries = discover_health(&[], &[], &sinks);
985
986        let sink_summary =
987            summaries.iter().find(|s| s.domain == HealthDomain::Sink("webhook-prod".to_string()));
988        assert!(sink_summary.is_some());
989        let sink = sink_summary.unwrap();
990        assert_eq!(sink.status, HealthStatus::Unhealthy);
991        assert_eq!(sink.error_count, 5);
992    }
993
994    #[test]
995    fn discover_health_finds_all_domains() {
996        let events = vec![
997            info_event("customers.csv", -10), // Profiling, Source
998            error_event("orders", -20),       // Materialization, Design subject
999            plugin_event("pg-dialect", -30),  // Plugin
1000        ];
1001        let summaries = discover_health(&events, &[], &[]);
1002
1003        // Should include: Overall, Category(Profiling), Category(Materialization),
1004        // Category(Plugin), Source(customers.csv), Plugin(pg-dialect),
1005        // Workflow(wf-1), plus Design(orders) as Entity
1006        assert!(summaries.iter().any(|s| s.domain == HealthDomain::Overall));
1007        assert!(
1008            summaries.iter().any(|s| s.domain == HealthDomain::Category(EventCategory::Profiling))
1009        );
1010        assert!(
1011            summaries
1012                .iter()
1013                .any(|s| s.domain == HealthDomain::Category(EventCategory::Materialization))
1014        );
1015        assert!(
1016            summaries.iter().any(|s| s.domain == HealthDomain::Source("customers.csv".to_string()))
1017        );
1018        assert!(
1019            summaries.iter().any(|s| s.domain == HealthDomain::Plugin("pg-dialect".to_string()))
1020        );
1021        assert!(summaries.iter().any(|s| s.domain == HealthDomain::Workflow("wf-1".to_string())));
1022    }
1023
1024    #[test]
1025    fn discover_health_deterministic_for_same_input() {
1026        let events = vec![
1027            info_event("a.csv", -10),
1028            error_event("design-a", -20),
1029            warning_event("design-b", -30),
1030            plugin_event("dialect-1", -40),
1031        ];
1032
1033        let result1 = discover_health(&events, &[], &[]);
1034        let result2 = discover_health(&events, &[], &[]);
1035
1036        assert_eq!(result1.len(), result2.len());
1037        for (a, b) in result1.iter().zip(result2.iter()) {
1038            assert_eq!(a.domain, b.domain);
1039            assert_eq!(a.status, b.status);
1040            assert_eq!(a.total_events, b.total_events);
1041            assert_eq!(a.error_count, b.error_count);
1042        }
1043    }
1044
1045    // ── Fixture tests: normal, degraded, and failing systems ───────────
1046
1047    /// Normal system: all info events, no errors or warnings.
1048    fn normal_fixture() -> Vec<RazeriEvent> {
1049        vec![
1050            info_event("customers.csv", -10),
1051            info_event("orders.csv", -20),
1052            plugin_event("pg-dialect", -30),
1053        ]
1054    }
1055
1056    /// Degraded system: some warnings but no errors.
1057    fn degraded_fixture() -> Vec<RazeriEvent> {
1058        vec![
1059            info_event("customers.csv", -10),
1060            warning_event("orders-design", -20),
1061            warning_event("products-design", -30),
1062            plugin_event("pg-dialect", -40),
1063        ]
1064    }
1065
1066    /// Failing system: errors across multiple categories.
1067    fn failing_fixture() -> Vec<RazeriEvent> {
1068        vec![
1069            error_event("orders-design", -10),
1070            error_event("customers-design", -20),
1071            warning_event("products-design", -30),
1072            info_event("source.csv", -40),
1073        ]
1074    }
1075
1076    #[test]
1077    fn fixture_normal_system_is_healthy() {
1078        let summary = health_for_domain(&HealthDomain::Overall, &normal_fixture(), &[]);
1079        assert_eq!(summary.status, HealthStatus::Healthy);
1080        assert_eq!(summary.error_count, 0);
1081        assert_eq!(summary.warning_count, 0);
1082    }
1083
1084    #[test]
1085    fn fixture_degraded_system_is_degraded() {
1086        let summary = health_for_domain(&HealthDomain::Overall, &degraded_fixture(), &[]);
1087        assert_eq!(summary.status, HealthStatus::Degraded);
1088        assert_eq!(summary.error_count, 0);
1089        assert!(summary.warning_count > 0);
1090    }
1091
1092    #[test]
1093    fn fixture_failing_system_is_unhealthy() {
1094        let summary = health_for_domain(&HealthDomain::Overall, &failing_fixture(), &[]);
1095        assert_eq!(summary.status, HealthStatus::Unhealthy);
1096        assert!(summary.error_count > 0);
1097    }
1098
1099    #[test]
1100    fn fixture_failing_system_alerts_fire() {
1101        let rules = [threshold_rule("mat-errors", AlertMetric::ErrorCount, 1)];
1102        let results = evaluate_rules(&rules, &failing_fixture(), now(), &[]);
1103
1104        assert_eq!(results.len(), 1);
1105        assert!(results[0].fired);
1106        assert_eq!(results[0].current_value, 2.0);
1107    }
1108
1109    #[test]
1110    fn fixture_normal_system_alerts_do_not_fire() {
1111        let rules = [threshold_rule("mat-errors", AlertMetric::ErrorCount, 0)];
1112        let results = evaluate_rules(&rules, &normal_fixture(), now(), &[]);
1113
1114        assert_eq!(results.len(), 1);
1115        assert!(!results[0].fired);
1116    }
1117
1118    // ── Serialization tests ────────────────────────────────────────────
1119
1120    #[test]
1121    fn alert_rule_serializes_deterministically() {
1122        let rule = threshold_rule("test-rule", AlertMetric::ErrorCount, 5);
1123        let json1 = serde_json::to_string(&rule).expect("serialize");
1124        let json2 = serde_json::to_string(&rule).expect("serialize");
1125        assert_eq!(json1, json2);
1126    }
1127
1128    #[test]
1129    fn alert_result_round_trips_through_json() {
1130        let result = AlertResult {
1131            rule_name: "test".to_string(),
1132            fired: true,
1133            severity: EventSeverity::Error,
1134            message: "test alert".to_string(),
1135            current_value: 5.0,
1136            threshold: 3.0,
1137            evaluated_at: now(),
1138            dedup_key: "test".to_string(),
1139        };
1140        let json = serde_json::to_string(&result).expect("serialize");
1141        let parsed: AlertResult = serde_json::from_str(&json).expect("deserialize");
1142        assert_eq!(parsed.rule_name, result.rule_name);
1143        assert_eq!(parsed.fired, result.fired);
1144        assert_eq!(parsed.current_value, result.current_value);
1145    }
1146
1147    #[test]
1148    fn health_summary_round_trips_through_json() {
1149        let summary = HealthSummary {
1150            domain: HealthDomain::Category(EventCategory::Storage),
1151            status: HealthStatus::Degraded,
1152            total_events: 10,
1153            error_count: 0,
1154            warning_count: 3,
1155            last_event_at: Some(now()),
1156            active_alerts: vec!["slow-storage".to_string()],
1157        };
1158        let json = serde_json::to_string(&summary).expect("serialize");
1159        let parsed: HealthSummary = serde_json::from_str(&json).expect("deserialize");
1160        assert_eq!(parsed.domain, summary.domain);
1161        assert_eq!(parsed.status, summary.status);
1162        assert_eq!(parsed.total_events, summary.total_events);
1163    }
1164
1165    // ── Snapshot tests ──────────────────────────────────────────────────
1166
1167    #[test]
1168    fn snapshot_alert_rule_json_shape() {
1169        let rule = threshold_rule("high-errors", AlertMetric::ErrorCount, 5);
1170        let val: serde_json::Value = serde_json::to_value(&rule).unwrap();
1171        let obj = val.as_object().unwrap();
1172        assert!(obj.contains_key("name"));
1173        assert!(obj.contains_key("scope"));
1174        assert!(obj.contains_key("condition"));
1175        assert!(obj.contains_key("severity"));
1176        assert!(obj.contains_key("message"));
1177        assert!(obj.contains_key("enabled"));
1178
1179        // Scope is internally tagged
1180        let scope = obj["scope"].as_object().unwrap();
1181        assert_eq!(scope["type"], "global");
1182
1183        // Condition is internally tagged
1184        let cond = obj["condition"].as_object().unwrap();
1185        assert_eq!(cond["type"], "threshold");
1186        assert!(cond.contains_key("metric"));
1187        assert!(cond.contains_key("op"));
1188        assert!(cond.contains_key("value"));
1189    }
1190
1191    #[test]
1192    fn snapshot_alert_result_json_shape() {
1193        let result = AlertResult {
1194            rule_name: "check".to_string(),
1195            fired: true,
1196            severity: EventSeverity::Warning,
1197            message: "test".to_string(),
1198            current_value: 3.0,
1199            threshold: 2.0,
1200            evaluated_at: now(),
1201            dedup_key: "check".to_string(),
1202        };
1203        let val: serde_json::Value = serde_json::to_value(&result).unwrap();
1204        let obj = val.as_object().unwrap();
1205        assert!(obj.contains_key("rule_name"));
1206        assert!(obj.contains_key("fired"));
1207        assert!(obj.contains_key("severity"));
1208        assert!(obj.contains_key("message"));
1209        assert!(obj.contains_key("current_value"));
1210        assert!(obj.contains_key("threshold"));
1211        assert!(obj.contains_key("evaluated_at"));
1212        assert!(obj.contains_key("dedup_key"));
1213    }
1214
1215    #[test]
1216    fn snapshot_health_summary_json_shape() {
1217        let summary = HealthSummary {
1218            domain: HealthDomain::Category(EventCategory::Profiling),
1219            status: HealthStatus::Healthy,
1220            total_events: 5,
1221            error_count: 0,
1222            warning_count: 0,
1223            last_event_at: Some(now()),
1224            active_alerts: vec![],
1225        };
1226        let val: serde_json::Value = serde_json::to_value(&summary).unwrap();
1227        let obj = val.as_object().unwrap();
1228
1229        // Domain is tagged with kind+name
1230        let domain = obj["domain"].as_object().unwrap();
1231        assert_eq!(domain["kind"], "category");
1232        assert_eq!(domain["name"], "profiling");
1233
1234        assert_eq!(obj["status"], "healthy");
1235        assert!(obj.contains_key("total_events"));
1236        assert!(obj.contains_key("error_count"));
1237        assert!(obj.contains_key("warning_count"));
1238        assert!(obj.contains_key("last_event_at"));
1239        assert!(obj.contains_key("active_alerts"));
1240    }
1241
1242    #[test]
1243    fn snapshot_discover_health_is_deterministic() {
1244        let events =
1245            vec![info_event("src1", 0), info_event("src2", -10), error_event("design1", -5)];
1246        let results = evaluate_rules(
1247            &[threshold_rule("errs", AlertMetric::ErrorCount, 0)],
1248            &events,
1249            now(),
1250            &[],
1251        );
1252        let h1 = discover_health(&events, &results, &[]);
1253        let h2 = discover_health(&events, &results, &[]);
1254        let j1 = serde_json::to_string(&h1).unwrap();
1255        let j2 = serde_json::to_string(&h2).unwrap();
1256        assert_eq!(j1, j2, "discover_health must be deterministic");
1257    }
1258}