Skip to content

Commit

Permalink
feat: add filter affected services internal endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
matiasb committed Jan 14, 2025
1 parent dcb3741 commit 507d9ed
Show file tree
Hide file tree
Showing 5 changed files with 129 additions and 15 deletions.
41 changes: 41 additions & 0 deletions engine/apps/api/tests/test_alert_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -2413,3 +2413,44 @@ def test_filter_default_started_at(
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["pk"] == old_alert_group.public_primary_key


@pytest.mark.django_db
def test_alert_group_affected_services(
alert_group_internal_api_setup,
make_user_for_organization,
make_user_auth_headers,
make_alert_group_label_association,
):
_, token, alert_groups = alert_group_internal_api_setup
resolved_ag, ack_ag, new_ag, silenced_ag = alert_groups
organization = new_ag.channel.organization
user = make_user_for_organization(organization)

# set firing alert group service label
make_alert_group_label_association(organization, new_ag, key_name="service_name", value_name="service-a")
# set other service name labels for other alert groups
make_alert_group_label_association(organization, ack_ag, key_name="service_name", value_name="service-2")
make_alert_group_label_association(organization, resolved_ag, key_name="service_name", value_name="service-3")
make_alert_group_label_association(organization, silenced_ag, key_name="service_name", value_name="service-4")

client = APIClient()
url = reverse("api-internal:alertgroup-filter-affected-services")

url = f"{url}?service=service-1&service=service-2&service=service-3&service=service-a"
response = client.get(url, format="json", **make_user_auth_headers(user, token))

assert response.status_code == status.HTTP_200_OK
expected = [
{
"name": "service-2",
"service_url": "a/grafana-slo-app/service/service-2",
"alert_groups_url": "a/grafana-oncall-app/alert-groups?status=0&status=1&started_at=now-7d_now&label=service_name:service-2",
},
{
"name": "service-a",
"service_url": "a/grafana-slo-app/service/service-a",
"alert_groups_url": "a/grafana-oncall-app/alert-groups?status=0&status=1&started_at=now-7d_now&label=service_name:service-a",
},
]
assert response.json() == expected
93 changes: 78 additions & 15 deletions engine/apps/api/views/alert_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from apps.api.serializers.team import TeamSerializer
from apps.auth_token.auth import PluginAuthentication
from apps.base.models.user_notification_policy_log_record import UserNotificationPolicyLogRecord
from apps.grafana_plugin.ui_url_builder import UIURLBuilder
from apps.labels.utils import is_labels_feature_enabled
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.user_management.models import Team, User
Expand Down Expand Up @@ -283,6 +284,7 @@ class AlertGroupView(
"bulk_action": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"preview_template": [RBACPermission.Permissions.INTEGRATIONS_TEST],
"escalation_snapshot": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"filter_affected_services": [RBACPermission.Permissions.ALERT_GROUPS_READ],
}

queryset = AlertGroup.objects.none() # needed for drf-spectacular introspection
Expand All @@ -299,9 +301,18 @@ def get_serializer_class(self):

return super().get_serializer_class()

def get_queryset(self, ignore_filtering_by_available_teams=False):
# no select_related or prefetch_related is used at this point, it will be done on paginate_queryset.

def _get_queryset(
self,
action=None,
ignore_filtering_by_available_teams=False,
team_values=None,
started_at=None,
label_query=None,
):
# make base get_queryset reusable via params
if action is None:
# assume stats by default
action = "stats"
alert_receive_channels_qs = AlertReceiveChannel.objects_with_deleted.filter(
organization_id=self.request.auth.organization.id
)
Expand All @@ -310,7 +321,6 @@ def get_queryset(self, ignore_filtering_by_available_teams=False):

# Filter by team(s). Since we really filter teams from integrations, this is not an AlertGroup model filter.
# This is based on the common.api_helpers.ByTeamModelFieldFilterMixin implementation
team_values = self.request.query_params.getlist("team", [])
if team_values:
null_team_lookup = Q(team__isnull=True) if NO_TEAM_VALUE in team_values else None
teams_lookup = Q(team__public_primary_key__in=[ppk for ppk in team_values if ppk != NO_TEAM_VALUE])
Expand All @@ -321,10 +331,10 @@ def get_queryset(self, ignore_filtering_by_available_teams=False):
alert_receive_channels_ids = list(alert_receive_channels_qs.values_list("id", flat=True))
queryset = AlertGroup.objects.filter(channel__in=alert_receive_channels_ids)

if self.action in ("list", "stats") and not self.request.query_params.get("started_at"):
if action in ("list", "stats") and not started_at:
queryset = queryset.filter(started_at__gte=timezone.now() - timezone.timedelta(days=30))

if self.action in ("list", "stats") and settings.ALERT_GROUPS_DISABLE_PREFER_ORDERING_INDEX:
if action in ("list", "stats") and settings.ALERT_GROUPS_DISABLE_PREFER_ORDERING_INDEX:
# workaround related to MySQL "ORDER BY LIMIT Query Optimizer Bug"
# read more: https://hackmysql.com/infamous-order-by-limit-query-optimizer-bug/
from django_mysql.models import add_QuerySetMixin
Expand All @@ -333,18 +343,28 @@ def get_queryset(self, ignore_filtering_by_available_teams=False):
queryset = queryset.force_index("alert_group_list_index")

# Filter by labels. Since alert group labels are "static" filter by names, not IDs.
label_query = self.request.query_params.getlist("label", [])
kv_pairs = parse_label_query(label_query)
for key, value in kv_pairs:
# Utilize (organization, key_name, value_name, alert_group) index on AlertGroupAssociatedLabel
queryset = queryset.filter(
labels__organization=self.request.auth.organization,
labels__key_name=key,
labels__value_name=value,
)
if label_query:
kv_pairs = parse_label_query(label_query)
for key, value in kv_pairs:
# Utilize (organization, key_name, value_name, alert_group) index on AlertGroupAssociatedLabel
queryset = queryset.filter(
labels__organization=self.request.auth.organization,
labels__key_name=key,
labels__value_name=value,
)

return queryset

def get_queryset(self, ignore_filtering_by_available_teams=False):
# no select_related or prefetch_related is used at this point, it will be done on paginate_queryset.
return self._get_queryset(
action=self.action,
ignore_filtering_by_available_teams=ignore_filtering_by_available_teams,
team_values=self.request.query_params.getlist("team", []),
started_at=self.request.query_params.get("started_at"),
label_query=self.request.query_params.getlist("label", []),
)

def get_object(self):
obj = super().get_object()
obj = self.enrich([obj])[0]
Expand Down Expand Up @@ -881,3 +901,46 @@ def escalation_snapshot(self, request, pk=None):
escalation_snapshot = alert_group.escalation_snapshot
result = AlertGroupEscalationSnapshotAPISerializer(escalation_snapshot).data if escalation_snapshot else {}
return Response(result)

@extend_schema(
responses=inline_serializer(
name="AffectedServices",
fields={
"name": serializers.CharField(),
"service_url": serializers.CharField(),
"alert_groups_url": serializers.CharField(),
},
many=True,
)
)
@action(methods=["get"], detail=False)
def filter_affected_services(self, request):
"""Given a list of service names, return the ones that have active alerts."""
organization = self.request.auth.organization
services = self.request.query_params.getlist("service", [])
url_builder = UIURLBuilder(organization)
affected_services = []
days_to_check = 7
for service_name in services:
is_affected = (
self._get_queryset(
started_at=timezone.now() - timezone.timedelta(days=days_to_check),
label_query=[f"service_name:{service_name}"],
)
.filter(
resolved=False,
silenced=False,
)
.exists()
)
if is_affected:
affected_services.append(
{
"name": service_name,
"service_url": url_builder.service_page(service_name),
"alert_groups_url": url_builder.alert_groups(
f"?status=0&status=1&started_at=now-{days_to_check}d_now&label=service_name:{service_name}"
),
}
)
return Response(affected_services)
6 changes: 6 additions & 0 deletions engine/apps/grafana_plugin/tests/test_ui_url_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,9 @@ def test_build_url_overriden_base_url(org_setup):
@pytest.mark.django_db
def test_build_url_works_for_irm_and_oncall_plugins(org_setup, is_grafana_irm_enabled, expected_url):
assert UIURLBuilder(org_setup(is_grafana_irm_enabled)).alert_group_detail(ALERT_GROUP_ID) == expected_url


@pytest.mark.django_db
def test_build_url_service_detail_page(org_setup):
builder = UIURLBuilder(org_setup())
assert builder.service_page("service-a") == f"{GRAFANA_URL}/a/{PluginID.SLO}/service/service-a"
3 changes: 3 additions & 0 deletions engine/apps/grafana_plugin/ui_url_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,6 @@ def settings(self, path_extra: str = "") -> str:

def declare_incident(self, path_extra: str = "") -> str:
return self._build_url("incidents/declare", path_extra, plugin_id=PluginID.INCIDENT)

def service_page(self, service_name: str, path_extra: str = "") -> str:
return self._build_url(f"service/{service_name}", path_extra, plugin_id=PluginID.SLO)
1 change: 1 addition & 0 deletions engine/common/constants/plugin_ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ class PluginID:
INCIDENT = "grafana-incident-app"
LABELS = "grafana-labels-app"
ML = "grafana-ml-app"
SLO = "grafana-slo-app"

0 comments on commit 507d9ed

Please sign in to comment.