-
Notifications
You must be signed in to change notification settings - Fork 412
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Backend support for feature health
- 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
Showing
18 changed files
with
469 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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",) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", ""), | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Oops, something went wrong.