Skip to content

Commit 6a4bfab

Browse files
committed
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
1 parent 3557e14 commit 6a4bfab

20 files changed

+775
-0
lines changed

api/api/urls/v1.py

+7
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from environments.identities.traits.views import SDKTraits
99
from environments.identities.views import SDKIdentities
1010
from environments.sdk.views import SDKEnvironmentAPIView
11+
from features.feature_health.views import feature_health_webhook
1112
from features.views import SDKFeatureStates
1213
from integrations.github.views import github_webhook
1314
from organisations.views import chargebee_webhook
@@ -49,6 +50,12 @@
4950
# GitHub integration webhook
5051
re_path(r"github-webhook/", github_webhook, name="github-webhook"),
5152
re_path(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"),
53+
# Feature health webhook
54+
re_path(
55+
r"feature-health/(?P<path>.{0,100})$",
56+
feature_health_webhook,
57+
name="feature-health-webhook",
58+
),
5259
# Client SDK urls
5360
re_path(r"^flags/$", SDKFeatureStates.as_view(), name="flags"),
5461
re_path(r"^identities/$", SDKIdentities.as_view(), name="sdk-identities"),

api/audit/related_object_type.py

+1
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ class RelatedObjectType(enum.Enum):
1010
EDGE_IDENTITY = "Edge Identity"
1111
IMPORT_REQUEST = "Import request"
1212
EF_VERSION = "Environment feature version"
13+
FEATURE_HEALTH = "Feature health status"

api/features/feature_health/__init__.py

Whitespace-only changes.

api/features/feature_health/admin.py

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import typing
2+
3+
from django.contrib import admin
4+
from django.http import HttpRequest
5+
6+
from features.feature_health.models import FeatureHealthProvider
7+
from features.feature_health.services import get_webhook_path_from_provider
8+
9+
10+
@admin.register(FeatureHealthProvider)
11+
class FeatureHealthProviderAdmin(admin.ModelAdmin):
12+
list_display = (
13+
"project",
14+
"type",
15+
"created_by",
16+
"webhook_url",
17+
)
18+
19+
def changelist_view(
20+
self,
21+
request: HttpRequest,
22+
*args: typing.Any,
23+
**kwargs: typing.Any,
24+
) -> None:
25+
self.request = request
26+
return super().changelist_view(request, *args, **kwargs)
27+
28+
def webhook_url(
29+
self,
30+
instance: FeatureHealthProvider,
31+
) -> str:
32+
path = get_webhook_path_from_provider(instance)
33+
return self.request.build_absolute_uri(path)

api/features/feature_health/apps.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from core.apps import BaseAppConfig
2+
3+
4+
class FeatureHealthConfig(BaseAppConfig):
5+
name = "features.feature_heath"
6+
default = True
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FEATURE_HEALTH_PROVIDER_CREATED_MESSAGE = "Health provider %s set up for project %s."
2+
FEATURE_HEALTH_PROVIDER_DELETED_MESSAGE = "Health provider %s removed for project %s."
3+
4+
FEATURE_HEALTH_EVENT_CREATED_MESSAGE = "Health status changed to %s for feature %s."
5+
FEATURE_HEALTH_EVENT_CREATED_FOR_ENVIRONMENT_MESSAGE = (
6+
"Health status changed to %s for feature %s in environment %s."
7+
)
8+
FEATURE_HEALTH_EVENT_CREATED_PROVIDER_MESSAGE = "\n\nProvided by %s"
9+
FEATURE_HEALTH_EVENT_CREATED_REASON_MESSAGE = "\n\nReason:\n%s"
10+
11+
UNHEALTHY_TAG_COLOUR = "#FFC0CB"
12+
13+
FEATURE_HEALTH_WEBHOOK_PATH_PREFIX = "/api/v1/feature-health/"

api/features/feature_health/models.py

+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import typing
2+
3+
from core.models import (
4+
AbstractBaseExportableModel,
5+
abstract_base_auditable_model_factory,
6+
)
7+
from django.db import models
8+
from django_lifecycle import AFTER_CREATE, LifecycleModelMixin, hook
9+
10+
from audit.related_object_type import RelatedObjectType
11+
from features.feature_health.constants import (
12+
FEATURE_HEALTH_EVENT_CREATED_FOR_ENVIRONMENT_MESSAGE,
13+
FEATURE_HEALTH_EVENT_CREATED_MESSAGE,
14+
FEATURE_HEALTH_EVENT_CREATED_PROVIDER_MESSAGE,
15+
FEATURE_HEALTH_EVENT_CREATED_REASON_MESSAGE,
16+
FEATURE_HEALTH_PROVIDER_CREATED_MESSAGE,
17+
FEATURE_HEALTH_PROVIDER_DELETED_MESSAGE,
18+
)
19+
20+
if typing.TYPE_CHECKING:
21+
from features.models import Feature
22+
from users.models import FFAdminUser
23+
24+
25+
class FeatureHealthProviderType(models.Choices):
26+
SAMPLE = "Sample"
27+
GRAFANA = "Grafana"
28+
29+
30+
class FeatureHealthEventType(models.Choices):
31+
UNHEALTHY = "UNHEALTHY"
32+
HEALTHY = "HEALTHY"
33+
34+
35+
class FeatureHealthProvider(
36+
AbstractBaseExportableModel,
37+
abstract_base_auditable_model_factory(["uuid"]),
38+
):
39+
type = models.CharField(max_length=50, choices=FeatureHealthProviderType.choices)
40+
project = models.ForeignKey("projects.Project", on_delete=models.CASCADE)
41+
created_by = models.ForeignKey("users.FFAdminUser", on_delete=models.CASCADE)
42+
43+
class Meta:
44+
unique_together = ("type", "project")
45+
46+
def get_create_log_message(
47+
self,
48+
history_instance: "FeatureHealthProvider",
49+
) -> str | None:
50+
return FEATURE_HEALTH_PROVIDER_CREATED_MESSAGE % (self.type, self.project.name)
51+
52+
def get_delete_log_message(
53+
self,
54+
history_instance: "FeatureHealthProvider",
55+
) -> str | None:
56+
return FEATURE_HEALTH_PROVIDER_DELETED_MESSAGE % (self.type, self.project.name)
57+
58+
def get_audit_log_author(
59+
self,
60+
history_instance: "FeatureHealthProvider",
61+
) -> "FFAdminUser | None":
62+
return self.created_by
63+
64+
65+
class FeatureHealthEventManager(models.Manager):
66+
def get_latest_by_feature(self, feature: "Feature") -> "FeatureHealthEvent | None":
67+
return self.filter(feature=feature).order_by("-created_at").first()
68+
69+
70+
class FeatureHealthEvent(
71+
LifecycleModelMixin,
72+
AbstractBaseExportableModel,
73+
abstract_base_auditable_model_factory(["uuid"]),
74+
):
75+
"""
76+
Holds the events that are generated when a feature health is changed.
77+
"""
78+
79+
related_object_type = RelatedObjectType.FEATURE_HEALTH
80+
81+
objects: FeatureHealthEventManager = FeatureHealthEventManager()
82+
83+
feature = models.ForeignKey(
84+
"features.Feature",
85+
on_delete=models.CASCADE,
86+
related_name="feature_health_events",
87+
)
88+
environment = models.ForeignKey(
89+
"environments.Environment",
90+
on_delete=models.CASCADE,
91+
related_name="feature_health_events",
92+
null=True,
93+
)
94+
95+
created_at = models.DateTimeField(auto_now_add=True)
96+
type = models.CharField(max_length=50, choices=FeatureHealthEventType.choices)
97+
provider_name = models.CharField(max_length=255, null=True, blank=True)
98+
reason = models.TextField(null=True, blank=True)
99+
100+
@hook(AFTER_CREATE)
101+
def set_feature_health_tag(self):
102+
from features.feature_health.tasks import update_feature_unhealthy_tag
103+
104+
update_feature_unhealthy_tag.delay(args=(self.feature.id,))
105+
106+
def get_create_log_message(
107+
self,
108+
history_instance: "FeatureHealthEvent",
109+
) -> str | None:
110+
if self.environment:
111+
message = FEATURE_HEALTH_EVENT_CREATED_FOR_ENVIRONMENT_MESSAGE % (
112+
self.type,
113+
self.feature.name,
114+
self.environment.name,
115+
)
116+
else:
117+
message = FEATURE_HEALTH_EVENT_CREATED_MESSAGE % (
118+
self.type,
119+
self.feature.name,
120+
)
121+
if self.provider_name:
122+
message += (
123+
FEATURE_HEALTH_EVENT_CREATED_PROVIDER_MESSAGE % self.provider_name
124+
)
125+
if self.reason:
126+
message += FEATURE_HEALTH_EVENT_CREATED_REASON_MESSAGE % self.reason
127+
return message

api/features/feature_health/providers/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from features.feature_health.providers.sample.mappers import (
2+
map_payload_to_provider_response,
3+
)
4+
5+
__all__ = ("map_payload_to_provider_response",)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import json
2+
3+
from features.feature_health.models import FeatureHealthEventType
4+
from features.feature_health.providers.sample.types import (
5+
SampleEvent,
6+
SampleEventStatus,
7+
)
8+
from features.feature_health.types import FeatureHealthProviderResponse
9+
10+
11+
def map_sample_event_status_to_feature_health_event_type(
12+
status: SampleEventStatus,
13+
) -> FeatureHealthEventType:
14+
return (
15+
FeatureHealthEventType.UNHEALTHY
16+
if status == "unhealthy"
17+
else FeatureHealthEventType.HEALTHY
18+
)
19+
20+
21+
def map_payload_to_provider_response(
22+
payload: str,
23+
) -> FeatureHealthProviderResponse | None:
24+
event_data: SampleEvent = json.loads(payload)
25+
26+
return FeatureHealthProviderResponse(
27+
feature_name=event_data["feature"],
28+
environment_name=event_data.get("environment"),
29+
event_type=map_sample_event_status_to_feature_health_event_type(
30+
event_data["status"]
31+
),
32+
reason=event_data.get("reason", ""),
33+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import typing
2+
3+
SampleEventStatus: typing.TypeAlias = typing.Literal["healthy", "unhealthy"]
4+
5+
6+
class SampleEvent(typing.TypedDict):
7+
environment: typing.NotRequired[str]
8+
feature: str
9+
status: SampleEventStatus
10+
reason: typing.NotRequired[str]
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from rest_framework import serializers
2+
3+
from features.feature_health.models import (
4+
FeatureHealthProvider,
5+
FeatureHealthProviderType,
6+
)
7+
from features.feature_health.services import get_webhook_path_from_provider
8+
9+
10+
class FeatureHealthProviderSerializer(serializers.ModelSerializer):
11+
created_by = serializers.SlugRelatedField(slug_field="email", read_only=True)
12+
webhook_url = serializers.SerializerMethodField()
13+
14+
def get_webhook_url(self, instance: FeatureHealthProvider) -> str:
15+
request = self.context["request"]
16+
path = get_webhook_path_from_provider(instance, self.context["request"])
17+
return request.build_absolute_uri(path)
18+
19+
class Meta:
20+
model = FeatureHealthProvider
21+
fields = (
22+
"uuid",
23+
"type",
24+
"project",
25+
"created_by",
26+
"webhook_url",
27+
)
28+
29+
30+
class CreateFeatureHealthProviderSerializer(serializers.Serializer):
31+
type = serializers.ChoiceField(choices=FeatureHealthProviderType.choices)
+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import typing
2+
import uuid
3+
4+
import structlog
5+
from django.core import signing
6+
7+
from environments.models import Environment
8+
from features.feature_health.constants import (
9+
FEATURE_HEALTH_WEBHOOK_PATH_PREFIX,
10+
UNHEALTHY_TAG_COLOUR,
11+
)
12+
from features.feature_health.models import (
13+
FeatureHealthEvent,
14+
FeatureHealthEventType,
15+
FeatureHealthProvider,
16+
FeatureHealthProviderType,
17+
)
18+
from features.feature_health.providers import sample
19+
from projects.tags.models import Tag, TagType
20+
21+
if typing.TYPE_CHECKING:
22+
from features.feature_health.types import FeatureHealthProviderResponse
23+
from features.models import Feature
24+
25+
logger = structlog.get_logger("feature_health")
26+
27+
_provider_webhook_signer = signing.Signer(sep="/", salt="feature_health")
28+
29+
30+
def get_webhook_path_from_provider(
31+
provider: FeatureHealthProvider,
32+
) -> str:
33+
return FEATURE_HEALTH_WEBHOOK_PATH_PREFIX + _provider_webhook_signer.sign_object(
34+
provider.uuid.hex,
35+
)
36+
37+
38+
def get_provider_from_webhook_path(path: str) -> FeatureHealthProvider | None:
39+
try:
40+
hex_string = _provider_webhook_signer.unsign_object(path)
41+
except signing.BadSignature:
42+
logger.warning("invalid-webhook-path-requested", path=path)
43+
return None
44+
feature_health_provider_uuid = uuid.UUID(hex_string)
45+
return FeatureHealthProvider.objects.filter(
46+
uuid=feature_health_provider_uuid
47+
).first()
48+
49+
50+
def get_provider_response(
51+
provider: FeatureHealthProvider, payload: str
52+
) -> "FeatureHealthProviderResponse | None":
53+
if provider.type == FeatureHealthProviderType.SAMPLE:
54+
return sample.map_payload_to_provider_response(payload)
55+
logger.error("invalid-provider-type-requested", provider_type=provider.type)
56+
return None
57+
58+
59+
def create_feature_health_event_from_webhook(
60+
path: str,
61+
payload: str,
62+
) -> FeatureHealthEvent | None:
63+
if provider := get_provider_from_webhook_path(path):
64+
if response := get_provider_response(provider, payload):
65+
project = provider.project
66+
if feature := Feature.objects.filter(
67+
project=provider.project, name=response.feature_name
68+
).first():
69+
if response.environment_name:
70+
environment = Environment.objects.filter(
71+
project=project, name=response.environment_name
72+
).first()
73+
else:
74+
environment = None
75+
return FeatureHealthEvent.objects.create(
76+
feature=feature,
77+
environment=environment,
78+
type=response.event_type,
79+
provider_name=provider.name,
80+
reason=response.reason,
81+
)
82+
return None
83+
84+
85+
def update_feature_unhealthy_tag(feature: "Feature") -> None:
86+
if feature_health_event := FeatureHealthEvent.objects.get_latest_by_feature(
87+
feature
88+
):
89+
unhealthy_tag, _ = Tag.objects.get_or_create(
90+
name="Unhealthy",
91+
project=feature.project,
92+
defaults={"color": UNHEALTHY_TAG_COLOUR},
93+
is_system_tag=True,
94+
type=TagType.UNHEALTHY,
95+
)
96+
if feature_health_event.type == FeatureHealthEventType.UNHEALTHY:
97+
feature.tags.add(unhealthy_tag)
98+
else:
99+
feature.tags.remove(unhealthy_tag)
100+
feature.save()

0 commit comments

Comments
 (0)