Skip to content

Commit 65350bc

Browse files
authored
Merge pull request #13 from stacknil/codex/issue-12-cooldown-keying
[codex] refine alert cooldown scope
2 parents 7fa2b9a + 4fcbf5b commit 65350bc

3 files changed

Lines changed: 108 additions & 8 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ With the bundled sample data, the default run currently produces:
4747

4848
- `41` normalized events
4949
- `24` windows
50-
- `12` alerts after applying a `60` second per-rule cooldown
50+
- `12` alerts after applying a `60` second cooldown
5151

52-
The default config suppresses repeated alerts with the same `rule_name` until `60` seconds have elapsed since that rule's last emitted alert. Different rules can still alert on the same window.
52+
The default config suppresses repeated alerts by cooldown key. The key is `rule_name` plus an entity scope when the rule input includes `entity`, `source`, `target`, or `host`; otherwise it falls back to `rule_name` alone. Different cooldown keys can still alert on the same window.
5353

5454
## Outputs
5555

src/telemetry_window_demo/rules.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"window_end",
1515
"message",
1616
)
17+
COOLDOWN_SCOPE_COLUMNS = ("entity", "source", "target", "host")
1718

1819

1920
def apply_rules(
@@ -46,7 +47,7 @@ def apply_rules(
4647
if not alerts:
4748
return pd.DataFrame(columns=ALERT_COLUMNS)
4849

49-
alerts_frame = pd.DataFrame(alerts, columns=ALERT_COLUMNS)
50+
alerts_frame = pd.DataFrame(alerts)
5051
alerts_frame = alerts_frame.sort_values(["alert_time", "rule_name"]).reset_index(drop=True)
5152
return _apply_alert_cooldown(alerts_frame, cooldown_seconds)
5253

@@ -56,6 +57,7 @@ def _row_alert(
5657
rule_name: str,
5758
severity: str,
5859
message: str,
60+
cooldown_scope: str | None = None,
5961
) -> dict[str, object]:
6062
return {
6163
"alert_time": row["window_end"],
@@ -64,33 +66,66 @@ def _row_alert(
6466
"window_start": row["window_start"],
6567
"window_end": row["window_end"],
6668
"message": message,
69+
"cooldown_scope": _resolve_cooldown_scope(row, cooldown_scope),
6770
}
6871

6972

73+
def _resolve_cooldown_scope(
74+
row: pd.Series,
75+
explicit_scope: str | None = None,
76+
) -> str | None:
77+
if explicit_scope is not None:
78+
value = explicit_scope.strip()
79+
if value:
80+
return value
81+
82+
for column in COOLDOWN_SCOPE_COLUMNS:
83+
if column not in row.index:
84+
continue
85+
86+
value = row[column]
87+
if pd.isna(value):
88+
continue
89+
90+
value_text = str(value).strip()
91+
if value_text:
92+
return f"{column}={value_text}"
93+
94+
return None
95+
96+
7097
def _apply_alert_cooldown(
7198
alerts: pd.DataFrame,
7299
cooldown_seconds: int,
73100
) -> pd.DataFrame:
74101
if alerts.empty or cooldown_seconds <= 0:
75-
return alerts.reset_index(drop=True)
102+
return alerts.loc[:, ALERT_COLUMNS].reset_index(drop=True)
76103

77-
last_kept_at: dict[str, pd.Timestamp] = {}
104+
last_kept_at: dict[tuple[str, str | None], pd.Timestamp] = {}
78105
kept_rows: list[int] = []
79106

80107
for index, row in alerts.iterrows():
81108
rule_name = str(row["rule_name"])
82109
alert_time = pd.Timestamp(row["alert_time"])
83-
last_alert_time = last_kept_at.get(rule_name)
110+
scope_value = row.get("cooldown_scope")
111+
if pd.isna(scope_value):
112+
scope = None
113+
else:
114+
scope_text = str(scope_value).strip()
115+
scope = scope_text or None
116+
117+
cooldown_key = (rule_name, scope)
118+
last_alert_time = last_kept_at.get(cooldown_key)
84119

85120
if last_alert_time is None:
86121
kept_rows.append(index)
87-
last_kept_at[rule_name] = alert_time
122+
last_kept_at[cooldown_key] = alert_time
88123
continue
89124

90125
elapsed = (alert_time - last_alert_time).total_seconds()
91126
if elapsed >= cooldown_seconds:
92127
kept_rows.append(index)
93-
last_kept_at[rule_name] = alert_time
128+
last_kept_at[cooldown_key] = alert_time
94129

95130
return alerts.loc[kept_rows, ALERT_COLUMNS].reset_index(drop=True)
96131

tests/test_rules.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,71 @@ def test_apply_rules_suppresses_repeated_same_rule_within_cooldown() -> None:
139139
]
140140

141141

142+
def test_apply_rules_scopes_same_rule_cooldown_by_source_when_present() -> None:
143+
features = pd.DataFrame(
144+
[
145+
{
146+
"window_start": pd.Timestamp("2026-03-10T10:00:00Z"),
147+
"window_end": pd.Timestamp("2026-03-10T10:01:00Z"),
148+
"source": "host_a",
149+
"event_count": 10,
150+
"error_count": 4,
151+
"error_rate": 0.40,
152+
"unique_sources": 4,
153+
"unique_targets": 2,
154+
"high_severity_count": 0,
155+
"login_fail_count": 0,
156+
"malware_alert_count": 0,
157+
},
158+
{
159+
"window_start": pd.Timestamp("2026-03-10T10:00:10Z"),
160+
"window_end": pd.Timestamp("2026-03-10T10:01:10Z"),
161+
"source": "host_b",
162+
"event_count": 11,
163+
"error_count": 5,
164+
"error_rate": 0.45,
165+
"unique_sources": 5,
166+
"unique_targets": 2,
167+
"high_severity_count": 0,
168+
"login_fail_count": 0,
169+
"malware_alert_count": 0,
170+
},
171+
{
172+
"window_start": pd.Timestamp("2026-03-10T10:00:20Z"),
173+
"window_end": pd.Timestamp("2026-03-10T10:01:20Z"),
174+
"source": "host_a",
175+
"event_count": 12,
176+
"error_count": 6,
177+
"error_rate": 0.50,
178+
"unique_sources": 6,
179+
"unique_targets": 2,
180+
"high_severity_count": 0,
181+
"login_fail_count": 0,
182+
"malware_alert_count": 0,
183+
},
184+
]
185+
)
186+
187+
alerts = apply_rules(
188+
features,
189+
{
190+
"cooldown_seconds": 60,
191+
"high_error_rate": {"threshold": 0.30, "severity": "medium"},
192+
"persistent_high_error": {
193+
"threshold": 1.0,
194+
"consecutive_windows": 10,
195+
"severity": "medium",
196+
},
197+
},
198+
)
199+
200+
assert list(alerts["rule_name"]) == ["high_error_rate", "high_error_rate"]
201+
assert list(alerts["alert_time"]) == [
202+
pd.Timestamp("2026-03-10T10:01:00Z"),
203+
pd.Timestamp("2026-03-10T10:01:10Z"),
204+
]
205+
206+
142207
def test_apply_rules_keeps_different_rules_during_same_cooldown_window() -> None:
143208
features = pd.DataFrame(
144209
[

0 commit comments

Comments
 (0)