Skip to content

Commit

Permalink
feat: Grafana feature health provider (#5098)
Browse files Browse the repository at this point in the history
  • Loading branch information
khvn26 authored Feb 19, 2025
1 parent 4e1ba8b commit 210519e
Show file tree
Hide file tree
Showing 29 changed files with 1,000 additions and 109 deletions.
25 changes: 24 additions & 1 deletion api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,21 @@ django-make-migrations:
poetry run python manage.py waitfordb
poetry run python manage.py makemigrations $(opts)

.PHONY: django-squash-migrations
django-squash-migrations:
poetry run python manage.py waitfordb
poetry run python manage.py squashmigrations $(opts)

.PHONY: django-migrate
django-migrate:
poetry run python manage.py waitfordb
poetry run python manage.py migrate
poetry run python manage.py createcachetable

.PHONY: django-shell
django-shell:
poetry run python manage.py shell

.PHONY: django-collect-static
django-collect-static:
poetry run python manage.py collectstatic --noinput
Expand All @@ -98,7 +107,7 @@ serve:
generate-ld-client-types:
curl -sSL https://app.launchdarkly.com/api/v2/openapi.json | \
npx openapi-format /dev/fd/0 \
--filterFile ld-openapi-filter.yaml | \
--filterFile openapi-filter-launchdarkly.yaml | \
datamodel-codegen \
--output integrations/launch_darkly/types.py \
--output-model-type typing.TypedDict \
Expand All @@ -108,6 +117,20 @@ generate-ld-client-types:
--wrap-string-literal \
--special-field-name-prefix=

.PHONY: generate-grafana-client-types
generate-grafana-client-types:
curl -sSL https://raw.githubusercontent.com/grafana/grafana/refs/heads/main/public/openapi3.json | \
npx openapi-format /dev/fd/0 \
--filterFile openapi-filter-grafana.yaml | \
datamodel-codegen \
--output integrations/grafana/types.py \
--output-model-type typing.TypedDict \
--target-python-version 3.10 \
--use-double-quotes \
--use-standard-collections \
--wrap-string-literal \
--special-field-name-prefix=

.PHONY: integrate-private-tests
integrate-private-tests:
$(eval WORKFLOW_REVISION := $(shell grep -A 1 "\[tool.poetry.group.workflows.dependencies\]" pyproject.toml | awk -F '"' '{printf $$4}'))
Expand Down
32 changes: 32 additions & 0 deletions api/features/feature_health/mappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import json
import typing

from features.feature_health.models import FeatureHealthEvent
from features.feature_health.types import FeatureHealthEventData

if typing.TYPE_CHECKING:
from environments.models import Environment
from features.models import Feature


def map_feature_health_event_data_to_feature_health_event(
*,
feature_health_event_data: FeatureHealthEventData,
feature: "Feature",
environment: "Environment | None",
) -> FeatureHealthEvent:
if reason := feature_health_event_data.reason:
instance_reason = json.dumps(reason)
else:
instance_reason = None
instance = FeatureHealthEvent(
feature=feature,
environment=environment,
type=feature_health_event_data.type.value,
reason=instance_reason,
external_id=feature_health_event_data.external_id,
provider_name=feature_health_event_data.provider_name,
)
if feature_health_event_data.created_at:
instance.created_at = feature_health_event_data.created_at
return instance
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Generated by Django 4.2.18 on 2025-02-17 15:13

from django.db import migrations, models
import django.utils.timezone


class Migration(migrations.Migration):

dependencies = [
("feature_health", "0001_initial"),
]

operations = [
migrations.AddField(
model_name="featurehealthevent",
name="external_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="historicalfeaturehealthevent",
name="external_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="featurehealthevent",
name="created_at",
field=models.DateTimeField(
blank=True, default=django.utils.timezone.now, editable=False
),
),
migrations.AlterField(
model_name="historicalfeaturehealthevent",
name="created_at",
field=models.DateTimeField(
blank=True, default=django.utils.timezone.now, editable=False
),
),
]
40 changes: 27 additions & 13 deletions api/features/feature_health/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
abstract_base_auditable_model_factory,
)
from django.db import models
from django_lifecycle import AFTER_CREATE, LifecycleModelMixin, hook # type: ignore[import-untyped]
from django.utils import timezone

from audit.related_object_type import RelatedObjectType
from features.feature_health.constants import (
Expand Down Expand Up @@ -83,8 +83,17 @@ def get_latest_by_feature(
) -> "models.QuerySet[FeatureHealthEvent]":
return (
self.filter(feature=feature)
.order_by("provider_name", "environment_id", "-created_at")
.distinct("provider_name", "environment_id")
.order_by(
"provider_name",
"environment_id",
"external_id",
"-created_at",
)
.distinct(
"provider_name",
"environment_id",
"external_id",
)
)

def get_latest_by_project(
Expand All @@ -93,13 +102,23 @@ def get_latest_by_project(
) -> "models.QuerySet[FeatureHealthEvent]":
return (
self.filter(feature__project=project)
.order_by("provider_name", "environment_id", "feature_id", "-created_at")
.distinct("provider_name", "environment_id", "feature_id")
.order_by(
"provider_name",
"environment_id",
"external_id",
"feature_id",
"-created_at",
)
.distinct(
"provider_name",
"environment_id",
"external_id",
"feature_id",
)
)


class FeatureHealthEvent(
LifecycleModelMixin, # type: ignore[misc]
AbstractBaseExportableModel,
abstract_base_auditable_model_factory(["uuid"]), # type: ignore[misc]
):
Expand All @@ -126,16 +145,11 @@ class FeatureHealthEvent(
null=True,
)

created_at = models.DateTimeField(auto_now_add=True)
created_at = models.DateTimeField(default=timezone.now, editable=False, blank=True)
type = models.CharField(max_length=50, choices=FeatureHealthEventType.choices)
provider_name = models.CharField(max_length=255, null=True, blank=True)
reason = models.TextField(null=True, blank=True)

@hook(AFTER_CREATE)
def set_feature_health_tag(self): # type: ignore[no-untyped-def]
from features.feature_health.tasks import update_feature_unhealthy_tag

update_feature_unhealthy_tag.delay(args=(self.feature.id,))
external_id = models.CharField(max_length=255, null=True, blank=True)

def get_create_log_message(
self,
Expand Down
5 changes: 5 additions & 0 deletions api/features/feature_health/providers/grafana/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from features.feature_health.providers.grafana.services import (
get_provider_response,
)

__all__ = ("get_provider_response",)
2 changes: 2 additions & 0 deletions api/features/feature_health/providers/grafana/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
GRAFANA_FEATURE_LABEL_NAME = "flagsmith_feature"
GRAFANA_ENVIRONMENT_LABEL_NAME = "flagsmith_environment"
83 changes: 83 additions & 0 deletions api/features/feature_health/providers/grafana/mappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from features.feature_health.models import (
FeatureHealthEventType,
FeatureHealthProviderName,
)
from features.feature_health.providers.grafana.constants import (
GRAFANA_ENVIRONMENT_LABEL_NAME,
GRAFANA_FEATURE_LABEL_NAME,
)
from features.feature_health.providers.grafana.types import (
GrafanaAlertInstance,
GrafanaWebhookData,
)
from features.feature_health.types import (
FeatureHealthEventData,
FeatureHealthEventReason,
)


def map_payload_to_alert_instances(payload: str) -> list[GrafanaAlertInstance]:
webhook_data = GrafanaWebhookData.model_validate_json(payload)

return webhook_data.alerts


def map_alert_instance_to_feature_health_event_reason(
alert_instance: GrafanaAlertInstance,
) -> FeatureHealthEventReason:
reason_data: FeatureHealthEventReason = {"text_blocks": [], "url_blocks": []}

annotations = alert_instance.annotations

# Populate text blocks.
alert_name = alert_instance.labels.get("alertname") or "Alertmanager Alert"
description = annotations.get("description") or ""
reason_data["text_blocks"].append(
{
"title": alert_name,
"text": description,
}
)
if summary := annotations.get("summary"):
reason_data["text_blocks"].append(
{
"title": "Summary",
"text": summary,
}
)

# Populate URL blocks.
reason_data["url_blocks"].append(
{"title": "Alert", "url": alert_instance.generatorURL}
)
if dashboard_url := alert_instance.dashboardURL:
reason_data["url_blocks"].append({"title": "Dashboard", "url": dashboard_url})
if panel_url := alert_instance.panelURL:
reason_data["url_blocks"].append({"title": "Panel", "url": panel_url})
if runbook_url := annotations.get("runbook_url"):
reason_data["url_blocks"].append({"title": "Runbook", "url": runbook_url})

return reason_data


def map_alert_instance_to_feature_health_event_data(
alert_instance: GrafanaAlertInstance,
) -> FeatureHealthEventData | None:
labels = alert_instance.labels
if feature_name := labels.get(GRAFANA_FEATURE_LABEL_NAME):
if alert_instance.status == "firing":
created_at = alert_instance.startsAt
event_type = FeatureHealthEventType.UNHEALTHY
else:
created_at = alert_instance.endsAt
event_type = FeatureHealthEventType.HEALTHY
return FeatureHealthEventData(
created_at=created_at,
feature_name=feature_name,
environment_name=labels.get(GRAFANA_ENVIRONMENT_LABEL_NAME),
type=event_type,
external_id=alert_instance.fingerprint,
reason=map_alert_instance_to_feature_health_event_reason(alert_instance),
provider_name=FeatureHealthProviderName.GRAFANA.value,
)
return None
20 changes: 20 additions & 0 deletions api/features/feature_health/providers/grafana/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from features.feature_health.providers.grafana.mappers import (
map_alert_instance_to_feature_health_event_data,
map_payload_to_alert_instances,
)
from features.feature_health.types import (
FeatureHealthEventData,
FeatureHealthProviderResponse,
)


def get_provider_response(payload: str) -> FeatureHealthProviderResponse:
events: list[FeatureHealthEventData] = []

for alert_instance in map_payload_to_alert_instances(payload):
if event_data := map_alert_instance_to_feature_health_event_data(
alert_instance
):
events.append(event_data)

return FeatureHealthProviderResponse(events=events)
23 changes: 23 additions & 0 deletions api/features/feature_health/providers/grafana/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from datetime import datetime

from pydantic import BaseModel


class AlertmanagerAlertInstance(BaseModel):
annotations: dict[str, str]
generatorURL: str
endsAt: datetime
fingerprint: str
labels: dict[str, str]
startsAt: datetime
status: str


class GrafanaAlertInstance(AlertmanagerAlertInstance):
dashboardURL: str = ""
panelURL: str = ""
silenceURL: str = ""


class GrafanaWebhookData(BaseModel):
alerts: list[GrafanaAlertInstance]
6 changes: 3 additions & 3 deletions api/features/feature_health/providers/sample/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from features.feature_health.providers.sample.mappers import (
map_payload_to_provider_response,
from features.feature_health.providers.sample.services import (
get_provider_response,
)

__all__ = ("map_payload_to_provider_response",)
__all__ = ("get_provider_response",)
33 changes: 23 additions & 10 deletions api/features/feature_health/providers/sample/mappers.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import json
from pydantic.type_adapter import TypeAdapter

from features.feature_health.models import FeatureHealthEventType
from features.feature_health.models import (
FeatureHealthEventType,
FeatureHealthProviderName,
)
from features.feature_health.providers.sample.types import (
SampleEvent,
SampleEventStatus,
)
from features.feature_health.types import FeatureHealthProviderResponse
from features.feature_health.types import (
FeatureHealthEventData,
FeatureHealthProviderResponse,
)

_sample_event_type_adapter = TypeAdapter(SampleEvent)


def map_sample_event_status_to_feature_health_event_type(
Expand All @@ -21,13 +29,18 @@ def map_sample_event_status_to_feature_health_event_type(
def map_payload_to_provider_response(
payload: str,
) -> FeatureHealthProviderResponse:
event_data: SampleEvent = json.loads(payload)
event_data: SampleEvent = _sample_event_type_adapter.validate_json(payload)

return FeatureHealthProviderResponse(
feature_name=event_data["feature"],
environment_name=event_data.get("environment"),
event_type=map_sample_event_status_to_feature_health_event_type(
event_data["status"]
),
reason=event_data.get("reason", ""),
events=[
FeatureHealthEventData(
feature_name=event_data["feature"],
environment_name=event_data.get("environment"),
type=map_sample_event_status_to_feature_health_event_type(
event_data["status"]
),
reason=event_data.get("reason"),
provider_name=FeatureHealthProviderName.SAMPLE.value,
),
],
)
Loading

0 comments on commit 210519e

Please sign in to comment.