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
256 lines
9.8 KiB
Python
256 lines
9.8 KiB
Python
"""Alert source resolution and tool-source routing helpers.
|
|
|
|
Naming convention:
|
|
|
|
- ``alert_source`` — vendor/format key on the incoming alert payload (e.g.
|
|
``"grafana"``, ``"eks"``). Keys ``ALERT_SOURCE_ROUTING``.
|
|
- ``tool source`` — integration key matching ``tool.source`` (e.g.
|
|
``"grafana"``, ``"ec2"``, ``"cloudtrail"``). Values in routing tuples.
|
|
|
|
Each ``AlertSourceRouting`` entry carries two tool-source lists:
|
|
|
|
- ``relevance_tool_sources`` — broad prioritization during tool planning.
|
|
- ``seed_tool_sources`` — narrower subset auto-invoked before the LLM loop.
|
|
Expensive or context-dependent tools stay out of seeding.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from core.domain.alerts.fields import iter_alert_blocks
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AlertSourceRouting:
|
|
relevance_tool_sources: tuple[str, ...]
|
|
seed_tool_sources: tuple[str, ...]
|
|
|
|
|
|
def _routing(
|
|
relevance_tool_sources: tuple[str, ...],
|
|
seed_tool_sources: tuple[str, ...],
|
|
) -> AlertSourceRouting:
|
|
return AlertSourceRouting(
|
|
relevance_tool_sources=relevance_tool_sources,
|
|
seed_tool_sources=seed_tool_sources,
|
|
)
|
|
|
|
|
|
# Single registry — relevance and seed lists are intentionally different per entry.
|
|
ALERT_SOURCE_ROUTING: dict[str, AlertSourceRouting] = {
|
|
"grafana": _routing(("grafana",), ("grafana",)),
|
|
"datadog": _routing(("datadog",), ("datadog",)),
|
|
# ec2/rds/cloudtrail stay relevance-only — context-dependent pre-LLM seeding.
|
|
"cloudwatch": _routing(("cloudwatch", "ec2", "rds", "cloudtrail"), ("cloudwatch",)),
|
|
# ec2/cloudtrail stay relevance-only — seed only the cluster integration.
|
|
"eks": _routing(("eks", "ec2", "cloudtrail"), ("eks",)),
|
|
# eks/cloudtrail stay relevance-only — seed grafana + cloudwatch dashboards/logs.
|
|
"alertmanager": _routing(
|
|
("eks", "cloudwatch", "grafana", "cloudtrail"),
|
|
("grafana", "cloudwatch"),
|
|
),
|
|
"sentry": _routing(("sentry",), ("sentry",)),
|
|
"honeycomb": _routing(("honeycomb",), ("honeycomb",)),
|
|
"coralogix": _routing(("coralogix",), ("coralogix",)),
|
|
# tracer_web stays relevance-only — secondary web context, not pre-LLM seed.
|
|
"airflow": _routing(("airflow", "tracer_web"), ("airflow",)),
|
|
"hermes": _routing(("hermes",), ("hermes",)),
|
|
"kafka": _routing(("kafka",), ("kafka",)),
|
|
"postgresql": _routing(("postgresql",), ("postgresql",)),
|
|
"mysql": _routing(("mysql",), ("mysql",)),
|
|
"mariadb": _routing(("mariadb",), ("mariadb",)),
|
|
"mongodb": _routing(("mongodb", "mongodb_atlas"), ("mongodb", "mongodb_atlas")),
|
|
"redis": _routing(("redis",), ("redis",)),
|
|
"snowflake": _routing(("snowflake",), ("snowflake",)),
|
|
"clickhouse": _routing(("clickhouse",), ("clickhouse",)),
|
|
"dagster": _routing(("dagster",), ("dagster",)),
|
|
"rabbitmq": _routing(("rabbitmq",), ("rabbitmq",)),
|
|
"supabase": _routing(("supabase",), ("supabase",)),
|
|
"opensearch": _routing(("opensearch",), ("opensearch",)),
|
|
"openobserve": _routing(("openobserve",), ("openobserve",)),
|
|
"betterstack": _routing(("betterstack",), ("betterstack",)),
|
|
"azure": _routing(("azure", "azure_sql"), ("azure", "azure_sql")),
|
|
"github": _routing(("github",), ("github",)),
|
|
"gitlab": _routing(("gitlab",), ("gitlab",)),
|
|
"bitbucket": _routing(("bitbucket",), ("bitbucket",)),
|
|
"argocd": _routing(("eks",), ("eks",)),
|
|
"splunk": _routing(("splunk",), ("splunk",)),
|
|
"signoz": _routing(("signoz",), ("signoz",)),
|
|
"jenkins": _routing(("jenkins",), ("jenkins",)),
|
|
"tempo": _routing(("tempo",), ("tempo",)),
|
|
"temporal": _routing(("temporal",), ("temporal",)),
|
|
}
|
|
|
|
# Generic fallback sources: useful, but never primary when incident-specific
|
|
# integrations match.
|
|
SECONDARY_TOOL_SOURCES = frozenset({"knowledge", "openclaw", "google_docs"})
|
|
|
|
DB_KEYWORDS: tuple[str, ...] = ("database", "db connection", "connection pool")
|
|
|
|
SOURCE_ALIASES: dict[str, tuple[str, ...]] = {
|
|
"datadog": ("datadog", "datadoghq", "dd monitor"),
|
|
"sentry": ("sentry", "exception", "stack trace", "stacktrace", "error tracking"),
|
|
"vercel": ("vercel", "deploy", "deployment", "build failed"),
|
|
"github": ("github", "commit", "pull request", "merge"),
|
|
"gitlab": ("gitlab", "merge request"),
|
|
"grafana": ("grafana", "loki", "mimir", "prometheus"),
|
|
"honeycomb": ("honeycomb", "span", "trace latency"),
|
|
"coralogix": ("coralogix",),
|
|
"splunk": ("splunk",),
|
|
"cloudwatch": ("cloudwatch", "lambda", "log group"),
|
|
"eks": ("eks", "kubernetes", "k8s", "kubectl", "pod"),
|
|
"ec2": ("ec2", "instance"),
|
|
"rds": ("rds", "aurora", *DB_KEYWORDS),
|
|
"postgresql": ("postgres", "postgresql", "psql", *DB_KEYWORDS),
|
|
"mysql": ("mysql", *DB_KEYWORDS),
|
|
"mariadb": ("mariadb", *DB_KEYWORDS),
|
|
"mongodb": ("mongodb", "mongo", *DB_KEYWORDS),
|
|
"redis": ("redis", "cache"),
|
|
"snowflake": ("snowflake",),
|
|
"clickhouse": ("clickhouse",),
|
|
"dagster": ("dagster",),
|
|
"airflow": ("airflow", "dag"),
|
|
"kafka": ("kafka",),
|
|
"rabbitmq": ("rabbitmq", "amqp"),
|
|
"supabase": ("supabase",),
|
|
"opensearch": ("opensearch", "elasticsearch"),
|
|
"openobserve": ("openobserve",),
|
|
"betterstack": ("betterstack", "better stack"),
|
|
"azure": ("azure",),
|
|
"signoz": ("signoz",),
|
|
"jenkins": ("jenkins",),
|
|
"tempo": ("tempo",),
|
|
"temporal": ("temporal", "temporal workflow", "task queue"),
|
|
}
|
|
|
|
|
|
def routing_for_alert_source(alert_source: str) -> AlertSourceRouting | None:
|
|
"""Return routing config for a resolved alert vendor key, if known."""
|
|
return ALERT_SOURCE_ROUTING.get(alert_source.strip().lower())
|
|
|
|
|
|
def primary_sources_for_alert(state: dict[str, Any]) -> tuple[str, ...]:
|
|
"""Return the routing entry's ``relevance_tool_sources`` for this alert.
|
|
|
|
Used for broad alert-driven tool prioritization; callers surface these
|
|
as ``primary_sources`` in plan audits and prompts.
|
|
"""
|
|
routing = routing_for_alert_source(resolve_alert_source(state))
|
|
return routing.relevance_tool_sources if routing is not None else ()
|
|
|
|
|
|
def seed_tool_sources_for_alert(state: dict[str, Any]) -> tuple[str, ...]:
|
|
"""Return tool sources auto-called before the investigation LLM loop."""
|
|
routing = routing_for_alert_source(resolve_alert_source(state))
|
|
return routing.seed_tool_sources if routing is not None else ()
|
|
|
|
|
|
def declared_context_sources(state: dict[str, Any]) -> set[str]:
|
|
"""Return explicit context source annotations from the raw alert, if any."""
|
|
raw = state.get("raw_alert")
|
|
if not isinstance(raw, dict):
|
|
return set()
|
|
for block in iter_alert_blocks(raw):
|
|
value = block.get("context_sources")
|
|
if isinstance(value, str) and value.strip():
|
|
return {item.strip().lower() for item in value.split(",") if item.strip()}
|
|
return set()
|
|
|
|
|
|
def collect_alert_text(state: dict[str, Any]) -> str:
|
|
"""Collect searchable alert text for deterministic source/tool matching."""
|
|
parts: list[str] = [
|
|
str(state.get("alert_name") or ""),
|
|
str(state.get("pipeline_name") or ""),
|
|
str(state.get("message") or ""),
|
|
]
|
|
raw = state.get("raw_alert")
|
|
if isinstance(raw, dict):
|
|
for key in ("alert_name", "title", "message", "text", "error_message", "kube_namespace"):
|
|
value = raw.get(key)
|
|
if isinstance(value, str):
|
|
parts.append(value)
|
|
for block in iter_alert_blocks(raw):
|
|
parts.extend(str(v) for v in block.values() if isinstance(v, (str, int, float)))
|
|
elif isinstance(raw, str):
|
|
parts.append(raw)
|
|
|
|
problem_md = state.get("problem_md")
|
|
if isinstance(problem_md, str):
|
|
parts.append(problem_md)
|
|
|
|
return " ".join(part for part in parts if part).lower()
|
|
|
|
|
|
def relevant_sources_for_alert(
|
|
state: dict[str, Any],
|
|
candidate_sources: Iterable[str],
|
|
) -> list[str]:
|
|
"""Select candidate sources relevant to the alert content."""
|
|
candidates = sorted(
|
|
source for source in candidate_sources if source not in SECONDARY_TOOL_SOURCES
|
|
)
|
|
if not candidates:
|
|
return []
|
|
|
|
declared = declared_context_sources(state)
|
|
if declared:
|
|
from_declared = [source for source in candidates if source in declared]
|
|
if from_declared:
|
|
return from_declared
|
|
|
|
text = collect_alert_text(state)
|
|
if not text:
|
|
return []
|
|
|
|
matched: list[str] = []
|
|
for source in candidates:
|
|
keywords = {source, *SOURCE_ALIASES.get(source, ())}
|
|
if any(keyword in text for keyword in keywords):
|
|
matched.append(source)
|
|
return matched
|
|
|
|
|
|
def resolve_alert_source(state: dict[str, Any]) -> str:
|
|
"""Return the alert vendor key used to look up tool-source routing.
|
|
|
|
Grafana managed alerts reuse the Alertmanager webhook schema, so
|
|
``alert_source`` is often missing from the payload — we sniff
|
|
``grafana_folder`` / ``datasource_uid`` labels and ``externalURL`` below.
|
|
"""
|
|
source = str(state.get("alert_source") or "").lower().strip()
|
|
if source:
|
|
return source
|
|
raw = state.get("raw_alert")
|
|
if isinstance(raw, dict):
|
|
source = str(raw.get("alert_source") or "").lower().strip()
|
|
if source:
|
|
return source
|
|
labels = raw.get("commonLabels") or raw.get("labels") or {}
|
|
if isinstance(labels, dict) and (
|
|
labels.get("grafana_folder") or labels.get("datasource_uid")
|
|
):
|
|
return "grafana"
|
|
ext_url = raw.get("externalURL", "")
|
|
if isinstance(ext_url, str) and "grafana" in ext_url.lower():
|
|
return "grafana"
|
|
return ""
|
|
|
|
|
|
__all__ = [
|
|
"ALERT_SOURCE_ROUTING",
|
|
"AlertSourceRouting",
|
|
"SECONDARY_TOOL_SOURCES",
|
|
"SOURCE_ALIASES",
|
|
"collect_alert_text",
|
|
"declared_context_sources",
|
|
"primary_sources_for_alert",
|
|
"relevant_sources_for_alert",
|
|
"resolve_alert_source",
|
|
"routing_for_alert_source",
|
|
"seed_tool_sources_for_alert",
|
|
]
|