Skip to content

Commit e5272ee

Browse files
committed
feat: Integrate Grafana
- Make Grafana integration project-level - Add `get_audited_instance_from_audit_log_record` service - Add tags, including feature tags - Improve docs
1 parent 6c185c2 commit e5272ee

19 files changed

+645
-289
lines changed

api/audit/models.py

+4
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ def history_record(self) -> typing.Optional[Model]:
9090
klass = self.get_history_record_model_class(self.history_record_class_path)
9191
return klass.objects.filter(history_id=self.history_record_id).first()
9292

93+
@property
94+
def project_name(self) -> str:
95+
return getattr(self.project, "name", "unknown")
96+
9397
@property
9498
def environment_name(self) -> str:
9599
return getattr(self.environment, "name", "unknown")

api/audit/services.py

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from core.models import AbstractBaseAuditableModel
2+
3+
from audit.models import AuditLog
4+
from audit.related_object_type import RelatedObjectType
5+
from features.models import Feature, FeatureState
6+
from features.versioning.models import EnvironmentFeatureVersion
7+
8+
9+
def get_audited_instance_from_audit_log_record(
10+
audit_log_record: AuditLog,
11+
) -> AbstractBaseAuditableModel | None:
12+
"""
13+
Given an `AuditLog` model instance, return a model instance that produced the log.
14+
"""
15+
# There's currently four (sigh) ways an audit log record is created:
16+
# 1. Historical record
17+
# 2. Segment priorities changed
18+
# 3. Change request
19+
# 4. Environment feature version published
20+
21+
# Try getting the historical record first.
22+
if history_record := audit_log_record.history_record:
23+
return history_record.instance
24+
25+
# Try to infer the model class from `AuditLog.related_object_type`.
26+
match audit_log_record.related_object_type:
27+
# Assume segment priorities change.
28+
case RelatedObjectType.FEATURE.name:
29+
return (
30+
Feature.objects.all_with_deleted()
31+
.filter(
32+
pk=audit_log_record.related_object_id,
33+
project=audit_log_record.project,
34+
)
35+
.first()
36+
)
37+
38+
# Assume change request.
39+
case RelatedObjectType.FEATURE_STATE.name:
40+
return (
41+
FeatureState.objects.all_with_deleted()
42+
.filter(
43+
pk=audit_log_record.related_object_id,
44+
environment=audit_log_record.environment,
45+
)
46+
.first()
47+
)
48+
49+
# Assume environment feature version.
50+
case RelatedObjectType.EF_VERSION.name:
51+
return (
52+
EnvironmentFeatureVersion.objects.all_with_deleted()
53+
.filter(
54+
uuid=audit_log_record.related_object_uuid,
55+
environment=audit_log_record.environment,
56+
)
57+
.first()
58+
)
59+
60+
# All known audit log sources exhausted by now.
61+
# Since `RelatedObjectType` is not a 1:1 mapping to a model class,
62+
# generalised heuristics might be dangerous.
63+
return None

api/core/models.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ class Meta:
9898
return BaseHistoricalModel
9999

100100

101-
class _AbstractBaseAuditableModel(models.Model):
101+
class AbstractBaseAuditableModel(models.Model):
102102
"""
103103
A base Model class that all models we want to be included in the audit log should inherit from.
104104
@@ -196,8 +196,8 @@ def abstract_base_auditable_model_factory(
196196
historical_records_excluded_fields: typing.List[str] = None,
197197
change_details_excluded_fields: typing.Sequence[str] = None,
198198
show_change_details_for_create: bool = False,
199-
) -> typing.Type[_AbstractBaseAuditableModel]:
200-
class Base(_AbstractBaseAuditableModel):
199+
) -> typing.Type[AbstractBaseAuditableModel]:
200+
class Base(AbstractBaseAuditableModel):
201201
history = HistoricalRecords(
202202
bases=[
203203
base_historical_model_factory(

api/core/signals.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from core.models import _AbstractBaseAuditableModel
1+
from core.models import AbstractBaseAuditableModel
22
from django.conf import settings
33
from django.utils import timezone
44
from simple_history.models import HistoricalRecords
@@ -9,7 +9,7 @@
99

1010

1111
def create_audit_log_from_historical_record(
12-
instance: _AbstractBaseAuditableModel,
12+
instance: AbstractBaseAuditableModel,
1313
history_user: FFAdminUser,
1414
history_instance,
1515
**kwargs,

api/integrations/grafana/grafana.py

+15-20
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,41 @@
11
import json
22
import logging
3-
import time
3+
from typing import Any
44

55
import requests
66

77
from audit.models import AuditLog
88
from integrations.common.wrapper import AbstractBaseEventIntegrationWrapper
9+
from integrations.grafana.mappers import (
10+
map_audit_log_record_to_grafana_annotation,
11+
)
912

1013
logger = logging.getLogger(__name__)
1114

12-
ANNOTATIONS_API_URI = "api/annotations"
15+
ROUTE_API_ANNOTATIONS = "/api/annotations"
1316

1417

1518
class GrafanaWrapper(AbstractBaseEventIntegrationWrapper):
1619
def __init__(self, base_url: str, api_key: str) -> None:
17-
self.url = f"{base_url}{ANNOTATIONS_API_URI}"
20+
base_url = base_url[:-1] if base_url.endswith("/") else base_url
21+
self.url = f"{base_url}{ROUTE_API_ANNOTATIONS}"
1822
self.api_key = api_key
1923

2024
@staticmethod
21-
def generate_event_data(audit_log_record: AuditLog) -> dict:
22-
log = audit_log_record.log
23-
email = audit_log_record.author_identifier
25+
def generate_event_data(audit_log_record: AuditLog) -> dict[str, Any]:
26+
return map_audit_log_record_to_grafana_annotation(audit_log_record)
2427

25-
epoch_time_in_milliseconds = round(time.time() * 1000)
26-
27-
return {
28-
"text": f"{log} by user {email}",
29-
"dashboardUID": "",
30-
"tags": ["Flagsmith Event"],
31-
"time": epoch_time_in_milliseconds,
32-
"timeEnd": epoch_time_in_milliseconds,
33-
}
34-
35-
def _headers(self) -> dict:
28+
def _headers(self) -> dict[str, str]:
3629
return {
3730
"Content-Type": "application/json",
38-
"Authorization": "Bearer %s" % self.api_key,
31+
"Authorization": f"Bearer {self.api_key}",
3932
}
4033

41-
def _track_event(self, event: dict) -> None:
34+
def _track_event(self, event: dict[str, Any]) -> None:
4235
response = requests.post(
43-
self.url, headers=self._headers(), data=json.dumps(event)
36+
url=self.url,
37+
headers=self._headers(),
38+
data=json.dumps(event),
4439
)
4540

4641
logger.debug(

api/integrations/grafana/mappers.py

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from audit.models import AuditLog
2+
from audit.services import get_audited_instance_from_audit_log_record
3+
from features.models import (
4+
Feature,
5+
FeatureSegment,
6+
FeatureState,
7+
FeatureStateValue,
8+
)
9+
from integrations.grafana.types import GrafanaAnnotation
10+
from segments.models import Segment
11+
12+
13+
def _get_feature_tags(
14+
feature: Feature,
15+
) -> list[str]:
16+
return list(feature.tags.values_list("label", flat=True))
17+
18+
19+
def _get_instance_tags_from_audit_log_record(
20+
audit_log_record: AuditLog,
21+
) -> list[str]:
22+
if instance := get_audited_instance_from_audit_log_record(audit_log_record):
23+
if isinstance(instance, Feature):
24+
return [
25+
f"feature:{instance.name}",
26+
*_get_feature_tags(instance),
27+
]
28+
29+
if isinstance(instance, FeatureState):
30+
return [
31+
f"feature:{(feature := instance.feature).name}",
32+
f'flag:{"enabled" if instance.enabled else "disabled"}',
33+
*_get_feature_tags(feature),
34+
]
35+
36+
if isinstance(instance, FeatureStateValue):
37+
return [
38+
f"feature:{(feature := instance.feature_state.feature).name}",
39+
*_get_feature_tags(feature),
40+
]
41+
42+
if isinstance(instance, Segment):
43+
return [f"segment:{instance.name}"]
44+
45+
if isinstance(instance, FeatureSegment):
46+
return [
47+
f"feature:{(feature := instance.feature).name}",
48+
f"segment:{instance.segment.name}",
49+
*_get_feature_tags(feature),
50+
]
51+
52+
return []
53+
54+
55+
def map_audit_log_record_to_grafana_annotation(
56+
audit_log_record: AuditLog,
57+
) -> GrafanaAnnotation:
58+
tags = [
59+
"flagsmith",
60+
f"project:{audit_log_record.project_name}",
61+
f"environment:{audit_log_record.environment_name}",
62+
f"by:{audit_log_record.author_identifier}",
63+
*_get_instance_tags_from_audit_log_record(audit_log_record),
64+
]
65+
time = int(audit_log_record.created_date.timestamp() * 1000) # ms since epoch
66+
67+
return {
68+
"tags": tags,
69+
"text": audit_log_record.log,
70+
"time": time,
71+
"timeEnd": time,
72+
}

api/integrations/grafana/migrations/0001_initial.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 3.2.25 on 2024-06-14 13:45
1+
# Generated by Django 3.2.25 on 2024-06-21 20:31
22

33
from django.db import migrations, models
44
import django.db.models.deletion
@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
2323
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
2424
('api_key', models.CharField(max_length=100)),
2525
('base_url', models.URLField(null=True)),
26-
('environment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='grafana_config', to='environments.environment')),
26+
('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='grafana_config', to='projects.Project')),
2727
],
2828
options={
2929
'abstract': False,

api/integrations/grafana/models.py

+34-5
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,43 @@
22

33
from django.db import models
44

5-
from environments.models import Environment
6-
from integrations.common.models import EnvironmentIntegrationModel
5+
from integrations.common.models import IntegrationsModel
6+
from projects.models import Project
77

88
logger = logging.getLogger(__name__)
99

1010

11-
class GrafanaConfiguration(EnvironmentIntegrationModel):
11+
class GrafanaConfiguration(IntegrationsModel):
12+
"""
13+
Example `integration_data` entry:
14+
15+
```
16+
"grafana": {
17+
"perEnvironment": false,
18+
"image": "/static/images/integrations/grafana.svg",
19+
"docs": "https://docs.flagsmith.com/integrations/apm/grafana",
20+
"fields": [
21+
{
22+
"key": "base_url",
23+
"label": "Base URL",
24+
"default": "https://grafana.com"
25+
},
26+
{
27+
"key": "api_key",
28+
"label": "Service account token",
29+
"hidden": true
30+
}
31+
],
32+
"tags": [
33+
"logging"
34+
],
35+
"title": "Grafana",
36+
"description": "Receive Flagsmith annotations to your Grafana instance on feature flag and segment changes."
37+
},
38+
```
39+
"""
40+
1241
base_url = models.URLField(blank=False, null=True)
13-
environment = models.OneToOneField(
14-
Environment, related_name="grafana_config", on_delete=models.CASCADE
42+
project = models.OneToOneField(
43+
Project, on_delete=models.CASCADE, related_name="grafana_config"
1544
)
+2-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from integrations.common.serializers import (
2-
BaseEnvironmentIntegrationModelSerializer,
2+
BaseProjectIntegrationModelSerializer,
33
)
44

55
from .models import GrafanaConfiguration
66

77

8-
class GrafanaConfigurationSerializer(BaseEnvironmentIntegrationModelSerializer):
8+
class GrafanaConfigurationSerializer(BaseProjectIntegrationModelSerializer):
99
class Meta:
1010
model = GrafanaConfiguration
1111
fields = ("id", "base_url", "api_key")

api/integrations/grafana/types.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from typing import TypedDict
2+
3+
4+
class GrafanaAnnotation(TypedDict):
5+
tags: list[str]
6+
text: str
7+
time: int
8+
timeEnd: int

api/integrations/grafana/views.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
from integrations.common.views import EnvironmentIntegrationCommonViewSet
1+
from integrations.common.views import ProjectIntegrationBaseViewSet
22
from integrations.grafana.models import GrafanaConfiguration
33
from integrations.grafana.serializers import GrafanaConfigurationSerializer
44

55

6-
class GrafanaConfigurationViewSet(EnvironmentIntegrationCommonViewSet):
6+
class GrafanaConfigurationViewSet(ProjectIntegrationBaseViewSet):
77
serializer_class = GrafanaConfigurationSerializer
88
pagination_class = None # set here to ensure documentation is correct
99
model_class = GrafanaConfiguration

0 commit comments

Comments
 (0)