271 lines
9.1 KiB
Python
271 lines
9.1 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from urllib.parse import urlencode
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from app.models import NormalizedEvent
|
|
from app.tag_utils import build_correlation_scopes, infer_domain_from_text, merge_tags, normalize_tag_dict
|
|
from app.zabbix_client import ZabbixApiClient
|
|
|
|
|
|
def _contains_unresolved_macros(value: str | None) -> bool:
|
|
if not value:
|
|
return False
|
|
return bool(re.search(r"\{[A-Z0-9\._:#\$]+\}", value))
|
|
|
|
|
|
def _safe_filename(value: str) -> str:
|
|
return re.sub(r"[^a-zA-Z0-9._-]+", "_", value).strip("_") or "graph"
|
|
|
|
|
|
def _detect_alert_scope(
|
|
item_key: str | None,
|
|
service: str | None,
|
|
trigger_name: str | None,
|
|
tags: dict[str, str] | None = None,
|
|
) -> str:
|
|
item_key_lower = (item_key or "").strip().lower()
|
|
service_lower = (service or "").strip().lower()
|
|
trigger_lower = (trigger_name or "").strip().lower()
|
|
tags = tags or {}
|
|
|
|
if tags.get("scope"):
|
|
return tags["scope"]
|
|
if tags.get("service"):
|
|
return tags["service"]
|
|
|
|
tag_values = " ".join(f"{k}={v}" for k, v in tags.items()).lower()
|
|
|
|
host_prefixes = (
|
|
"system.cpu.",
|
|
"system.swap.",
|
|
"system.uptime",
|
|
"vm.memory.",
|
|
"vfs.fs.",
|
|
"vfs.dev.",
|
|
"proc.num",
|
|
"kernel.",
|
|
"net.if.",
|
|
"agent.",
|
|
)
|
|
|
|
container_prefixes = (
|
|
"docker.",
|
|
"container.",
|
|
"podman.",
|
|
"kube.",
|
|
"kubernetes.",
|
|
"cri.",
|
|
)
|
|
|
|
if item_key_lower.startswith(host_prefixes):
|
|
return "host_os"
|
|
|
|
if item_key_lower.startswith(container_prefixes):
|
|
return "container"
|
|
|
|
host_hints = (
|
|
"cpu utilization",
|
|
"memory utilization",
|
|
"filesystem",
|
|
"disk space",
|
|
"load average",
|
|
"linux:",
|
|
"windows:",
|
|
)
|
|
|
|
container_hints = (
|
|
"container",
|
|
"docker",
|
|
"pod",
|
|
"kube",
|
|
"kubernetes",
|
|
)
|
|
|
|
combined = " ".join([service_lower, trigger_lower, tag_values])
|
|
|
|
if any(h in combined for h in container_hints):
|
|
return "container"
|
|
|
|
if any(h in combined for h in host_hints):
|
|
return "host_os"
|
|
|
|
return "unknown"
|
|
|
|
|
|
class ZabbixEnricher:
|
|
def __init__(
|
|
self,
|
|
client: ZabbixApiClient,
|
|
web_url: str | None = None,
|
|
graph_period_hours: int = 1,
|
|
graph_image_dir: str = ".graph_images",
|
|
graph_timezone: str = "UTC",
|
|
) -> None:
|
|
self.client = client
|
|
self.web_url = (web_url or "").rstrip("/")
|
|
self.graph_period_hours = graph_period_hours
|
|
self.graph_image_dir = Path(graph_image_dir)
|
|
self.graph_image_dir.mkdir(parents=True, exist_ok=True)
|
|
self.graph_timezone_name = graph_timezone or "UTC"
|
|
self.graph_timezone = ZoneInfo(self.graph_timezone_name)
|
|
|
|
async def enrich_event(self, event: NormalizedEvent) -> NormalizedEvent:
|
|
context: dict[str, object] = {}
|
|
|
|
if self.web_url and event.trigger_id and event.event_id:
|
|
event.event_url = (
|
|
f"{self.web_url}/tr_events.php"
|
|
f"?triggerid={event.trigger_id}&eventid={event.event_id}"
|
|
)
|
|
elif event.zabbix_url and not event.event_url:
|
|
event.event_url = event.zabbix_url
|
|
|
|
item_value_type: int | None = None
|
|
item_name: str | None = None
|
|
item_units: str | None = None
|
|
item_key: str | None = None
|
|
|
|
incoming_tags = normalize_tag_dict(event.tags)
|
|
payload_tags = normalize_tag_dict(event.raw_payload.get("tags"))
|
|
|
|
trigger_tags: dict[str, str] = {}
|
|
host_tags: dict[str, str] = {}
|
|
item_tags: dict[str, str] = {}
|
|
event_tags: dict[str, str] = {}
|
|
|
|
if event.trigger_id:
|
|
trigger = await self.client.get_trigger(event.trigger_id)
|
|
if trigger:
|
|
trigger_tags = normalize_tag_dict(trigger.get("tags"))
|
|
hosts = trigger.get("hosts") or []
|
|
if hosts and not event.host:
|
|
event.host = hosts[0].get("host") or hosts[0].get("name") or event.host
|
|
if hosts and not event.host_id:
|
|
event.host_id = hosts[0].get("hostid") or event.host_id
|
|
|
|
items = trigger.get("items") or []
|
|
if items and not event.item_id:
|
|
event.item_id = items[0].get("itemid") or event.item_id
|
|
|
|
description = trigger.get("description")
|
|
comments = trigger.get("comments")
|
|
trigger_opdata = trigger.get("opdata")
|
|
if description:
|
|
context["trigger_description"] = str(description)
|
|
if comments:
|
|
context["trigger_comments"] = str(comments)
|
|
|
|
if trigger_opdata and not _contains_unresolved_macros(str(trigger_opdata)):
|
|
context["resolved_opdata"] = str(trigger_opdata)
|
|
|
|
if event.host_id:
|
|
host_info = await self.client.get_host(event.host_id)
|
|
if host_info:
|
|
host_tags = normalize_tag_dict(host_info.get("tags"))
|
|
|
|
if event.item_id:
|
|
item = await self.client.get_item(event.item_id)
|
|
if item:
|
|
item_tags = normalize_tag_dict(item.get("tags"))
|
|
|
|
if not event.service:
|
|
event.service = item.get("name") or event.service
|
|
|
|
item_name = item.get("name")
|
|
item_key = item.get("key_")
|
|
lastvalue = item.get("lastvalue")
|
|
item_units = item.get("units") or ""
|
|
try:
|
|
item_value_type = int(item.get("value_type"))
|
|
except (TypeError, ValueError):
|
|
item_value_type = None
|
|
|
|
if item_name:
|
|
context["item_name"] = str(item_name)
|
|
if item_key:
|
|
context["item_key"] = str(item_key)
|
|
if lastvalue not in (None, ""):
|
|
context["last_value"] = f"{lastvalue}{(' ' + item_units) if item_units else ''}"
|
|
|
|
if event.event_id:
|
|
zbx_event = await self.client.get_event(event.event_id)
|
|
if zbx_event:
|
|
event_tags = normalize_tag_dict(zbx_event.get("tags"))
|
|
event_name = zbx_event.get("name")
|
|
if event_name and not event.trigger_name:
|
|
event.trigger_name = str(event_name)
|
|
|
|
merged_tags = merge_tags(host_tags, trigger_tags, item_tags, event_tags, payload_tags, incoming_tags)
|
|
|
|
if "service" not in merged_tags and event.service:
|
|
merged_tags["service"] = str(event.service)
|
|
|
|
domain = merged_tags.get("domain") or infer_domain_from_text(
|
|
event.trigger_name,
|
|
event.zabbix_url,
|
|
event.event_url,
|
|
event.raw_payload.get("url"),
|
|
event.raw_payload.get("name"),
|
|
)
|
|
if domain:
|
|
merged_tags["domain"] = domain
|
|
|
|
alert_scope = _detect_alert_scope(
|
|
item_key=item_key,
|
|
service=merged_tags.get("service") or event.service,
|
|
trigger_name=event.trigger_name,
|
|
tags=merged_tags,
|
|
)
|
|
|
|
correlation_scopes = build_correlation_scopes(
|
|
tags=merged_tags,
|
|
host=event.host,
|
|
trigger_name=event.trigger_name,
|
|
)
|
|
|
|
if merged_tags:
|
|
event.tags = merged_tags
|
|
|
|
if not context.get("resolved_opdata"):
|
|
if event.opdata and not _contains_unresolved_macros(event.opdata):
|
|
context["resolved_opdata"] = event.opdata
|
|
elif context.get("last_value"):
|
|
metric_label = context.get("item_name") or event.service or "Metric value"
|
|
context["resolved_opdata"] = f"{metric_label}: {context['last_value']}"
|
|
|
|
if self.web_url and event.item_id:
|
|
now_local = datetime.now(self.graph_timezone)
|
|
from_local = now_local - timedelta(hours=self.graph_period_hours)
|
|
|
|
params = {
|
|
"from": from_local.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"to": now_local.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"itemids[0]": event.item_id,
|
|
}
|
|
event.graph_url = f"{self.web_url}/chart.php?{urlencode(params)}"
|
|
|
|
if item_value_type is not None and item_value_type in {0, 3}:
|
|
file_name = _safe_filename(f"{event.host}_{event.item_id}_{event.event_id or 'graph'}.png")
|
|
event.graph_image_path = str(self.graph_image_dir / file_name)
|
|
else:
|
|
event.graph_image_path = None
|
|
|
|
context["alert_scope"] = alert_scope
|
|
context["service"] = merged_tags.get("service")
|
|
context["scope"] = merged_tags.get("scope")
|
|
context["component"] = merged_tags.get("component")
|
|
context["domain"] = merged_tags.get("domain")
|
|
context["tags"] = merged_tags
|
|
context["host_tags"] = host_tags
|
|
context["trigger_tags"] = trigger_tags
|
|
context["item_tags"] = item_tags
|
|
context["event_tags"] = event_tags
|
|
context["correlation_scopes"] = correlation_scopes
|
|
|
|
event.zabbix_context.update(context)
|
|
return event
|