Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions keep/providers/site24x7_provider/site24x7_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import pydantic
import requests

from keep.api.models.alert import AlertDto, AlertSeverity
from keep.api.models.alert import AlertDto, AlertSeverity, AlertStatus
from keep.contextmanager.contextmanager import ContextManager
from keep.providers.base.base_provider import BaseProvider
from keep.providers.models.provider_config import ProviderConfig, ProviderScope
Expand Down Expand Up @@ -86,6 +86,12 @@ class Site24X7Provider(BaseProvider):
"UP": AlertSeverity.INFO,
"CRITICAL": AlertSeverity.CRITICAL,
}
STATUS_MAP = {
"UP": AlertStatus.RESOLVED,
"DOWN": AlertStatus.FIRING,
"TROUBLE": AlertStatus.FIRING,
"CRITICAL": AlertStatus.FIRING,
}

def __init__(
self, context_manager: ContextManager, provider_id: str, config: ProviderConfig
Expand Down Expand Up @@ -217,13 +223,37 @@ def setup_webhook(
def _format_alert(
event: dict, provider_instance: "BaseProvider" = None
) -> AlertDto:
site24x7_status = event.get("STATUS", "DOWN")

# Parse TAGS field: Site24x7 sends comma-separated "key:value" pairs
# e.g. "env:prod,team:backend" or plain "tagname"
tags_raw = event.get("TAGS", "")
labels = {}
if isinstance(tags_raw, str) and tags_raw:
for tag in tags_raw.split(","):
tag = tag.strip()
if ":" in tag:
key, value = tag.split(":", 1)
labels[key.strip()] = value.strip()
elif tag:
labels[tag] = tag
elif isinstance(tags_raw, dict):
labels = tags_raw

return AlertDto(
url=event.get("MONITORURL", ""),
lastReceived=event.get("INCIDENT_TIME_ISO", ""),
description=event.get("INCIDENT_REASON", ""),
name=event.get("MONITORNAME", ""),
id=event.get("MONITOR_ID", ""),
severity=Site24X7Provider.SEVERITIES_MAP.get(event.get("STATUS", "DOWN")),
severity=Site24X7Provider.SEVERITIES_MAP.get(
site24x7_status, AlertSeverity.WARNING
),
status=Site24X7Provider.STATUS_MAP.get(
site24x7_status, AlertStatus.FIRING
),
labels=labels,
source=["site24x7"],
)

def _get_alerts(self) -> list[AlertDto]:
Expand Down
137 changes: 137 additions & 0 deletions tests/providers/site24x7_provider/test_site24x7_bugs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""
Tests for Site24x7 provider bug fixes.

Bug 1 (issue #6195): STATUS "UP" was never mapped to AlertStatus.RESOLVED —
_format_alert() passed no `status` argument to AlertDto, so it always
defaulted to AlertStatus.FIRING via the root_validator fallback.

Bug 2 (issue #6196): TAGS field from the webhook payload was never read —
_format_alert() ignored TAGS entirely, so AlertDto.labels was always {}.
"""

import pytest

from keep.providers.site24x7_provider.site24x7_provider import Site24X7Provider


def _minimal_event(**kwargs) -> dict:
"""Return a minimal valid Site24x7 webhook payload, with optional overrides."""
base = {
"MONITORURL": "https://example.com",
"INCIDENT_TIME_ISO": "2026-04-04T10:00:00Z",
"INCIDENT_REASON": "Connection refused",
"MONITORNAME": "Website - example.com",
"MONITOR_ID": "12345",
"STATUS": "DOWN",
"TAGS": "",
}
base.update(kwargs)
return base


class TestStatusMapping:
"""Bug #6195: STATUS field must drive AlertDto.status, not just severity."""

def test_status_up_resolves_alert(self):
"""STATUS 'UP' (monitor recovered) must produce AlertStatus.RESOLVED."""
alert = Site24X7Provider._format_alert(_minimal_event(STATUS="UP"))
assert alert.status == "resolved"

def test_status_down_fires_alert(self):
"""STATUS 'DOWN' must produce AlertStatus.FIRING."""
alert = Site24X7Provider._format_alert(_minimal_event(STATUS="DOWN"))
assert alert.status == "firing"

def test_status_trouble_fires_alert(self):
"""STATUS 'TROUBLE' must produce AlertStatus.FIRING."""
alert = Site24X7Provider._format_alert(_minimal_event(STATUS="TROUBLE"))
assert alert.status == "firing"

def test_status_critical_fires_alert(self):
"""STATUS 'CRITICAL' must produce AlertStatus.FIRING."""
alert = Site24X7Provider._format_alert(_minimal_event(STATUS="CRITICAL"))
assert alert.status == "firing"

def test_status_unknown_defaults_to_firing(self):
"""Unknown STATUS values must fall back to AlertStatus.FIRING."""
alert = Site24X7Provider._format_alert(_minimal_event(STATUS="SOMETHING_NEW"))
assert alert.status == "firing"

def test_status_up_preserves_info_severity(self):
"""STATUS 'UP' must still map severity to INFO (existing SEVERITIES_MAP)."""
alert = Site24X7Provider._format_alert(_minimal_event(STATUS="UP"))
assert alert.severity == "info"


class TestTagsParsing:
"""Bug #6196: TAGS field must be parsed and stored in AlertDto.labels."""

def test_tags_key_value_pairs(self):
"""'env:prod,team:backend' must produce {'env': 'prod', 'team': 'backend'}."""
alert = Site24X7Provider._format_alert(
_minimal_event(TAGS="env:prod,team:backend")
)
assert alert.labels == {"env": "prod", "team": "backend"}

def test_tags_empty_string(self):
"""Empty TAGS string must produce an empty labels dict."""
alert = Site24X7Provider._format_alert(_minimal_event(TAGS=""))
assert alert.labels == {}

def test_tags_field_absent(self):
"""Missing TAGS key in payload must produce an empty labels dict."""
event = _minimal_event()
event.pop("TAGS")
alert = Site24X7Provider._format_alert(event)
assert alert.labels == {}

def test_tags_plain_name_without_value(self):
"""Tag without ':' separator must map to {'tagname': 'tagname'}."""
alert = Site24X7Provider._format_alert(_minimal_event(TAGS="tagname"))
assert alert.labels == {"tagname": "tagname"}

def test_tags_mixed_plain_and_kv(self):
"""Mix of plain tags and key:value tags must all be captured."""
alert = Site24X7Provider._format_alert(
_minimal_event(TAGS="env:prod,critical")
)
assert alert.labels == {"env": "prod", "critical": "critical"}

def test_tags_value_containing_colon(self):
"""Tag value may itself contain ':' — only split on the first one."""
alert = Site24X7Provider._format_alert(
_minimal_event(TAGS="url:https://example.com")
)
assert alert.labels == {"url": "https://example.com"}

def test_tags_whitespace_trimmed(self):
"""Spaces around tag names and values must be stripped."""
alert = Site24X7Provider._format_alert(
_minimal_event(TAGS=" env : prod , team : backend ")
)
assert alert.labels == {"env": "prod", "team": "backend"}

def test_tags_as_dict_passed_through(self):
"""If TAGS is already a dict, it must be used as-is."""
alert = Site24X7Provider._format_alert(
_minimal_event(TAGS={"env": "prod", "team": "backend"})
)
assert alert.labels == {"env": "prod", "team": "backend"}


class TestFormatAlertFields:
"""Verify that the other AlertDto fields are still mapped correctly."""

def test_source_is_site24x7(self):
"""source must always be ['site24x7']."""
alert = Site24X7Provider._format_alert(_minimal_event())
assert alert.source == ["site24x7"]

def test_basic_fields_mapped(self):
"""Core fields from the webhook payload must be present."""
alert = Site24X7Provider._format_alert(_minimal_event())
assert "example.com" in str(alert.url)
assert alert.name == "Website - example.com"
assert alert.id == "12345"
assert alert.description == "Connection refused"
assert alert.lastReceived.startswith("2026-04-04T10:00:00")
Loading