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

219 lines
7.7 KiB
Python

"""Wrap an :class:`IncidentSink` with correlator-driven dedup & routing.
The :class:`IncidentCorrelator` decides *whether* and *where* an
incident should go; this module adapts that decision into the existing
sink contract (a callable that takes a :class:`HermesIncident`).
Usage::
correlator = IncidentCorrelator()
telegram = make_telegram_sink(dispatcher)
sink = CorrelatingSink(
correlator=correlator,
routes={
RouteDestination.TELEGRAM: telegram,
RouteDestination.TELEGRAM_WITH_RCA: telegram,
},
)
agent = HermesAgent(..., incident_sink=sink)
Incidents bound for :attr:`RouteDestination.DROP` are counted but
never forwarded. Routes that aren't registered are logged at WARNING
once per (rule, destination) pair so misconfiguration is visible under
typical production log thresholds without flooding the logs.
"""
from __future__ import annotations
import logging
import threading
from collections.abc import Callable
from integrations.hermes.correlator import (
CorrelatorDecision,
IncidentCorrelator,
RouteDestination,
)
from integrations.hermes.incident import HermesIncident
logger = logging.getLogger(__name__)
IncidentSinkFn = Callable[[HermesIncident], None]
__all__ = ["CorrelatingSink"]
class CorrelatingSink:
"""Sink wrapper that consults a correlator before dispatching."""
__slots__ = (
"_correlator",
"_routes",
"_default_route",
"_missing_warned",
"_lock",
"_metrics",
)
def __init__(
self,
*,
correlator: IncidentCorrelator,
routes: dict[RouteDestination, IncidentSinkFn],
default_route: IncidentSinkFn | None = None,
) -> None:
self._correlator = correlator
self._routes: dict[RouteDestination, IncidentSinkFn] = dict(routes)
self._default_route = default_route
self._missing_warned: set[tuple[str, RouteDestination]] = set()
self._lock = threading.Lock()
self._metrics: dict[str, int] = {
"delivered": 0,
"suppressed": 0,
"escalated": 0,
"dropped": 0,
"unrouted": 0,
"sink_errors": 0,
}
def __call__(self, incident: HermesIncident) -> None:
decision = self._correlator.correlate(incident)
if decision.suppressed:
self._count_suppressed(decision)
return
if decision.destination is RouteDestination.DROP:
self._count_dropped(decision)
return
sink_fn = self._routes.get(decision.destination, self._default_route)
if sink_fn is None:
self._warn_missing_route(incident.rule, decision.destination)
self._count_unrouted(decision)
return
# Escalated incidents must use a distinct cooldown key so a downstream
# AlarmDispatcher does not silently suppress them under the same
# per-fingerprint cooldown window that already fired for the first
# occurrence. Appending ":escalated" keeps the key stable across
# repeated escalations for the same underlying event.
deliver = (
_with_escalated_fingerprint(decision.deliver)
if decision.escalated_from is not None
else decision.deliver
)
try:
sink_fn(deliver)
# Count as delivered only after the sink call succeeds; a
# raising sink must not inflate the delivered counter.
self._count_delivered(decision)
except Exception: # noqa: BLE001 — sinks must never crash the agent
logger.exception(
"downstream sink raised for incident rule=%s destination=%s",
incident.rule,
decision.destination.value,
)
with self._lock:
self._metrics["sink_errors"] += 1
def close(self) -> None:
"""Close downstream sinks (if supported) and reset correlator state.
Every downstream sink's ``close()`` is called even if an earlier one
raises, and ``_correlator.reset()`` always runs in the finally clause
so :class:`~integrations.hermes.sinks.TelegramSink` thread-pool workers are
never leaked by an exception from a sibling sink.
"""
errors: list[BaseException] = []
seen: set[int] = set()
candidates: list[IncidentSinkFn] = list(self._routes.values())
if self._default_route is not None:
candidates.append(self._default_route)
try:
for sink_fn in candidates:
key = id(sink_fn)
if key in seen:
continue
seen.add(key)
close_fn = getattr(sink_fn, "close", None)
if not callable(close_fn):
continue
try:
close_fn()
except Exception as exc: # noqa: BLE001
logger.exception(
"error closing downstream sink %r during CorrelatingSink.close()",
sink_fn,
)
errors.append(exc)
finally:
self._correlator.reset()
if errors:
# Re-raise the first exception after all sinks have been given a
# chance to close. The correlator is already reset at this point.
raise errors[0]
def metrics_snapshot(self) -> dict[str, int]:
"""Return a copy of the running counters. Useful for ops dashboards.
Keys: ``delivered``, ``suppressed``, ``escalated``, ``dropped``,
``unrouted`` (no handler for destination), ``sink_errors`` (downstream
sink raised).
"""
with self._lock:
return dict(self._metrics)
def _count_suppressed(self, _decision: CorrelatorDecision) -> None:
# Suppressed implies within_dedup with no escalation (see correlator).
with self._lock:
self._metrics["suppressed"] += 1
def _count_dropped(self, decision: CorrelatorDecision) -> None:
with self._lock:
self._metrics["dropped"] += 1
if decision.escalated_from is not None:
self._metrics["escalated"] += 1
def _count_unrouted(self, decision: CorrelatorDecision) -> None:
with self._lock:
self._metrics["unrouted"] += 1
if decision.escalated_from is not None:
self._metrics["escalated"] += 1
def _count_delivered(self, decision: CorrelatorDecision) -> None:
with self._lock:
self._metrics["delivered"] += 1
if decision.escalated_from is not None:
self._metrics["escalated"] += 1
def _warn_missing_route(self, rule: str, destination: RouteDestination) -> None:
key = (rule, destination)
with self._lock:
if key in self._missing_warned:
return
self._missing_warned.add(key)
logger.warning(
"no sink registered for destination=%s (rule=%s); dropping",
destination.value,
rule,
)
def _with_escalated_fingerprint(incident: HermesIncident) -> HermesIncident:
"""Return a copy of *incident* whose fingerprint has an ':escalated' suffix.
This ensures downstream :class:`AlarmDispatcher` instances use a
distinct cooldown bucket for escalated notifications, preventing the
first-occurrence cooldown from silently suppressing the escalation alert.
"""
return HermesIncident(
rule=incident.rule,
severity=incident.severity,
title=incident.title,
detected_at=incident.detected_at,
logger=incident.logger,
fingerprint=f"{incident.fingerprint}:escalated",
records=incident.records,
run_id=incident.run_id,
)