Skip to content

Commit

Permalink
feat: Grafana integration (#4144)
Browse files Browse the repository at this point in the history
Co-authored-by: Kim Gustyr <kim.gustyr@flagsmith.com>
  • Loading branch information
dabeeeenster and khvn26 authored Jun 24, 2024
1 parent 57f8d68 commit 5c25c41
Show file tree
Hide file tree
Showing 28 changed files with 874 additions and 6 deletions.
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
"integrations.flagsmith",
"integrations.launch_darkly",
"integrations.github",
"integrations.grafana",
# Rate limiting admin endpoints
"axes",
"telemetry",
Expand Down
4 changes: 4 additions & 0 deletions api/audit/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
63 changes: 63 additions & 0 deletions api/audit/services.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions api/audit/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
6 changes: 3 additions & 3 deletions api/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions api/core/signals.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,7 +9,7 @@


def create_audit_log_from_historical_record(
instance: _AbstractBaseAuditableModel,
instance: AbstractBaseAuditableModel,
history_user: FFAdminUser,
history_instance,
**kwargs,
Expand Down
6 changes: 6 additions & 0 deletions api/environments/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions api/integrations/grafana/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
default_app_config = "integrations.grafana.apps.GrafanaConfigurationConfig"
6 changes: 6 additions & 0 deletions api/integrations/grafana/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from django.apps import AppConfig


class GrafanaConfigurationConfig(AppConfig):
name = "integrations.grafana"
43 changes: 43 additions & 0 deletions api/integrations/grafana/grafana.py
Original file line number Diff line number Diff line change
@@ -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
)
72 changes: 72 additions & 0 deletions api/integrations/grafana/mappers.py
Original file line number Diff line number Diff line change
@@ -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,
}
33 changes: 33 additions & 0 deletions api/integrations/grafana/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
Empty file.
44 changes: 44 additions & 0 deletions api/integrations/grafana/models.py
Original file line number Diff line number Diff line change
@@ -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"
)
11 changes: 11 additions & 0 deletions api/integrations/grafana/serializers.py
Original file line number Diff line number Diff line change
@@ -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")
8 changes: 8 additions & 0 deletions api/integrations/grafana/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from typing import TypedDict


class GrafanaAnnotation(TypedDict):
tags: list[str]
text: str
time: int
timeEnd: int
Loading

0 comments on commit 5c25c41

Please sign in to comment.