Skip to content

Commit 6d2f830

Browse files
authored
Merge pull request #716 from portableDD/portabledd/alerting-tests-bench
test(devkit/alerting): cooldown, history & integration tests + evaluator bench
2 parents 4c5fe23 + 47f74de commit 6d2f830

5 files changed

Lines changed: 288 additions & 0 deletions

File tree

packages/devkit/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,7 @@ harness = false
7575
[[bench]]
7676
name = "streaming_bench"
7777
harness = false
78+
79+
[[bench]]
80+
name = "alerting_bench"
81+
harness = false
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//! Throughput benchmark for the alert evaluator (#643).
2+
//!
3+
//! Measures rule evaluations per second with 10 active rules.
4+
//! Target: > 100,000 evaluations/sec.
5+
6+
use criterion::{criterion_group, criterion_main, Criterion, Throughput};
7+
8+
use stellar_devkit::alerting::evaluator::{FeeSnapshot, RuleEvaluator};
9+
use stellar_devkit::alerting::rule::{AlertCondition, AlertRule};
10+
11+
fn ten_rules() -> Vec<AlertRule> {
12+
let conditions = [
13+
AlertCondition::FeeAbove,
14+
AlertCondition::FeeBelow,
15+
AlertCondition::P95Above,
16+
AlertCondition::SpikeCountExceeds,
17+
AlertCondition::CapacityUsageAbove,
18+
];
19+
(0..10)
20+
.map(|i| {
21+
AlertRule::new(
22+
format!("rule-{i}"),
23+
format!("rule {i}"),
24+
conditions[i % conditions.len()],
25+
50,
26+
)
27+
// cooldown 0 so every evaluation exercises the full trigger path.
28+
.with_cooldown(0)
29+
})
30+
.collect()
31+
}
32+
33+
fn bench_evaluator(c: &mut Criterion) {
34+
let rules = ten_rules();
35+
let snapshot = FeeSnapshot {
36+
base_fee: 500,
37+
p95: 500,
38+
spike_count: 500,
39+
capacity_usage_pct: 99,
40+
};
41+
42+
let mut group = c.benchmark_group("alerting_evaluator");
43+
group.throughput(Throughput::Elements(rules.len() as u64));
44+
45+
group.bench_function("evaluate_10_rules", |b| {
46+
let evaluator = RuleEvaluator::new();
47+
b.iter(|| {
48+
let events = evaluator.evaluate(&rules, &snapshot);
49+
criterion::black_box(events)
50+
});
51+
});
52+
53+
group.finish();
54+
}
55+
56+
criterion_group!(benches, bench_evaluator);
57+
criterion_main!(benches);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//! Unit tests for the alert cooldown tracker (#640).
2+
3+
use std::time::{Duration, Instant};
4+
5+
use stellar_devkit::alerting::evaluator::CooldownTracker;
6+
7+
#[test]
8+
fn second_alert_within_window_is_suppressed() {
9+
let ct = CooldownTracker::new();
10+
let t0 = Instant::now();
11+
12+
assert!(ct.should_fire_at("r", 300, t0), "first fire allowed");
13+
ct.record_at("r", t0);
14+
assert!(
15+
!ct.should_fire_at("r", 300, t0 + Duration::from_secs(120)),
16+
"within the cooldown window must be suppressed"
17+
);
18+
}
19+
20+
#[test]
21+
fn alert_fires_after_cooldown_expires() {
22+
let ct = CooldownTracker::new();
23+
let t0 = Instant::now();
24+
ct.record_at("r", t0);
25+
26+
assert!(!ct.should_fire_at("r", 300, t0 + Duration::from_secs(299)));
27+
// At exactly the boundary (elapsed == cooldown) the rule may fire again.
28+
assert!(ct.should_fire_at("r", 300, t0 + Duration::from_secs(300)));
29+
assert!(ct.should_fire_at("r", 300, t0 + Duration::from_secs(301)));
30+
}
31+
32+
#[test]
33+
fn reset_cooldown_allows_immediate_fire() {
34+
let ct = CooldownTracker::new();
35+
let t0 = Instant::now();
36+
ct.record_at("r", t0);
37+
assert!(!ct.should_fire_at("r", 300, t0));
38+
39+
ct.reset_cooldown("r");
40+
assert!(ct.should_fire_at("r", 300, t0), "reset clears the cooldown");
41+
}
42+
43+
#[test]
44+
fn unknown_rule_is_always_allowed() {
45+
let ct = CooldownTracker::new();
46+
assert!(ct.should_fire("never-recorded", 300));
47+
}
48+
49+
#[test]
50+
fn cooldowns_are_tracked_per_rule() {
51+
let ct = CooldownTracker::new();
52+
let t0 = Instant::now();
53+
ct.record_at("a", t0);
54+
55+
assert!(!ct.should_fire_at("a", 300, t0), "a is on cooldown");
56+
assert!(
57+
ct.should_fire_at("b", 300, t0),
58+
"b has an independent cooldown"
59+
);
60+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//! Unit tests for the alert history store (#641).
2+
3+
use chrono::{TimeZone, Utc};
4+
5+
use stellar_devkit::alerting::history::{AlertHistory, HistoryQuery};
6+
use stellar_devkit::alerting::rule::{AlertEvent, AlertSeverity};
7+
8+
fn event(rule_id: &str, severity: AlertSeverity, ts_secs: i64, value: u64) -> AlertEvent {
9+
AlertEvent {
10+
rule_id: rule_id.to_string(),
11+
rule_name: rule_id.to_string(),
12+
severity,
13+
triggered_at: Utc.timestamp_opt(ts_secs, 0).unwrap(),
14+
current_value: value,
15+
threshold: 0,
16+
message: String::new(),
17+
}
18+
}
19+
20+
#[test]
21+
fn ring_buffer_caps_at_1000_and_drops_oldest() {
22+
let mut history = AlertHistory::new();
23+
for i in 0..1_500i64 {
24+
history.record(event(&format!("r{i}"), AlertSeverity::Info, i, i as u64));
25+
}
26+
assert_eq!(history.len(), 1_000, "capped at 1,000");
27+
28+
let all = history.all();
29+
// The first 500 (r0..r499) must have been evicted, oldest-first.
30+
assert_eq!(all.first().unwrap().rule_id, "r500");
31+
assert_eq!(all.last().unwrap().rule_id, "r1499");
32+
}
33+
34+
#[test]
35+
fn query_filters_by_rule_id() {
36+
let mut history = AlertHistory::new();
37+
history.record(event("a", AlertSeverity::Info, 1, 1));
38+
history.record(event("b", AlertSeverity::Info, 2, 2));
39+
history.record(event("a", AlertSeverity::Warning, 3, 3));
40+
41+
let a = history.by_rule("a");
42+
assert_eq!(a.len(), 2);
43+
assert!(a.iter().all(|e| e.rule_id == "a"));
44+
}
45+
46+
#[test]
47+
fn query_filters_by_severity() {
48+
let mut history = AlertHistory::new();
49+
history.record(event("a", AlertSeverity::Info, 1, 1));
50+
history.record(event("b", AlertSeverity::Critical, 2, 2));
51+
history.record(event("c", AlertSeverity::Critical, 3, 3));
52+
53+
let crit = history.by_severity(AlertSeverity::Critical);
54+
assert_eq!(crit.len(), 2);
55+
}
56+
57+
#[test]
58+
fn query_filters_by_time_range() {
59+
let mut history = AlertHistory::new();
60+
for i in 0..10i64 {
61+
history.record(event(&format!("r{i}"), AlertSeverity::Info, 100 + i, 0));
62+
}
63+
let result = history.query(&HistoryQuery {
64+
from: Some(Utc.timestamp_opt(103, 0).unwrap()),
65+
to: Some(Utc.timestamp_opt(106, 0).unwrap()),
66+
..Default::default()
67+
});
68+
// inclusive 103..=106 → 4 events
69+
assert_eq!(result.len(), 4);
70+
}
71+
72+
#[test]
73+
fn combined_filters_are_anded() {
74+
let mut history = AlertHistory::new();
75+
history.record(event("a", AlertSeverity::Critical, 10, 0));
76+
history.record(event("a", AlertSeverity::Info, 11, 0));
77+
history.record(event("b", AlertSeverity::Critical, 12, 0));
78+
79+
let result = history.query(&HistoryQuery {
80+
rule_id: Some("a".to_string()),
81+
severity: Some(AlertSeverity::Critical),
82+
..Default::default()
83+
});
84+
assert_eq!(result.len(), 1);
85+
assert_eq!(result[0].triggered_at.timestamp(), 10);
86+
}
87+
88+
#[test]
89+
fn export_json_round_trips() {
90+
let mut history = AlertHistory::new();
91+
history.record(event("a", AlertSeverity::Warning, 1, 42));
92+
let json = history.export_json().unwrap();
93+
let parsed: Vec<AlertEvent> = serde_json::from_str(&json).unwrap();
94+
assert_eq!(parsed.len(), 1);
95+
assert_eq!(parsed[0].current_value, 42);
96+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//! Integration test for the full alert pipeline (#642).
2+
//!
3+
//! simulation source → spike transformer → alerting engine.
4+
//! Asserts an alert is fired and recorded in history.
5+
6+
use stellar_devkit::alerting::{
7+
AlertCondition, AlertEngine, AlertRegistry, AlertRule, FeeSnapshot, StdoutDispatcher,
8+
};
9+
use stellar_devkit::streaming::transformer::FeeEvent as StreamFeeEvent;
10+
use stellar_devkit::streaming::{FeeRecord, SpikeDetectionTransformer, SpikeTransformerEvent};
11+
12+
/// Deterministic simulation source: every 5th record is a large fee (spike).
13+
fn simulation_source(n: u64) -> Vec<FeeRecord> {
14+
(0..n)
15+
.map(|i| FeeRecord {
16+
fee_amount: if i % 5 == 0 { 900 } else { 200 },
17+
ledger_sequence: i,
18+
timestamp_ms: 1_700_000_000_000 + i as i64,
19+
transaction_hash: None,
20+
is_spike: false,
21+
created_at: "2026-01-01T00:00:00Z".to_string(),
22+
})
23+
.collect()
24+
}
25+
26+
#[tokio::test]
27+
async fn full_pipeline_fires_and_records_alert() {
28+
// 1. Source
29+
let source = simulation_source(20);
30+
31+
// 2. Spike transformer (baseline 200, 2× threshold → >400 is a spike)
32+
let transformer = SpikeDetectionTransformer::new(200, 2.0);
33+
let spike_count = source
34+
.iter()
35+
.filter(|record| {
36+
matches!(
37+
transformer.transform(StreamFeeEvent::NewFeeRecord((*record).clone())),
38+
Some(SpikeTransformerEvent::SpikeDetected(_))
39+
)
40+
})
41+
.count() as u64;
42+
assert!(spike_count > 0, "the source must produce spikes");
43+
44+
// 3. Alerting engine with a SpikeCountExceeds rule
45+
let mut registry = AlertRegistry::new();
46+
registry.add(
47+
AlertRule::new("spike", "spike alert", AlertCondition::SpikeCountExceeds, 2)
48+
.with_cooldown(0),
49+
);
50+
let mut engine = AlertEngine::with_registry(registry);
51+
engine.add_dispatcher(Box::new(StdoutDispatcher::new()));
52+
53+
let snapshot = FeeSnapshot {
54+
base_fee: 900,
55+
spike_count,
56+
..Default::default()
57+
};
58+
59+
let fired = engine.process(&snapshot).await;
60+
61+
// 4. Assertions
62+
assert!(!fired.is_empty(), "an alert should have fired");
63+
assert_eq!(fired[0].rule_id, "spike");
64+
assert_eq!(fired[0].current_value, spike_count);
65+
assert_eq!(
66+
engine.history().len(),
67+
fired.len(),
68+
"fired events are recorded in history"
69+
);
70+
assert_eq!(engine.history().all()[0].rule_id, "spike");
71+
}

0 commit comments

Comments
 (0)