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
260 lines
8.7 KiB
Python
260 lines
8.7 KiB
Python
"""``opensre hermes`` command group: live-tail Hermes logs and dispatch to Telegram.
|
|
|
|
The ``opensre hermes watch`` command wires the existing detection
|
|
backbone (:class:`~integrations.hermes.HermesAgent`) to the
|
|
:class:`~integrations.hermes.TelegramSink` and blocks until ``SIGINT`` /
|
|
``SIGTERM``. Credentials are loaded via
|
|
:func:`~integrations.telegram.credentials.load_credentials_from_env`, so the
|
|
``TELEGRAM_BOT_TOKEN`` env var must be set; ``--chat-id`` overrides the
|
|
``TELEGRAM_DEFAULT_CHAT_ID`` env var when both are present.
|
|
|
|
This command intentionally does *not* run an OpenSRE investigation by
|
|
default. Pass ``--investigate`` (or set ``OPENSRE_HERMES_INVESTIGATE=1``)
|
|
to enable the RCA bridge for ``HIGH``/``CRITICAL`` incidents.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import signal
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import click
|
|
|
|
from integrations.hermes.agent import DEFAULT_LOG_PATH, HermesAgent
|
|
from integrations.hermes.correlating_sink import CorrelatingSink
|
|
from integrations.hermes.correlator import IncidentCorrelator, RouteDestination
|
|
from integrations.hermes.investigation import run_incident_investigation
|
|
from integrations.hermes.sinks import TelegramSink
|
|
from integrations.telegram.alarms import AlarmDispatcher
|
|
from integrations.telegram.credentials import load_credentials_from_env
|
|
from tools.investigation.capability import run_investigation
|
|
|
|
|
|
@click.group(name="hermes", invoke_without_command=True)
|
|
@click.pass_context
|
|
def hermes_command(ctx: click.Context) -> None:
|
|
"""Live-tail Hermes logs and route detected incidents to Telegram."""
|
|
if ctx.invoked_subcommand is None:
|
|
click.echo(ctx.get_help())
|
|
ctx.exit(0)
|
|
|
|
|
|
@hermes_command.command(name="watch")
|
|
@click.option(
|
|
"--log-path",
|
|
type=click.Path(dir_okay=False, path_type=Path),
|
|
default=None,
|
|
help=(
|
|
f"Path to the Hermes log file. Defaults to {DEFAULT_LOG_PATH}. "
|
|
"The file does not need to exist yet — the tailer waits for it to appear."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--chat-id",
|
|
"chat_id",
|
|
type=str,
|
|
default=None,
|
|
help=(
|
|
"Telegram chat ID to deliver incidents to. Overrides "
|
|
"TELEGRAM_DEFAULT_CHAT_ID when both are set."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--cooldown-seconds",
|
|
type=float,
|
|
default=300.0,
|
|
show_default=True,
|
|
help="Per-incident-fingerprint cooldown before a duplicate is re-sent.",
|
|
)
|
|
@click.option(
|
|
"--from-start/--from-end",
|
|
default=False,
|
|
show_default=True,
|
|
help=(
|
|
"Replay the existing log contents before live-tailing. Off by default so "
|
|
"starting the watcher does not flood Telegram with backlog."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--investigate/--no-investigate",
|
|
"investigate",
|
|
default=None,
|
|
help=(
|
|
"Run an OpenSRE investigation for HIGH/CRITICAL incidents and append "
|
|
"the RCA summary before delivery. Defaults to off; set "
|
|
"OPENSRE_HERMES_INVESTIGATE=1 to enable globally."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--correlate/--no-correlate",
|
|
"correlate",
|
|
default=True,
|
|
show_default=True,
|
|
help=(
|
|
"Route classifier output through the IncidentCorrelator (dedup, "
|
|
"severity escalation, rule→destination routing). Disable to pipe "
|
|
"raw incidents straight into the TelegramSink — the legacy behavior."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--dedup-window-seconds",
|
|
type=float,
|
|
default=300.0,
|
|
show_default=True,
|
|
help=(
|
|
"Correlator dedup window. Identical fingerprints within this window "
|
|
"are suppressed unless escalation breaks through. Ignored when "
|
|
"--no-correlate is set."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--escalation-threshold",
|
|
type=click.IntRange(min=2),
|
|
default=3,
|
|
show_default=True,
|
|
help=(
|
|
"Number of repeat hits within --escalation-window-seconds that "
|
|
"bumps an incident's severity one rung (HIGH→CRITICAL). Must be ≥2. "
|
|
"Ignored when --no-correlate is set."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--escalation-window-seconds",
|
|
type=float,
|
|
default=600.0,
|
|
show_default=True,
|
|
help=(
|
|
"Window over which repeat hits are counted for escalation. Ignored "
|
|
"when --no-correlate is set."
|
|
),
|
|
)
|
|
def hermes_watch(
|
|
log_path: Path | None,
|
|
chat_id: str | None,
|
|
cooldown_seconds: float,
|
|
from_start: bool,
|
|
investigate: bool | None,
|
|
correlate: bool,
|
|
dedup_window_seconds: float,
|
|
escalation_threshold: int,
|
|
escalation_window_seconds: float,
|
|
) -> None:
|
|
"""Start the Hermes log watcher and block until interrupted.
|
|
|
|
Loads Telegram credentials from the environment, constructs a
|
|
:class:`HermesAgent` wired to a :class:`TelegramSink`, then waits
|
|
for ``SIGINT``/``SIGTERM`` before shutting the agent down cleanly.
|
|
"""
|
|
creds = load_credentials_from_env(chat_id_override=chat_id)
|
|
dispatcher = AlarmDispatcher(creds, cooldown_seconds=cooldown_seconds)
|
|
|
|
investigate_enabled = _resolve_investigate_flag(investigate)
|
|
bridge = (
|
|
(lambda incident: run_incident_investigation(incident, run_investigation))
|
|
if investigate_enabled
|
|
else None
|
|
)
|
|
telegram_sink = TelegramSink(dispatcher, investigation_bridge=bridge)
|
|
|
|
correlator: IncidentCorrelator | None = None
|
|
sink: Any
|
|
if correlate:
|
|
correlator = IncidentCorrelator(
|
|
dedup_window_s=dedup_window_seconds,
|
|
escalation_window_s=escalation_window_seconds,
|
|
escalation_threshold=escalation_threshold,
|
|
)
|
|
# PAGER destinations currently fall back to Telegram with a clear
|
|
# marker — there is no separate pager integration yet. Routing the
|
|
# same sink under both keys keeps escalations visible until a
|
|
# dedicated PagerDuty/Opsgenie sink is added.
|
|
sink = CorrelatingSink(
|
|
correlator=correlator,
|
|
routes={
|
|
RouteDestination.TELEGRAM: telegram_sink,
|
|
RouteDestination.TELEGRAM_WITH_RCA: telegram_sink,
|
|
RouteDestination.PAGER: telegram_sink,
|
|
},
|
|
default_route=telegram_sink,
|
|
)
|
|
else:
|
|
sink = telegram_sink
|
|
|
|
resolved_log_path = log_path or DEFAULT_LOG_PATH
|
|
agent = HermesAgent(
|
|
sink=sink,
|
|
log_path=resolved_log_path,
|
|
from_start=from_start,
|
|
)
|
|
|
|
stop_event = threading.Event()
|
|
_install_shutdown_handlers(stop_event)
|
|
|
|
click.echo(
|
|
f"hermes-watch: tailing {resolved_log_path} "
|
|
f"(cooldown={cooldown_seconds:.0f}s, "
|
|
f"investigate={'on' if investigate_enabled else 'off'}, "
|
|
f"correlate={'on' if correlate else 'off'})"
|
|
)
|
|
|
|
agent.start()
|
|
try:
|
|
# Block on the stop_event so SIGINT/SIGTERM wake the main
|
|
# thread immediately. A plain ``thread.join()`` would not
|
|
# respond to signals on its own.
|
|
stop_event.wait()
|
|
finally:
|
|
click.echo("hermes-watch: stopping…")
|
|
agent.stop()
|
|
# Drain in-flight investigation calls (no-op when bridge is
|
|
# disabled). Done after agent.stop() so no new bridge submits
|
|
# can race the shutdown.
|
|
telegram_sink.close()
|
|
if correlator is not None and isinstance(sink, CorrelatingSink):
|
|
snapshot = sink.metrics_snapshot()
|
|
click.echo(
|
|
"hermes-watch: correlator metrics "
|
|
f"delivered={snapshot['delivered']} "
|
|
f"suppressed={snapshot['suppressed']} "
|
|
f"escalated={snapshot['escalated']} "
|
|
f"dropped={snapshot['dropped']} "
|
|
f"unrouted={snapshot['unrouted']} "
|
|
f"sink_errors={snapshot['sink_errors']}"
|
|
)
|
|
click.echo("hermes-watch: stopped.")
|
|
|
|
|
|
def _resolve_investigate_flag(cli_value: bool | None) -> bool:
|
|
"""CLI flag wins; otherwise fall back to the env var."""
|
|
if cli_value is not None:
|
|
return cli_value
|
|
env_value = os.getenv("OPENSRE_HERMES_INVESTIGATE", "").strip().lower()
|
|
return env_value in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _install_shutdown_handlers(stop_event: threading.Event) -> None:
|
|
"""Install SIGINT/SIGTERM handlers that set ``stop_event``.
|
|
|
|
Click already installs a SIGINT-based ``KeyboardInterrupt`` path,
|
|
but the watcher blocks on a ``threading.Event`` rather than a
|
|
user-input prompt, so we replace it with a handler that wakes the
|
|
event explicitly. SIGTERM gets the same treatment for systemd /
|
|
container shutdowns.
|
|
"""
|
|
|
|
def _handler(_signum: int, _frame: Any) -> None:
|
|
stop_event.set()
|
|
|
|
signal.signal(signal.SIGINT, _handler)
|
|
# SIGTERM is not defined on Windows; guard for portability even
|
|
# though the watcher is a Linux/macOS workflow today.
|
|
sigterm = getattr(signal, "SIGTERM", None)
|
|
if sigterm is not None:
|
|
signal.signal(sigterm, _handler)
|
|
|
|
|
|
__all__ = ["hermes_command"]
|