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 @@
+