Skip to content

Commit 4d07f64

Browse files
authored
fix: co-int/str for CELs (#5109)
1 parent 956ba15 commit 4d07f64

File tree

3 files changed

+225
-7
lines changed

3 files changed

+225
-7
lines changed

keep/rulesengine/rulesengine.py

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,9 @@ def _sanitize_dict(d):
443443
return sanitized
444444

445445
def _check_if_rule_apply(self, rule: Rule, event: AlertDto) -> List[str]:
446+
"""
447+
Evaluates if a rule applies to an event using CEL. Handles type coercion for ==/!= between int and str.
448+
"""
446449
sub_rules = self._extract_subrules(rule.definition_cel)
447450
payload = event.dict()
448451
# workaround since source is a list
@@ -474,12 +477,77 @@ def _check_if_rule_apply(self, rule: Rule, event: AlertDto) -> List[str]:
474477
if "no such member" in str(e):
475478
continue
476479
# unknown
480+
# --- Fix for https://github.com/keephq/keep/issues/5107 ---
481+
if "no such overload" in str(e) or "found no matching overload" in str(
482+
e
483+
):
484+
try:
485+
coerced = self._coerce_eq_type_error(
486+
sub_rule, prgm, activation, event
487+
)
488+
if coerced:
489+
sub_rules_matched.append(sub_rule)
490+
continue
491+
except Exception:
492+
pass
477493
raise
478494
if r:
479495
sub_rules_matched.append(sub_rule)
480496
# no subrules matched
481497
return sub_rules_matched
482498

499+
def _coerce_eq_type_error(self, cel, prgm, activation, alert):
500+
"""
501+
Helper for type coercion fallback for ==/!= between int and str in CEL.
502+
Fixes https://github.com/keephq/keep/issues/5107
503+
"""
504+
import re
505+
506+
m = re.match(r"([a-zA-Z0-9_\.]+)\s*([!=]=)\s*(.+)", cel)
507+
if not m:
508+
return False
509+
left, op, right = m.groups()
510+
left = left.strip()
511+
right = (
512+
right.strip().strip('"')
513+
if right.strip().startswith('"') and right.strip().endswith('"')
514+
else right.strip()
515+
)
516+
try:
517+
518+
def get_nested(d, path):
519+
for part in path.split("."):
520+
if isinstance(d, dict):
521+
d = d.get(part)
522+
else:
523+
return None
524+
return d
525+
526+
left_val = get_nested(activation, left)
527+
try:
528+
right_val = int(right)
529+
except Exception:
530+
try:
531+
right_val = float(right)
532+
except Exception:
533+
right_val = right
534+
# If one is str and the other is int/float, compare as str
535+
if (isinstance(left_val, (int, float)) and isinstance(right_val, str)) or (
536+
isinstance(left_val, str) and isinstance(right_val, (int, float))
537+
):
538+
if op == "==":
539+
return str(left_val) == str(right_val)
540+
else:
541+
return str(left_val) != str(right_val)
542+
# Also handle both as str for robustness
543+
if op == "==":
544+
return str(left_val) == str(right_val)
545+
else:
546+
return str(left_val) != str(right_val)
547+
except Exception:
548+
pass
549+
return False
550+
483551
def _calc_rule_fingerprint(self, event: AlertDto, rule: Rule) -> list[list[str]]:
484552
# extract all the grouping criteria from the event
485553
# e.g. if the grouping criteria is ["event.labels.queue", "event.labels.cluster"]
@@ -639,12 +707,19 @@ def filter_alerts(
639707
if "no such member" in str(e):
640708
continue
641709
# unknown
642-
elif "no such overload" in str(e):
643-
logger.debug(
644-
f"Type mismtach between operator and operand in the CEL expression {cel} for alert {alert.id}"
645-
)
646-
continue
647-
elif "found no matching overload" in str(e):
710+
elif "no such overload" in str(
711+
e
712+
) or "found no matching overload" in str(e):
713+
# Try type coercion for == and !=
714+
try:
715+
coerced = self._coerce_eq_type_error(
716+
cel, prgm, activation, alert
717+
)
718+
if coerced:
719+
filtered_alerts.append(alert)
720+
continue
721+
except Exception:
722+
pass
648723
logger.debug(
649724
f"Type mismtach between operator and operand in the CEL expression {cel} for alert {alert.id}"
650725
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "keep"
3-
version = "0.45.8"
3+
version = "0.45.9"
44
description = "Alerting. for developers, by developers."
55
authors = ["Keep Alerting LTD"]
66
packages = [{include = "keep"}]

tests/test_alert_evaluation.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,3 +931,146 @@ def test_state_alerts_flapping(db_session):
931931
assert alert.status == AlertStatus.PENDING
932932
else:
933933
assert alert.status == AlertStatus.FIRING
934+
935+
936+
def test_cel_equality_int_str_type_coercion(db_session):
937+
"""
938+
Reproduce the bug: CEL 'field == "2"' should match payload {"field": 2} and vice versa.
939+
"""
940+
from keep.api.models.alert import AlertDto
941+
from keep.rulesengine.rulesengine import RulesEngine
942+
943+
# Case 1: field is int, CEL checks for string
944+
alert1 = AlertDto(id="a1", name="test", field=2, fingerprint="fp1")
945+
cel1 = 'field == "2"'
946+
engine = RulesEngine()
947+
result1 = engine.filter_alerts([alert1], cel1)
948+
print(f"Case 1 result: {result1}")
949+
assert len(result1) == 1, "CEL 'field == \"2\"' should match payload {field: 2}"
950+
951+
# Case 2: field is str, CEL checks for int
952+
alert2 = AlertDto(id="a2", name="test", field="2", fingerprint="fp2")
953+
cel2 = "field == 2"
954+
result2 = engine.filter_alerts([alert2], cel2)
955+
print(f"Case 2 result: {result2}")
956+
assert len(result2) == 1, "CEL 'field == 2' should match payload {field: '2'}"
957+
958+
# Case 3: field is int, CEL checks for int (should match)
959+
alert3 = AlertDto(id="a3", name="test", field=2, fingerprint="fp3")
960+
cel3 = "field == 2"
961+
result3 = engine.filter_alerts([alert3], cel3)
962+
assert len(result3) == 1
963+
964+
# Case 4: field is str, CEL checks for str (should match)
965+
alert4 = AlertDto(id="a4", name="test", field="2", fingerprint="fp4")
966+
cel4 = 'field == "2"'
967+
result4 = engine.filter_alerts([alert4], cel4)
968+
assert len(result4) == 1
969+
970+
971+
def test_check_if_rule_apply_int_str_type_coercion(db_session):
972+
"""
973+
Test that _check_if_rule_apply handles type coercion between int and str in CEL expressions.
974+
This reproduces the same bug as test_cel_equality_int_str_type_coercion but for rule evaluation.
975+
"""
976+
from datetime import datetime
977+
978+
from keep.api.core.dependencies import SINGLE_TENANT_UUID
979+
from keep.api.models.alert import AlertDto
980+
from keep.api.models.db.rule import Rule
981+
from keep.rulesengine.rulesengine import RulesEngine
982+
983+
# Create a test rule with CEL expression that checks for string equality with int payload
984+
rule = Rule(
985+
id="test-rule-1",
986+
tenant_id=SINGLE_TENANT_UUID,
987+
name="Test Rule - Int Str Coercion",
988+
definition_cel='field == "2"', # CEL checks for string "2"
989+
definition={},
990+
timeframe=60,
991+
timeunit="seconds",
992+
created_by="[email protected]",
993+
creation_time=datetime.utcnow(),
994+
grouping_criteria=[],
995+
threshold=1,
996+
)
997+
998+
engine = RulesEngine(tenant_id=SINGLE_TENANT_UUID)
999+
1000+
# Case 1: field is int (2), CEL checks for string ("2") - should match
1001+
alert1 = AlertDto(id="a1", name="test", field=2, fingerprint="fp1", source=["test"])
1002+
matched_rules1 = engine._check_if_rule_apply(rule, alert1)
1003+
print(f"Case 1 - field=2, CEL='field == \"2\"': matched_rules={matched_rules1}")
1004+
assert (
1005+
len(matched_rules1) == 1
1006+
), "Rule with 'field == \"2\"' should match alert with field=2"
1007+
1008+
# Case 2: field is string ("2"), CEL checks for int (2) - should match
1009+
rule2 = Rule(
1010+
id="test-rule-2",
1011+
tenant_id=SINGLE_TENANT_UUID,
1012+
name="Test Rule - Str Int Coercion",
1013+
definition_cel="field == 2", # CEL checks for int 2
1014+
definition={},
1015+
timeframe=60,
1016+
timeunit="seconds",
1017+
created_by="[email protected]",
1018+
creation_time=datetime.utcnow(),
1019+
grouping_criteria=[],
1020+
threshold=1,
1021+
)
1022+
1023+
alert2 = AlertDto(
1024+
id="a2", name="test", field="2", fingerprint="fp2", source=["test"]
1025+
)
1026+
matched_rules2 = engine._check_if_rule_apply(rule2, alert2)
1027+
print(f"Case 2 - field='2', CEL='field == 2': matched_rules={matched_rules2}")
1028+
assert (
1029+
len(matched_rules2) == 1
1030+
), "Rule with 'field == 2' should match alert with field='2'"
1031+
1032+
# Case 3: field is int (2), CEL checks for int (2) - should match
1033+
rule3 = Rule(
1034+
id="test-rule-3",
1035+
tenant_id=SINGLE_TENANT_UUID,
1036+
name="Test Rule - Int Int",
1037+
definition_cel="field == 2", # CEL checks for int 2
1038+
definition={},
1039+
timeframe=60,
1040+
timeunit="seconds",
1041+
created_by="[email protected]",
1042+
creation_time=datetime.utcnow(),
1043+
grouping_criteria=[],
1044+
threshold=1,
1045+
)
1046+
1047+
alert3 = AlertDto(id="a3", name="test", field=2, fingerprint="fp3", source=["test"])
1048+
matched_rules3 = engine._check_if_rule_apply(rule3, alert3)
1049+
print(f"Case 3 - field=2, CEL='field == 2': matched_rules={matched_rules3}")
1050+
assert (
1051+
len(matched_rules3) == 1
1052+
), "Rule with 'field == 2' should match alert with field=2"
1053+
1054+
# Case 4: field is string ("2"), CEL checks for string ("2") - should match
1055+
rule4 = Rule(
1056+
id="test-rule-4",
1057+
tenant_id=SINGLE_TENANT_UUID,
1058+
name="Test Rule - Str Str",
1059+
definition_cel='field == "2"', # CEL checks for string "2"
1060+
definition={},
1061+
timeframe=60,
1062+
timeunit="seconds",
1063+
created_by="[email protected]",
1064+
creation_time=datetime.utcnow(),
1065+
grouping_criteria=[],
1066+
threshold=1,
1067+
)
1068+
1069+
alert4 = AlertDto(
1070+
id="a4", name="test", field="2", fingerprint="fp4", source=["test"]
1071+
)
1072+
matched_rules4 = engine._check_if_rule_apply(rule4, alert4)
1073+
print(f"Case 4 - field='2', CEL='field == \"2\"': matched_rules={matched_rules4}")
1074+
assert (
1075+
len(matched_rules4) == 1
1076+
), "Rule with 'field == \"2\"' should match alert with field='2'"

0 commit comments

Comments
 (0)