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
267 lines
9.4 KiB
Python
267 lines
9.4 KiB
Python
"""Incident correlation, deduplication, escalation, and routing.
|
|
|
|
The classifier emits raw incidents; the correlator decides:
|
|
|
|
* Have we already paged on this fingerprint recently? (**dedup**)
|
|
* Are the same fingerprint or related rules firing repeatedly?
|
|
Escalate severity. (**escalation**)
|
|
* Which sink should this incident go to? (**routing**)
|
|
|
|
Design goals:
|
|
|
|
* Pure in-memory; no external store. Operators that want durable
|
|
dedup across restarts can subclass and override :meth:`_seen`.
|
|
* Thread-safe (single lock guards bucket state). Cheap: the hot path
|
|
is two dict lookups and a deque trim.
|
|
* Composable with the existing TelegramSink. The correlator is the
|
|
layer *between* classifier and sink; it doesn't replace either.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from collections import deque
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta
|
|
from enum import StrEnum
|
|
from typing import Final
|
|
|
|
from integrations.hermes.incident import HermesIncident, IncidentSeverity
|
|
|
|
DEFAULT_DEDUP_WINDOW_S: Final[float] = 300.0 # 5 minutes
|
|
DEFAULT_ESCALATION_WINDOW_S: Final[float] = 600.0 # 10 minutes
|
|
DEFAULT_ESCALATION_THRESHOLD: Final[int] = 3
|
|
_SEVERITY_ORDER: Final[tuple[IncidentSeverity, ...]] = (
|
|
IncidentSeverity.LOW,
|
|
IncidentSeverity.MEDIUM,
|
|
IncidentSeverity.HIGH,
|
|
IncidentSeverity.CRITICAL,
|
|
)
|
|
|
|
|
|
class RouteDestination(StrEnum):
|
|
"""Routing targets a correlator may pick for an incident."""
|
|
|
|
DROP = "drop"
|
|
TELEGRAM = "telegram"
|
|
TELEGRAM_WITH_RCA = "telegram_with_rca"
|
|
PAGER = "pager"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CorrelatorDecision:
|
|
"""Result of correlating one classifier-emitted incident.
|
|
|
|
``deliver`` is the (possibly mutated) incident the sink should
|
|
publish — severity may have been raised by escalation, and
|
|
``records`` may have been augmented with prior occurrences.
|
|
``destination`` tells the dispatcher where to send it.
|
|
``suppressed`` is True when the incident was deduplicated and the
|
|
dispatcher should drop it; ``deliver`` is still populated so
|
|
callers can log what was suppressed.
|
|
"""
|
|
|
|
deliver: HermesIncident
|
|
destination: RouteDestination
|
|
suppressed: bool
|
|
repeat_count: int
|
|
escalated_from: IncidentSeverity | None
|
|
|
|
|
|
@dataclass
|
|
class _FingerprintState:
|
|
last_seen: datetime
|
|
timestamps: deque[datetime] = field(default_factory=deque)
|
|
|
|
|
|
def default_routing_matrix() -> dict[str, RouteDestination]:
|
|
"""Default rule → destination map.
|
|
|
|
Rules not present in the matrix fall through to
|
|
:attr:`RouteDestination.TELEGRAM` for HIGH/CRITICAL and
|
|
:attr:`RouteDestination.DROP` for everything else.
|
|
"""
|
|
return {
|
|
# Structural rules
|
|
"error_severity": RouteDestination.TELEGRAM_WITH_RCA,
|
|
"traceback": RouteDestination.TELEGRAM_WITH_RCA,
|
|
"warning_burst": RouteDestination.TELEGRAM,
|
|
# Pattern rules
|
|
"oom_killed": RouteDestination.TELEGRAM_WITH_RCA,
|
|
"crash_loop": RouteDestination.PAGER,
|
|
"context_window_exceeded": RouteDestination.TELEGRAM,
|
|
"auth_failure": RouteDestination.TELEGRAM_WITH_RCA,
|
|
"rate_limit": RouteDestination.TELEGRAM,
|
|
"database_wal_growth": RouteDestination.TELEGRAM_WITH_RCA,
|
|
"deadlock": RouteDestination.PAGER,
|
|
"disk_full": RouteDestination.PAGER,
|
|
}
|
|
|
|
|
|
class IncidentCorrelator:
|
|
"""Dedup, escalate, and route classifier-emitted incidents."""
|
|
|
|
__slots__ = (
|
|
"_dedup_window",
|
|
"_escalation_window",
|
|
"_escalation_threshold",
|
|
"_routing_matrix",
|
|
"_state",
|
|
"_lock",
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
dedup_window_s: float = DEFAULT_DEDUP_WINDOW_S,
|
|
escalation_window_s: float = DEFAULT_ESCALATION_WINDOW_S,
|
|
escalation_threshold: int = DEFAULT_ESCALATION_THRESHOLD,
|
|
routing_matrix: dict[str, RouteDestination] | None = None,
|
|
) -> None:
|
|
if dedup_window_s < 0:
|
|
raise ValueError("dedup_window_s must be >= 0")
|
|
if escalation_window_s <= 0:
|
|
raise ValueError("escalation_window_s must be > 0")
|
|
if escalation_threshold < 2:
|
|
raise ValueError("escalation_threshold must be >= 2")
|
|
self._dedup_window = timedelta(seconds=dedup_window_s)
|
|
self._escalation_window = timedelta(seconds=escalation_window_s)
|
|
self._escalation_threshold = escalation_threshold
|
|
self._routing_matrix = routing_matrix or default_routing_matrix()
|
|
self._state: dict[str, _FingerprintState] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
def correlate(self, incident: HermesIncident) -> CorrelatorDecision:
|
|
"""Apply dedup, escalation, and routing to a single incident."""
|
|
with self._lock:
|
|
state = self._state.get(incident.fingerprint)
|
|
now = incident.detected_at
|
|
stale_cutoff = now - max(self._dedup_window, self._escalation_window)
|
|
|
|
if state is None:
|
|
state = _FingerprintState(
|
|
last_seen=now,
|
|
timestamps=deque([now]),
|
|
)
|
|
self._state[incident.fingerprint] = state
|
|
destination = self._route(incident.rule, incident.severity)
|
|
self._evict_stale(stale_cutoff)
|
|
return CorrelatorDecision(
|
|
deliver=incident,
|
|
destination=destination,
|
|
suppressed=False,
|
|
repeat_count=1,
|
|
escalated_from=None,
|
|
)
|
|
|
|
state.timestamps.append(now)
|
|
self._trim(state.timestamps, now - self._escalation_window)
|
|
repeat_count = len(state.timestamps)
|
|
|
|
within_dedup = (now - state.last_seen) < self._dedup_window
|
|
state.last_seen = now
|
|
|
|
escalated_from: IncidentSeverity | None = None
|
|
effective_severity = incident.severity
|
|
if repeat_count >= self._escalation_threshold:
|
|
bumped = _bump_severity(effective_severity)
|
|
if bumped is not effective_severity:
|
|
escalated_from = effective_severity
|
|
effective_severity = bumped
|
|
|
|
delivered = (
|
|
incident
|
|
if effective_severity is incident.severity
|
|
else _with_severity(incident, effective_severity)
|
|
)
|
|
|
|
# Escalated incidents always go through, even inside the dedup
|
|
# window — the whole point of escalation is to break through
|
|
# dedup when the same thing keeps happening.
|
|
suppressed = within_dedup and escalated_from is None
|
|
destination = (
|
|
RouteDestination.DROP
|
|
if suppressed
|
|
else self._route(incident.rule, effective_severity)
|
|
)
|
|
self._evict_stale(stale_cutoff)
|
|
return CorrelatorDecision(
|
|
deliver=delivered,
|
|
destination=destination,
|
|
suppressed=suppressed,
|
|
repeat_count=repeat_count,
|
|
escalated_from=escalated_from,
|
|
)
|
|
|
|
def reset(self) -> None:
|
|
with self._lock:
|
|
self._state.clear()
|
|
|
|
def _evict_stale(self, cutoff: datetime) -> None:
|
|
"""Drop fingerprint rows whose ``last_seen`` predates *cutoff*.
|
|
|
|
Called under ``_lock`` after updating the active row so the current
|
|
incident's fingerprint is never evicted in the same call.
|
|
"""
|
|
stale = [fp for fp, s in self._state.items() if s.last_seen < cutoff]
|
|
for fp in stale:
|
|
del self._state[fp]
|
|
|
|
def _route(self, rule: str, severity: IncidentSeverity) -> RouteDestination:
|
|
explicit = self._routing_matrix.get(rule)
|
|
if explicit is not None:
|
|
# Promote to PAGER when escalation has pushed us to CRITICAL.
|
|
if severity is IncidentSeverity.CRITICAL and explicit is RouteDestination.TELEGRAM:
|
|
return RouteDestination.PAGER
|
|
return explicit
|
|
# Fallback for unknown rules: severity-driven.
|
|
if severity in (IncidentSeverity.HIGH, IncidentSeverity.CRITICAL):
|
|
return RouteDestination.TELEGRAM
|
|
return RouteDestination.DROP
|
|
|
|
@staticmethod
|
|
def _trim(timestamps: deque[datetime], cutoff: datetime) -> None:
|
|
while timestamps and timestamps[0] < cutoff:
|
|
timestamps.popleft()
|
|
|
|
|
|
def correlate_all(
|
|
correlator: IncidentCorrelator,
|
|
incidents: Iterable[HermesIncident],
|
|
) -> list[CorrelatorDecision]:
|
|
"""Convenience: feed a batch of incidents through *correlator*."""
|
|
return [correlator.correlate(inc) for inc in incidents]
|
|
|
|
|
|
__all__ = [
|
|
"CorrelatorDecision",
|
|
"IncidentCorrelator",
|
|
"RouteDestination",
|
|
"default_routing_matrix",
|
|
"correlate_all",
|
|
]
|
|
|
|
|
|
def _bump_severity(current: IncidentSeverity) -> IncidentSeverity:
|
|
try:
|
|
idx = _SEVERITY_ORDER.index(current)
|
|
except ValueError:
|
|
return current
|
|
if idx >= len(_SEVERITY_ORDER) - 1:
|
|
return current
|
|
return _SEVERITY_ORDER[idx + 1]
|
|
|
|
|
|
def _with_severity(incident: HermesIncident, severity: IncidentSeverity) -> HermesIncident:
|
|
return HermesIncident(
|
|
rule=incident.rule,
|
|
severity=severity,
|
|
title=f"[ESCALATED] {incident.title}",
|
|
detected_at=incident.detected_at,
|
|
logger=incident.logger,
|
|
fingerprint=incident.fingerprint,
|
|
records=incident.records,
|
|
run_id=incident.run_id,
|
|
)
|