Files

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