aboutsummaryrefslogtreecommitdiffstats
path: root/pylogsentinel/core.py
diff options
context:
space:
mode:
authorJan Tuomi <jan@jantuomi.fi>2026-07-21 13:44:06 +0300
committerJan Tuomi <jan@jantuomi.fi>2026-07-21 13:44:47 +0300
commit9c54af24fb787ca505c0b6fc8abcd6f3f43d753a (patch)
treebb619eef10dc580bb852eecf417e112990bce776 /pylogsentinel/core.py
parent415f77f05ae648bb2a2640971f3268e1fae59b06 (diff)
0.4.0: Add match clustering
Diffstat (limited to 'pylogsentinel/core.py')
-rw-r--r--pylogsentinel/core.py81
1 files changed, 66 insertions, 15 deletions
diff --git a/pylogsentinel/core.py b/pylogsentinel/core.py
index 1b6ce13..5ee033e 100644
--- a/pylogsentinel/core.py
+++ b/pylogsentinel/core.py
@@ -67,6 +67,8 @@ class MatchEvent:
line_number: int
context: str
matched_text: str
+ match_count: int = 1
+ matched_lines: str = ""
def parse_size(value: str) -> int:
@@ -516,6 +518,15 @@ def read_new_lines(
return lines, new_offset, new_last_line_number
+def _rule_applies(rule: Rule, file_path: str) -> bool:
+ """Check if a rule applies to the given file path."""
+ for base in rule.log_base_paths:
+ b = base.rstrip(os.sep)
+ if file_path == b or file_path.startswith(b + os.sep):
+ return True
+ return False
+
+
def generate_matches(
file_path: str,
lines: list[str],
@@ -524,33 +535,71 @@ def generate_matches(
context_radius: int = CONTEXT_RADIUS,
) -> Iterator[MatchEvent]:
"""
- Iterate over lines and yield MatchEvent objects for each rule match.
- A rule is only applied if the file belongs to its configured log set
- (file path equal to or under one of the set's base paths).
+ Iterate over lines and yield one MatchEvent per cluster of nearby matches.
+
+ Matches for the same rule are grouped into clusters: consecutive matches
+ whose gap (number of non-matching lines between them) is at most
+ context_radius are merged into one cluster. Each cluster produces a
+ single MatchEvent whose context spans from context_radius lines before
+ the first match to context_radius lines after the last match.
"""
total_lines = len(lines)
+
+ # Collect per-rule match indices in one pass.
+ # Key: rule_id, Value: list of (line_index, matched_text)
+ rule_matches: dict[str, list[tuple[int, str]]] = {}
+
for idx, line in enumerate(lines):
- absolute_line_number = initial_line_number + idx + 1
for rule in rules.values():
- applies = False
- for base in rule.log_base_paths:
- b = base.rstrip(os.sep)
- if file_path == b or file_path.startswith(b + os.sep):
- applies = True
- break
- if not applies:
+ if not _rule_applies(rule, file_path):
continue
m = rule.compiled.search(line)
if not m:
continue
- start_ctx = max(0, idx - context_radius)
- context = "".join(lines[start_ctx : idx + 1])
+ rule_matches.setdefault(rule.rule_id, []).append((idx, m.group(0)))
+
+ # Build clusters per rule and yield one MatchEvent per cluster.
+ for rule in rules.values():
+ matches = rule_matches.get(rule.rule_id)
+ if not matches:
+ continue
+
+ # matches are already in line order; group into clusters.
+ clusters: list[list[tuple[int, str]]] = []
+ current_cluster: list[tuple[int, str]] = [matches[0]]
+
+ for i in range(1, len(matches)):
+ prev_idx = current_cluster[-1][0]
+ curr_idx = matches[i][0]
+ gap = curr_idx - prev_idx - 1 # non-matching lines between
+ if gap <= context_radius:
+ current_cluster.append(matches[i])
+ else:
+ clusters.append(current_cluster)
+ current_cluster = [matches[i]]
+ clusters.append(current_cluster)
+
+ for cluster in clusters:
+ first_idx = cluster[0][0]
+ last_idx = cluster[-1][0]
+
+ ctx_start = max(0, first_idx - context_radius)
+ ctx_end = min(total_lines - 1, last_idx + context_radius)
+ context = "".join(lines[ctx_start : ctx_end + 1])
+
+ first_line_number = initial_line_number + first_idx + 1
+ matched_lines_str = ",".join(
+ str(initial_line_number + idx + 1) for idx, _ in cluster
+ )
+
yield MatchEvent(
rule=rule,
file_path=file_path,
- line_number=absolute_line_number,
+ line_number=first_line_number,
context=context,
- matched_text=m.group(0),
+ matched_text=cluster[0][1],
+ match_count=len(cluster),
+ matched_lines=matched_lines_str,
)
@@ -574,6 +623,8 @@ def execute_action(
"FILE": event.file_path,
"LINE": str(event.line_number),
"CONTEXT": event.context,
+ "MATCH_COUNT": str(event.match_count),
+ "MATCHED_LINES": event.matched_lines,
}
)
if env_overrides: