Skip to content

Commit 4d415a1

Browse files
authored
feat: Integrate with Sentry feature flag Change Tracking (#5531)
1 parent 9182c41 commit 4d415a1

File tree

15 files changed

+837
-28
lines changed

15 files changed

+837
-28
lines changed

api/audit/signals.py

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
import logging
2-
import typing
2+
from typing import Any, Callable, Literal, Protocol, Type
33

44
from django.conf import settings
55
from django.db.models.signals import post_save
66
from django.dispatch import receiver
77

8-
from audit.models import AuditLog, RelatedObjectType # type: ignore[attr-defined]
8+
from audit.models import AuditLog
9+
from audit.related_object_type import RelatedObjectType
910
from audit.serializers import AuditLogListSerializer
11+
from audit.services import get_audited_instance_from_audit_log_record
12+
from features.models import FeatureState
13+
from features.signals import feature_state_change_went_live
1014
from integrations.common.models import IntegrationsModel
1115
from integrations.datadog.datadog import DataDogWrapper
1216
from integrations.dynatrace.dynatrace import DynatraceWrapper
1317
from integrations.grafana.grafana import GrafanaWrapper
1418
from integrations.new_relic.new_relic import NewRelicWrapper
19+
from integrations.sentry.change_tracking import SentryChangeTracking
20+
from integrations.sentry.models import SentryChangeTrackingConfiguration
1521
from integrations.slack.slack import SlackWrapper
1622
from organisations.models import OrganisationWebhook
1723
from webhooks.tasks import call_organisation_webhooks
@@ -20,7 +26,7 @@
2026
logger = logging.getLogger(__name__)
2127

2228

23-
AuditLogIntegrationAttrName = typing.Literal[
29+
AuditLogIntegrationAttrName = Literal[
2430
"data_dog_config",
2531
"dynatrace_config",
2632
"grafana_config",
@@ -29,6 +35,18 @@
2935
]
3036

3137

38+
class _AuditLogSignalHandler(Protocol):
39+
def __call__(
40+
self,
41+
sender: Type[AuditLog],
42+
instance: AuditLog,
43+
**kwargs: Any,
44+
) -> None: ...
45+
46+
47+
_DecoratedSignal = Callable[[_AuditLogSignalHandler], _AuditLogSignalHandler]
48+
49+
3250
def _get_integration_config(
3351
instance: AuditLog,
3452
integration_name: AuditLogIntegrationAttrName,
@@ -71,25 +89,41 @@ def call_webhooks(sender, instance, **kwargs): # type: ignore[no-untyped-def]
7189
)
7290

7391

74-
def track_only_feature_related_events(signal_function): # type: ignore[no-untyped-def]
75-
def signal_wrapper(sender, instance, **kwargs): # type: ignore[no-untyped-def]
76-
# Only handle Feature related changes
77-
if instance.related_object_type not in [
78-
RelatedObjectType.FEATURE.name,
79-
RelatedObjectType.FEATURE_STATE.name,
80-
RelatedObjectType.SEGMENT.name,
81-
RelatedObjectType.EF_VERSION.name,
82-
]:
83-
return None
84-
return signal_function(sender, instance, **kwargs)
92+
def track_only(types: list[RelatedObjectType]) -> _DecoratedSignal:
93+
"""
94+
Restrict an AuditLog signal to a certain list of RelatedObjectType
95+
"""
8596

86-
return signal_wrapper
97+
def decorator(signal_function: _AuditLogSignalHandler) -> _AuditLogSignalHandler:
98+
def signal_wrapper(
99+
sender: Type[AuditLog],
100+
instance: AuditLog,
101+
**kwargs: Any,
102+
) -> None:
103+
type_names = (t.name for t in types)
104+
if instance.related_object_type not in type_names:
105+
return None
106+
return signal_function(sender, instance, **kwargs)
87107

108+
return signal_wrapper
88109

89-
def _track_event_async(instance, integration_client): # type: ignore[no-untyped-def]
90-
event_data = integration_client.generate_event_data(audit_log_record=instance)
110+
return decorator
91111

92-
integration_client.track_event_async(event=event_data)
112+
113+
def track_only_feature_related_events(signal_function): # type: ignore[no-untyped-def]
114+
allowed_types = [
115+
RelatedObjectType.FEATURE,
116+
RelatedObjectType.FEATURE_STATE,
117+
RelatedObjectType.SEGMENT,
118+
RelatedObjectType.EF_VERSION,
119+
]
120+
return track_only(allowed_types)(signal_function)
121+
122+
123+
def _track_event_async(instance, integration_client): # type: ignore[no-untyped-def]
124+
if event_data := integration_client.generate_event_data(audit_log_record=instance):
125+
integration_client.track_event_async(event=event_data)
126+
return
93127

94128

95129
@receiver(post_save, sender=AuditLog)
@@ -166,3 +200,34 @@ def send_audit_log_event_to_slack(sender, instance, **kwargs): # type: ignore[n
166200
api_token=slack_project_config.api_token, channel_id=env_config.channel_id
167201
)
168202
_track_event_async(instance, slack) # type: ignore[no-untyped-call]
203+
204+
205+
@receiver(post_save, sender=AuditLog)
206+
@track_only([RelatedObjectType.FEATURE_STATE])
207+
def send_feature_flag_went_live_signal(sender, instance, **kwargs): # type: ignore[no-untyped-def]
208+
feature_state = get_audited_instance_from_audit_log_record(instance)
209+
if not isinstance(feature_state, FeatureState):
210+
return
211+
212+
if feature_state.is_scheduled:
213+
return # This is handled by audit.tasks.create_feature_state_went_live_audit_log
214+
215+
feature_state_change_went_live.send(instance)
216+
217+
218+
@receiver(feature_state_change_went_live)
219+
def send_audit_log_event_to_sentry(sender: AuditLog, **kwargs: Any) -> None:
220+
try:
221+
sentry_configuration = SentryChangeTrackingConfiguration.objects.get(
222+
environment=sender.environment,
223+
deleted_at__isnull=True,
224+
)
225+
except SentryChangeTrackingConfiguration.DoesNotExist:
226+
return
227+
228+
sentry_change_tracking = SentryChangeTracking(
229+
webhook_url=sentry_configuration.webhook_url,
230+
secret=sentry_configuration.secret,
231+
)
232+
233+
_track_event_async(sender, sentry_change_tracking) # type: ignore[no-untyped-call]

api/audit/tasks.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def _create_feature_state_audit_log_for_change_request( # type: ignore[no-untyp
3636
feature_state_id: int, msg_template: str
3737
):
3838
from features.models import FeatureState
39+
from features.signals import feature_state_change_went_live
3940

4041
feature_state = FeatureState.objects.filter(id=feature_state_id).first()
4142

@@ -64,7 +65,7 @@ def _create_feature_state_audit_log_for_change_request( # type: ignore[no-untyp
6465
feature_state.change_request.title,
6566
)
6667
# NOTE: This NEEDS to leverage btree indexes on AuditLog
67-
_, log_created = AuditLog.objects.get_or_create(
68+
audit_log, log_created = AuditLog.objects.get_or_create(
6869
created_date=feature_state.live_from,
6970
environment=feature_state.environment,
7071
is_system_event=True,
@@ -73,7 +74,9 @@ def _create_feature_state_audit_log_for_change_request( # type: ignore[no-untyp
7374
related_object_id=feature_state.id,
7475
related_object_type=RelatedObjectType.FEATURE_STATE.name,
7576
)
76-
if not log_created:
77+
if log_created:
78+
feature_state_change_went_live.send(audit_log)
79+
else:
7780
logger.info(
7881
"FeatureState update audit log already exists. "
7982
"Likely the change request was rescheduled to an earlier date.",
@@ -96,16 +99,17 @@ def create_audit_log_from_historical_record( # type: ignore[no-untyped-def]
9699
):
97100
return
98101

99-
user_model = get_user_model()
100-
101102
instance = history_instance.instance
102103
if instance.get_skip_create_audit_log():
103104
return
104105

105-
history_user = user_model.objects.filter(id=history_user_id).first()
106+
if history_user_id is not None:
107+
user_model = get_user_model()
108+
history_user = user_model.objects.filter(id=history_user_id).first()
109+
else:
110+
history_user = instance.get_audit_log_author(history_instance)
106111

107-
override_author = instance.get_audit_log_author(history_instance)
108-
if not (history_user or override_author or history_instance.master_api_key):
112+
if not (history_user or history_instance.master_api_key):
109113
return
110114

111115
environment, project = instance.get_environment_and_project()
@@ -130,7 +134,7 @@ def create_audit_log_from_historical_record( # type: ignore[no-untyped-def]
130134
history_record_class_path=history_record_class_path,
131135
environment=environment,
132136
project=project,
133-
author=override_author or history_user,
137+
author=history_user,
134138
related_object_id=related_object_id,
135139
related_object_type=related_object_type.name,
136140
log=log_message,

api/environments/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from integrations.mixpanel.views import MixpanelConfigurationViewSet
2020
from integrations.rudderstack.views import RudderstackConfigurationViewSet
2121
from integrations.segment.views import SegmentConfigurationViewSet
22+
from integrations.sentry.views import SentryChangeTrackingConfigurationViewSet
2223
from integrations.slack.views import (
2324
SlackEnvironmentViewSet,
2425
SlackGetChannelsViewSet,
@@ -99,6 +100,11 @@
99100
MixpanelConfigurationViewSet,
100101
basename="integrations-mixpanel",
101102
)
103+
environments_router.register(
104+
r"integrations/sentry",
105+
SentryChangeTrackingConfigurationViewSet,
106+
basename="integrations-sentry",
107+
)
102108
environments_router.register(
103109
r"integrations/slack", SlackEnvironmentViewSet, basename="integrations-slack"
104110
)

api/features/signals.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import logging
22

33
from django.db.models.signals import post_save
4-
from django.dispatch import receiver
4+
from django.dispatch import Signal, receiver
55

66
# noinspection PyUnresolvedReferences
77
from .models import FeatureState
88
from .tasks import trigger_feature_state_change_webhooks
99

1010
logger = logging.getLogger(__name__)
1111

12+
feature_state_change_went_live = Signal()
13+
1214

1315
@receiver(post_save, sender=FeatureState)
1416
def trigger_feature_state_change_webhooks_signal(instance, **kwargs): # type: ignore[no-untyped-def]
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import json
2+
import logging
3+
from typing import Any
4+
5+
import requests
6+
from django.core.serializers.json import DjangoJSONEncoder
7+
8+
from audit.models import AuditLog
9+
from audit.services import get_audited_instance_from_audit_log_record
10+
from core.signing import sign_payload
11+
from features.models import FeatureState
12+
from integrations.common.wrapper import AbstractBaseEventIntegrationWrapper
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class SentryChangeTracking(AbstractBaseEventIntegrationWrapper):
18+
"""
19+
Change Tracking integration with Sentry
20+
21+
Spec: https://github.com/getsentry/sentry/blob/master/src/sentry/flags/docs/api.md#create-generic-flag-log-post
22+
23+
NOTE: This triggers when...
24+
- ...creating the flag because there is a hook to create (save) the initial feature state.
25+
- ...updating the flag because there is a signal to trigger this on model save.
26+
- ...deleting the flag because deleting means saving the model instance with a deleted_at timestamp.
27+
"""
28+
29+
def __init__(self, webhook_url: str, secret: str) -> None:
30+
self.webhook_url = webhook_url
31+
self.secret = secret
32+
33+
@staticmethod
34+
def generate_event_data(audit_log_record: AuditLog) -> dict[str, Any]:
35+
feature_state = get_audited_instance_from_audit_log_record(audit_log_record)
36+
if not isinstance(feature_state, FeatureState): # pragma: no cover
37+
logger.warning(
38+
f"{type(feature_state)} is not supported by Sentry Change Tracking integration."
39+
)
40+
return {}
41+
42+
update_published_at = feature_state.deleted_at or (
43+
max(feature_state.live_from, feature_state.updated_at)
44+
if feature_state.live_from
45+
else feature_state.updated_at
46+
)
47+
48+
action = {
49+
"+": "created",
50+
"-": "deleted",
51+
"~": "updated",
52+
}[audit_log_record.history_record.history_type] # type: ignore[union-attr]
53+
54+
inner_payload = {
55+
"action": action,
56+
"flag": feature_state.feature.name,
57+
"created_at": update_published_at.isoformat(timespec="seconds"),
58+
"created_by": {
59+
"id": getattr(audit_log_record.author, "email", "app@flagsmith.com"),
60+
"type": "email",
61+
},
62+
}
63+
64+
if action == "updated":
65+
inner_payload["change_id"] = str(feature_state.pk)
66+
67+
return inner_payload
68+
69+
def _track_event(self, event: dict[str, Any]) -> None:
70+
action = event["action"]
71+
feature_name = event["flag"]
72+
logger.debug("Sending '%s' (%s) to Sentry...", feature_name, action)
73+
74+
payload = {
75+
"data": [event],
76+
"meta": {"version": 1},
77+
}
78+
json_payload = json.dumps(payload, sort_keys=True, cls=DjangoJSONEncoder)
79+
80+
headers = {
81+
"Content-Type": "application/json",
82+
"X-Sentry-Signature": sign_payload(json_payload, self.secret),
83+
}
84+
85+
response = requests.post(
86+
url=self.webhook_url,
87+
headers=headers,
88+
data=json_payload,
89+
)
90+
91+
try:
92+
response.raise_for_status()
93+
except requests.exceptions.RequestException as error:
94+
# TODO: Should we retry, or ultimately notify the admin of a persisting issue?
95+
logger.error(
96+
"Error sending '%s' (%s) to Sentry: %s",
97+
feature_name,
98+
action,
99+
repr(error),
100+
)
101+
else:
102+
logger.debug("Sent '%s' (%s) to Sentry", feature_name, action)

0 commit comments

Comments
 (0)