1use crate::event::{EventCategory, EventSeverity, EventSubjectKind, RazeriEvent};
17use chrono::{DateTime, TimeDelta, Utc};
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case", tag = "type")]
26#[non_exhaustive]
27pub enum AlertScope {
28 Global,
30 Category {
32 category: EventCategory,
34 },
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ComparisonOp {
41 GreaterThan,
43 GreaterThanOrEqual,
45 LessThan,
47 LessThanOrEqual,
49 Equal,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case", tag = "type")]
56#[non_exhaustive]
57pub enum AlertMetric {
58 EventCount,
60 ErrorCount,
62 PayloadCount {
64 payload_name: String,
66 },
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case", tag = "type")]
72#[non_exhaustive]
73pub enum AlertCondition {
74 Threshold {
76 metric: AlertMetric,
78 op: ComparisonOp,
80 value: u64,
82 },
83 Rate {
85 metric: AlertMetric,
87 window_secs: u64,
89 op: ComparisonOp,
91 value: u64,
93 },
94 Absence {
96 window_secs: u64,
98 },
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct AlertRule {
104 pub name: String,
106 pub scope: AlertScope,
108 pub condition: AlertCondition,
110 pub severity: EventSeverity,
112 pub message: String,
114 pub enabled: bool,
116 pub dedup_window_secs: Option<u64>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct AlertResult {
124 pub rule_name: String,
126 pub fired: bool,
128 pub severity: EventSeverity,
130 pub message: String,
132 pub current_value: f64,
134 pub threshold: f64,
136 pub evaluated_at: DateTime<Utc>,
138 pub dedup_key: String,
140}
141
142#[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 Overall,
151 Category(EventCategory),
153 Workflow(String),
155 Source(String),
157 Entity(String),
159 Plugin(String),
161 Sink(String),
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum HealthStatus {
169 Healthy,
171 Degraded,
173 Unhealthy,
175 Unknown,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct HealthSummary {
182 pub domain: HealthDomain,
184 pub status: HealthStatus,
186 pub total_events: usize,
188 pub error_count: usize,
190 pub warning_count: usize,
192 pub last_event_at: Option<DateTime<Utc>>,
194 pub active_alerts: Vec<String>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct SinkStatus {
201 pub name: String,
203 pub errors: usize,
205 pub last_error: Option<String>,
207}
208
209#[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#[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#[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
292fn 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
303fn 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
310fn 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
333fn 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
364fn 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
389fn 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 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 #[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), info_event("b.csv", -30), info_event("c.csv", -50), info_event("d.csv", -120), info_event("e.csv", -3600), ];
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), info_event("b.csv", -3600), ];
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), ];
719 let rules = [absence_rule("heartbeat", 3600)]; 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), ];
731 let rules = [absence_rule("heartbeat", 3600)]; 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), 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); }
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), 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); }
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), info_event("source.csv", -20), plugin_event("pg-dialect", -30), ];
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); }
833
834 #[test]
835 fn payload_count_metric_filters_by_name() {
836 let events = vec![
837 error_event("design-a", -10), error_event("design-b", -20), info_event("source.csv", -30), ];
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), warning_event("design-b", -20), info_event("source.csv", -30), ];
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 #[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), error_event("design-a", -20), ];
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), error_event("orders", -20), plugin_event("pg-dialect", -30), ];
1003 let summaries = discover_health(&events, &[], &[]);
1004
1005 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 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 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 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, °raded_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 #[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 #[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 let scope = obj["scope"].as_object().unwrap();
1183 assert_eq!(scope["type"], "global");
1184
1185 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 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}