use std::collections::HashMap;
use tower::event::{Event, EventScope, Level};
use tower::rules::{compiler, parse};
use tower_rules::*;
fn parse_fixture(name: &str) -> TowerRule {
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
let src = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("could not read fixture {path}: {e}"));
parse::parse_rule_yaml(&src)
.unwrap_or_else(|e| panic!("could not parse fixture {name}: {e}"))
}
fn health_event(signal: HealthSignal, seq: u64) -> Event {
Event::health(signal, seq)
}
fn service_event(
ident: &str,
level: Level,
target: &str,
fields: HashMap<String, serde_json::Value>,
seq: u64,
) -> Event {
Event {
scope: EventScope::Service(MeshIdent(ident.into())),
level,
target: target.into(),
msg: String::new(),
fields,
seq,
}
}
#[test]
fn yubaba_quorum_loss_matches_quorum_signal() {
let rule = parse_fixture("yubaba-quorum-loss.yaml");
let filter = compiler::compile(&rule).unwrap();
let events = [
health_event(HealthSignal::YubabaRaftQuorumLost, 1), health_event(HealthSignal::YubabaRaftQuorumLost, 2), health_event(HealthSignal::IngestionLag, 3), service_event("yubaba.local", Level::Error, "raft.quorum_lost", HashMap::new(), 4), ];
let matches: Vec<_> = events.iter().filter(|e| filter.matches(e)).collect();
assert_eq!(matches.len(), 3, "should match 2 health events + 1 service event via Compound(Or)");
}
#[test]
fn yubaba_quorum_loss_does_not_match_service_events() {
let rule = parse_fixture("yubaba-quorum-loss.yaml");
let filter = compiler::compile(&rule).unwrap();
let non_health_events = [
service_event("yubaba.local", Level::Error, "raft.term_change", HashMap::new(), 1),
service_event("yubaba.local", Level::Warn, "mesh.peer_timeout", HashMap::new(), 2),
];
let matches: Vec<_> = non_health_events.iter().filter(|e| filter.matches(e)).collect();
assert_eq!(matches.len(), 0);
}
#[test]
fn db_error_burst_matches_correct_service_and_level() {
let rule = parse_fixture("db-error-burst.yaml");
let filter = compiler::compile(&rule).unwrap();
let events = [
service_event("noisetable-db.pdx", Level::Error, "database.query_slow", HashMap::new(), 1),
service_event("noisetable-db.pdx", Level::Error, "database.connection_failed", HashMap::new(), 2),
service_event("noisetable-api.pdx", Level::Error, "database.query_slow", HashMap::new(), 3),
service_event("noisetable-db.pdx", Level::Warn, "database.query_slow", HashMap::new(), 4),
service_event("noisetable-db.pdx", Level::Error, "redis.timeout", HashMap::new(), 5),
];
let matches: Vec<_> = events.iter().filter(|e| filter.matches(e)).collect();
assert_eq!(matches.len(), 2, "expected 2 matches");
}
#[test]
fn db_error_burst_rate_predicate_stored_but_not_blocking() {
let rule = parse_fixture("db-error-burst.yaml");
let filter = compiler::compile(&rule).unwrap();
let event = service_event(
"noisetable-db.pdx",
Level::Error,
"database.query_slow",
HashMap::new(),
1,
);
assert!(filter.matches(&event));
}
#[test]
fn compound_or_matches_either_signal() {
let rule = parse_fixture("compound-or.yaml");
let filter = compiler::compile(&rule).unwrap();
let events = [
health_event(HealthSignal::YubabaRaftQuorumLost, 1),
health_event(HealthSignal::RingOverflow, 2),
health_event(HealthSignal::IngestionLag, 3), health_event(HealthSignal::FederationTimeout, 4), ];
let matches: Vec<_> = events.iter().filter(|e| filter.matches(e)).collect();
assert_eq!(matches.len(), 2);
}
#[test]
fn event_match_with_field_filter_eq() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("field-eq".into()),
name: "test".into(),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![FieldFilter {
path: "error.code".into(),
op: FieldOp::Eq { value: serde_json::json!("PG-23505") },
}],
rate: None,
},
trigger: Trigger::Notification {
channels: vec![NotificationChannel::DesktopBadge],
severity: Severity::Warning,
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let filter = compiler::compile(&rule).unwrap();
let matching = Event {
scope: EventScope::Service(MeshIdent("db.pdx".into())),
level: Level::Error,
target: "database.constraint".into(),
msg: String::new(),
fields: {
let mut m = HashMap::new();
m.insert("error".into(), serde_json::json!({"code": "PG-23505"}));
m
},
seq: 1,
};
let non_matching = Event {
scope: EventScope::Service(MeshIdent("db.pdx".into())),
level: Level::Error,
target: "database.constraint".into(),
msg: String::new(),
fields: {
let mut m = HashMap::new();
m.insert("error".into(), serde_json::json!({"code": "PG-23514"}));
m
},
seq: 2,
};
assert!(filter.matches(&matching));
assert!(!filter.matches(&non_matching));
}
#[test]
fn event_match_field_exists() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("field-exists".into()),
name: "test".into(),
predicate: Predicate::EventMatch {
scope: ScopeFilter::Any,
level: None,
target: None,
fields: vec![FieldFilter {
path: "stack_trace".into(),
op: FieldOp::Exists,
}],
rate: None,
},
trigger: Trigger::Notification {
channels: vec![NotificationChannel::DesktopBadge],
severity: Severity::Warning,
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let filter = compiler::compile(&rule).unwrap();
let with_trace = Event {
scope: EventScope::Service(MeshIdent("api.pdx".into())),
level: Level::Error,
target: "service.panic".into(),
msg: String::new(),
fields: {
let mut m = HashMap::new();
m.insert("stack_trace".into(), serde_json::json!("at line 42..."));
m
},
seq: 1,
};
let without_trace = Event {
scope: EventScope::Service(MeshIdent("api.pdx".into())),
level: Level::Error,
target: "service.error".into(),
msg: String::new(),
fields: HashMap::new(),
seq: 2,
};
assert!(filter.matches(&with_trace));
assert!(!filter.matches(&without_trace));
}
#[test]
fn not_predicate_inverts_match() {
let rule = TowerRule {
schema_version: SchemaVersion::V1,
id: RuleId("not-forge".into()),
name: "test".into(),
predicate: Predicate::Compound {
op: CompoundOp::Not,
children: vec![Predicate::EventMatch {
scope: ScopeFilter::Forge { id: None },
level: None,
target: None,
fields: vec![],
rate: None,
}],
},
trigger: Trigger::Notification {
channels: vec![NotificationChannel::DesktopBadge],
severity: Severity::Info,
},
debounce_ms: None,
federation: FederationPolicy::LocalOnly,
enabled: true,
};
let filter = compiler::compile(&rule).unwrap();
let service_ev = Event {
scope: EventScope::Service(MeshIdent("api.pdx".into())),
level: Level::Info,
target: "service.start".into(),
msg: String::new(),
fields: HashMap::new(),
seq: 1,
};
let forge_ev = Event {
scope: EventScope::Forge(tower_rules::ForgeId("run-123".into())),
level: Level::Info,
target: "forge.start".into(),
msg: String::new(),
fields: HashMap::new(),
seq: 2,
};
assert!(filter.matches(&service_ev), "service event should match NOT(forge)");
assert!(!filter.matches(&forge_ev), "forge event should NOT match NOT(forge)");
}