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