Skip to content

Commit 6972ee7

Browse files
committed
Push feature flag events to Sentry
1 parent 1f3df8b commit 6972ee7

File tree

5 files changed

+302
-0
lines changed

5 files changed

+302
-0
lines changed

api/integrations/sentry/apps.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ def ready(self): # type: ignore[no-untyped-def]
2020
# django.contrib.auth) you may enable sending PII data.
2121
send_default_pii=True,
2222
)
23+
24+
# noinspection PyUnresolvedReferences
25+
from . import signals # noqa
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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 core.signing import sign_payload
9+
from features.models import FeatureState
10+
11+
from .models import SentryChangeTrackingConfiguration
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
def post_change_tracking_update_to_sentry(
17+
feature_state: FeatureState,
18+
configuration: SentryChangeTrackingConfiguration,
19+
) -> None:
20+
"""
21+
Send a Change Tracking update to Sentry
22+
23+
Spec: https://github.com/getsentry/sentry/blob/master/src/sentry/flags/docs/api.md#create-generic-flag-log-post
24+
25+
NOTE:
26+
- This works when creating the flag because there is a hook to create (save) a feature state.
27+
- This works when updating the flag because there is a signal to trigger this on model save.
28+
- This works when deleting the flag because deleting means saving the model instance with a deleted_at timestamp.
29+
"""
30+
feature_name = feature_state.feature.name
31+
logger.debug("Sending '%s' update to Sentry...", feature_name)
32+
33+
# Sentry-specific fields
34+
sentry_payload: dict[str, Any] = {
35+
"created_at": ( # i.e. Sentry event's created_at
36+
feature_state.deleted_at
37+
or feature_state.live_from
38+
or feature_state.updated_at
39+
).isoformat(timespec="seconds"),
40+
"flag": feature_state.feature.name,
41+
}
42+
sentry_payload["created_by"] = {
43+
"id": _guess_change_author_email(feature_state),
44+
"type": "email",
45+
}
46+
sentry_payload["action"] = action = _guess_action(feature_state)
47+
if action == "updated":
48+
sentry_payload["change_id"] = str(feature_state.pk)
49+
50+
payload = {
51+
"data": [sentry_payload],
52+
"meta": {"version": 1},
53+
}
54+
json_payload = json.dumps(payload, sort_keys=True, cls=DjangoJSONEncoder)
55+
56+
headers = {
57+
"Content-Type": "application/json",
58+
"X-Sentry-Signature": sign_payload(json_payload, configuration.secret),
59+
}
60+
61+
response = requests.post(
62+
url=configuration.webhook_url,
63+
headers=headers,
64+
data=json_payload,
65+
)
66+
67+
try:
68+
response.raise_for_status()
69+
except requests.exceptions.RequestException as error:
70+
logger.error("Failed to send Sentry update: %s", repr(error))
71+
# TODO: Should we retry, or ultimately notify the admin of a persisting issue?
72+
73+
logger.info("Sent '%s' (%s) to Sentry", feature_name, action)
74+
75+
76+
def _guess_action(feature_state: FeatureState) -> str:
77+
"""
78+
Detect which kind of event occurred for a feature state
79+
"""
80+
if feature_state.deleted_at:
81+
return "deleted"
82+
83+
# NOTE: 0~1 items depending on whether this runs synchronously
84+
is_new_feature = feature_state.history.order_by()[:2].count() <= 1
85+
if is_new_feature:
86+
return "created"
87+
88+
return "updated"
89+
90+
91+
def _guess_change_author_email(feature_state: FeatureState) -> str:
92+
"""
93+
Best-effort to find the author of a feature state change
94+
95+
NOTE: Audit logs are created asynchronously to this function, meaning that
96+
we cannot expect the most recent audit log to be what we're looking for.
97+
98+
TODO: Revisit this if the actor is a relevant piece of information.
99+
"""
100+
return "[email protected]" # :(

api/integrations/sentry/signals.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django.db.models.signals import post_save
2+
from django.dispatch import receiver
3+
4+
from features.models import FeatureState
5+
6+
from .tasks import send_sentry_change_tracking_webhook_update
7+
8+
9+
@receiver(post_save, sender=FeatureState)
10+
def trigger_feature_state_change_sentry_change_tracking(instance, **kwargs): # type: ignore[no-untyped-def]
11+
send_sentry_change_tracking_webhook_update.delay(args=(instance.pk,))

api/integrations/sentry/tasks.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import logging
2+
3+
from task_processor.decorators import register_task_handler
4+
5+
from environments.models import Environment
6+
7+
from .change_tracking import post_change_tracking_update_to_sentry
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
@register_task_handler()
13+
def send_sentry_change_tracking_webhook_update(feature_state_id: int) -> None:
14+
from features.models import FeatureState
15+
16+
feature_state = FeatureState.objects.get(pk=feature_state_id)
17+
18+
try:
19+
sentry_configuration = (
20+
feature_state.environment.sentry_change_tracking_configuration
21+
)
22+
except Environment.sentry_change_tracking_configuration.RelatedObjectDoesNotExist:
23+
return
24+
25+
post_change_tracking_update_to_sentry(feature_state, sentry_configuration)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import json
2+
import re
3+
from datetime import UTC, datetime, timedelta
4+
from typing import Callable
5+
from unittest.mock import Mock
6+
7+
import freezegun
8+
import pytest
9+
from pytest_mock import MockerFixture
10+
11+
from features.models import Feature, FeatureState
12+
from integrations.sentry import change_tracking
13+
from integrations.sentry.models import SentryChangeTrackingConfiguration
14+
15+
16+
@pytest.fixture
17+
def sentry_configuration(environment: int) -> SentryChangeTrackingConfiguration:
18+
configuration = SentryChangeTrackingConfiguration(
19+
environment_id=environment,
20+
webhook_url="https://sentry.example.com/webhook",
21+
secret="hush hush!",
22+
)
23+
configuration.save()
24+
return configuration
25+
26+
27+
@pytest.fixture
28+
def requests(mocker: MockerFixture) -> Mock:
29+
return mocker.patch.object(change_tracking, "requests")
30+
31+
32+
def test_sentry_change_tracking__flag_created__sends_update_to_sentry(
33+
mocker: MockerFixture,
34+
project: int,
35+
requests: Mock,
36+
sentry_configuration: SentryChangeTrackingConfiguration,
37+
) -> None:
38+
# Given
39+
feature_obj = Feature(
40+
name="yet_another_feature",
41+
project_id=project,
42+
)
43+
44+
# When
45+
with freezegun.freeze_time("2199-01-01T00:00:00.500000+00:00"):
46+
feature_obj.save()
47+
48+
# Then
49+
requests.post.assert_called_once_with(
50+
url=sentry_configuration.webhook_url,
51+
headers={
52+
"Content-Type": "application/json",
53+
"X-Sentry-Signature": mocker.ANY,
54+
},
55+
data=json.dumps(
56+
{
57+
"data": [
58+
{
59+
"action": "created",
60+
"created_at": "2199-01-01T00:00:00+00:00",
61+
"created_by": {"id": "[email protected]", "type": "email"},
62+
"flag": feature_obj.name,
63+
},
64+
],
65+
"meta": {"version": 1},
66+
},
67+
sort_keys=True,
68+
),
69+
)
70+
signature = requests.post.call_args[1]["headers"]["X-Sentry-Signature"]
71+
assert re.match(r"^[0-9a-f]{64}$", signature)
72+
73+
74+
def test_sentry_change_tracking__flag_state_change__sends_update_to_sentry(
75+
feature_name: str,
76+
feature_state: int,
77+
mocker: MockerFixture,
78+
requests: Mock,
79+
sentry_configuration: SentryChangeTrackingConfiguration,
80+
) -> None:
81+
# Given
82+
feature_state_obj = FeatureState.objects.get(pk=feature_state)
83+
84+
# When
85+
with freezegun.freeze_time("2199-01-01T00:00:00.500000+00:00"):
86+
now = datetime.now(UTC)
87+
feature_state_obj.enabled = False
88+
feature_state_obj.live_from = now + timedelta(hours=1)
89+
feature_state_obj.save()
90+
91+
# Then
92+
requests.post.assert_called_once_with(
93+
url=sentry_configuration.webhook_url,
94+
headers={
95+
"Content-Type": "application/json",
96+
"X-Sentry-Signature": mocker.ANY,
97+
},
98+
data=json.dumps(
99+
{
100+
"data": [
101+
{
102+
"action": "updated",
103+
"created_at": "2199-01-01T01:00:00+00:00",
104+
"created_by": {"id": "[email protected]", "type": "email"},
105+
"change_id": str(feature_state),
106+
"flag": feature_name,
107+
},
108+
],
109+
"meta": {"version": 1},
110+
},
111+
sort_keys=True,
112+
),
113+
)
114+
signature = requests.post.call_args[1]["headers"]["X-Sentry-Signature"]
115+
assert re.match(r"^[0-9a-f]{64}$", signature)
116+
117+
118+
@pytest.mark.parametrize(
119+
"kaboom",
120+
[
121+
lambda feature_state: feature_state.delete(),
122+
lambda feature_state: feature_state.feature.delete(),
123+
],
124+
)
125+
def test_sentry_change_tracking__flag_deleted__sends_update_to_sentry(
126+
feature_name: str,
127+
feature_state: int,
128+
kaboom: Callable[[FeatureState], None],
129+
mocker: MockerFixture,
130+
requests: Mock,
131+
sentry_configuration: SentryChangeTrackingConfiguration,
132+
) -> None:
133+
# Given
134+
feature_state_obj = FeatureState.objects.get(pk=feature_state)
135+
136+
# When
137+
with freezegun.freeze_time("2199-01-01T01:00:00+00:00"):
138+
kaboom(feature_state_obj)
139+
140+
# Then
141+
requests.post.assert_called_once_with(
142+
url=sentry_configuration.webhook_url,
143+
headers={
144+
"Content-Type": "application/json",
145+
"X-Sentry-Signature": mocker.ANY,
146+
},
147+
data=json.dumps(
148+
{
149+
"data": [
150+
{
151+
"action": "deleted",
152+
"created_at": "2199-01-01T01:00:00+00:00",
153+
"created_by": {"id": "[email protected]", "type": "email"},
154+
"flag": feature_name,
155+
},
156+
],
157+
"meta": {"version": 1},
158+
},
159+
sort_keys=True,
160+
),
161+
)
162+
signature = requests.post.call_args[1]["headers"]["X-Sentry-Signature"]
163+
assert re.match(r"^[0-9a-f]{64}$", signature)

0 commit comments

Comments
 (0)