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

449 lines
18 KiB
Python

"""Incident sinks for the Hermes agent.
The Hermes agent emits :class:`HermesIncident` objects to a pluggable
``IncidentSink`` callable. This module provides the concrete sinks used
in production:
* :class:`TelegramSink` — formats an incident into a human-readable
Telegram message and routes it through :class:`AlarmDispatcher` so
duplicate incidents respect the per-fingerprint cooldown. For
``HIGH``/``CRITICAL`` incidents it can optionally trigger the OpenSRE
investigation pipeline and append the resulting root-cause summary to
the Telegram message before delivery.
* :func:`make_telegram_sink` — convenience factory returning an
:data:`IncidentSink` callable bound to an existing
:class:`AlarmDispatcher` (and optional investigation bridge).
The sink is intentionally *defensive*: any exception raised by the
investigation bridge or by Telegram delivery is logged but does not
re-raise. A buggy bridge must never silently disable incident
notifications.
Investigation bridge execution model
------------------------------------
Investigation calls (LLM round-trips via the investigation agent) can take 30+
seconds. To keep the agent's polling thread responsive — so the
``FileTailer`` keeps reading and the classifier keeps observing during
an investigation — bridge calls are dispatched to a bounded thread pool
and waited on with a configurable timeout. When the investigation
completes within budget its summary is appended to the Telegram body;
otherwise the sink falls back to a clearly-marked notification so the
operator can distinguish:
* **no bridge configured** → no investigation section
* **bridge attempted, returned None** → ``investigation: attempted (no
summary produced)``
* **bridge attempted, raised** → ``investigation: attempted (failed —
see server logs)``
* **bridge attempted, timed out** → ``investigation: attempted (timed
out after N.Ns — see server logs)``
* **bridge attempted, returned summary** → ``investigation summary:`` block
"""
from __future__ import annotations
import logging
import threading
from collections.abc import Callable
from concurrent.futures import CancelledError as FutureCancelledError
from concurrent.futures import Future, ThreadPoolExecutor
from concurrent.futures import TimeoutError as FutureTimeoutError
from dataclasses import dataclass
from typing import Final
from integrations.hermes.agent import IncidentSink
from integrations.hermes.errors import InvestigationOutcome
from integrations.hermes.incident import HermesIncident, IncidentSeverity, LogRecord
from integrations.telegram.alarms import AlarmDispatcher
logger = logging.getLogger(__name__)
# Severities that trigger a full RCA investigation. MEDIUM (warning
# bursts) intentionally short-circuits to a lighter-weight notification:
# bursts are noisy and the marginal investigation rarely surfaces a true
# root cause for them.
_INVESTIGATION_SEVERITIES: Final[frozenset[IncidentSeverity]] = frozenset(
{IncidentSeverity.HIGH, IncidentSeverity.CRITICAL}
)
# Soft cap on how many raw log records we inline into the Telegram body.
# AlarmDispatcher truncates the final payload at the Telegram 4096 char
# limit, but trimming here keeps the message useful instead of having
# half the records cut off mid-traceback.
_MAX_INLINED_RECORDS: Final[int] = 8
_MAX_RECORD_CHARS: Final[int] = 280
_MAX_SUMMARY_CHARS: Final[int] = 1200
# Default thread-pool worker count. The bridge is I/O-bound (LLM
# round-trips) so a small pool is enough; we keep this conservative so a
# burst of HIGH/CRITICAL incidents doesn't fan out into dozens of
# concurrent LLM calls.
_DEFAULT_BRIDGE_WORKERS: Final[int] = 2
# How long the sink waits for an in-flight investigation before giving
# up and falling back to a timeout notice. Tuned to be slightly larger
# than a typical investigation pipeline but well under the
# AlarmDispatcher cooldown (300s default) so a retry on the next
# matching incident gets a fresh shot.
_DEFAULT_BRIDGE_TIMEOUT_S: Final[float] = 45.0
_SEVERITY_EMOJI: Final[dict[IncidentSeverity, str]] = {
IncidentSeverity.LOW: "🟢",
IncidentSeverity.MEDIUM: "🟡",
IncidentSeverity.HIGH: "🟠",
IncidentSeverity.CRITICAL: "🔴",
}
# An investigation bridge is any callable that, given an incident,
# returns a human-readable RCA summary (or ``None`` if it could not
# produce one). Implementations typically wrap ``run_investigation`` and
# extract ``state["summary"]``/``state["root_cause"]``. Returning
# ``None`` rather than raising is the documented contract — the sink
# treats exceptions and ``None`` distinctly (different operator-visible
# markers) and logs the former at WARNING.
InvestigationBridge = Callable[[HermesIncident], str | None]
@dataclass(frozen=True, slots=True)
class TelegramSinkConfig:
"""Optional knobs for :class:`TelegramSink`.
Defaults match the values used in production. The dataclass is
frozen so tests can pass a config instance into the sink without
worrying about cross-test mutation.
"""
max_inlined_records: int = _MAX_INLINED_RECORDS
max_record_chars: int = _MAX_RECORD_CHARS
max_summary_chars: int = _MAX_SUMMARY_CHARS
bridge_timeout_s: float = _DEFAULT_BRIDGE_TIMEOUT_S
bridge_workers: int = _DEFAULT_BRIDGE_WORKERS
# Run bridge synchronously on the calling thread instead of offloading
# to the executor. Used by unit tests that want deterministic
# bridge-call ordering without spinning up a pool.
bridge_run_inline: bool = False
class TelegramSink:
"""Format Hermes incidents and dispatch them to Telegram.
Parameters
----------
dispatcher:
Pre-constructed :class:`AlarmDispatcher`. The sink uses
``dispatch(threshold_name=incident.fingerprint, message=...)`` so
duplicate incidents (same fingerprint) are suppressed by the
dispatcher's cooldown window.
investigation_bridge:
Optional callable invoked for ``HIGH``/``CRITICAL`` incidents.
The call runs in a bounded thread pool with a timeout (see
:class:`TelegramSinkConfig`) so the agent's polling thread is
never blocked for more than ``bridge_timeout_s`` seconds. Its
return value is appended to the Telegram message before
dispatch. Exceptions are caught and replaced with an explicit
marker in the message body.
config:
Optional :class:`TelegramSinkConfig` overriding inline
truncation, bridge timeout, and pool size.
"""
__slots__ = (
"_dispatcher",
"_investigation_bridge",
"_config",
"_bridge_executor",
"_bridge_shutdown",
"_bridge_lock",
)
def __init__(
self,
dispatcher: AlarmDispatcher,
*,
investigation_bridge: InvestigationBridge | None = None,
config: TelegramSinkConfig | None = None,
) -> None:
self._dispatcher = dispatcher
self._investigation_bridge = investigation_bridge
self._config = config if config is not None else TelegramSinkConfig()
# The executor is constructed lazily — only created if a bridge
# is actually configured AND we're not running inline. This
# keeps the no-investigation hot path zero-cost.
self._bridge_executor: ThreadPoolExecutor | None = None
self._bridge_shutdown = False
self._bridge_lock = threading.Lock()
if investigation_bridge is not None and not self._config.bridge_run_inline:
self._bridge_executor = ThreadPoolExecutor(
max_workers=max(1, self._config.bridge_workers),
thread_name_prefix="hermes-bridge",
)
def __call__(self, incident: HermesIncident) -> None:
"""Format the incident and dispatch it. Never raises."""
try:
investigation = self._maybe_investigate(incident)
message = self._format_message(incident, investigation=investigation)
self._dispatcher.dispatch(incident.fingerprint, message)
except Exception:
# The Hermes agent already guards sink exceptions in its own
# dispatch loop, but logging here gives the operator the
# incident metadata that the agent's logger does not have.
logger.exception(
"telegram sink failed: rule=%s severity=%s fingerprint=%s",
incident.rule,
incident.severity.value,
incident.fingerprint,
)
def close(self) -> None:
"""Shut down the bridge executor without blocking the caller.
Safe to call multiple times. This method returns immediately:
* Queued (not-yet-started) futures are cancelled via
``cancel_futures=True`` so they never start after close.
* Already-running bridge calls are left to complete or time
out on their own. They are bounded by ``bridge_timeout_s``
(default 45 s) so they cannot block indefinitely. Using
``wait=False`` prevents ``close()`` from hanging when called
from a SIGTERM handler while a bridge call is in flight.
"""
# Set shutdown first so in-flight ``_run_bridge_in_pool`` paths that
# still hold a future can finish, while new work sees ``sink_closed``
# before racing ``submit`` against ``shutdown``.
with self._bridge_lock:
self._bridge_shutdown = True
executor = self._bridge_executor
if executor is not None:
executor.shutdown(wait=False, cancel_futures=True)
self._bridge_executor = None
# After close, never fall back to `_run_bridge_inline` just because
# the executor handle is None — that path would run investigations
# on the caller thread after shutdown and fight in-flight pool work.
# ------------------------------------------------------------------
# Investigation bridge
def _maybe_investigate(self, incident: HermesIncident) -> _InvestigationResult:
bridge = self._investigation_bridge
if bridge is None:
return _InvestigationResult.not_attempted()
if incident.severity not in _INVESTIGATION_SEVERITIES:
return _InvestigationResult.not_attempted()
if self._bridge_shutdown:
return _InvestigationResult.sink_closed()
if self._config.bridge_run_inline:
return self._run_bridge_inline(bridge, incident)
if self._bridge_executor is not None:
return self._run_bridge_in_pool(bridge, incident)
# Pooled mode but executor is gone (e.g. after close): never run inline here.
return _InvestigationResult.sink_closed()
def _run_bridge_inline(
self, bridge: InvestigationBridge, incident: HermesIncident
) -> _InvestigationResult:
try:
summary = bridge(incident)
except Exception:
logger.warning(
"hermes investigation bridge raised: rule=%s fingerprint=%s",
incident.rule,
incident.fingerprint,
exc_info=True,
)
return _InvestigationResult.failed()
return self._coerce_summary(summary)
def _run_bridge_in_pool(
self, bridge: InvestigationBridge, incident: HermesIncident
) -> _InvestigationResult:
# Caller (_maybe_investigate) only reaches this branch when the
# executor is not None; guard defensively rather than asserting so
# the path is safe under optimised bytecode (-O) and across any
# future refactor that may relax the precondition.
with self._bridge_lock:
if self._bridge_shutdown:
return _InvestigationResult.sink_closed()
executor = self._bridge_executor
if executor is None:
return _InvestigationResult.sink_closed()
try:
future: Future[str | None] = executor.submit(bridge, incident)
except RuntimeError:
# Pool already shut down (TOCTOU with :meth:`close`) — still
# deliver Telegram; investigation section is skipped only.
return _InvestigationResult.sink_closed()
timeout = self._config.bridge_timeout_s
try:
summary = future.result(timeout=timeout)
except FutureTimeoutError:
# Leave the future running — cancelling a long LLM call
# mid-flight is rarely clean, and the next matching incident
# will get a fresh budget after cooldown. Log so operators
# can correlate timed-out alerts with server-side activity.
logger.warning(
"hermes investigation bridge timed out after %.1fs: rule=%s fingerprint=%s",
timeout,
incident.rule,
incident.fingerprint,
)
return _InvestigationResult.timed_out(timeout)
except FutureCancelledError:
# shutdown(cancel_futures=True) cancels outstanding futures.
# CancelledError signals sink closure, not an investigation
# failure, so the operator-visible marker must reflect that.
return _InvestigationResult.sink_closed()
except Exception:
logger.warning(
"hermes investigation bridge raised: rule=%s fingerprint=%s",
incident.rule,
incident.fingerprint,
exc_info=True,
)
return _InvestigationResult.failed()
return self._coerce_summary(summary)
def _coerce_summary(self, summary: str | None) -> _InvestigationResult:
if not summary:
return _InvestigationResult.empty()
return _InvestigationResult.success(
_truncate(summary.strip(), self._config.max_summary_chars)
)
# ------------------------------------------------------------------
# Message formatting
def _format_message(
self,
incident: HermesIncident,
*,
investigation: _InvestigationResult,
) -> str:
emoji = _SEVERITY_EMOJI.get(incident.severity, "⚠️")
header = (
f"{emoji} Hermes incident: {incident.title}\n"
f"severity: {incident.severity.value.upper()} "
f"rule: {incident.rule}\n"
f"logger: {incident.logger or '<unknown>'}\n"
f"detected_at: {incident.detected_at.isoformat()}\n"
f"fingerprint: {incident.fingerprint}"
)
if incident.run_id:
header += f"\nrun_id: {incident.run_id}"
body_parts: list[str] = [header]
records_block = self._format_records(incident.records)
if records_block:
body_parts.append("recent log records:\n" + records_block)
investigation_block = investigation.render(incident.severity)
if investigation_block:
body_parts.append(investigation_block)
return "\n\n".join(body_parts)
def _format_records(self, records: tuple[LogRecord, ...]) -> str:
if not records:
return ""
inlined = records[: self._config.max_inlined_records]
omitted = len(records) - len(inlined)
lines = [_truncate(record.raw, self._config.max_record_chars) for record in inlined]
if omitted > 0:
lines.append(f"… ({omitted} more record{'s' if omitted != 1 else ''} omitted)")
return "\n".join(lines)
@dataclass(frozen=True, slots=True)
class _InvestigationResult:
"""Internal value type carrying the outcome of a bridge call.
A :class:`TelegramSink._maybe_investigate` call returns one of several
states (see module docstring). Encoding them as a small value type
keeps :meth:`TelegramSink._format_message` branchless and makes the
operator-visible marker for each state easy to audit in tests.
"""
state: InvestigationOutcome
summary: str | None = None
timeout_s: float | None = None
@classmethod
def not_attempted(cls) -> _InvestigationResult:
return cls(state=InvestigationOutcome.NOT_ATTEMPTED)
@classmethod
def success(cls, summary: str) -> _InvestigationResult:
return cls(state=InvestigationOutcome.SUCCESS, summary=summary)
@classmethod
def empty(cls) -> _InvestigationResult:
return cls(state=InvestigationOutcome.EMPTY)
@classmethod
def failed(cls) -> _InvestigationResult:
return cls(state=InvestigationOutcome.FAILED)
@classmethod
def timed_out(cls, timeout_s: float) -> _InvestigationResult:
return cls(state=InvestigationOutcome.TIMED_OUT, timeout_s=timeout_s)
@classmethod
def sink_closed(cls) -> _InvestigationResult:
return cls(state=InvestigationOutcome.SINK_CLOSED)
def render(self, severity: IncidentSeverity) -> str:
if self.state is InvestigationOutcome.SINK_CLOSED:
return "investigation: skipped (Hermes sink closed — notification only)"
if self.state is InvestigationOutcome.SUCCESS and self.summary is not None:
return "investigation summary:\n" + self.summary
if self.state is InvestigationOutcome.EMPTY:
return "investigation: attempted (no summary produced)"
if self.state is InvestigationOutcome.FAILED:
return "investigation: attempted (failed — see server logs)"
if self.state is InvestigationOutcome.TIMED_OUT and self.timeout_s is not None:
return (
f"investigation: attempted (timed out after "
f"{self.timeout_s:.1f}s — see server logs)"
)
# not_attempted → no investigation block, but MEDIUM severity
# gets an explicit marker so the operator knows the rule is
# notify-only by design.
if severity == IncidentSeverity.MEDIUM:
return "note: warning-burst severity — notify only, no investigation run."
return ""
def make_telegram_sink(
dispatcher: AlarmDispatcher,
*,
investigation_bridge: InvestigationBridge | None = None,
config: TelegramSinkConfig | None = None,
) -> IncidentSink:
"""Build an :data:`IncidentSink` callable bound to ``dispatcher``."""
sink = TelegramSink(
dispatcher,
investigation_bridge=investigation_bridge,
config=config,
)
return sink
def _truncate(text: str, limit: int) -> str:
if limit <= 0 or len(text) <= limit:
return text
if limit <= 1:
return text[:limit]
return text[: limit - 1] + "…"
__all__ = [
"InvestigationBridge",
"TelegramSink",
"TelegramSinkConfig",
"make_telegram_sink",
]