1use crate::event::{EventCategory, EventSeverity, EventSubjectKind, RazeriEvent};
15use chrono::{DateTime, TimeDelta, Utc};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case", tag = "type")]
24#[non_exhaustive]
25pub enum AlertScope {
26 Global,
28 Category {
30 category: EventCategory,
32 },
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ComparisonOp {
39 GreaterThan,
41 GreaterThanOrEqual,
43 LessThan,
45 LessThanOrEqual,
47 Equal,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case", tag = "type")]
54#[non_exhaustive]
55pub enum AlertMetric {
56 EventCount,
58 ErrorCount,
60 PayloadCount {
62 payload_name: String,
64 },
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case", tag = "type")]
70#[non_exhaustive]
71pub enum AlertCondition {
72 Threshold {
74 metric: AlertMetric,
76 op: ComparisonOp,
78 value: u64,
80 },
81 Rate {
83 metric: AlertMetric,
85 window_secs: u64,
87 op: ComparisonOp,
89 value: u64,
91 },
92 Absence {
94 window_secs: u64,
96 },
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct AlertRule {
102 pub name: String,
104 pub scope: AlertScope,
106 pub condition: AlertCondition,
108 pub severity: EventSeverity,
110 pub message: String,
112 pub enabled: bool,
114 pub dedup_window_secs: Option<u64>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct AlertResult {
122 pub rule_name: String,
124 pub fired: bool,
126 pub severity: EventSeverity,
128 pub message: String,
130 pub current_value: f64,
132 pub threshold: f64,
134 pub evaluated_at: DateTime<Utc>,
136 pub dedup_key: String,
138}
139
140#[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 Overall,
149 Category(EventCategory),
151 Workflow(String),
153 Source(String),
155 Entity(String),
157 Plugin(String),
159 Sink(String),
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum HealthStatus {
167 Healthy,
169 Degraded,
171 Unhealthy,
173 Unknown,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct HealthSummary {
180 pub domain: HealthDomain,
182 pub status: HealthStatus,
184 pub total_events: usize,
186 pub error_count: usize,
188 pub warning_count: usize,
190 pub last_event_at: Option<DateTime<Utc>>,
192 pub active_alerts: Vec<String>,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct SinkStatus {
199 pub name: String,
201 pub errors: usize,
203 pub last_error: Option<String>,
205}
206
207#[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#[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#[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
290fn 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
301fn 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
308fn 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
331fn 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
362fn 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
387fn 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 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 #[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), info_event("b.csv", -30), info_event("c.csv", -50), info_event("d.csv", -120), info_event("e.csv", -3600), ];
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), info_event("b.csv", -3600), ];
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), ];
717 let rules = [absence_rule("heartbeat", 3600)]; 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), ];
729 let rules = [absence_rule("heartbeat", 3600)]; 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), 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); }
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), 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); }
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), info_event("source.csv", -20), plugin_event("pg-dialect", -30), ];
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); }
831
832 #[test]
833 fn payload_count_metric_filters_by_name() {
834 let events = vec![
835 error_event("design-a", -10), error_event("design-b", -20), info_event("source.csv", -30), ];
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), warning_event("design-b", -20), info_event("source.csv", -30), ];
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 #[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), error_event("design-a", -20), ];
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), error_event("orders", -20), plugin_event("pg-dialect", -30), ];
1001 let summaries = discover_health(&events, &[], &[]);
1002
1003 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 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 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 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, °raded_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 #[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 #[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 let scope = obj["scope"].as_object().unwrap();
1181 assert_eq!(scope["type"], "global");
1182
1183 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 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}