Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

319 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Rule-based incident classifier for parsed Hermes log records.
The classifier consumes :class:`LogRecord` objects in chronological order
and emits :class:`HermesIncident` events. It is intentionally rule-based
(no ML, no LLM) so detection latency is bounded and behavior is auditable.
Rules (each maps to a stable ``rule`` string used for deduplication):
* ``error_severity`` any ``ERROR`` or ``CRITICAL`` record. Severity is
``HIGH`` for ``ERROR`` and ``CRITICAL`` for ``CRITICAL``.
* ``traceback`` a ``Traceback (most recent call last):`` line plus
its continuation frames. Severity ``CRITICAL``. Continuation lines are
attached to the parent until a non-continuation record arrives.
* ``warning_burst`` ``warning_burst_threshold`` ``WARNING`` records
from the same logger within ``warning_burst_window_s``. Severity
``MEDIUM``. The burst is debounced by logger so a single noisy
subsystem fires once per burst rather than once per warning.
The classifier is stateful but thread-safe: one ``threading.Lock`` guards
all bucket mutations. :meth:`observe` is intended to be called from a
single producer thread (the agent's tailer pump); the lock is defensive
and primarily exists so external callers can flush from another thread.
"""
from __future__ import annotations
import hashlib
import re
import threading
from collections import deque
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Final
from integrations.hermes.incident import HermesIncident, IncidentSeverity, LogLevel, LogRecord
from integrations.hermes.rules import PatternRule, RepeatRule, default_pattern_rules
DEFAULT_WARNING_BURST_THRESHOLD: Final[int] = 5
DEFAULT_WARNING_BURST_WINDOW_S: Final[float] = 60.0
DEFAULT_TRACEBACK_FOLLOWUP_S: Final[float] = 5.0
_TRACEBACK_HEADER: Final[str] = "Traceback (most recent call last)"
_IPV4_RE: Final[re.Pattern[str]] = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
_HEX_RE: Final[re.Pattern[str]] = re.compile(r"\b0x[0-9a-fA-F]+\b")
_NUM_RE: Final[re.Pattern[str]] = re.compile(r"\b\d+\b")
_WS_RE: Final[re.Pattern[str]] = re.compile(r"\s+")
@dataclass
class _OpenTraceback:
parent: LogRecord
frames: list[LogRecord]
deadline: datetime
class IncidentClassifier:
"""Stateful classifier that turns log records into incidents."""
__slots__ = (
"_warning_burst_threshold",
"_warning_burst_window",
"_traceback_followup",
"_warning_buckets",
"_open_tracebacks",
"_lock",
"_pattern_rules",
)
def __init__(
self,
*,
warning_burst_threshold: int = DEFAULT_WARNING_BURST_THRESHOLD,
warning_burst_window_s: float = DEFAULT_WARNING_BURST_WINDOW_S,
traceback_followup_s: float = DEFAULT_TRACEBACK_FOLLOWUP_S,
pattern_rules: list[PatternRule | RepeatRule] | None = None,
use_default_pattern_rules: bool = True,
) -> None:
if warning_burst_threshold < 2:
raise ValueError("warning_burst_threshold must be >= 2")
if warning_burst_window_s <= 0:
raise ValueError("warning_burst_window_s must be > 0")
if traceback_followup_s < 0:
raise ValueError("traceback_followup_s must be >= 0")
self._warning_burst_threshold = warning_burst_threshold
self._warning_burst_window = timedelta(seconds=warning_burst_window_s)
self._traceback_followup = timedelta(seconds=traceback_followup_s)
self._warning_buckets: dict[str, deque[LogRecord]] = {}
self._open_tracebacks: dict[str, _OpenTraceback] = {}
self._lock = threading.Lock()
rules: list[PatternRule | RepeatRule] = []
if use_default_pattern_rules:
rules.extend(default_pattern_rules())
if pattern_rules:
rules.extend(_clone_rule(rule) for rule in pattern_rules)
self._pattern_rules = rules
def observe(self, record: LogRecord) -> list[HermesIncident]:
"""Feed a single record; return any incidents triggered by it.
The order of incidents matches the order rules are evaluated
(traceback close > severity > warning burst).
"""
incidents: list[HermesIncident] = []
with self._lock:
incidents.extend(self._collect_finalized_tracebacks(record.timestamp))
if record.is_continuation:
self._extend_open_tracebacks(record)
return incidents
traceback_incident = self._maybe_open_or_finalize_traceback(record)
if traceback_incident is not None:
incidents.append(traceback_incident)
severity_incident = self._maybe_emit_severity(record)
if severity_incident is not None:
incidents.append(severity_incident)
burst_incident = self._maybe_emit_warning_burst(record)
if burst_incident is not None:
incidents.append(burst_incident)
for rule in self._pattern_rules:
pattern_incident = rule.evaluate(record)
if pattern_incident is not None:
incidents.append(pattern_incident)
return incidents
def flush(self, *, now: datetime | None = None) -> list[HermesIncident]:
"""Force-emit any buffered tracebacks.
Used at shutdown so a traceback whose continuation frames never
receive a follow-up record still surfaces as an incident.
"""
cutoff = now if now is not None else datetime.max
with self._lock:
return self._collect_finalized_tracebacks(cutoff, force=True)
def _maybe_emit_severity(self, record: LogRecord) -> HermesIncident | None:
if record.level.severity_rank < LogLevel.ERROR.severity_rank:
return None
# Traceback headers are handled exclusively by
# _maybe_open_or_finalize_traceback which will emit a ``traceback``
# incident (CRITICAL, with frames) once the block is complete.
# Emitting ``error_severity`` for the same record would create two
# separate incidents for every Python exception — different
# fingerprints, different dedup buckets — resulting in duplicate
# Telegram notifications and two concurrent RCA investigation calls.
if _looks_like_traceback_header(record):
return None
severity = (
IncidentSeverity.CRITICAL
if record.level is LogLevel.CRITICAL
else IncidentSeverity.HIGH
)
return HermesIncident(
rule="error_severity",
severity=severity,
title=f"{record.level.value} from {record.logger or 'unknown'}",
detected_at=record.timestamp,
logger=record.logger,
fingerprint=_fingerprint(
"error_severity",
record.logger,
_message_signature(record.message),
),
records=(record,),
run_id=record.run_id,
)
def _maybe_emit_warning_burst(self, record: LogRecord) -> HermesIncident | None:
if record.level is not LogLevel.WARNING or not record.logger:
return None
bucket = self._warning_buckets.setdefault(record.logger, deque())
bucket.append(record)
cutoff = record.timestamp - self._warning_burst_window
while bucket and bucket[0].timestamp < cutoff:
bucket.popleft()
if len(bucket) < self._warning_burst_threshold:
return None
# Drain the bucket on emit so the next burst requires a fresh
# threshold's worth of warnings rather than re-firing every line.
contributing = tuple(bucket)
bucket.clear()
return HermesIncident(
rule="warning_burst",
severity=IncidentSeverity.MEDIUM,
title=(
f"{len(contributing)} warnings from {record.logger} "
f"in {self._warning_burst_window.total_seconds():.0f}s"
),
detected_at=record.timestamp,
logger=record.logger,
fingerprint=_fingerprint("warning_burst", record.logger, ""),
records=contributing,
run_id=record.run_id,
)
def _maybe_open_or_finalize_traceback(self, record: LogRecord) -> HermesIncident | None:
# Close the open traceback for this logger only when a new
# non-continuation record arrives from the *same* logger; that's
# the python-logging signal that the traceback's frames are done.
if not record.logger:
return None
finalized: HermesIncident | None = None
existing = self._open_tracebacks.pop(record.logger, None)
if existing is not None:
finalized = _build_traceback_incident(existing)
if _looks_like_traceback_header(record):
self._open_tracebacks[record.logger] = _OpenTraceback(
parent=record,
frames=[],
deadline=record.timestamp + self._traceback_followup,
)
return finalized
def _extend_open_tracebacks(self, record: LogRecord) -> None:
# Continuations don't carry a logger, so attach to every open
# traceback. In practice Hermes writes one traceback at a time,
# so this is at most one entry; the loop is defensive.
for state in self._open_tracebacks.values():
state.frames.append(record)
def _collect_finalized_tracebacks(
self,
now: datetime,
*,
force: bool = False,
) -> list[HermesIncident]:
if not self._open_tracebacks:
return []
emitted: list[HermesIncident] = []
for logger_name in list(self._open_tracebacks):
state = self._open_tracebacks[logger_name]
if not force and now < state.deadline:
continue
del self._open_tracebacks[logger_name]
emitted.append(_build_traceback_incident(state))
return emitted
def classify_all(records: Iterable[LogRecord]) -> list[HermesIncident]:
"""Convenience: run a fresh classifier over a finite record stream."""
classifier = IncidentClassifier()
incidents: list[HermesIncident] = []
for record in records:
incidents.extend(classifier.observe(record))
incidents.extend(classifier.flush())
return incidents
def _looks_like_traceback_header(record: LogRecord) -> bool:
return _TRACEBACK_HEADER in record.message
def _build_traceback_incident(state: _OpenTraceback) -> HermesIncident:
records = (state.parent, *state.frames)
return HermesIncident(
rule="traceback",
severity=IncidentSeverity.CRITICAL,
title=f"Traceback in {state.parent.logger}",
detected_at=state.parent.timestamp,
logger=state.parent.logger,
fingerprint=_fingerprint("traceback", state.parent.logger, state.parent.message),
records=records,
run_id=state.parent.run_id,
)
def _fingerprint(rule: str, logger_name: str, message: str) -> str:
digest = hashlib.sha256(f"{rule}|{logger_name}|{message}".encode())
return digest.hexdigest()[:16]
def _message_signature(message: str) -> str:
"""Normalize volatile values so dedup keys stay stable across retries."""
normalized = message.lower()
normalized = _IPV4_RE.sub("<ip>", normalized)
normalized = _HEX_RE.sub("<hex>", normalized)
normalized = _NUM_RE.sub("<num>", normalized)
normalized = _WS_RE.sub(" ", normalized).strip()
return normalized[:120]
def _clone_rule(rule: PatternRule | RepeatRule) -> PatternRule | RepeatRule:
"""Return an equivalent rule instance with isolated mutable state.
PatternRule is immutable so reuse is safe. RepeatRule carries mutable
per-logger hit buckets; cloning prevents accidental cross-classifier
state sharing when callers pass the same rule instance to multiple
IncidentClassifier objects.
"""
if isinstance(rule, PatternRule):
return rule
return RepeatRule(
name=rule.name,
severity=rule.severity,
title_template=rule.title_template,
patterns=rule.patterns,
threshold=rule.threshold,
window=rule.window,
)
__all__ = [
"DEFAULT_TRACEBACK_FOLLOWUP_S",
"DEFAULT_WARNING_BURST_THRESHOLD",
"DEFAULT_WARNING_BURST_WINDOW_S",
"IncidentClassifier",
"classify_all",
]