Skip to content

Commit

Permalink
feat: Backend support for feature health
Browse files Browse the repository at this point in the history
- Auditable feature health events
- Sample feature health event provider
- Feature health event ingestion webhook with signed paths
- "Unhealthy" system tag automatically added and removed based on feature health event data
  • Loading branch information
khvn26 committed Jan 21, 2025
1 parent 3557e14 commit 0a13670
Show file tree
Hide file tree
Showing 18 changed files with 469 additions and 0 deletions.
7 changes: 7 additions & 0 deletions api/api/urls/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from environments.identities.traits.views import SDKTraits
from environments.identities.views import SDKIdentities
from environments.sdk.views import SDKEnvironmentAPIView
from features.feature_health.views import feature_health_webhook
from features.views import SDKFeatureStates
from integrations.github.views import github_webhook
from organisations.views import chargebee_webhook
Expand Down Expand Up @@ -49,6 +50,12 @@
# GitHub integration webhook
re_path(r"github-webhook/", github_webhook, name="github-webhook"),
re_path(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"),
# Feature health webhook
re_path(
r"feature-health/(?P<path>.{0,100})$",
feature_health_webhook,
name="feature-health-webhook",
),
# Client SDK urls
re_path(r"^flags/$", SDKFeatureStates.as_view(), name="flags"),
re_path(r"^identities/$", SDKIdentities.as_view(), name="sdk-identities"),
Expand Down
1 change: 1 addition & 0 deletions api/audit/related_object_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ class RelatedObjectType(enum.Enum):
EDGE_IDENTITY = "Edge Identity"
IMPORT_REQUEST = "Import request"
EF_VERSION = "Environment feature version"
FEATURE_HEALTH = "Feature health status"
Empty file.
33 changes: 33 additions & 0 deletions api/features/feature_health/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import typing

from django.contrib import admin
from django.http import HttpRequest

from features.feature_health.models import FeatureHealthProvider
from features.feature_health.services import get_webhook_path_from_provider


@admin.register(FeatureHealthProvider)
class FeatureHealthProviderAdmin(admin.ModelAdmin):
list_display = (
"project",
"type",
"created_by",
"webhook_url",
)

def changelist_view(
self,
request: HttpRequest,
*args: typing.Any,
**kwargs: typing.Any,
) -> None:
self.request = request
return super().changelist_view(request, *args, **kwargs)

def webhook_url(
self,
instance: FeatureHealthProvider,
) -> str:
path = get_webhook_path_from_provider(instance)
return self.request.build_absolute_uri(path)
6 changes: 6 additions & 0 deletions api/features/feature_health/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from core.apps import BaseAppConfig


class FeatureHealthConfig(BaseAppConfig):
name = "features.feature_heath"
default = True
13 changes: 13 additions & 0 deletions api/features/feature_health/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FEATURE_HEALTH_PROVIDER_CREATED_MESSAGE = "Health provider %s set up for project %s."
FEATURE_HEALTH_PROVIDER_DELETED_MESSAGE = "Health provider %s removed for project %s."

FEATURE_HEALTH_EVENT_CREATED_MESSAGE = "Health status changed to %s for feature %s."
FEATURE_HEALTH_EVENT_CREATED_FOR_ENVIRONMENT_MESSAGE = (
"Health status changed to %s for feature %s in environment %s."
)
FEATURE_HEALTH_EVENT_CREATED_PROVIDER_MESSAGE = "\n\nProvided by %s"
FEATURE_HEALTH_EVENT_CREATED_REASON_MESSAGE = "\n\nReason:\n%s"

UNHEALTHY_TAG_COLOUR = "#FFC0CB"

FEATURE_HEALTH_WEBHOOK_PATH_PREFIX = "/api/v1/feature-health/"
127 changes: 127 additions & 0 deletions api/features/feature_health/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import typing

from core.models import (
AbstractBaseExportableModel,
abstract_base_auditable_model_factory,
)
from django.db import models
from django_lifecycle import AFTER_CREATE, LifecycleModelMixin, hook

from audit.related_object_type import RelatedObjectType
from features.feature_health.constants import (
FEATURE_HEALTH_EVENT_CREATED_FOR_ENVIRONMENT_MESSAGE,
FEATURE_HEALTH_EVENT_CREATED_MESSAGE,
FEATURE_HEALTH_EVENT_CREATED_PROVIDER_MESSAGE,
FEATURE_HEALTH_EVENT_CREATED_REASON_MESSAGE,
FEATURE_HEALTH_PROVIDER_CREATED_MESSAGE,
FEATURE_HEALTH_PROVIDER_DELETED_MESSAGE,
)

if typing.TYPE_CHECKING:
from features.models import Feature
from users.models import FFAdminUser


class FeatureHealthProviderType(models.Choices):
SAMPLE = "Sample"
GRAFANA = "Grafana"


class FeatureHealthEventType(models.Choices):
UNHEALTHY = "UNHEALTHY"
HEALTHY = "HEALTHY"


class FeatureHealthProvider(
AbstractBaseExportableModel,
abstract_base_auditable_model_factory(["uuid"]),
):
type = models.CharField(max_length=50, choices=FeatureHealthProviderType.choices)
project = models.ForeignKey("projects.Project", on_delete=models.CASCADE)
created_by = models.ForeignKey("users.FFAdminUser", on_delete=models.CASCADE)

class Meta:
unique_together = ("type", "project")

def get_create_log_message(
self,
history_instance: "FeatureHealthProvider",
) -> str | None:
return FEATURE_HEALTH_PROVIDER_CREATED_MESSAGE % (self.type, self.project.name)

def get_delete_log_message(
self,
history_instance: "FeatureHealthProvider",
) -> str | None:
return FEATURE_HEALTH_PROVIDER_DELETED_MESSAGE % (self.type, self.project.name)

def get_audit_log_author(
self,
history_instance: "FeatureHealthProvider",
) -> "FFAdminUser | None":
return self.created_by


class FeatureHealthEventManager(models.Manager):
def get_latest_by_feature(self, feature: "Feature") -> "FeatureHealthEvent | None":
return self.filter(feature=feature).order_by("-created_at").first()


class FeatureHealthEvent(
LifecycleModelMixin,
AbstractBaseExportableModel,
abstract_base_auditable_model_factory(["uuid"]),
):
"""
Holds the events that are generated when a feature health is changed.
"""

related_object_type = RelatedObjectType.FEATURE_HEALTH

objects: FeatureHealthEventManager = FeatureHealthEventManager()

feature = models.ForeignKey(
"features.Feature",
on_delete=models.CASCADE,
related_name="feature_health_events",
)
environment = models.ForeignKey(
"environments.Environment",
on_delete=models.CASCADE,
related_name="feature_health_events",
null=True,
)

created_at = models.DateTimeField(auto_now_add=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):
from features.feature_health.tasks import update_feature_unhealthy_tag

update_feature_unhealthy_tag.delay(args=(self.feature.id,))

def get_create_log_message(
self,
history_instance: "FeatureHealthEvent",
) -> str | None:
if self.environment:
message = FEATURE_HEALTH_EVENT_CREATED_FOR_ENVIRONMENT_MESSAGE % (
self.type,
self.feature.name,
self.environment.name,
)
else:
message = FEATURE_HEALTH_EVENT_CREATED_MESSAGE % (
self.type,
self.feature.name,
)
if self.provider_name:
message += (
FEATURE_HEALTH_EVENT_CREATED_PROVIDER_MESSAGE % self.provider_name
)
if self.reason:
message += FEATURE_HEALTH_EVENT_CREATED_REASON_MESSAGE % self.reason
return message
Empty file.
5 changes: 5 additions & 0 deletions api/features/feature_health/providers/sample/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from features.feature_health.providers.sample.mappers import (
map_payload_to_provider_response,
)

__all__ = ("map_payload_to_provider_response",)
33 changes: 33 additions & 0 deletions api/features/feature_health/providers/sample/mappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import json

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


def map_sample_event_status_to_feature_health_event_type(
status: SampleEventStatus,
) -> FeatureHealthEventType:
return (
FeatureHealthEventType.UNHEALTHY
if status == "unhealthy"
else FeatureHealthEventType.HEALTHY
)


def map_payload_to_provider_response(
payload: str,
) -> FeatureHealthProviderResponse | None:
event_data: SampleEvent = json.loads(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", ""),
)
10 changes: 10 additions & 0 deletions api/features/feature_health/providers/sample/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import typing

SampleEventStatus: typing.TypeAlias = typing.Literal["healthy", "unhealthy"]


class SampleEvent(typing.TypedDict):
environment: typing.NotRequired[str]
feature: str
status: SampleEventStatus
reason: typing.NotRequired[str]
23 changes: 23 additions & 0 deletions api/features/feature_health/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from rest_framework import serializers

from features.feature_health.models import (
FeatureHealthProvider,
FeatureHealthProviderType,
)
from features.feature_health.services import get_webhook_path_from_provider


class FeatureHealthProviderSerializer(serializers.ModelSerializer):
webhook_url = serializers.SerializerMethodField()

def get_webhook_url(self, instance: FeatureHealthProvider) -> str:
request = self.context["request"]
path = get_webhook_path_from_provider(instance, self.context["request"])
return request.build_absolute_uri(path)

class Meta:
model = FeatureHealthProvider


class CreateFeatureHealthProviderSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=FeatureHealthProviderType.choices)
100 changes: 100 additions & 0 deletions api/features/feature_health/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import typing
import uuid

import structlog
from django.core import signing

from environments.models import Environment
from features.feature_health.constants import (
FEATURE_HEALTH_WEBHOOK_PATH_PREFIX,
UNHEALTHY_TAG_COLOUR,
)
from features.feature_health.models import (
FeatureHealthEvent,
FeatureHealthEventType,
FeatureHealthProvider,
FeatureHealthProviderType,
)
from features.feature_health.providers import sample
from projects.tags.models import Tag, TagType

if typing.TYPE_CHECKING:
from features.feature_health.types import FeatureHealthProviderResponse
from features.models import Feature

logger = structlog.get_logger("feature_health")

_provider_webhook_signer = signing.Signer(sep="/", salt="feature_health")


def get_webhook_path_from_provider(
provider: FeatureHealthProvider,
) -> str:
return FEATURE_HEALTH_WEBHOOK_PATH_PREFIX + _provider_webhook_signer.sign_object(
provider.uuid.hex,
)


def get_provider_from_webhook_path(path: str) -> FeatureHealthProvider | None:
try:
hex_string = _provider_webhook_signer.unsign_object(path)
except signing.BadSignature:
logger.warning("invalid-webhook-path-requested", path=path)
return None
feature_health_provider_uuid = uuid.UUID(hex_string)
return FeatureHealthProvider.objects.filter(
uuid=feature_health_provider_uuid
).first()


def get_provider_response(
provider: FeatureHealthProvider, payload: str
) -> "FeatureHealthProviderResponse | None":
if provider.type == FeatureHealthProviderType.SAMPLE:
return sample.map_payload_to_provider_response(payload)
logger.error("invalid-provider-type-requested", provider_type=provider.type)
return None


def create_feature_health_event_from_webhook(
path: str,
payload: str,
) -> FeatureHealthEvent | None:
if provider := get_provider_from_webhook_path(path):
if response := get_provider_response(provider, payload):
project = provider.project
if feature := Feature.objects.filter(
project=provider.project, name=response.feature_name
).first():
if response.environment_name:
environment = Environment.objects.filter(
project=project, name=response.environment_name
).first()
else:
environment = None
return FeatureHealthEvent.objects.create(
feature=feature,
environment=environment,
type=response.event_type,
provider_name=provider.name,
reason=response.reason,
)
return None


def update_feature_unhealthy_tag(feature: "Feature") -> None:
if feature_health_event := FeatureHealthEvent.objects.get_latest_by_feature(
feature
):
unhealthy_tag, _ = Tag.objects.get_or_create(
name="Unhealthy",
project=feature.project,
defaults={"color": UNHEALTHY_TAG_COLOUR},
is_system_tag=True,
type=TagType.UNHEALTHY,
)
if feature_health_event.type == FeatureHealthEventType.UNHEALTHY:
feature.tags.add(unhealthy_tag)
else:
feature.tags.remove(unhealthy_tag)
feature.save()
10 changes: 10 additions & 0 deletions api/features/feature_health/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from task_processor.decorators import register_task_handler

from features.feature_health import services
from features.models import Feature


@register_task_handler()
def update_feature_unhealthy_tag(feature_id: int) -> None:
if feature := Feature.objects.filter(id=feature_id).first():
services.update_feature_unhealthy_tag(feature)
Loading

0 comments on commit 0a13670

Please sign in to comment.