Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Grafana feature health provider #5098

Merged
merged 11 commits into from
Feb 19, 2025
Merged
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)
Comment on lines +80 to +83
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this added here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm used to interacting with Django environment through make. The Makefile loads the appropriate environment for me. During this PR development, I added some new migrations, but I only want to merge one, so I'm squashing them. See

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If they were both added here though, why not delete them both, and just recreate a single migration?

Copy link
Member Author

@khvn26 khvn26 Feb 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because I care about other engineers that play with the PR's Docker images against their local databases. With squashed migrations, they won't have to recreate their schemas.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... I don't think we should, but ok.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This thread is now irrelevant as per 7ffe3b6.


.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
Loading