From 5c25c41e01ba68b18887759f2a5650caa6a9f39d Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Mon, 24 Jun 2024 14:37:00 +0100 Subject: [PATCH] feat: Grafana integration (#4144) Co-authored-by: Kim Gustyr --- api/app/settings/common.py | 1 + api/audit/models.py | 4 + api/audit/services.py | 63 +++++ api/audit/signals.py | 15 ++ api/core/models.py | 6 +- api/core/signals.py | 4 +- api/environments/urls.py | 6 + api/integrations/grafana/__init__.py | 1 + api/integrations/grafana/apps.py | 6 + api/integrations/grafana/grafana.py | 43 ++++ api/integrations/grafana/mappers.py | 72 ++++++ .../grafana/migrations/0001_initial.py | 33 +++ .../grafana/migrations/__init__.py | 0 api/integrations/grafana/models.py | 44 ++++ api/integrations/grafana/serializers.py | 11 + api/integrations/grafana/types.py | 8 + api/integrations/grafana/views.py | 9 + api/projects/urls.py | 6 + .../unit/audit/test_unit_audit_services.py | 116 +++++++++ .../unit/audit/test_unit_audit_signals.py | 34 ++- .../unit/integrations/grafana/__init__.py | 0 .../unit/integrations/grafana/test_grafana.py | 55 ++++ .../unit/integrations/grafana/test_mappers.py | 239 ++++++++++++++++++ docs/docs/deployment/overview.md | 20 ++ docs/docs/integrations/apm/grafana.md | 35 +++ docs/docs/integrations/overview.md | 7 + .../img/integrations/grafana/grafana-logo.svg | 21 ++ .../static/images/integrations/grafana.svg | 21 ++ 28 files changed, 874 insertions(+), 6 deletions(-) create mode 100644 api/audit/services.py create mode 100644 api/integrations/grafana/__init__.py create mode 100644 api/integrations/grafana/apps.py create mode 100644 api/integrations/grafana/grafana.py create mode 100644 api/integrations/grafana/mappers.py create mode 100644 api/integrations/grafana/migrations/0001_initial.py create mode 100644 api/integrations/grafana/migrations/__init__.py create mode 100644 api/integrations/grafana/models.py create mode 100644 api/integrations/grafana/serializers.py create mode 100644 api/integrations/grafana/types.py create mode 100644 api/integrations/grafana/views.py create mode 100644 api/tests/unit/audit/test_unit_audit_services.py create mode 100644 api/tests/unit/integrations/grafana/__init__.py create mode 100644 api/tests/unit/integrations/grafana/test_grafana.py create mode 100644 api/tests/unit/integrations/grafana/test_mappers.py create mode 100644 docs/docs/integrations/apm/grafana.md create mode 100644 docs/static/img/integrations/grafana/grafana-logo.svg create mode 100644 frontend/web/static/images/integrations/grafana.svg diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 38bc38ebb1f8..4ad7ba16141e 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -149,6 +149,7 @@ "integrations.flagsmith", "integrations.launch_darkly", "integrations.github", + "integrations.grafana", # Rate limiting admin endpoints "axes", "telemetry", diff --git a/api/audit/models.py b/api/audit/models.py index 700fc8085f36..e027f5f5f9ec 100644 --- a/api/audit/models.py +++ b/api/audit/models.py @@ -90,6 +90,10 @@ def history_record(self) -> typing.Optional[Model]: klass = self.get_history_record_model_class(self.history_record_class_path) return klass.objects.filter(history_id=self.history_record_id).first() + @property + def project_name(self) -> str: + return getattr(self.project, "name", "unknown") + @property def environment_name(self) -> str: return getattr(self.environment, "name", "unknown") diff --git a/api/audit/services.py b/api/audit/services.py new file mode 100644 index 000000000000..2663c7ecf431 --- /dev/null +++ b/api/audit/services.py @@ -0,0 +1,63 @@ +from core.models import AbstractBaseAuditableModel + +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType +from features.models import Feature, FeatureState +from features.versioning.models import EnvironmentFeatureVersion + + +def get_audited_instance_from_audit_log_record( + audit_log_record: AuditLog, +) -> AbstractBaseAuditableModel | None: + """ + Given an `AuditLog` model instance, return a model instance that produced the log. + """ + # There's currently four (sigh) ways an audit log record is created: + # 1. Historical record + # 2. Segment priorities changed + # 3. Change request + # 4. Environment feature version published + + # Try getting the historical record first. + if history_record := audit_log_record.history_record: + return history_record.instance + + # Try to infer the model class from `AuditLog.related_object_type`. + match audit_log_record.related_object_type: + # Assume segment priorities change. + case RelatedObjectType.FEATURE.name: + return ( + Feature.objects.all_with_deleted() + .filter( + pk=audit_log_record.related_object_id, + project=audit_log_record.project, + ) + .first() + ) + + # Assume change request. + case RelatedObjectType.FEATURE_STATE.name: + return ( + FeatureState.objects.all_with_deleted() + .filter( + pk=audit_log_record.related_object_id, + environment=audit_log_record.environment, + ) + .first() + ) + + # Assume environment feature version. + case RelatedObjectType.EF_VERSION.name: + return ( + EnvironmentFeatureVersion.objects.all_with_deleted() + .filter( + uuid=audit_log_record.related_object_uuid, + environment=audit_log_record.environment, + ) + .first() + ) + + # All known audit log sources exhausted by now. + # Since `RelatedObjectType` is not a 1:1 mapping to a model class, + # generalised heuristics might be dangerous. + return None diff --git a/api/audit/signals.py b/api/audit/signals.py index 19591bea7be4..6e6bb0289da0 100644 --- a/api/audit/signals.py +++ b/api/audit/signals.py @@ -8,6 +8,7 @@ from audit.serializers import AuditLogListSerializer from integrations.datadog.datadog import DataDogWrapper from integrations.dynatrace.dynatrace import DynatraceWrapper +from integrations.grafana.grafana import GrafanaWrapper from integrations.new_relic.new_relic import NewRelicWrapper from integrations.slack.slack import SlackWrapper from organisations.models import OrganisationWebhook @@ -113,6 +114,20 @@ def send_audit_log_event_to_dynatrace(sender, instance, **kwargs): _track_event_async(instance, dynatrace) +@receiver(post_save, sender=AuditLog) +@track_only_feature_related_events +def send_audit_log_event_to_grafana(sender, instance, **kwargs): + grafana_config = _get_integration_config(instance, "grafana_config") + if not grafana_config: + return + + grafana = GrafanaWrapper( + base_url=grafana_config.base_url, + api_key=grafana_config.api_key, + ) + _track_event_async(instance, grafana) + + @receiver(post_save, sender=AuditLog) @track_only_feature_related_events def send_audit_log_event_to_slack(sender, instance, **kwargs): diff --git a/api/core/models.py b/api/core/models.py index e1082e49a3cc..b561833b5e51 100644 --- a/api/core/models.py +++ b/api/core/models.py @@ -98,7 +98,7 @@ class Meta: return BaseHistoricalModel -class _AbstractBaseAuditableModel(models.Model): +class AbstractBaseAuditableModel(models.Model): """ A base Model class that all models we want to be included in the audit log should inherit from. @@ -196,8 +196,8 @@ def abstract_base_auditable_model_factory( historical_records_excluded_fields: typing.List[str] = None, change_details_excluded_fields: typing.Sequence[str] = None, show_change_details_for_create: bool = False, -) -> typing.Type[_AbstractBaseAuditableModel]: - class Base(_AbstractBaseAuditableModel): +) -> typing.Type[AbstractBaseAuditableModel]: + class Base(AbstractBaseAuditableModel): history = HistoricalRecords( bases=[ base_historical_model_factory( diff --git a/api/core/signals.py b/api/core/signals.py index a77a6c7d1da3..0b12a2afaa52 100644 --- a/api/core/signals.py +++ b/api/core/signals.py @@ -1,4 +1,4 @@ -from core.models import _AbstractBaseAuditableModel +from core.models import AbstractBaseAuditableModel from django.conf import settings from django.utils import timezone from simple_history.models import HistoricalRecords @@ -9,7 +9,7 @@ def create_audit_log_from_historical_record( - instance: _AbstractBaseAuditableModel, + instance: AbstractBaseAuditableModel, history_user: FFAdminUser, history_instance, **kwargs, diff --git a/api/environments/urls.py b/api/environments/urls.py index 1858d7895099..5489d426c5cd 100644 --- a/api/environments/urls.py +++ b/api/environments/urls.py @@ -14,6 +14,7 @@ ) from integrations.amplitude.views import AmplitudeConfigurationViewSet from integrations.dynatrace.views import DynatraceConfigurationViewSet +from integrations.grafana.views import GrafanaConfigurationViewSet from integrations.heap.views import HeapConfigurationViewSet from integrations.mixpanel.views import MixpanelConfigurationViewSet from integrations.rudderstack.views import RudderstackConfigurationViewSet @@ -80,6 +81,11 @@ DynatraceConfigurationViewSet, basename="integrations-dynatrace", ) +environments_router.register( + r"integrations/grafana", + GrafanaConfigurationViewSet, + basename="integrations-grafana", +) environments_router.register( r"integrations/mixpanel", MixpanelConfigurationViewSet, diff --git a/api/integrations/grafana/__init__.py b/api/integrations/grafana/__init__.py new file mode 100644 index 000000000000..55afdc499649 --- /dev/null +++ b/api/integrations/grafana/__init__.py @@ -0,0 +1 @@ +default_app_config = "integrations.grafana.apps.GrafanaConfigurationConfig" diff --git a/api/integrations/grafana/apps.py b/api/integrations/grafana/apps.py new file mode 100644 index 000000000000..f41451ce0dd4 --- /dev/null +++ b/api/integrations/grafana/apps.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from django.apps import AppConfig + + +class GrafanaConfigurationConfig(AppConfig): + name = "integrations.grafana" diff --git a/api/integrations/grafana/grafana.py b/api/integrations/grafana/grafana.py new file mode 100644 index 000000000000..1cbc61098286 --- /dev/null +++ b/api/integrations/grafana/grafana.py @@ -0,0 +1,43 @@ +import json +import logging +from typing import Any + +import requests + +from audit.models import AuditLog +from integrations.common.wrapper import AbstractBaseEventIntegrationWrapper +from integrations.grafana.mappers import ( + map_audit_log_record_to_grafana_annotation, +) + +logger = logging.getLogger(__name__) + +ROUTE_API_ANNOTATIONS = "/api/annotations" + + +class GrafanaWrapper(AbstractBaseEventIntegrationWrapper): + def __init__(self, base_url: str, api_key: str) -> None: + base_url = base_url[:-1] if base_url.endswith("/") else base_url + self.url = f"{base_url}{ROUTE_API_ANNOTATIONS}" + self.api_key = api_key + + @staticmethod + def generate_event_data(audit_log_record: AuditLog) -> dict[str, Any]: + return map_audit_log_record_to_grafana_annotation(audit_log_record) + + def _headers(self) -> dict[str, str]: + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + + def _track_event(self, event: dict[str, Any]) -> None: + response = requests.post( + url=self.url, + headers=self._headers(), + data=json.dumps(event), + ) + + logger.debug( + "Sent event to Grafana. Response code was %s" % response.status_code + ) diff --git a/api/integrations/grafana/mappers.py b/api/integrations/grafana/mappers.py new file mode 100644 index 000000000000..8f6648d87787 --- /dev/null +++ b/api/integrations/grafana/mappers.py @@ -0,0 +1,72 @@ +from audit.models import AuditLog +from audit.services import get_audited_instance_from_audit_log_record +from features.models import ( + Feature, + FeatureSegment, + FeatureState, + FeatureStateValue, +) +from integrations.grafana.types import GrafanaAnnotation +from segments.models import Segment + + +def _get_feature_tags( + feature: Feature, +) -> list[str]: + return list(feature.tags.values_list("label", flat=True)) + + +def _get_instance_tags_from_audit_log_record( + audit_log_record: AuditLog, +) -> list[str]: + if instance := get_audited_instance_from_audit_log_record(audit_log_record): + if isinstance(instance, Feature): + return [ + f"feature:{instance.name}", + *_get_feature_tags(instance), + ] + + if isinstance(instance, FeatureState): + return [ + f"feature:{(feature := instance.feature).name}", + f'flag:{"enabled" if instance.enabled else "disabled"}', + *_get_feature_tags(feature), + ] + + if isinstance(instance, FeatureStateValue): + return [ + f"feature:{(feature := instance.feature_state.feature).name}", + *_get_feature_tags(feature), + ] + + if isinstance(instance, Segment): + return [f"segment:{instance.name}"] + + if isinstance(instance, FeatureSegment): + return [ + f"feature:{(feature := instance.feature).name}", + f"segment:{instance.segment.name}", + *_get_feature_tags(feature), + ] + + return [] + + +def map_audit_log_record_to_grafana_annotation( + audit_log_record: AuditLog, +) -> GrafanaAnnotation: + tags = [ + "flagsmith", + f"project:{audit_log_record.project_name}", + f"environment:{audit_log_record.environment_name}", + f"by:{audit_log_record.author_identifier}", + *_get_instance_tags_from_audit_log_record(audit_log_record), + ] + time = int(audit_log_record.created_date.timestamp() * 1000) # ms since epoch + + return { + "tags": tags, + "text": audit_log_record.log, + "time": time, + "timeEnd": time, + } diff --git a/api/integrations/grafana/migrations/0001_initial.py b/api/integrations/grafana/migrations/0001_initial.py new file mode 100644 index 000000000000..c4c4bd170455 --- /dev/null +++ b/api/integrations/grafana/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.25 on 2024-06-21 20:31 + +from django.db import migrations, models +import django.db.models.deletion +import django_lifecycle.mixins +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('environments', '0034_alter_environment_project'), + ] + + operations = [ + migrations.CreateModel( + name='GrafanaConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('deleted_at', models.DateTimeField(blank=True, db_index=True, default=None, editable=False, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('api_key', models.CharField(max_length=100)), + ('base_url', models.URLField(null=True)), + ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='grafana_config', to='projects.Project')), + ], + options={ + 'abstract': False, + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + ] diff --git a/api/integrations/grafana/migrations/__init__.py b/api/integrations/grafana/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/grafana/models.py b/api/integrations/grafana/models.py new file mode 100644 index 000000000000..ba9e01af9535 --- /dev/null +++ b/api/integrations/grafana/models.py @@ -0,0 +1,44 @@ +import logging + +from django.db import models + +from integrations.common.models import IntegrationsModel +from projects.models import Project + +logger = logging.getLogger(__name__) + + +class GrafanaConfiguration(IntegrationsModel): + """ + Example `integration_data` entry: + + ``` + "grafana": { + "perEnvironment": false, + "image": "/static/images/integrations/grafana.svg", + "docs": "https://docs.flagsmith.com/integrations/apm/grafana", + "fields": [ + { + "key": "base_url", + "label": "Base URL", + "default": "https://grafana.com" + }, + { + "key": "api_key", + "label": "Service account token", + "hidden": true + } + ], + "tags": [ + "logging" + ], + "title": "Grafana", + "description": "Receive Flagsmith annotations to your Grafana instance on feature flag and segment changes." + }, + ``` + """ + + base_url = models.URLField(blank=False, null=True) + project = models.OneToOneField( + Project, on_delete=models.CASCADE, related_name="grafana_config" + ) diff --git a/api/integrations/grafana/serializers.py b/api/integrations/grafana/serializers.py new file mode 100644 index 000000000000..47ee252c30cf --- /dev/null +++ b/api/integrations/grafana/serializers.py @@ -0,0 +1,11 @@ +from integrations.common.serializers import ( + BaseProjectIntegrationModelSerializer, +) + +from .models import GrafanaConfiguration + + +class GrafanaConfigurationSerializer(BaseProjectIntegrationModelSerializer): + class Meta: + model = GrafanaConfiguration + fields = ("id", "base_url", "api_key") diff --git a/api/integrations/grafana/types.py b/api/integrations/grafana/types.py new file mode 100644 index 000000000000..431d0054c6cd --- /dev/null +++ b/api/integrations/grafana/types.py @@ -0,0 +1,8 @@ +from typing import TypedDict + + +class GrafanaAnnotation(TypedDict): + tags: list[str] + text: str + time: int + timeEnd: int diff --git a/api/integrations/grafana/views.py b/api/integrations/grafana/views.py new file mode 100644 index 000000000000..cd36b2c0e9e1 --- /dev/null +++ b/api/integrations/grafana/views.py @@ -0,0 +1,9 @@ +from integrations.common.views import ProjectIntegrationBaseViewSet +from integrations.grafana.models import GrafanaConfiguration +from integrations.grafana.serializers import GrafanaConfigurationSerializer + + +class GrafanaConfigurationViewSet(ProjectIntegrationBaseViewSet): + serializer_class = GrafanaConfigurationSerializer + pagination_class = None # set here to ensure documentation is correct + model_class = GrafanaConfiguration diff --git a/api/projects/urls.py b/api/projects/urls.py index 79bfabc62afc..d68750b97d86 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -12,6 +12,7 @@ from features.multivariate.views import MultivariateFeatureOptionViewSet from features.views import FeatureViewSet from integrations.datadog.views import DataDogConfigurationViewSet +from integrations.grafana.views import GrafanaConfigurationViewSet from integrations.launch_darkly.views import LaunchDarklyImportRequestViewSet from integrations.new_relic.views import NewRelicConfigurationViewSet from projects.tags.views import TagViewSet @@ -56,6 +57,11 @@ LaunchDarklyImportRequestViewSet, basename="imports-launch-darkly", ) +projects_router.register( + r"integrations/grafana", + GrafanaConfigurationViewSet, + basename="integrations-grafana", +) projects_router.register( "audit", ProjectAuditLogViewSet, diff --git a/api/tests/unit/audit/test_unit_audit_services.py b/api/tests/unit/audit/test_unit_audit_services.py new file mode 100644 index 000000000000..48308bc97dd1 --- /dev/null +++ b/api/tests/unit/audit/test_unit_audit_services.py @@ -0,0 +1,116 @@ +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType +from audit.services import get_audited_instance_from_audit_log_record +from audit.tasks import ( + create_feature_state_updated_by_change_request_audit_log, + create_segment_priorities_changed_audit_log, +) +from environments.models import Environment +from features.models import Feature, FeatureSegment, FeatureState +from features.versioning.models import EnvironmentFeatureVersion +from features.versioning.tasks import ( + create_environment_feature_version_published_audit_log_task, +) +from features.workflows.core.models import ChangeRequest + + +def test_get_audited_instance_from_audit_log_record__change_request__return_expected( + change_request_feature_state: FeatureState, +) -> None: + # Given + create_feature_state_updated_by_change_request_audit_log( + change_request_feature_state.id + ) + audit_log_record = AuditLog.objects.get( + related_object_type=RelatedObjectType.FEATURE_STATE.name, + related_object_id=change_request_feature_state.id, + ) + change_request_feature_state.delete() + + # When + instance = get_audited_instance_from_audit_log_record(audit_log_record) + + # Then + assert instance == change_request_feature_state + + +def test_get_audited_instance_from_audit_log_record__segment_priorities__return_expected( + feature: Feature, + feature_segment: FeatureSegment, +) -> None: + # Given + create_segment_priorities_changed_audit_log( + previous_id_priority_pairs=[ + (feature_segment.id, 0), + ], + feature_segment_ids=[feature_segment.id], + ) + audit_log_record = AuditLog.objects.get( + related_object_type=RelatedObjectType.FEATURE.name, + related_object_id=feature.id, + ) + feature.delete() + + # When + instance = get_audited_instance_from_audit_log_record(audit_log_record) + + # Then + assert instance == feature + + +def test_get_audited_instance_from_audit_log_record__feature_versioning__return_expected( + environment_v2_versioning: Environment, + feature: Feature, +) -> None: + # Given + version = EnvironmentFeatureVersion.objects.create( + feature=feature, + environment=environment_v2_versioning, + ) + create_environment_feature_version_published_audit_log_task(version.uuid) + audit_log_record = AuditLog.objects.get( + related_object_type=RelatedObjectType.EF_VERSION.name, + related_object_uuid=version.uuid, + ) + version.delete() + + # When + instance = get_audited_instance_from_audit_log_record(audit_log_record) + + # Then + assert instance == version + + +def test_get_audited_instance_from_audit_log_record__historical_record__return_expected( + change_request: ChangeRequest, +) -> None: + # Given + audit_log_record = AuditLog.objects.get( + related_object_type=RelatedObjectType.CHANGE_REQUEST.name, + related_object_id=change_request.id, + ) + change_request.delete() + + # When + instance = get_audited_instance_from_audit_log_record(audit_log_record) + + # Then + assert instance == change_request + + +def test_get_audited_instance_from_audit_log_record__unexpected_audit_log__return_none( + change_request: ChangeRequest, +) -> None: + # Given + # A change request log was created not via history + audit_log_record = AuditLog.objects.create( + history_record_id=None, + related_object_id=change_request.id, + related_object_type=RelatedObjectType.CHANGE_REQUEST.name, + ) + + # When + instance = get_audited_instance_from_audit_log_record(audit_log_record) + + # Then + assert instance is None diff --git a/api/tests/unit/audit/test_unit_audit_signals.py b/api/tests/unit/audit/test_unit_audit_signals.py index 3fd924141635..6265276f2ff0 100644 --- a/api/tests/unit/audit/test_unit_audit_signals.py +++ b/api/tests/unit/audit/test_unit_audit_signals.py @@ -2,7 +2,9 @@ from pytest_mock import MockerFixture from audit.models import AuditLog -from audit.signals import call_webhooks +from audit.related_object_type import RelatedObjectType +from audit.signals import call_webhooks, send_audit_log_event_to_grafana +from integrations.grafana.models import GrafanaConfiguration from organisations.models import Organisation, OrganisationWebhook from projects.models import Project from webhooks.webhooks import WebhookEventType @@ -81,3 +83,33 @@ def test_call_webhooks_creates_task_if_organisation_has_webhooks( assert mock_call["args"][0] == organisation.id assert mock_call["args"][1]["id"] == audit_log.id assert mock_call["args"][2] == WebhookEventType.AUDIT_LOG_CREATED.name + + +def test_send_audit_log_event_to_grafana__project_grafana_config__calls_expected( + mocker: MockerFixture, + project: Project, +) -> None: + # Given + grafana_config = GrafanaConfiguration(base_url="test.com", api_key="test") + project.grafana_config = grafana_config + audit_log_record = AuditLog.objects.create( + project=project, + related_object_type=RelatedObjectType.FEATURE.name, + ) + grafana_wrapper_mock = mocker.patch("audit.signals.GrafanaWrapper", autospec=True) + grafana_wrapper_instance_mock = grafana_wrapper_mock.return_value + + # When + send_audit_log_event_to_grafana(AuditLog, audit_log_record) + + # Then + grafana_wrapper_mock.assert_called_once_with( + base_url=grafana_config.base_url, + api_key=grafana_config.api_key, + ) + grafana_wrapper_instance_mock.generate_event_data.assert_called_once_with( + audit_log_record + ) + grafana_wrapper_instance_mock.track_event_async.assert_called_once_with( + event=grafana_wrapper_instance_mock.generate_event_data.return_value + ) diff --git a/api/tests/unit/integrations/grafana/__init__.py b/api/tests/unit/integrations/grafana/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/integrations/grafana/test_grafana.py b/api/tests/unit/integrations/grafana/test_grafana.py new file mode 100644 index 000000000000..10ed17d31ec3 --- /dev/null +++ b/api/tests/unit/integrations/grafana/test_grafana.py @@ -0,0 +1,55 @@ +import json + +import pytest +import responses +from pytest_mock import MockerFixture + +from audit.models import AuditLog +from integrations.grafana.grafana import GrafanaWrapper + + +@pytest.mark.parametrize("base_url", ["test.com", "test.com/"]) +def test_grafana_wrapper__base_url__expected_url(base_url: str) -> None: + # When + wrapper = GrafanaWrapper(base_url=base_url, api_key="any") + + # Then + assert wrapper.url == "test.com/api/annotations" + + +@responses.activate() +def test_grafana_wrapper__track_event__expected_api_call() -> None: + # Given + wrapper = GrafanaWrapper(base_url="https://test.com", api_key="any") + event = {"sample": "event"} + + responses.add(url="https://test.com/api/annotations", method="POST", status=200) + + # When + wrapper._track_event(event) + + # Then + assert len(responses.calls) == 1 + assert responses.calls[0].request.headers["Authorization"] == "Bearer any" + assert responses.calls[0].request.headers["Content-Type"] == "application/json" + assert json.loads(responses.calls[0].request.body) == event + + +def test_grafana_wrapper__generate_event_data__return_expected( + mocker: MockerFixture, +) -> None: + # Given + map_audit_log_record_to_grafana_annotation_mock = mocker.patch( + "integrations.grafana.grafana.map_audit_log_record_to_grafana_annotation", + autospec=True, + ) + audit_log_record_mock = mocker.MagicMock(spec=AuditLog) + + # When + event_data = GrafanaWrapper.generate_event_data(audit_log_record_mock) + + # Then + map_audit_log_record_to_grafana_annotation_mock.assert_called_once_with( + audit_log_record_mock + ) + assert event_data == map_audit_log_record_to_grafana_annotation_mock.return_value diff --git a/api/tests/unit/integrations/grafana/test_mappers.py b/api/tests/unit/integrations/grafana/test_mappers.py new file mode 100644 index 000000000000..ed4b61244dd2 --- /dev/null +++ b/api/tests/unit/integrations/grafana/test_mappers.py @@ -0,0 +1,239 @@ +from datetime import datetime, timezone + +import pytest +from pytest_mock import MockerFixture + +from audit.models import AuditLog +from environments.models import Environment +from features.models import Feature, FeatureSegment, FeatureState +from integrations.grafana.mappers import ( + map_audit_log_record_to_grafana_annotation, +) +from projects.models import Project +from projects.tags.models import Tag +from segments.models import Segment +from users.models import FFAdminUser + + +@pytest.fixture +def audit_log_record( + superuser: FFAdminUser, + project: Project, +) -> AuditLog: + return AuditLog.objects.create( + created_date=datetime(2024, 6, 24, 9, 9, 47, 325132, tzinfo=timezone.utc), + log="Test event", + author=superuser, + project=project, + ) + + +@pytest.fixture +def tagged_feature( + feature: Feature, + tag_one: Tag, + tag_two: Tag, +) -> Feature: + feature.tags.add(tag_one, tag_two) + feature.save() + return feature + + +def test_map_audit_log_record_to_grafana_annotation__feature__return_expected( + mocker: MockerFixture, + tagged_feature: Feature, + audit_log_record: AuditLog, +) -> None: + # Given + mocker.patch( + "integrations.grafana.mappers.get_audited_instance_from_audit_log_record", + return_value=tagged_feature, + ) + + # When + annotation = map_audit_log_record_to_grafana_annotation(audit_log_record) + + # Then + assert annotation == { + "tags": [ + "flagsmith", + "project:Test Project", + "environment:unknown", + "by:superuser@example.com", + "feature:Test Feature1", + "Test Tag", + "Test Tag2", + ], + "text": "Test event", + "time": 1719220187325, + "timeEnd": 1719220187325, + } + + +def test_map_audit_log_record_to_grafana_annotation__feature_state__return_expected( + mocker: MockerFixture, + tagged_feature: Feature, + environment: Environment, + audit_log_record: AuditLog, +) -> None: + # Given + feature_state = FeatureState.objects.filter( + environment=environment, + feature=tagged_feature, + ).first() + mocker.patch( + "integrations.grafana.mappers.get_audited_instance_from_audit_log_record", + return_value=feature_state, + ) + audit_log_record.environment = environment + + # When + annotation = map_audit_log_record_to_grafana_annotation(audit_log_record) + + # Then + assert annotation == { + "tags": [ + "flagsmith", + "project:Test Project", + "environment:Test Environment", + "by:superuser@example.com", + "feature:Test Feature1", + "flag:disabled", + "Test Tag", + "Test Tag2", + ], + "text": "Test event", + "time": 1719220187325, + "timeEnd": 1719220187325, + } + + +def test_map_audit_log_record_to_grafana_annotation__feature_state_value__return_expected( + mocker: MockerFixture, + tagged_feature: Feature, + environment: Environment, + audit_log_record: AuditLog, +) -> None: + # Given + feature_state_value = ( + FeatureState.objects.filter( + environment=environment, + feature=tagged_feature, + ) + .first() + .feature_state_value + ) + mocker.patch( + "integrations.grafana.mappers.get_audited_instance_from_audit_log_record", + return_value=feature_state_value, + ) + audit_log_record.environment = environment + + # When + annotation = map_audit_log_record_to_grafana_annotation(audit_log_record) + + # Then + assert annotation == { + "tags": [ + "flagsmith", + "project:Test Project", + "environment:Test Environment", + "by:superuser@example.com", + "feature:Test Feature1", + "Test Tag", + "Test Tag2", + ], + "text": "Test event", + "time": 1719220187325, + "timeEnd": 1719220187325, + } + + +def test_map_audit_log_record_to_grafana_annotation__segment__return_expected( + mocker: MockerFixture, + segment: Segment, + audit_log_record: AuditLog, +) -> None: + # Given + mocker.patch( + "integrations.grafana.mappers.get_audited_instance_from_audit_log_record", + return_value=segment, + ) + + # When + annotation = map_audit_log_record_to_grafana_annotation(audit_log_record) + + # Then + assert annotation == { + "tags": [ + "flagsmith", + "project:Test Project", + "environment:unknown", + "by:superuser@example.com", + "segment:segment", + ], + "text": "Test event", + "time": 1719220187325, + "timeEnd": 1719220187325, + } + + +def test_map_audit_log_record_to_grafana_annotation__feature_segment__return_expected( + mocker: MockerFixture, + tagged_feature: Feature, + feature_segment: FeatureSegment, + audit_log_record: AuditLog, +) -> None: + # Given + mocker.patch( + "integrations.grafana.mappers.get_audited_instance_from_audit_log_record", + return_value=feature_segment, + ) + + # When + annotation = map_audit_log_record_to_grafana_annotation(audit_log_record) + + # Then + assert annotation == { + "tags": [ + "flagsmith", + "project:Test Project", + "environment:unknown", + "by:superuser@example.com", + "feature:Test Feature1", + "segment:segment", + "Test Tag", + "Test Tag2", + ], + "text": "Test event", + "time": 1719220187325, + "timeEnd": 1719220187325, + } + + +@pytest.mark.django_db +def test_map_audit_log_record_to_grafana_annotation__generic__return_expected( + mocker: MockerFixture, + audit_log_record: AuditLog, +) -> None: + # Given + mocker.patch( + "integrations.grafana.mappers.get_audited_instance_from_audit_log_record", + return_value=None, + ) + + # When + annotation = map_audit_log_record_to_grafana_annotation(audit_log_record) + + # Then + assert annotation == { + "tags": [ + "flagsmith", + "project:Test Project", + "environment:unknown", + "by:superuser@example.com", + ], + "text": "Test event", + "time": 1719220187325, + "timeEnd": 1719220187325, + } diff --git a/docs/docs/deployment/overview.md b/docs/docs/deployment/overview.md index db88edc2840f..851528f5ae66 100644 --- a/docs/docs/deployment/overview.md +++ b/docs/docs/deployment/overview.md @@ -257,6 +257,26 @@ The list of the flags and remote config we're currently using in production is b "title": "Dynatrace", "description": "Sends events to Dynatrace for when flags are created, updated and removed. Logs are tagged with the environment they came from e.g. production." }, + "grafana": { + "perEnvironment": false, + "image": "/static/images/integrations/grafana.svg", + "docs": "https://docs.flagsmith.com/integrations/apm/grafana", + "fields": [ + { + "key": "base_url", + "label": "Base URL", + "default": "https://grafana.com" + }, + { + "key": "api_key", + "label": "Service account token", + "hidden": true + } + ], + "tags": ["logging"], + "title": "Grafana", + "description": "Receive Flagsmith annotations to your Grafana instance on feature flag and segment changes." + }, "jira": { "perEnvironment": false, "image": "https://docs.flagsmith.com/img/integrations/jira/jira-logo.svg", diff --git a/docs/docs/integrations/apm/grafana.md b/docs/docs/integrations/apm/grafana.md new file mode 100644 index 000000000000..1b9b083f7741 --- /dev/null +++ b/docs/docs/integrations/apm/grafana.md @@ -0,0 +1,35 @@ +--- +title: Grafana Integration +description: Integrate Flagsmith with Grafana +sidebar_label: Grafana +hide_title: true +--- + +![Image](/img/integrations/grafana/grafana-logo.svg) + +You can integrate Flagsmith with Grafana. Send flag change events from Flagsmith into Grafana as annotations. + +## Integration Setup + +Log into Grafana and generate a Service Account Key: + +1. Navigate to Administration > 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 Flagsmith: + +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. +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/). + +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. + +Annotations reporting feature-specific events include the project tag and Flagsmith user-defined tags, and flag change +events include the environment tag as well. diff --git a/docs/docs/integrations/overview.md b/docs/docs/integrations/overview.md index 5992999eea60..eb6eb4285c2e 100644 --- a/docs/docs/integrations/overview.md +++ b/docs/docs/integrations/overview.md @@ -87,6 +87,13 @@ 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). + +--- + You can integrate Flagsmith with New Relic. Send flag change events from Flagsmith into your Datadog event stream. diff --git a/docs/static/img/integrations/grafana/grafana-logo.svg b/docs/static/img/integrations/grafana/grafana-logo.svg new file mode 100644 index 000000000000..92ced7b6e505 --- /dev/null +++ b/docs/static/img/integrations/grafana/grafana-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/web/static/images/integrations/grafana.svg b/frontend/web/static/images/integrations/grafana.svg new file mode 100644 index 000000000000..92ced7b6e505 --- /dev/null +++ b/frontend/web/static/images/integrations/grafana.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + +