tracer-cloud--opensre
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
319 行
12 KiB
Python
319 行
12 KiB
Python
"""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",
|
|
]
|