From 210519e44364b4eece64465f0c9e6c1741aaf36c Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 19 Feb 2025 19:30:50 +0000 Subject: [PATCH] feat: Grafana feature health provider (#5098) --- api/Makefile | 25 +- api/features/feature_health/mappers.py | 32 +++ ...hevent_add_external_id_alter_created_at.py | 38 +++ api/features/feature_health/models.py | 40 ++- .../providers/grafana/__init__.py | 5 + .../providers/grafana/constants.py | 2 + .../providers/grafana/mappers.py | 83 ++++++ .../providers/grafana/services.py | 20 ++ .../feature_health/providers/grafana/types.py | 23 ++ .../providers/sample/__init__.py | 6 +- .../providers/sample/mappers.py | 33 ++- .../providers/sample/services.py | 8 + .../feature_health/providers/sample/types.py | 8 +- api/features/feature_health/serializers.py | 44 ++- api/features/feature_health/services.py | 92 +++--- api/features/feature_health/types.py | 38 ++- api/features/feature_health/views.py | 4 +- api/integrations/grafana/mappers.py | 4 +- api/integrations/grafana/types.py | 54 +++- api/openapi-filter-grafana.yaml | 10 + ....yaml => openapi-filter-launchdarkly.yaml} | 0 .../features/feature_health/conftest.py | 30 +- .../features/feature_health/test_views.py | 261 +++++++++++++++++- .../features/feature_health/test_services.py | 4 +- docs/docs/advanced-use/feature-health.md | 96 +++++++ docs/docs/integrations/apm/_category_.json | 4 +- docs/docs/integrations/apm/grafana.md | 68 +++-- docs/docs/integrations/index.md | 3 +- .../feature-health-sample-provider.json | 74 +++++ 29 files changed, 1000 insertions(+), 109 deletions(-) create mode 100644 api/features/feature_health/mappers.py create mode 100644 api/features/feature_health/migrations/0002_featurehealthevent_add_external_id_alter_created_at.py create mode 100644 api/features/feature_health/providers/grafana/__init__.py create mode 100644 api/features/feature_health/providers/grafana/constants.py create mode 100644 api/features/feature_health/providers/grafana/mappers.py create mode 100644 api/features/feature_health/providers/grafana/services.py create mode 100644 api/features/feature_health/providers/grafana/types.py create mode 100644 api/features/feature_health/providers/sample/services.py create mode 100644 api/openapi-filter-grafana.yaml rename api/{ld-openapi-filter.yaml => openapi-filter-launchdarkly.yaml} (100%) create mode 100644 docs/docs/advanced-use/feature-health.md create mode 100644 public/webhooks/feature-health-sample-provider.json diff --git a/api/Makefile b/api/Makefile index 4f0239f57238..c5e217f671e1 100644 --- a/api/Makefile +++ b/api/Makefile @@ -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) + .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 @@ -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 \ @@ -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}')) diff --git a/api/features/feature_health/mappers.py b/api/features/feature_health/mappers.py new file mode 100644 index 000000000000..96752d5228e2 --- /dev/null +++ b/api/features/feature_health/mappers.py @@ -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 diff --git a/api/features/feature_health/migrations/0002_featurehealthevent_add_external_id_alter_created_at.py b/api/features/feature_health/migrations/0002_featurehealthevent_add_external_id_alter_created_at.py new file mode 100644 index 000000000000..4ca4a6b507a9 --- /dev/null +++ b/api/features/feature_health/migrations/0002_featurehealthevent_add_external_id_alter_created_at.py @@ -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 + ), + ), + ] diff --git a/api/features/feature_health/models.py b/api/features/feature_health/models.py index 44d4bbcb04d7..709d3abcbde3 100644 --- a/api/features/feature_health/models.py +++ b/api/features/feature_health/models.py @@ -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 ( @@ -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( @@ -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] ): @@ -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, diff --git a/api/features/feature_health/providers/grafana/__init__.py b/api/features/feature_health/providers/grafana/__init__.py new file mode 100644 index 000000000000..fa24f885a3f9 --- /dev/null +++ b/api/features/feature_health/providers/grafana/__init__.py @@ -0,0 +1,5 @@ +from features.feature_health.providers.grafana.services import ( + get_provider_response, +) + +__all__ = ("get_provider_response",) diff --git a/api/features/feature_health/providers/grafana/constants.py b/api/features/feature_health/providers/grafana/constants.py new file mode 100644 index 000000000000..7eff42c04246 --- /dev/null +++ b/api/features/feature_health/providers/grafana/constants.py @@ -0,0 +1,2 @@ +GRAFANA_FEATURE_LABEL_NAME = "flagsmith_feature" +GRAFANA_ENVIRONMENT_LABEL_NAME = "flagsmith_environment" diff --git a/api/features/feature_health/providers/grafana/mappers.py b/api/features/feature_health/providers/grafana/mappers.py new file mode 100644 index 000000000000..2ed2f02c095b --- /dev/null +++ b/api/features/feature_health/providers/grafana/mappers.py @@ -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 diff --git a/api/features/feature_health/providers/grafana/services.py b/api/features/feature_health/providers/grafana/services.py new file mode 100644 index 000000000000..99d512fe3ed6 --- /dev/null +++ b/api/features/feature_health/providers/grafana/services.py @@ -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) diff --git a/api/features/feature_health/providers/grafana/types.py b/api/features/feature_health/providers/grafana/types.py new file mode 100644 index 000000000000..d91bbb6a24c2 --- /dev/null +++ b/api/features/feature_health/providers/grafana/types.py @@ -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] diff --git a/api/features/feature_health/providers/sample/__init__.py b/api/features/feature_health/providers/sample/__init__.py index 9a46e16aee62..c783deb93886 100644 --- a/api/features/feature_health/providers/sample/__init__.py +++ b/api/features/feature_health/providers/sample/__init__.py @@ -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",) diff --git a/api/features/feature_health/providers/sample/mappers.py b/api/features/feature_health/providers/sample/mappers.py index 1317277f9020..012f6db08e42 100644 --- a/api/features/feature_health/providers/sample/mappers.py +++ b/api/features/feature_health/providers/sample/mappers.py @@ -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( @@ -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, + ), + ], ) diff --git a/api/features/feature_health/providers/sample/services.py b/api/features/feature_health/providers/sample/services.py new file mode 100644 index 000000000000..74ee7eeb121a --- /dev/null +++ b/api/features/feature_health/providers/sample/services.py @@ -0,0 +1,8 @@ +from features.feature_health.providers.sample.mappers import ( + map_payload_to_provider_response, +) +from features.feature_health.types import FeatureHealthProviderResponse + + +def get_provider_response(payload: str) -> FeatureHealthProviderResponse: + return map_payload_to_provider_response(payload) diff --git a/api/features/feature_health/providers/sample/types.py b/api/features/feature_health/providers/sample/types.py index 615aa863a4ff..c0517ca87afa 100644 --- a/api/features/feature_health/providers/sample/types.py +++ b/api/features/feature_health/providers/sample/types.py @@ -1,10 +1,14 @@ import typing +from typing_extensions import TypedDict + +from features.feature_health.types import FeatureHealthEventReason + SampleEventStatus: typing.TypeAlias = typing.Literal["healthy", "unhealthy"] -class SampleEvent(typing.TypedDict): +class SampleEvent(TypedDict): environment: typing.NotRequired[str] feature: str status: SampleEventStatus - reason: typing.NotRequired[str] + reason: typing.NotRequired[FeatureHealthEventReason] diff --git a/api/features/feature_health/serializers.py b/api/features/feature_health/serializers.py index 348c7282a139..c414307526dd 100644 --- a/api/features/feature_health/serializers.py +++ b/api/features/feature_health/serializers.py @@ -1,3 +1,6 @@ +import json +import typing + from rest_framework import serializers from features.feature_health.models import ( @@ -8,9 +11,48 @@ from features.feature_health.providers.services import ( get_webhook_path_from_provider, ) +from features.feature_health.types import ( + FeatureHealthEventReasonTextBlock, + FeatureHealthEventReasonUrlBlock, +) + +FeatureHealthEventReasonStr: typing.TypeAlias = str + + +class FeatureHealthEventReasonTextBlockSerializer( + serializers.Serializer[FeatureHealthEventReasonTextBlock] +): + text = serializers.CharField() + title = serializers.CharField(required=False) + + +class FeatureHealthEventReasonUrlBlockSerializer( + serializers.Serializer[FeatureHealthEventReasonUrlBlock] +): + url = serializers.CharField() + title = serializers.CharField(required=False) + + +class FeatureHealthEventReasonSerializer( + serializers.Serializer[FeatureHealthEventReasonStr] +): + text_blocks = serializers.ListField( + child=FeatureHealthEventReasonTextBlockSerializer(), + ) + url_blocks = serializers.ListField( + child=FeatureHealthEventReasonUrlBlockSerializer(), + ) + + def to_representation( + self, + instance: FeatureHealthEventReasonStr, + ) -> dict[str, typing.Any]: + return super().to_representation(json.loads(instance)) + +class FeatureHealthEventSerializer(serializers.ModelSerializer[FeatureHealthEvent]): + reason = FeatureHealthEventReasonSerializer(allow_null=True) -class FeatureHealthEventSerializer(serializers.ModelSerializer): # type: ignore[type-arg] class Meta: model = FeatureHealthEvent fields = read_only_fields = ( diff --git a/api/features/feature_health/services.py b/api/features/feature_health/services.py index de0cbc41fcca..2d6cf6a862ee 100644 --- a/api/features/feature_health/services.py +++ b/api/features/feature_health/services.py @@ -7,13 +7,16 @@ UNHEALTHY_TAG_COLOUR, UNHEALTHY_TAG_LABEL, ) +from features.feature_health.mappers import ( + map_feature_health_event_data_to_feature_health_event, +) from features.feature_health.models import ( FeatureHealthEvent, FeatureHealthEventType, FeatureHealthProvider, FeatureHealthProviderName, ) -from features.feature_health.providers import sample +from features.feature_health.providers import grafana, sample from features.models import Feature from projects.tags.models import Tag, TagType @@ -23,51 +26,65 @@ logger = structlog.get_logger("feature_health") +PROVIDER_RESPONSE_GETTERS: dict[ + str, + typing.Callable[[str], "FeatureHealthProviderResponse"], +] = { + FeatureHealthProviderName.GRAFANA.value: grafana.get_provider_response, + FeatureHealthProviderName.SAMPLE.value: sample.get_provider_response, +} + + def get_provider_response( provider: FeatureHealthProvider, payload: str ) -> "FeatureHealthProviderResponse | None": - if provider.name == FeatureHealthProviderName.SAMPLE.value: - return sample.map_payload_to_provider_response(payload) - logger.error( - "invalid-feature-health-provider-requested", - provider_name=provider.name, - provider_id=provider.uuid, - ) - return None - - -def create_feature_health_event_from_provider( - provider: FeatureHealthProvider, - payload: str, -) -> FeatureHealthEvent | None: + response = None try: - response = get_provider_response(provider, payload) + response = PROVIDER_RESPONSE_GETTERS[provider.name](payload) except (KeyError, ValueError) as exc: logger.error( - "invalid-feature-health-event-data", + "feature-health-provider-error", provider_name=provider.name, - project_id=provider.project.id, + provider_id=provider.uuid, exc_info=exc, ) - return None + return response + + +def create_feature_health_events_from_provider( + provider: FeatureHealthProvider, + payload: str, +) -> list[FeatureHealthEvent]: + from features.feature_health import tasks + + response = get_provider_response(provider, payload) project = provider.project - if feature := Feature.objects.filter( - project=provider.project, name=response.feature_name # type: ignore[union-attr] - ).first(): - if response.environment_name: # type: ignore[union-attr] - environment = Environment.objects.filter( - project=project, name=response.environment_name # type: ignore[union-attr] - ).first() - else: - environment = None - return FeatureHealthEvent.objects.create( # type: ignore[no-any-return] - feature=feature, - environment=environment, - provider_name=provider.name, - type=response.event_type, # type: ignore[union-attr] - reason=response.reason, # type: ignore[union-attr] - ) - return None + events_to_create = [] + feature_ids_to_update = set() + if response: + for event_data in response.events: + if feature := Feature.objects.filter( + project=provider.project, name=event_data.feature_name + ).first(): + feature_ids_to_update.add(feature.id) + if environment_name := event_data.environment_name: + environment = Environment.objects.filter( + project=project, + name=environment_name, + ).first() + else: + environment = None + events_to_create.append( + map_feature_health_event_data_to_feature_health_event( + feature_health_event_data=event_data, + feature=feature, + environment=environment, + ) + ) + FeatureHealthEvent.objects.bulk_create(events_to_create) + for feature_id in feature_ids_to_update: + tasks.update_feature_unhealthy_tag.delay(args=(feature_id,)) + return events_to_create def update_feature_unhealthy_tag(feature: "Feature") -> None: @@ -82,7 +99,8 @@ def update_feature_unhealthy_tag(feature: "Feature") -> None: type=TagType.UNHEALTHY, ) if any( - feature_health_event.type == FeatureHealthEventType.UNHEALTHY.value + FeatureHealthEventType(feature_health_event.type) + == FeatureHealthEventType.UNHEALTHY for feature_health_event in feature_health_events ): feature.tags.add(unhealthy_tag) diff --git a/api/features/feature_health/types.py b/api/features/feature_health/types.py index 71909c573540..b318100bf02f 100644 --- a/api/features/feature_health/types.py +++ b/api/features/feature_health/types.py @@ -1,11 +1,39 @@ +import typing from dataclasses import dataclass +from datetime import datetime -from features.feature_health.models import FeatureHealthEventType +from typing_extensions import TypedDict + +if typing.TYPE_CHECKING: + from features.feature_health.models import FeatureHealthEventType + + +class FeatureHealthEventReasonTextBlock(TypedDict): + text: str + title: typing.NotRequired[str] + + +class FeatureHealthEventReasonUrlBlock(TypedDict): + url: str + title: typing.NotRequired[str] + + +class FeatureHealthEventReason(TypedDict): + text_blocks: list[FeatureHealthEventReasonTextBlock] + url_blocks: list[FeatureHealthEventReasonUrlBlock] @dataclass -class FeatureHealthProviderResponse: +class FeatureHealthEventData: feature_name: str - environment_name: str | None - event_type: FeatureHealthEventType - reason: str + type: "FeatureHealthEventType" + provider_name: str + reason: FeatureHealthEventReason | None = None + environment_name: str | None = None + external_id: str | None = None + created_at: datetime | None = None + + +@dataclass +class FeatureHealthProviderResponse: + events: list[FeatureHealthEventData] diff --git a/api/features/feature_health/views.py b/api/features/feature_health/views.py index a76d3cc17b36..eeb90dcca287 100644 --- a/api/features/feature_health/views.py +++ b/api/features/feature_health/views.py @@ -22,7 +22,7 @@ FeatureHealthProviderSerializer, ) from features.feature_health.services import ( - create_feature_health_event_from_provider, + create_feature_health_events_from_provider, ) from projects.models import Project from projects.permissions import NestedProjectPermissions @@ -115,6 +115,6 @@ def feature_health_webhook(request: Request, **kwargs: typing.Any) -> Response: if not (provider := get_provider_from_webhook_path(path)): return Response(status=status.HTTP_404_NOT_FOUND) payload = request.body.decode("utf-8") - if create_feature_health_event_from_provider(provider=provider, payload=payload): + if create_feature_health_events_from_provider(provider=provider, payload=payload): return Response(status=status.HTTP_200_OK) return Response(status=status.HTTP_400_BAD_REQUEST) diff --git a/api/integrations/grafana/mappers.py b/api/integrations/grafana/mappers.py index bbe2bfa0256c..f5121b15786a 100644 --- a/api/integrations/grafana/mappers.py +++ b/api/integrations/grafana/mappers.py @@ -7,7 +7,7 @@ FeatureStateValue, ) from features.versioning.models import EnvironmentFeatureVersion -from integrations.grafana.types import GrafanaAnnotation +from integrations.grafana.types import PostAnnotationsCmd from segments.models import Segment @@ -61,7 +61,7 @@ def _get_instance_tags_from_audit_log_record( def map_audit_log_record_to_grafana_annotation( audit_log_record: AuditLog, -) -> GrafanaAnnotation: +) -> PostAnnotationsCmd: tags = [ "flagsmith", f"project:{audit_log_record.project_name}", diff --git a/api/integrations/grafana/types.py b/api/integrations/grafana/types.py index 431d0054c6cd..72f258e50695 100644 --- a/api/integrations/grafana/types.py +++ b/api/integrations/grafana/types.py @@ -1,8 +1,52 @@ -from typing import TypedDict +# generated by datamodel-codegen: +# filename: +# timestamp: 2025-01-29T13:53:55+00:00 +from __future__ import annotations -class GrafanaAnnotation(TypedDict): - tags: list[str] +from typing import Literal, TypedDict + +from typing_extensions import NotRequired + + +class Json(TypedDict): + pass + + +class PostAnnotationsCmd(TypedDict): + dashboardId: NotRequired[int] + dashboardUID: NotRequired[str] + data: NotRequired[Json] + panelId: NotRequired[int] + tags: NotRequired[list[str]] text: str - time: int - timeEnd: int + time: NotRequired[int] + timeEnd: NotRequired[int] + + +class EmbeddedContactPoint(TypedDict): + disableResolveMessage: NotRequired[bool] + name: NotRequired[str] + provenance: NotRequired[str] + settings: Json + type: Literal[ + "alertmanager", + "dingding", + "discord", + "email", + "googlechat", + "kafka", + "line", + "opsgenie", + "pagerduty", + "pushover", + "sensugo", + "slack", + "teams", + "telegram", + "threema", + "victorops", + "webhook", + "wecom", + ] + uid: NotRequired[str] diff --git a/api/openapi-filter-grafana.yaml b/api/openapi-filter-grafana.yaml new file mode 100644 index 000000000000..82f06b3bfdd8 --- /dev/null +++ b/api/openapi-filter-grafana.yaml @@ -0,0 +1,10 @@ +inverseOperationIds: + # List operations used by Flagsmith's Grafana integration here. + - RoutePostContactpoints + - postAnnotation +unusedComponents: + - schemas + - parameters + - examples + - headers + - responses diff --git a/api/ld-openapi-filter.yaml b/api/openapi-filter-launchdarkly.yaml similarity index 100% rename from api/ld-openapi-filter.yaml rename to api/openapi-filter-launchdarkly.yaml diff --git a/api/tests/integration/features/feature_health/conftest.py b/api/tests/integration/features/feature_health/conftest.py index c86c2c839869..aea8be25ee1e 100644 --- a/api/tests/integration/features/feature_health/conftest.py +++ b/api/tests/integration/features/feature_health/conftest.py @@ -5,14 +5,25 @@ from rest_framework.test import APIClient +def _get_feature_health_provider_webhook_url( + project: int, api_client: APIClient, name: str +) -> str: + feature_health_provider_data = {"name": name} + url = reverse("api-v1:projects:feature-health-providers-list", args=[project]) + response = api_client.post(url, data=feature_health_provider_data) + webhook_url: str = response.json()["webhook_url"] + return webhook_url + + @pytest.fixture def sample_feature_health_provider_webhook_url( project: int, admin_client_new: APIClient ) -> str: - feature_health_provider_data = {"name": "Sample"} - url = reverse("api-v1:projects:feature-health-providers-list", args=[project]) - response = admin_client_new.post(url, data=feature_health_provider_data) - return response.json()["webhook_url"] # type: ignore[no-any-return] + return _get_feature_health_provider_webhook_url( + project=project, + api_client=admin_client_new, + name="Sample", + ) @pytest.fixture @@ -28,3 +39,14 @@ def unhealthy_feature( content_type="application/json", ) return feature + + +@pytest.fixture +def grafana_feature_health_provider_webhook_url( + project: int, admin_client_new: APIClient +) -> str: + return _get_feature_health_provider_webhook_url( + project=project, + api_client=admin_client_new, + name="Grafana", + ) diff --git a/api/tests/integration/features/feature_health/test_views.py b/api/tests/integration/features/feature_health/test_views.py index 425a6f35dc90..f8d9a84225b4 100644 --- a/api/tests/integration/features/feature_health/test_views.py +++ b/api/tests/integration/features/feature_health/test_views.py @@ -125,7 +125,7 @@ def test_webhook__sample_provider__post__expected_feature_health_event_created__ "environment": None, "feature": feature, "provider_name": "Sample", - "reason": "", + "reason": None, "type": "UNHEALTHY", } ] @@ -183,7 +183,7 @@ def test_webhook__sample_provider__post_with_environment_expected_feature_health "environment": environment, "feature": feature, "provider_name": "Sample", - "reason": "", + "reason": None, "type": "UNHEALTHY", } ] @@ -226,7 +226,7 @@ def test_webhook__unhealthy_feature__post__expected_feature_health_event_created "environment": None, "feature": unhealthy_feature, "provider_name": "Sample", - "reason": "", + "reason": None, "type": "HEALTHY", } ] @@ -264,3 +264,258 @@ def test_webhook__sample_provider__post__invalid_payload__expected_response( # Then assert response.status_code == 400 + + +def test_webhook__grafana_provider__post__expected_feature_health_event_created( + project: int, + feature: int, + feature_name: str, + grafana_feature_health_provider_webhook_url: str, + api_client: APIClient, + admin_client_new: APIClient, +) -> None: + # Given + feature_health_events_url = reverse( + "api-v1:projects:feature-health-events-list", args=[project] + ) + webhook_data = { + "alerts": [ + { + "status": "firing", + "labels": { + "alertname": "Panel Title", + "flagsmith_feature": feature_name, + "grafana_folder": "Test", + }, + "annotations": { + "description": "This is the description.", + "runbook_url": "https://hit.me", + "summary": "This is a summary.", + }, + "startsAt": "2025-02-12T21:06:50Z", + "endsAt": "0001-01-01T00:00:00Z", + "generatorURL": "https://grafana.example.com/alerting/grafana/aebbhjnirottsa/view?orgId=1", + "dashboardURL": "https://grafana.example.com/d/ce99ti2tuu3nka?orgId=1", + "panelURL": "https://grafana.example.com/d/ce99ti2tuu3nka?orgId=1&viewPanel=1", + "fingerprint": "e8790ab48f71f61e", + } + ], + } + expected_reason = { + "text_blocks": [ + {"text": "This is the description.", "title": "Panel Title"}, + {"text": "This is a summary.", "title": "Summary"}, + ], + "url_blocks": [ + { + "title": "Alert", + "url": "https://grafana.example.com/alerting/grafana/aebbhjnirottsa/view?orgId=1", + }, + { + "title": "Dashboard", + "url": "https://grafana.example.com/d/ce99ti2tuu3nka?orgId=1", + }, + { + "title": "Panel", + "url": "https://grafana.example.com/d/ce99ti2tuu3nka?orgId=1&viewPanel=1", + }, + {"title": "Runbook", "url": "https://hit.me"}, + ], + } + + # When + response = api_client.post( + grafana_feature_health_provider_webhook_url, + data=json.dumps(webhook_data), + content_type="application/json", + ) + + # Then + assert response.status_code == 200 + response = admin_client_new.get(feature_health_events_url) + assert response.json() == [ + { + "created_at": "2025-02-12T21:06:50Z", + "environment": None, + "feature": feature, + "provider_name": "Grafana", + "reason": expected_reason, + "type": "UNHEALTHY", + } + ] + + +def test_webhook__grafana_provider__post__multiple__expected_feature_health_events( + project: int, + environment: int, + environment_name: str, + feature: int, + feature_name: str, + grafana_feature_health_provider_webhook_url: str, + api_client: APIClient, + admin_client_new: APIClient, +) -> None: + # Given + feature_health_events_url = reverse( + "api-v1:projects:feature-health-events-list", args=[project] + ) + webhook_data = { + "alerts": [ + { + "status": "firing", + "labels": { + "alertname": "Panel Title", + "flagsmith_feature": feature_name, + "grafana_folder": "Test", + }, + "annotations": { + "description": "This is the description.", + "runbook_url": "https://hit.me", + "summary": "This is a summary.", + }, + "startsAt": "2025-02-12T21:06:50Z", + "endsAt": "0001-01-01T00:00:00Z", + "generatorURL": "https://grafana.example.com/alerting/grafana/aebbhjnirottsa/view?orgId=1", + "fingerprint": "e8790ab48f71f61e", + } + ], + } + expected_reason = { + "text_blocks": [ + {"text": "This is the description.", "title": "Panel Title"}, + {"text": "This is a summary.", "title": "Summary"}, + ], + "url_blocks": [ + { + "title": "Alert", + "url": "https://grafana.example.com/alerting/grafana/aebbhjnirottsa/view?orgId=1", + }, + {"title": "Runbook", "url": "https://hit.me"}, + ], + } + other_webhook_data = { + "alerts": [ + { + "status": "firing", + "labels": { + "alertname": "Other Panel Title", + "flagsmith_feature": feature_name, + "flagsmith_environment": environment_name, + "grafana_folder": "Test", + }, + "annotations": { + "description": "This is the description.", + "summary": "This is a summary.", + }, + "startsAt": "2025-02-12T21:07:50Z", + "endsAt": "0001-01-01T00:00:00Z", + "generatorURL": "https://grafana.example.com/alerting/grafana/xjshhbiigohd/view?orgId=1", + "fingerprint": "ba6b7c8d9e0f1", + } + ], + } + expected_other_reason = { + "text_blocks": [ + {"text": "This is the description.", "title": "Other Panel Title"}, + {"text": "This is a summary.", "title": "Summary"}, + ], + "url_blocks": [ + { + "title": "Alert", + "url": "https://grafana.example.com/alerting/grafana/xjshhbiigohd/view?orgId=1", + } + ], + } + unrelated_webhook_data = { + "alerts": [ + { + "status": "firing", + "labels": { + "alertname": "Different", + "grafana_folder": "Test", + }, + "annotations": { + "description": "This is the description.", + "runbook_url": "https://hit.me", + "summary": "This is a summary.", + }, + "startsAt": "2025-02-12T21:08:50Z", + "endsAt": "0001-01-01T00:00:00Z", + "generatorURL": "https://grafana.example.com/alerting/grafana/aebbhjnirottsa/view?orgId=1", + "fingerprint": "a6b7c8d9e0f1", + } + ], + } + resolved_webhook_data = { + "alerts": [ + { + "status": "resolved", + "labels": { + "alertname": "Panel Title", + "flagsmith_feature": feature_name, + "grafana_folder": "Test", + }, + "annotations": { + "description": "This is the description.", + "runbook_url": "https://hit.me", + "summary": "This is a summary.", + }, + "startsAt": "2025-02-12T21:10:50Z", + "endsAt": "2025-02-12T21:12:50Z", + "generatorURL": "https://grafana.example.com/alerting/grafana/aebbhjnirottsa/view?orgId=1", + "fingerprint": "e8790ab48f71f61e", + } + ], + } + + # When + # webhook is triggered by a firing alert... + api_client.post( + grafana_feature_health_provider_webhook_url, + data=json.dumps(webhook_data), + content_type="application/json", + ) + # ...webhook triggered by a resolved alert that previously fired... + api_client.post( + grafana_feature_health_provider_webhook_url, + data=json.dumps(resolved_webhook_data), + content_type="application/json", + ) + # ...webhook triggered by a firing alert that is unrelated to the first and has an environment label... + api_client.post( + grafana_feature_health_provider_webhook_url, + data=json.dumps(other_webhook_data), + content_type="application/json", + ) + # ...webhook triggered by a firing alert that is unrelated to the feature. + response = api_client.post( + grafana_feature_health_provider_webhook_url, + data=json.dumps(unrelated_webhook_data), + content_type="application/json", + ) + + # Then + # unrelated alert was not accepted by webhook + assert response.status_code == 400 + response = admin_client_new.get(feature_health_events_url) + assert response.json() == [ + # second firing alert has not been resolved + # and provided an environment label + { + "created_at": "2025-02-12T21:07:50Z", + "environment": environment, + "feature": feature, + "provider_name": "Grafana", + "reason": expected_other_reason, + "type": "UNHEALTHY", + }, + # first firing alert has been resolved + { + "created_at": "2025-02-12T21:12:50Z", + "environment": None, + "feature": feature, + "provider_name": "Grafana", + "reason": expected_reason, + "type": "HEALTHY", + }, + ] diff --git a/api/tests/unit/features/feature_health/test_services.py b/api/tests/unit/features/feature_health/test_services.py index 0f95c9596127..b7b2bc3e49ec 100644 --- a/api/tests/unit/features/feature_health/test_services.py +++ b/api/tests/unit/features/feature_health/test_services.py @@ -25,9 +25,11 @@ def test_get_provider_response__invalid_provider__return_none__log_expected( assert response is None assert log.events == [ { - "event": "invalid-feature-health-provider-requested", + "event": "feature-health-provider-error", "level": "error", "provider_id": expected_provider_uuid, "provider_name": expected_provider_name, + "exc_info": mocker.ANY, }, ] + assert isinstance(log.events[0]["exc_info"], KeyError) diff --git a/docs/docs/advanced-use/feature-health.md b/docs/docs/advanced-use/feature-health.md new file mode 100644 index 000000000000..7242a4798c60 --- /dev/null +++ b/docs/docs/advanced-use/feature-health.md @@ -0,0 +1,96 @@ +--- +title: Feature Health +--- + +:::info + +Feature Health is an upcoming feature that's not yet available. + +::: + +Feature Health enables users to monitor observability metrics within Flagsmith, specifically in relation to Flagsmith's Features and Environments. Flagsmith receives alert notifications from your observability provider and, based on this data, marks your Features and optionally Environments with an **Unhealthy** status, providing details about the alerts. This enhances your team's observability, allowing for quicker, more informed decisions. + +## Integrations + +The following is an overview of the Feature Health providers currently supported. + +### Grafana / Prometheus Alertmanager + +[Learn more](/integrations/apm/grafana/#feature-health-provider-setup) about configuring Grafana / Prometheus Alertmanager Feature Health provider. + +### Sample Provider + +We provide a Sample Provider for your custom integrations. To create a Sample Feature Health webhook: + +1. Go to Project Settings > Feature Health. +2. Select "Sample" from the Provider Name drop-down menu. +3. Click Create and copy the Webhook URL. + +You can use the webhook in your custom integration. Refer to the payload schema below: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SampleEvent", + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "feature": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["healthy", "unhealthy"] + }, + "reason": { + "$ref": "#/definitions/FeatureHealthEventReason" + } + }, + "required": ["feature", "status"], + "definitions": { + "FeatureHealthEventReason": { + "type": "object", + "properties": { + "text_blocks": { + "type": "array", + "items": { + "$ref": "#/definitions/FeatureHealthEventReasonTextBlock" + } + }, + "url_blocks": { + "type": "array", + "items": { + "$ref": "#/definitions/FeatureHealthEventReasonUrlBlock" + } + } + } + }, + "FeatureHealthEventReasonTextBlock": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": ["text"] + }, + "FeatureHealthEventReasonUrlBlock": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": ["url"] + } + } +} +``` diff --git a/docs/docs/integrations/apm/_category_.json b/docs/docs/integrations/apm/_category_.json index 78d05182e838..6f54b0997a45 100644 --- a/docs/docs/integrations/apm/_category_.json +++ b/docs/docs/integrations/apm/_category_.json @@ -1,5 +1,5 @@ { - "label": "APM", + "label": "Observability", "collapsed": false, "position": 20 -} +} \ No newline at end of file diff --git a/docs/docs/integrations/apm/grafana.md b/docs/docs/integrations/apm/grafana.md index db007ae1b581..52e558aee833 100644 --- a/docs/docs/integrations/apm/grafana.md +++ b/docs/docs/integrations/apm/grafana.md @@ -9,9 +9,9 @@ import ReactPlayer from 'react-player' ![Image](/img/integrations/grafana/grafana-logo.svg) -You can integrate Flagsmith with Grafana. Send flag change events from Flagsmith into Grafana as annotations. +Integrate Flagsmith with Grafana to send flag change events as annotations. -The video below will walk you through the steps of adding the integration: +The video below demonstrates the integration process: Users and access > Service accounts -2. Add Service Account -3. Change the Role selection to "Annotation Writer" or "Editor". -4. Click on Add service account token and make a note of the generated token. +### In Grafana: -In Flagsmith: +1. Go to Administration > Users and access > Service accounts. +2. Add a Service Account. +3. Set the Role to "Annotation Writer" or "Editor". +4. Click on "Add service account token" and save the generated token. -1. Navigate to Integrations, then add the Grafana integration. -2. Enter the URL for your web interface of your Grafana installation. For example, `https://grafana.flagsmith.com`. -3. Paste the service account token you created in Grafana to `Service account token` field. +### In Flagsmith: + +1. Go to Integrations and add the Grafana integration. +2. Enter the URL of your Grafana installation (e.g., `https://grafana.flagsmith.com`). +3. Paste the service account token from Grafana into the `Service account token` field. 4. Click Save. -Flag change events will now be sent to Grafana as _Organisation Level_ -[Annotations](https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/). +Flag change events will now be sent to Grafana as _Organisation Level_ [Annotations](https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/). + +To view the annotations in Grafana, go to Dashboard Settings > Annotations, select the `Grafana` data source, and filter by the `flagsmith` tag. + +Annotations for feature-specific events include project tags, user-defined tags, and environment tags for flag change events. + +## Feature Health Provider Setup + +:::info + +[Feature Health](/advanced-use/feature-health) is an upcoming feature that is not yet available. + +::: + +### In Flagsmith: + +1. Go to Project Settings > Feature Health. +2. Select "Grafana" from the Provider Name drop-down menu. +3. Click Create and copy the Webhook URL. + +### In Grafana: + +1. Create a new Webhook contact point using the Webhook URL from Flagsmith. Refer to the [Grafana documentation on contact points](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/#add-a-contact-point) for details. +2. Leave Optional Webhook settings empty. Ensure the "Disable resolved message" checkbox is unchecked. +3. Add the `flagsmith_feature` label to your alert rule, specifying the Flagsmith Feature name. Refer to the [Grafana documentation on alert rule labels](https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/annotation-label/#labels) for more information. +4. Optionally, include the `flagsmith_environment` label in your alert rule, using the Flagsmith Environment name as the value. +5. Set the previously created contact point as the alert rule recipient. + +You can create multiple alert rules pointing to the Feature Health Provider webhook. Ensure they include the `flagsmith_feature` label with a Feature name from the Project you created the Feature Health Provider for, to see Feature Health status changes for your features. -You can view the annotations in your Grafana dashboards but going to Dashboard Settings > Annotations, selecting the -`Grafana` data source and then filtering on annotations that are tagged with the `flagsmith` tag. +You can integrate Grafana Feature Health with Prometheus Alertmanager. For detailed instructions on adding Flagsmith labels to your alerts in Prometheus, refer to the [Prometheus Alertmanager webhook configuration](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) and [Alerting rules configuration](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/#defining-alerting-rules) documentation. -Annotations reporting feature-specific events include the project tag and Flagsmith user-defined tags, and flag change -events include the environment tag as well. +The Feature Health UI will display the following information: +- Alert name +- Link to the alert instance in your Alertmanager +- Alert description (if provided in alert annotations) +- Alert summary (if provided in alert annotations) +- Dashboard URL (if Grafana) +- Panel URL (if Grafana) +- Runbook URL (if provided in alert annotations) diff --git a/docs/docs/integrations/index.md b/docs/docs/integrations/index.md index 7b55c6f62a69..857dfad2e506 100644 --- a/docs/docs/integrations/index.md +++ b/docs/docs/integrations/index.md @@ -68,7 +68,7 @@ analysis. [Learn more](/integrations/analytics/rudderstack). You can integrate Flagsmith with your own Analytics Platform/Data Warehouse. Send your Identity flag states into Snowflake, RedShift or anywhere else for further downstream analysis! [Learn more](/integrations/webhook). -## Application Monitoring and Performance +## Observability --- @@ -90,6 +90,7 @@ You can integrate Flagsmith with Dynatrace. Send flag change events from Flagsmi You can integrate Flagsmith with Grafana. Send flag change events from Flagsmith into Grafana as annotations. [Learn more](/integrations/apm/grafana). +**Coming soon**: Integrate your Grafana and Prometheus alerts with Feature Health. --- diff --git a/public/webhooks/feature-health-sample-provider.json b/public/webhooks/feature-health-sample-provider.json new file mode 100644 index 000000000000..899306f6a570 --- /dev/null +++ b/public/webhooks/feature-health-sample-provider.json @@ -0,0 +1,74 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SampleEvent", + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "feature": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy" + ] + }, + "reason": { + "$ref": "#/definitions/FeatureHealthEventReason" + } + }, + "required": [ + "feature", + "status" + ], + "definitions": { + "FeatureHealthEventReason": { + "type": "object", + "properties": { + "text_blocks": { + "type": "array", + "items": { + "$ref": "#/definitions/FeatureHealthEventReasonTextBlock" + } + }, + "url_blocks": { + "type": "array", + "items": { + "$ref": "#/definitions/FeatureHealthEventReasonUrlBlock" + } + } + } + }, + "FeatureHealthEventReasonTextBlock": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "FeatureHealthEventReasonUrlBlock": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "url" + ] + } + } +} \ No newline at end of file