From 53945a62ee88eb2883668d88c68b47cf54afa272 Mon Sep 17 00:00:00 2001 From: Fernando Collado Date: Fri, 3 Jul 2026 08:13:28 +0200 Subject: [PATCH 1/4] fix(zabbix): populate hostname on pulled and pushed alerts Zabbix problem.get() alerts never carried a host, since _get_alerts() never requested host data from the API. Add selectHosts to the request and map it onto AlertDto's service/hostname fields. The webhook push path had a separate bug: the installed script sends the host as host_name/host_ip (matching its own parameter names), but _format_alert() looked for hostName/hostIp, so the fallback silently returned None whenever {HOST.HOST} was empty (e.g. multi-host trigger problems). --- keep/providers/zabbix_provider/zabbix_provider.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/keep/providers/zabbix_provider/zabbix_provider.py b/keep/providers/zabbix_provider/zabbix_provider.py index 6e92a6a638..ba047e41b6 100644 --- a/keep/providers/zabbix_provider/zabbix_provider.py +++ b/keep/providers/zabbix_provider/zabbix_provider.py @@ -529,6 +529,7 @@ def _get_alerts(self) -> list[AlertDto]: { "recent": False, "selectSuppressionData": "extend", + "selectHosts": ["host", "name"], "time_from": time_from, }, ) @@ -546,6 +547,9 @@ def _get_alerts(self) -> list[AlertDto]: problem.pop("status", "").lower(), AlertStatus.FIRING ) + hosts = problem.pop("hosts", None) or [] + hostname = hosts[0].get("name") or hosts[0].get("host") if hosts else None + formatted_alerts.append( AlertDto( id=problem.pop("eventid"), @@ -559,6 +563,8 @@ def _get_alerts(self) -> list[AlertDto]: message=name, severity=severity, environment=environment, + service=hostname, + hostname=hostname, problem=problem, ) ) @@ -735,8 +741,8 @@ def _format_alert( event_id = event.get("id") trigger_id = event.get("triggerId") zabbix_url = event.pop("url", None) - hostname = event.pop("service", None) or event.get("hostName") - ip_address = event.get("hostIp") + hostname = event.pop("service", None) or event.pop("host_name", None) + ip_address = event.pop("host_ip", None) if zabbix_url == "{$ZABBIX.URL}": # This means user did not configure $ZABBIX.URL in Zabbix probably From ef914e64c0749acba2e92ccabb5cf0253fd38efe Mon Sep 17 00:00:00 2001 From: Fernando Collado Date: Fri, 10 Jul 2026 16:29:43 +0200 Subject: [PATCH 2/4] fix(zabbix): keep legacy host field fallbacks for backward compat Per review: don't drop the hostName/hostIp lookups, add host_name/ host_ip alongside them. Also restore HOST.NAME/HOST.IP, the raw macro keys used by the pre-script-based (mediatype) webhook installs that predate #2397 and may still be configured on older Zabbix instances. --- keep/providers/zabbix_provider/zabbix_provider.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/keep/providers/zabbix_provider/zabbix_provider.py b/keep/providers/zabbix_provider/zabbix_provider.py index ba047e41b6..6d416c31ae 100644 --- a/keep/providers/zabbix_provider/zabbix_provider.py +++ b/keep/providers/zabbix_provider/zabbix_provider.py @@ -741,8 +741,18 @@ def _format_alert( event_id = event.get("id") trigger_id = event.get("triggerId") zabbix_url = event.pop("url", None) - hostname = event.pop("service", None) or event.pop("host_name", None) - ip_address = event.pop("host_ip", None) + hostname = ( + event.pop("service", None) + or event.pop("host_name", None) + # Kept for backward compatibility with older Keep-Zabbix webhook installs + or event.pop("hostName", None) + or event.pop("HOST.NAME", None) + ) + ip_address = ( + event.pop("host_ip", None) + or event.pop("hostIp", None) + or event.pop("HOST.IP", None) + ) if zabbix_url == "{$ZABBIX.URL}": # This means user did not configure $ZABBIX.URL in Zabbix probably From e0d33d07aaeb1aa034f17e1e6c56d5984bdb128f Mon Sep 17 00:00:00 2001 From: Fernando Collado Date: Fri, 10 Jul 2026 16:41:13 +0200 Subject: [PATCH 3/4] fix(zabbix): problem.get doesn't support selectHosts, use event.get selectHosts is not a valid problem.get parameter -- Zabbix rejects it with "Invalid parameter: unexpected parameter selectHosts", breaking every poll (confirmed against a live production instance). Fetch hosts via a separate batched event.get call on the same eventids and join them back in by eventid instead. Requires the event.get scope. --- .../zabbix_provider/zabbix_provider.py | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/keep/providers/zabbix_provider/zabbix_provider.py b/keep/providers/zabbix_provider/zabbix_provider.py index 6d416c31ae..42ba73258a 100644 --- a/keep/providers/zabbix_provider/zabbix_provider.py +++ b/keep/providers/zabbix_provider/zabbix_provider.py @@ -89,6 +89,13 @@ class ZabbixProvider(BaseProvider): mandatory_for_webhook=True, documentation_url="https://www.zabbix.com/documentation/current/en/manual/api/reference/event/acknowledge", ), + ProviderScope( + name="event.get", + description="This method allows to retrieve events, used to attach host names to pulled problems.", + mandatory=True, + mandatory_for_webhook=False, + documentation_url="https://www.zabbix.com/documentation/current/en/manual/api/reference/event/get", + ), ProviderScope( name="mediatype.create", description="This method allows to create new media types.", @@ -529,12 +536,32 @@ def _get_alerts(self) -> list[AlertDto]: { "recent": False, "selectSuppressionData": "extend", - "selectHosts": ["host", "name"], "time_from": time_from, }, ) + results = problems.get("result", []) + + # problem.get doesn't support selectHosts, so hosts are fetched separately + # via event.get (same underlying events) and joined back in by eventid. + hostname_by_eventid = {} + eventids = [problem["eventid"] for problem in results] + if eventids: + events = self.__send_request( + "event.get", + { + "eventids": eventids, + "output": ["eventid"], + "selectHosts": ["host", "name"], + }, + ) + for event in events.get("result", []): + hosts = event.get("hosts") or [] + hostname_by_eventid[event["eventid"]] = ( + (hosts[0].get("name") or hosts[0].get("host")) if hosts else None + ) + formatted_alerts = [] - for problem in problems.get("result", []): + for problem in results: name = problem.pop("name") problem.pop("source") @@ -547,8 +574,7 @@ def _get_alerts(self) -> list[AlertDto]: problem.pop("status", "").lower(), AlertStatus.FIRING ) - hosts = problem.pop("hosts", None) or [] - hostname = hosts[0].get("name") or hosts[0].get("host") if hosts else None + hostname = hostname_by_eventid.get(problem["eventid"]) formatted_alerts.append( AlertDto( From bbf54bf8a0e4ddd2a90d62793e1f9af6304701fe Mon Sep 17 00:00:00 2001 From: Fernando Collado Date: Fri, 10 Jul 2026 16:54:01 +0200 Subject: [PATCH 4/4] fix(zabbix): derive status from acknowledged/suppressed on pull problem.get never returns a "status" text field, so the previous STATUS_MAP.get(problem.pop("status", ""), ...) always fell through to FIRING regardless of the problem's real state. Derive status from the acknowledged/suppressed flags problem.get actually returns instead, matching the pattern other providers (Datadog, PagerDuty, Grafana...) already use to report these states from pull. problem.get only returns open problems, so resolved/ok stay unreachable here (as before). Verified against real production problems: suppressed=1 now yields SUPPRESSED, acknowledged=1 yields ACKNOWLEDGED, instead of both showing as FIRING. --- .../providers/zabbix_provider/zabbix_provider.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/keep/providers/zabbix_provider/zabbix_provider.py b/keep/providers/zabbix_provider/zabbix_provider.py index 42ba73258a..c48e0b3955 100644 --- a/keep/providers/zabbix_provider/zabbix_provider.py +++ b/keep/providers/zabbix_provider/zabbix_provider.py @@ -570,9 +570,19 @@ def _get_alerts(self) -> list[AlertDto]: environment = "unknown" severity = self._convert_severity(problem.pop("severity", 1)) - status = ZabbixProvider.STATUS_MAP.get( - problem.pop("status", "").lower(), AlertStatus.FIRING - ) + + # problem.get never returns a "status" field (that's a UI concept, not an + # API field), so this always fell through to the AlertStatus.FIRING default. + # problem.get only returns problems that are still open, so resolved/ok are + # not reachable here; acknowledged/suppressed come from their own flags. + suppressed = problem.pop("suppressed", "0") == "1" + acknowledged = problem.pop("acknowledged", "0") == "1" + if suppressed: + status = AlertStatus.SUPPRESSED + elif acknowledged: + status = AlertStatus.ACKNOWLEDGED + else: + status = AlertStatus.FIRING hostname = hostname_by_eventid.get(problem["eventid"])