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: Backend support for feature health #5023

Merged
merged 29 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
),
Comment on lines +54 to +58
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is the path limited to 100 characters?

Copy link
Member Author

@khvn26 khvn26 Jan 28, 2025

Choose a reason for hiding this comment

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

This is to prevent the business logic from parsing webhook hashes that are unreasonably long. Valid hashes are not expected to be longer than 100 characters.

# 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/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"environments.identities",
"environments.identities.traits",
"features",
"features.feature_health",
"features.import_export",
"features.multivariate",
"features.versioning",
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",
"name",
"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_health"
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 from 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/"
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved
272 changes: 272 additions & 0 deletions api/features/feature_health/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
# Generated by Django 4.2.18 on 2025-01-27 14:15

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_lifecycle.mixins
import simple_history.models
import uuid


class Migration(migrations.Migration):

initial = True

dependencies = [
("features", "0065_make_feature_value_size_configurable"),
("api_keys", "0003_masterapikey_is_admin"),
("environments", "0037_add_uuid_field"),
("projects", "0026_add_change_request_approval_limit_to_projects"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="HistoricalFeatureHealthProvider",
fields=[
(
"id",
models.IntegerField(
auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
(
"name",
models.CharField(
choices=[("Sample", "Sample"), ("Grafana", "Grafana")],
max_length=50,
),
),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField()),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"created_by",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"master_api_key",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="api_keys.masterapikey",
),
),
(
"project",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="projects.project",
),
),
],
options={
"verbose_name": "historical feature health provider",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": "history_date",
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name="HistoricalFeatureHealthEvent",
fields=[
(
"id",
models.IntegerField(
auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
("created_at", models.DateTimeField(blank=True, editable=False)),
(
"type",
models.CharField(
choices=[("UNHEALTHY", "Unhealthy"), ("HEALTHY", "Healthy")],
max_length=50,
),
),
(
"provider_name",
models.CharField(blank=True, max_length=255, null=True),
),
("reason", models.TextField(blank=True, null=True)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField()),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"environment",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="environments.environment",
),
),
(
"feature",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="features.feature",
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"master_api_key",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="api_keys.masterapikey",
),
),
],
options={
"verbose_name": "historical feature health event",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": "history_date",
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name="FeatureHealthEvent",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"uuid",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"type",
models.CharField(
choices=[("UNHEALTHY", "Unhealthy"), ("HEALTHY", "Healthy")],
max_length=50,
),
),
(
"provider_name",
models.CharField(blank=True, max_length=255, null=True),
),
("reason", models.TextField(blank=True, null=True)),
(
"environment",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="feature_health_events",
to="environments.environment",
),
),
(
"feature",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="feature_health_events",
to="features.feature",
),
),
],
options={
"abstract": False,
},
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
),
migrations.CreateModel(
name="FeatureHealthProvider",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"uuid",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
(
"name",
models.CharField(
choices=[("Sample", "Sample"), ("Grafana", "Grafana")],
max_length=50,
),
),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="projects.project",
),
),
],
options={
"unique_together": {("name", "project")},
},
),
]
Empty file.
Loading
Loading