Skip to main content

razeri_plugin_api/
alerting.rs

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