Загрузить файлы в «alert-processor/app»

This commit is contained in:
2026-08-06 18:33:44 +03:00
parent 24ac6af45c
commit 8ff9e30993
5 changed files with 1140 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# empty
+330
View File
@@ -0,0 +1,330 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from redis.asyncio import Redis
from app.models import NotificationDecision, ProcessorForwardEnvelope
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
class AuditLogger:
def __init__(
self,
client: Redis,
key_prefix: str,
ttl_seconds: int = 604800,
max_stage_records: int = 200,
) -> None:
self.client = client
self.key_prefix = key_prefix
self.ttl_seconds = ttl_seconds
self.max_stage_records = max_stage_records
def _journal_key(self, correlation_id: str) -> str:
return f"{self.key_prefix}:journal:{correlation_id}"
def _stages_key(self, correlation_id: str) -> str:
return f"{self.key_prefix}:stages:{correlation_id}"
def _event_index_key(self, event_id: str) -> str:
return f"{self.key_prefix}:event:{event_id}"
def _recent_key(self) -> str:
return f"{self.key_prefix}:recent"
async def log_ingest_queued(
self,
envelope: ProcessorForwardEnvelope,
job_id: str,
queue_name: str,
) -> None:
event = envelope.event
correlation_id = event.correlation_id
event_id = event.event_id or ""
await self._upsert_journal(
correlation_id=correlation_id,
event_id=event_id or None,
fields={
"state": "queued",
"job_id": job_id,
"queue_name": queue_name,
"correlation_id": correlation_id,
"event_id": event_id,
"host": event.host or "",
"service": event.service or "",
"trigger_name": event.trigger_name or "",
"severity": event.severity or "",
"queued_at": _utc_now(),
"updated_at": _utc_now(),
},
)
await self.log_stage(
correlation_id=correlation_id,
event_id=event.event_id,
stage="ingest_queued",
status="ok",
details={
"job_id": job_id,
"queue_name": queue_name,
},
)
async def log_worker_started(
self,
envelope: ProcessorForwardEnvelope,
job_id: str,
attempt: int,
identity: str,
) -> None:
event = envelope.event
await self._upsert_journal(
correlation_id=event.correlation_id,
event_id=event.event_id,
fields={
"state": "processing",
"worker_started_at": _utc_now(),
"worker_attempt": attempt,
"processing_identity": identity,
"updated_at": _utc_now(),
},
)
await self.log_stage(
correlation_id=event.correlation_id,
event_id=event.event_id,
stage="worker_started",
status="ok",
details={
"job_id": job_id,
"attempt": attempt,
"identity": identity,
},
)
async def log_stage(
self,
correlation_id: str,
event_id: str | None,
stage: str,
status: str = "ok",
details: dict[str, Any] | None = None,
) -> None:
payload = {
"ts": _utc_now(),
"stage": stage,
"status": status,
"details": details or {},
}
raw = json.dumps(payload, ensure_ascii=False)
stages_key = self._stages_key(correlation_id)
await self.client.rpush(stages_key, raw)
await self.client.ltrim(stages_key, -self.max_stage_records, -1)
await self.client.expire(stages_key, self.ttl_seconds)
await self._upsert_journal(
correlation_id=correlation_id,
event_id=event_id,
fields={
"last_stage": stage,
"last_stage_status": status,
"updated_at": _utc_now(),
},
)
async def log_decision(
self,
envelope: ProcessorForwardEnvelope,
decision: NotificationDecision,
) -> None:
event = envelope.event
await self._upsert_journal(
correlation_id=event.correlation_id,
event_id=event.event_id,
fields={
"state": "decision_made",
"decision_at": _utc_now(),
"notify": str(decision.notify).lower(),
"suppressed": str(decision.suppressed).lower(),
"routing_class": decision.routing_class or "",
"decision_reason": decision.reason or "",
"decision_json": json.dumps(
decision.model_dump(mode="json"),
ensure_ascii=False,
),
"updated_at": _utc_now(),
},
)
await self.log_stage(
correlation_id=event.correlation_id,
event_id=event.event_id,
stage="decision_made",
status="ok",
details={
"notify": decision.notify,
"suppressed": decision.suppressed,
"routing_class": decision.routing_class,
"reason": decision.reason,
},
)
async def log_delivery(
self,
envelope: ProcessorForwardEnvelope,
delivery_payload: dict[str, Any],
) -> None:
event = envelope.event
await self._upsert_journal(
correlation_id=event.correlation_id,
event_id=event.event_id,
fields={
"state": "delivered",
"delivery_at": _utc_now(),
"delivery_json": json.dumps(delivery_payload, ensure_ascii=False),
"updated_at": _utc_now(),
},
)
await self.log_stage(
correlation_id=event.correlation_id,
event_id=event.event_id,
stage="delivery_completed",
status="ok",
details=delivery_payload,
)
async def log_worker_outcome(
self,
envelope: ProcessorForwardEnvelope,
state: str,
error: str | None = None,
attempt: int | None = None,
) -> None:
event = envelope.event
fields = {
"state": state,
"updated_at": _utc_now(),
}
if error:
fields["last_error"] = error
fields["last_error_at"] = _utc_now()
if attempt is not None:
fields["worker_attempt"] = attempt
await self._upsert_journal(
correlation_id=event.correlation_id,
event_id=event.event_id,
fields=fields,
)
await self.log_stage(
correlation_id=event.correlation_id,
event_id=event.event_id,
stage=f"worker_{state}",
status="error" if error else "ok",
details={
"attempt": attempt,
"error": error,
},
)
async def get_event_audit(
self,
correlation_id: str | None = None,
event_id: str | None = None,
) -> dict[str, Any] | None:
resolved_correlation = correlation_id
if not resolved_correlation and event_id:
value = await self.client.get(self._event_index_key(event_id))
if value is None:
return None
resolved_correlation = value.decode() if isinstance(value, bytes) else str(value)
if not resolved_correlation:
return None
journal_raw = await self.client.hgetall(self._journal_key(resolved_correlation))
if not journal_raw:
return None
stages_raw = await self.client.lrange(self._stages_key(resolved_correlation), 0, -1)
journal = self._decode_hash(journal_raw)
stages = [json.loads(item.decode() if isinstance(item, bytes) else str(item)) for item in stages_raw]
if journal.get("decision_json"):
try:
journal["decision"] = json.loads(journal["decision_json"])
except Exception:
journal["decision"] = None
if journal.get("delivery_json"):
try:
journal["delivery"] = json.loads(journal["delivery_json"])
except Exception:
journal["delivery"] = None
return {
"journal": journal,
"stages": stages,
}
async def list_recent(self, limit: int = 20) -> list[dict[str, Any]]:
correlation_ids_raw = await self.client.zrevrange(self._recent_key(), 0, max(limit - 1, 0))
results: list[dict[str, Any]] = []
for raw in correlation_ids_raw:
correlation_id = raw.decode() if isinstance(raw, bytes) else str(raw)
audit = await self.get_event_audit(correlation_id=correlation_id)
if audit is not None:
results.append(audit)
return results
async def _upsert_journal(
self,
correlation_id: str,
event_id: str | None,
fields: dict[str, Any],
) -> None:
journal_key = self._journal_key(correlation_id)
mapping: dict[str, str] = {}
for key, value in fields.items():
if value is None:
continue
mapping[key] = str(value)
await self.client.hset(journal_key, mapping=mapping)
await self.client.expire(journal_key, self.ttl_seconds)
if event_id:
await self.client.set(
self._event_index_key(event_id),
correlation_id,
ex=self.ttl_seconds,
)
await self.client.zadd(self._recent_key(), {correlation_id: datetime.now(timezone.utc).timestamp()})
await self.client.expire(self._recent_key(), self.ttl_seconds)
@staticmethod
def _decode_hash(raw: dict[bytes, bytes]) -> dict[str, str]:
result: dict[str, str] = {}
for k, v in raw.items():
key = k.decode() if isinstance(k, bytes) else str(k)
value = v.decode() if isinstance(v, bytes) else str(v)
result[key] = value
return result
+257
View File
@@ -0,0 +1,257 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from dotenv import load_dotenv
load_dotenv()
def _parse_bool(value: str | None, default: bool = False) -> bool:
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class Settings:
app_name: str = os.getenv("APP_NAME", "alert-processor")
app_host: str = os.getenv("APP_HOST", "0.0.0.0")
app_port: int = int(os.getenv("APP_PORT", "8081"))
internal_api_token: str = os.getenv("INTERNAL_API_TOKEN", "").strip()
require_internal_api_token: bool = _parse_bool(
os.getenv("REQUIRE_INTERNAL_API_TOKEN", "true"),
default=True,
)
redis_enabled: bool = _parse_bool(
os.getenv("REDIS_ENABLED", "true"),
default=True,
)
redis_url: str = os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0").strip()
redis_key_prefix: str = os.getenv("REDIS_KEY_PREFIX", "alert").strip()
redis_fingerprint_ttl_seconds: int = int(
os.getenv("REDIS_FINGERPRINT_TTL_SECONDS", "86400")
)
redis_event_ttl_seconds: int = int(
os.getenv("REDIS_EVENT_TTL_SECONDS", "604800")
)
suppress_enabled: bool = _parse_bool(
os.getenv("SUPPRESS_ENABLED", "true"),
default=True,
)
suppress_window_seconds: int = int(
os.getenv("SUPPRESS_WINDOW_SECONDS", "900")
)
suppress_apply_to_average: bool = _parse_bool(
os.getenv("SUPPRESS_APPLY_TO_AVERAGE", "true"),
default=True,
)
flap_enabled: bool = _parse_bool(
os.getenv("FLAP_ENABLED", "true"),
default=True,
)
flap_window_seconds: int = int(
os.getenv("FLAP_WINDOW_SECONDS", "120")
)
flap_threshold: int = int(
os.getenv("FLAP_THRESHOLD", "4")
)
flap_apply_to_average: bool = _parse_bool(
os.getenv("FLAP_APPLY_TO_AVERAGE", "true"),
default=True,
)
correlation_enabled: bool = _parse_bool(
os.getenv("CORRELATION_ENABLED", "true"),
default=True,
)
correlation_window_seconds: int = int(
os.getenv("CORRELATION_WINDOW_SECONDS", "180")
)
correlation_suppress_children: bool = _parse_bool(
os.getenv("CORRELATION_SUPPRESS_CHILDREN", "true"),
default=True,
)
matrix_enabled: bool = _parse_bool(
os.getenv("MATRIX_ENABLED", "false"),
default=False,
)
matrix_homeserver_url: str = os.getenv("MATRIX_HOMESERVER_URL", "").strip().rstrip("/")
matrix_room_id: str = os.getenv("MATRIX_ROOM_ID", "").strip()
matrix_access_token: str = os.getenv("MATRIX_ACCESS_TOKEN", "").strip()
matrix_refresh_token: str = os.getenv("MATRIX_REFRESH_TOKEN", "").strip()
matrix_oauth_token_endpoint: str = os.getenv("MATRIX_OAUTH_TOKEN_ENDPOINT", "").strip()
matrix_oauth_client_id: str = os.getenv("MATRIX_OAUTH_CLIENT_ID", "").strip()
matrix_oauth_client_secret: str = os.getenv("MATRIX_OAUTH_CLIENT_SECRET", "").strip()
matrix_access_token_expires_in_seconds: int = int(
os.getenv("MATRIX_ACCESS_TOKEN_EXPIRES_IN_SECONDS", "300")
)
matrix_refresh_margin_seconds: int = int(
os.getenv("MATRIX_REFRESH_MARGIN_SECONDS", "60")
)
matrix_token_state_file: str = os.getenv(
"MATRIX_TOKEN_STATE_FILE",
".matrix_token_state.json",
).strip()
matrix_message_type: str = os.getenv("MATRIX_MESSAGE_TYPE", "m.notice").strip()
matrix_request_timeout_seconds: float = float(
os.getenv("MATRIX_REQUEST_TIMEOUT_SECONDS", "10")
)
matrix_verify_tls: bool = _parse_bool(
os.getenv("MATRIX_VERIFY_TLS", "true"),
default=True,
)
mail_enabled: bool = _parse_bool(
os.getenv("MAIL_ENABLED", "false"),
default=False,
)
mail_smtp_host: str = os.getenv("MAIL_SMTP_HOST", "").strip()
mail_smtp_port: int = int(os.getenv("MAIL_SMTP_PORT", "587"))
mail_smtp_username: str = os.getenv("MAIL_SMTP_USERNAME", "").strip()
mail_smtp_password: str = os.getenv("MAIL_SMTP_PASSWORD", "").strip()
mail_from: str = os.getenv("MAIL_FROM", "").strip()
mail_to: str = os.getenv("MAIL_TO", "").strip()
mail_use_starttls: bool = _parse_bool(
os.getenv("MAIL_USE_STARTTLS", "true"),
default=True,
)
mail_use_tls: bool = _parse_bool(
os.getenv("MAIL_USE_TLS", "false"),
default=False,
)
mail_timeout_seconds: float = float(
os.getenv("MAIL_TIMEOUT_SECONDS", "15")
)
mail_subject_prefix: str = os.getenv(
"MAIL_SUBJECT_PREFIX",
"[LLM-Zabbix]",
).strip()
zabbix_api_enabled: bool = _parse_bool(
os.getenv("ZABBIX_API_ENABLED", "false"),
default=False,
)
zabbix_api_url: str = os.getenv("ZABBIX_API_URL", "").strip()
zabbix_api_token: str = os.getenv("ZABBIX_API_TOKEN", "").strip()
zabbix_web_url: str = os.getenv("ZABBIX_WEB_URL", "").strip().rstrip("/")
zabbix_api_timeout_seconds: float = float(
os.getenv("ZABBIX_API_TIMEOUT_SECONDS", "10")
)
zabbix_api_verify_tls: bool = _parse_bool(
os.getenv("ZABBIX_API_VERIFY_TLS", "true"),
default=True,
)
zabbix_graph_period_hours: int = int(
os.getenv("ZABBIX_GRAPH_PERIOD_HOURS", "1")
)
zabbix_graph_timezone: str = os.getenv(
"ZABBIX_GRAPH_TIMEZONE",
"UTC",
).strip()
zabbix_enrich_only_notify: bool = _parse_bool(
os.getenv("ZABBIX_ENRICH_ONLY_NOTIFY", "true"),
default=True,
)
llm_enabled: bool = _parse_bool(
os.getenv("LLM_ENABLED", "false"),
default=False,
)
llm_base_url: str = os.getenv("LLM_BASE_URL", "").strip().rstrip("/")
llm_model: str = os.getenv("LLM_MODEL", "").strip()
llm_timeout_seconds: float = float(
os.getenv("LLM_TIMEOUT_SECONDS", "45")
)
llm_verify_tls: bool = _parse_bool(
os.getenv("LLM_VERIFY_TLS", "true"),
default=True,
)
llm_temperature: float = float(
os.getenv("LLM_TEMPERATURE", "0.1")
)
llm_max_steps: int = int(
os.getenv("LLM_MAX_STEPS", "4")
)
llm_max_commands: int = int(
os.getenv("LLM_MAX_COMMANDS", "4")
)
llm_triage_enabled: bool = _parse_bool(
os.getenv("LLM_TRIAGE_ENABLED", "false"),
default=False,
)
llm_triage_cache_ttl_seconds: int = int(
os.getenv("LLM_TRIAGE_CACHE_TTL_SECONDS", "3600")
)
queue_enabled: bool = _parse_bool(
os.getenv("QUEUE_ENABLED", "true"),
default=True,
)
queue_name: str = os.getenv("QUEUE_NAME", "alert:queue:events").strip()
queue_processing_name: str = os.getenv(
"QUEUE_PROCESSING_NAME",
"alert:queue:processing",
).strip()
queue_deadletter_name: str = os.getenv(
"QUEUE_DEADLETTER_NAME",
"alert:queue:deadletter",
).strip()
queue_block_timeout_seconds: int = int(
os.getenv("QUEUE_BLOCK_TIMEOUT_SECONDS", "5")
)
queue_max_attempts: int = int(
os.getenv("QUEUE_MAX_ATTEMPTS", "3")
)
queue_dedup_ttl_seconds: int = int(
os.getenv("QUEUE_DEDUP_TTL_SECONDS", "86400")
)
queue_requeue_processing_on_startup: bool = _parse_bool(
os.getenv("QUEUE_REQUEUE_PROCESSING_ON_STARTUP", "true"),
default=True,
)
correlation_kind_rules_path: str = os.getenv(
"CORRELATION_KIND_RULES_PATH",
"./config/event_kind_rules.yaml",
).strip()
correlation_root_cause_path: str = os.getenv(
"CORRELATION_ROOT_CAUSE_PATH",
"./config/root_cause_map.yaml",
).strip()
audit_enabled: bool = _parse_bool(
os.getenv("AUDIT_ENABLED", "true"),
default=True,
)
audit_key_prefix: str = os.getenv(
"AUDIT_KEY_PREFIX",
"alert:audit",
).strip()
audit_ttl_seconds: int = int(
os.getenv("AUDIT_TTL_SECONDS", "604800")
)
audit_max_stage_records: int = int(
os.getenv("AUDIT_MAX_STAGE_RECORDS", "200")
)
llm_correlation_enabled: bool = _parse_bool(
os.getenv("LLM_CORRELATION_ENABLED", "false"),
default=False,
)
llm_correlation_min_confidence: str = os.getenv(
"LLM_CORRELATION_MIN_CONFIDENCE",
"medium",
).strip().lower()
settings = Settings()
+530
View File
@@ -0,0 +1,530 @@
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from app.config import settings
from app.models import NotificationDecision, ProcessorForwardEnvelope
logger = logging.getLogger(__name__)
def _norm(value: str | None) -> str:
return (value or "").strip().lower()
def severity_rank(severity: str | None) -> int:
value = _norm(severity)
if value in {"disaster"}:
return 4
if value in {"high"}:
return 3
if value in {"average"}:
return 2
if value in {"warning", "information", "not classified", "not_classified"}:
return 1
return 0
@dataclass(frozen=True)
class EventKindRule:
kind: str
trigger_patterns: tuple[str, ...] = ()
item_key_patterns: tuple[str, ...] = ()
service_patterns: tuple[str, ...] = ()
tag_patterns: tuple[str, ...] = ()
scope_in: tuple[str, ...] = ()
severity_in: tuple[str, ...] = ()
def matches(
self,
envelope: ProcessorForwardEnvelope,
decision: NotificationDecision,
) -> bool:
event = envelope.event
zbx_context = event.zabbix_context or {}
trigger_name = _norm(event.trigger_name)
item_key = _norm(zbx_context.get("item_key"))
service = _norm(zbx_context.get("service") or event.service)
alert_scope = _norm(zbx_context.get("alert_scope"))
severity = _norm(decision.severity)
tags_joined = " ".join(
f"{_norm(str(k))}={_norm(str(v))}" for k, v in (event.tags or {}).items()
)
if self.scope_in and alert_scope not in self.scope_in:
return False
if self.severity_in and severity not in self.severity_in:
return False
if self.trigger_patterns and not any(re.search(pattern, trigger_name) for pattern in self.trigger_patterns):
return False
if self.item_key_patterns and not any(re.search(pattern, item_key) for pattern in self.item_key_patterns):
return False
if self.service_patterns and not any(re.search(pattern, service) for pattern in self.service_patterns):
return False
if self.tag_patterns and not all(re.search(pattern, tags_joined) for pattern in self.tag_patterns):
return False
return True
@dataclass
class CorrelationEventRecord:
host: str
event_id: str | None
correlation_id: str
kind: str
severity: str | None
routing_class: str | None
fingerprint: str | None
root_candidate: bool
role: str
group_id: str | None
parent_event_id: str | None
parent_correlation_id: str | None
timestamp: float
tags: dict[str, str] = field(default_factory=dict)
service: str | None = None
scope: str | None = None
component: str | None = None
domain: str | None = None
@dataclass
class CorrelationAssessment:
applied: bool
role: str
kind: str
group_id: str | None
reason: str | None
parent_event_id: str | None = None
parent_correlation_id: str | None = None
root_cause_candidate: bool = False
correlated_event_count: int = 0
suppress_child: bool = False
source: str = "deterministic"
confidence: str | None = "high"
@dataclass
class CorrelationRegistry:
event_kind_rules: list[EventKindRule] = field(default_factory=list)
root_cause_map: dict[str, set[str]] = field(default_factory=dict)
def classify_event_kind(
self,
envelope: ProcessorForwardEnvelope,
decision: NotificationDecision,
) -> str:
for rule in self.event_kind_rules:
if rule.matches(envelope, decision):
return rule.kind
return "unknown"
def is_root_candidate(self, kind: str) -> bool:
return kind in self.root_cause_map
def explains(self, parent_kind: str, child_kind: str) -> bool:
if parent_kind == child_kind:
return False
return child_kind in self.root_cause_map.get(parent_kind, set())
@classmethod
def default(cls) -> "CorrelationRegistry":
return cls(
event_kind_rules=[
EventKindRule(
kind="postgresql_unavailable",
trigger_patterns=(r"postgresql.+unavailable", r"postgres.+unavailable"),
),
EventKindRule(
kind="host_down",
trigger_patterns=(r"host unavailable", r"icmp.+unreachable", r"unreachable"),
scope_in=("host_os",),
),
EventKindRule(
kind="network_port_down",
trigger_patterns=(r"port down", r"interface down"),
),
EventKindRule(
kind="ap_down",
trigger_patterns=(r"ap down",),
),
EventKindRule(
kind="disk_full",
item_key_patterns=(r"^vfs\.fs\.",),
trigger_patterns=(r"space", r"full", r"disk"),
scope_in=("host_os",),
),
EventKindRule(
kind="container_unavailable",
trigger_patterns=(
r"container",
r"docker",
r"health state",
r"not running",
r"stopped",
r"unavailable",
r"down",
r"failed",
),
scope_in=("container",),
),
EventKindRule(
kind="cpu_high",
item_key_patterns=(r"^system\.cpu\.",),
trigger_patterns=(r"cpu utilization",),
scope_in=("host_os",),
),
EventKindRule(
kind="memory_high",
item_key_patterns=(r"^vm\.memory\.", r"^system\.swap\."),
trigger_patterns=(r"memory utilization",),
scope_in=("host_os",),
),
EventKindRule(
kind="ssl_expiry",
trigger_patterns=(r"ssl", r"certificate", r"expire"),
),
EventKindRule(
kind="service_unavailable",
trigger_patterns=(r"service unavailable", r"unavailable"),
),
EventKindRule(
kind="site_unavailable",
trigger_patterns=(r"site.+is down", r"failed step of scenario"),
),
EventKindRule(
kind="db_connection_error",
trigger_patterns=(r"connection", r"db"),
),
],
root_cause_map={
"host_down": {
"service_unavailable",
"container_unavailable",
"postgresql_unavailable",
"cpu_high",
"memory_high",
"disk_full",
"ssl_expiry",
"db_connection_error",
"site_unavailable",
},
"network_port_down": {
"host_down",
"service_unavailable",
"ap_down",
"site_unavailable",
},
"postgresql_unavailable": {
"service_unavailable",
"db_connection_error",
"site_unavailable",
},
"disk_full": {
"postgresql_unavailable",
"container_unavailable",
"service_unavailable",
"db_connection_error",
"site_unavailable",
},
"container_unavailable": {
"service_unavailable",
"site_unavailable",
},
},
)
@classmethod
def from_yaml_files(
cls,
event_kind_rules_path: str,
root_cause_map_path: str,
) -> "CorrelationRegistry":
base = cls.default()
custom_kind_rules = _load_event_kind_rules(event_kind_rules_path)
custom_root_cause_map = _load_root_cause_map(root_cause_map_path)
merged_kind_rules = custom_kind_rules + base.event_kind_rules
merged_root_cause_map: dict[str, set[str]] = {
root_kind: set(children)
for root_kind, children in base.root_cause_map.items()
}
for root_kind, explained in custom_root_cause_map.items():
if root_kind not in merged_root_cause_map:
merged_root_cause_map[root_kind] = set()
merged_root_cause_map[root_kind].update(explained)
logger.info(
"Correlation registry loaded: custom_kind_rules=%s custom_root_kinds=%s total_kind_rules=%s total_root_kinds=%s",
len(custom_kind_rules),
len(custom_root_cause_map),
len(merged_kind_rules),
len(merged_root_cause_map),
)
return cls(
event_kind_rules=merged_kind_rules,
root_cause_map=merged_root_cause_map,
)
def _load_yaml(path: str) -> dict[str, Any]:
file_path = Path(path)
if not file_path.exists():
logger.warning("Correlation YAML file not found: %s. Using defaults/merge fallback.", path)
return {}
try:
with file_path.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if not isinstance(data, dict):
logger.warning("Correlation YAML root must be a mapping: %s", path)
return {}
return data
except Exception as exc:
logger.exception("Failed to load correlation YAML %s: %s", path, exc)
return {}
def _load_event_kind_rules(path: str) -> list[EventKindRule]:
data = _load_yaml(path)
raw_rules = data.get("event_kind_rules", [])
if not isinstance(raw_rules, list):
logger.warning("event_kind_rules must be a list in %s", path)
return []
rules: list[EventKindRule] = []
for idx, item in enumerate(raw_rules, start=1):
if not isinstance(item, dict):
logger.warning("Skipping invalid event_kind_rules[%s] in %s", idx, path)
continue
kind = _norm(item.get("kind"))
if not kind:
logger.warning("Skipping event_kind_rules[%s] without kind in %s", idx, path)
continue
rules.append(
EventKindRule(
kind=kind,
trigger_patterns=tuple(item.get("trigger_patterns", []) or []),
item_key_patterns=tuple(item.get("item_key_patterns", []) or []),
service_patterns=tuple(item.get("service_patterns", []) or []),
tag_patterns=tuple(item.get("tag_patterns", []) or []),
scope_in=tuple(_norm(v) for v in (item.get("scope_in", []) or [])),
severity_in=tuple(_norm(v) for v in (item.get("severity_in", []) or [])),
)
)
return rules
def _load_root_cause_map(path: str) -> dict[str, set[str]]:
data = _load_yaml(path)
raw_map = data.get("root_cause_map", {})
if not isinstance(raw_map, dict):
logger.warning("root_cause_map must be a mapping in %s", path)
return {}
result: dict[str, set[str]] = {}
for root_kind_raw, payload in raw_map.items():
root_kind = _norm(root_kind_raw)
if not root_kind:
continue
explains: list[str] = []
if isinstance(payload, dict):
explains_raw = payload.get("explains", [])
if isinstance(explains_raw, list):
explains = [_norm(v) for v in explains_raw if _norm(v)]
elif isinstance(payload, list):
explains = [_norm(v) for v in payload if _norm(v)]
if explains:
result[root_kind] = set(explains)
return result
def _shared_service_scope_or_domain(
envelope: ProcessorForwardEnvelope,
candidate: CorrelationEventRecord,
) -> bool:
event = envelope.event
zbx_context = event.zabbix_context or {}
current_tags = {str(k).lower(): str(v).strip() for k, v in (event.tags or {}).items()}
current_service = _norm(current_tags.get("service") or zbx_context.get("service") or event.service)
current_scope = _norm(current_tags.get("scope") or zbx_context.get("scope"))
current_domain = _norm(current_tags.get("domain") or zbx_context.get("domain"))
candidate_service = _norm(candidate.service or candidate.tags.get("service"))
candidate_scope = _norm(candidate.scope or candidate.tags.get("scope"))
candidate_domain = _norm(candidate.domain or candidate.tags.get("domain"))
if current_scope and candidate_scope and current_scope == candidate_scope:
return True
if current_service and candidate_service and current_service == candidate_service:
return True
if current_domain and candidate_domain and current_domain == candidate_domain:
return True
if event.host and candidate.host and _norm(event.host) == _norm(candidate.host):
return True
return False
def assess_correlation(
envelope: ProcessorForwardEnvelope,
decision: NotificationDecision,
recent_events: list[CorrelationEventRecord],
registry: CorrelationRegistry,
) -> CorrelationAssessment:
kind = registry.classify_event_kind(envelope, decision)
root_candidate = registry.is_root_candidate(kind)
if kind == "unknown":
return CorrelationAssessment(
applied=False,
role="standalone",
kind=kind,
group_id=None,
reason=None,
root_cause_candidate=False,
correlated_event_count=0,
)
parent_candidates = [
item
for item in recent_events
if item.root_candidate
and registry.explains(item.kind, kind)
and _shared_service_scope_or_domain(envelope, item)
]
parent_candidates.sort(
key=lambda item: (item.timestamp, severity_rank(item.severity)),
reverse=True,
)
if parent_candidates:
parent = parent_candidates[0]
suppress_child = (
settings.correlation_suppress_children
and decision.routing_class in {
"average_priority",
"low_priority",
"triage_low_priority_notify",
"triage_low_priority_hold",
"triage_low_priority_suppressed",
}
)
return CorrelationAssessment(
applied=True,
role="child",
kind=kind,
group_id=parent.group_id or parent.event_id or parent.correlation_id,
reason=(
f"Likely downstream of {parent.kind}; matched recent event "
f"in the same service/scope/domain window within {settings.correlation_window_seconds}s"
),
parent_event_id=parent.event_id,
parent_correlation_id=parent.correlation_id,
root_cause_candidate=False,
correlated_event_count=0,
suppress_child=suppress_child,
)
if root_candidate:
correlated_count = sum(
1
for item in recent_events
if registry.explains(kind, item.kind) and _shared_service_scope_or_domain(envelope, item)
)
return CorrelationAssessment(
applied=True,
role="root",
kind=kind,
group_id=envelope.event.event_id or envelope.event.correlation_id,
reason=(
f"Potential root cause candidate; {correlated_count} related alerts seen "
f"in matching service/scope/domain window within {settings.correlation_window_seconds}s"
),
root_cause_candidate=True,
correlated_event_count=correlated_count,
suppress_child=False,
)
return CorrelationAssessment(
applied=True,
role="standalone",
kind=kind,
group_id=envelope.event.event_id or envelope.event.correlation_id,
reason="No root cause candidate found in matching service/scope/domain correlation window",
root_cause_candidate=False,
correlated_event_count=0,
suppress_child=False,
)
def apply_correlation_to_decision(
decision: NotificationDecision,
assessment: CorrelationAssessment,
) -> NotificationDecision:
if not assessment.applied:
return decision
update = {
"correlation_applied": True,
"correlation_role": assessment.role,
"correlation_group_id": assessment.group_id,
"correlation_kind": assessment.kind,
"correlation_reason": assessment.reason,
"correlation_source": assessment.source,
"correlation_confidence": assessment.confidence,
"parent_event_id": assessment.parent_event_id,
"parent_correlation_id": assessment.parent_correlation_id,
"root_cause_candidate": assessment.root_cause_candidate,
"correlated_event_count": assessment.correlated_event_count,
}
if assessment.role == "child" and assessment.suppress_child:
update.update(
{
"notify": False,
"suppressed": True,
"channels": [],
"routing_class": "correlated_child_suppressed",
"reason": "Correlated child alert suppressed by RCA baseline",
"suppress_reason": assessment.reason,
}
)
return decision.model_copy(update=update)
if assessment.role == "child":
update["reason"] = f"{decision.reason}. RCA baseline matched a parent event in the same service/scope."
return decision.model_copy(update=update)
if assessment.role == "root":
update["reason"] = f"{decision.reason}. RCA baseline marked this event as root cause candidate"
return decision.model_copy(update=update)
return decision.model_copy(update=update)
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
import re
from app.models import NormalizedEvent
def _norm(value: str | None) -> str:
if not value:
return "unknown"
value = value.strip().lower()
value = re.sub(r"\s+", "_", value)
value = re.sub(r"[^a-z0-9_\-\.]+", "_", value)
return value.strip("_") or "unknown"
def build_fingerprint(event: NormalizedEvent) -> str:
host = _norm(event.host)
service = _norm(event.service)
trigger = _norm(event.trigger_name)
return f"{host}|{service}|{trigger}"