Skip to content

Commit b9019e0

Browse files
committed
feat: sentry api
add sentry api client and pull_sentry_project_slug task
1 parent 5dc7a7a commit b9019e0

File tree

13 files changed

+761
-1250
lines changed

13 files changed

+761
-1250
lines changed

controller/sentry/admin.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class AppAdmin(
3131

3232
list_display = [
3333
"reference",
34+
"sentry_project_slug",
3435
"last_seen",
3536
"default_sample_rate",
3637
"active_sample_rate",
@@ -59,6 +60,13 @@ class AppAdmin(
5960
)
6061
},
6162
],
63+
[
64+
"Sentry",
65+
{
66+
"classes": ("collapse", "open"),
67+
"fields": ("sentry_project_slug",),
68+
},
69+
],
6270
[
6371
"WSGI",
6472
{
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.1.5 on 2023-02-01 11:13
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("sentry", "0006_alter_app_options"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="app",
15+
name="sentry_project_slug",
16+
field=models.CharField(blank=True, max_length=50, null=True),
17+
),
18+
]

controller/sentry/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class App(models.Model):
2828
active_sample_rate = models.FloatField(default=settings.DEFAULT_SAMPLE_RATE)
2929
active_window_end = models.DateTimeField(null=True, blank=True)
3030

31+
# Sentry Api
32+
sentry_project_slug = models.CharField(max_length=50, null=True, blank=True)
33+
3134
# WSGI
3235
wsgi_ignore_path = ArrayField(
3336
models.CharField(max_length=50, blank=True),
@@ -58,6 +61,12 @@ def get_metric(self, metric_type: MetricType):
5861
prefix = metric_type.value.lower()
5962
return getattr(self, f"{prefix}_collect_metrics"), getattr(self, f"{prefix}_metrics")
6063

64+
def get_sentry_id(self):
65+
res = self.reference.split("_")
66+
if len(res) != 3:
67+
return None
68+
return res[0]
69+
6170
class Meta:
6271
permissions = [
6372
("bump_sample_rate_app", "Can bump sample rate"),

controller/sentry/tasks.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import timedelta
2+
from itertools import chain
23

34
from celery import shared_task
45
from celery.utils.log import get_task_logger
@@ -7,6 +8,7 @@
78
from django.utils import timezone
89

910
from controller.sentry.models import App
11+
from controller.sentry.webservices.sentry import PaginatedSentryClient
1012

1113
LOGGER = get_task_logger(__name__)
1214

@@ -24,3 +26,21 @@ def prune_inactive_app() -> None:
2426
def close_window() -> None:
2527
apps = App.objects.filter(active_window_end__lt=timezone.now())
2628
apps.update(active_sample_rate=F("default_sample_rate"), active_window_end=None)
29+
30+
31+
@shared_task()
32+
def pull_sentry_project_slug() -> None:
33+
client = PaginatedSentryClient()
34+
apps = App.objects.filter(sentry_project_slug__isnull=True)
35+
36+
apps_by_id = {app.get_sentry_id(): app for app in apps}
37+
38+
modified_apps = []
39+
for project in chain.from_iterable(client.list_projects()):
40+
_id = project["id"]
41+
if _id in apps_by_id:
42+
app = apps_by_id[_id]
43+
app.sentry_project_slug = project["slug"]
44+
modified_apps.append(app)
45+
46+
App.objects.bulk_update(modified_apps, ["sentry_project_slug"])

controller/sentry/tests/test_models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,10 @@ def test_app_model_merge():
1414
collect, metrics = app.get_metric(MetricType.CELERY)
1515
assert not collect
1616
assert metrics == {"test1": 5, "test": 6}
17+
18+
19+
def test_app_model_get_sentry_id():
20+
app = App(reference="abc")
21+
assert app.get_sentry_id() is None
22+
app = App(reference="123_prod_uwsgi")
23+
assert app.get_sentry_id() == "123"
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from datetime import datetime, timedelta
2+
from unittest.mock import MagicMock, call, patch
3+
4+
import pytest
5+
from requests.exceptions import HTTPError
6+
7+
from controller.sentry.webservices.sentry import BearerAuth, PaginatedSentryClient
8+
9+
10+
class Response:
11+
def __init__(self, status_code, data, links=None, headers=None) -> None:
12+
self.status_code = status_code
13+
self.data = data
14+
self.links = links if links else {}
15+
self.headers = headers if headers else {}
16+
17+
def json(self):
18+
return self.data
19+
20+
def raise_for_status(self):
21+
if self.status_code > 399:
22+
raise HTTPError
23+
24+
25+
def test_auth():
26+
auth = BearerAuth("my_token")
27+
request_mock = MagicMock()
28+
auth(request_mock)
29+
30+
request_mock.headers.__setitem__.assert_called_once_with("authorization", "Bearer my_token")
31+
32+
33+
@patch("controller.sentry.webservices.sentry.request")
34+
def test_client(mock_request: MagicMock):
35+
client = PaginatedSentryClient()
36+
37+
res = client.list_projects()
38+
mock_request.assert_not_called()
39+
return_value = [
40+
Response(200, [object(), object()], links={"next": {"results": True, "url": "http://next.sentry"}}),
41+
Response(200, [object(), object()]),
42+
]
43+
44+
mock_request.side_effect = return_value
45+
46+
for chunk, expected in zip(res, return_value):
47+
assert chunk == expected.data
48+
49+
call_1 = call("GET", "https://sentry.io/api/0/projects/", timeout=20, auth=client.auth)
50+
call_2 = call("GET", "http://next.sentry", timeout=20, auth=client.auth)
51+
mock_request.assert_has_calls((call_1, call_2))
52+
53+
54+
@patch("controller.sentry.webservices.sentry.request")
55+
def test_client_rate_limited(mock_request: MagicMock):
56+
client = PaginatedSentryClient()
57+
58+
res = client.list_projects()
59+
mock_request.assert_not_called()
60+
reset = datetime.now() + timedelta(seconds=5)
61+
return_value = [
62+
Response(429, [object(), object()], headers={"x-sentry-rate-limit-reset": reset.timestamp()}),
63+
Response(500, [object(), object()]),
64+
]
65+
66+
mock_request.side_effect = return_value
67+
68+
with pytest.raises(HTTPError):
69+
next(res)
70+
71+
call_1 = call("GET", "https://sentry.io/api/0/projects/", timeout=20, auth=client.auth)
72+
call_2 = call("GET", "https://sentry.io/api/0/projects/", timeout=20, auth=client.auth)
73+
mock_request.assert_has_calls((call_1, call_2))
74+
75+
76+
@patch("controller.sentry.webservices.sentry.request")
77+
def test_client_rate_limited_rest_in_past(mock_request: MagicMock):
78+
client = PaginatedSentryClient()
79+
80+
res = client.list_projects()
81+
mock_request.assert_not_called()
82+
83+
reset = datetime.now() - timedelta(seconds=5)
84+
return_value = [
85+
Response(429, [object(), object()], headers={"x-sentry-rate-limit-reset": reset.timestamp()}),
86+
Response(500, [object(), object()]),
87+
]
88+
89+
mock_request.side_effect = return_value
90+
91+
with pytest.raises(HTTPError):
92+
next(res)
93+
94+
call_1 = call("GET", "https://sentry.io/api/0/projects/", timeout=20, auth=client.auth)
95+
call_2 = call("GET", "https://sentry.io/api/0/projects/", timeout=20, auth=client.auth)
96+
mock_request.assert_has_calls((call_1, call_2))

controller/sentry/tests/test_tasks.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from datetime import timedelta
2+
from unittest.mock import MagicMock, patch
3+
4+
import pytest
5+
from django.conf import settings
6+
from django.utils import timezone
7+
8+
from controller.sentry.models import App
9+
from controller.sentry.tasks import (
10+
close_window,
11+
prune_inactive_app,
12+
pull_sentry_project_slug,
13+
)
14+
15+
16+
@pytest.mark.django_db
17+
def test_prune_inactive_app():
18+
app = App(reference="abc")
19+
app.save()
20+
prune_inactive_app()
21+
assert App.objects.filter(reference=app.reference).exists()
22+
23+
app.last_seen = timezone.now() - timedelta(days=settings.APP_AUTO_PRUNE_MAX_AGE_DAY + 1)
24+
app.save()
25+
prune_inactive_app()
26+
assert not App.objects.filter(reference=app.reference).exists()
27+
28+
29+
@pytest.mark.django_db
30+
def test_close_window():
31+
tomorrow = timezone.now() + timedelta(days=1)
32+
app = App(reference="abc1", active_window_end=tomorrow, active_sample_rate=1)
33+
app.save()
34+
close_window()
35+
app.refresh_from_db()
36+
assert app.active_sample_rate == 1
37+
assert app.active_window_end == tomorrow
38+
39+
app = App(reference="abc2", active_window_end=timezone.now(), active_sample_rate=1)
40+
app.save()
41+
close_window()
42+
app.refresh_from_db()
43+
assert app.active_sample_rate == app.default_sample_rate
44+
assert app.active_window_end is None
45+
46+
47+
@patch("controller.sentry.tasks.PaginatedSentryClient")
48+
@pytest.mark.django_db
49+
def test_pull_sentry_project_slug(client_mock: MagicMock):
50+
client_mock.return_value.list_projects.return_value = [
51+
[],
52+
[{"id": "123", "slug": "test"}],
53+
[{"id": "1235", "slug": "test2"}],
54+
]
55+
app = App(reference="123_prod_wsgi")
56+
app.save()
57+
pull_sentry_project_slug()
58+
client_mock.assert_called_once_with()
59+
60+
app.refresh_from_db()
61+
assert app.sentry_project_slug == "test"

controller/sentry/tests/test_views.py

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
from datetime import timedelta
21
from unittest.mock import Mock, patch
32

43
import pytest
54
from django.conf import settings
65
from django.urls import reverse
7-
from django.utils import timezone
86

97
from controller.sentry.choices import MetricType
108
from controller.sentry.models import App
@@ -22,29 +20,6 @@ def test_app_view_retrieve(client):
2220
assert not response.data["celery_collect_metrics"]
2321

2422

25-
@pytest.mark.django_db
26-
def test_app_view_retrieve_windows_end(client):
27-
reference = "test"
28-
app = App(reference=reference)
29-
app.active_window_end = timezone.now() + timedelta(hours=5)
30-
app.active_sample_rate = 1
31-
app.default_sample_rate = 0.5
32-
app.save()
33-
url = reverse("sentry:apps-detail", kwargs={"pk": reference})
34-
response = client.get(url)
35-
assert response.status_code == 200
36-
assert response.data["active_window_end"] is not None
37-
assert response.data["active_sample_rate"] == 1
38-
39-
app.active_window_end = timezone.now() - timedelta(hours=5)
40-
app.save()
41-
url = reverse("sentry:apps-detail", kwargs={"pk": reference})
42-
response = client.get(url)
43-
assert response.status_code == 200
44-
assert response.data["active_window_end"] is None
45-
assert response.data["active_sample_rate"] == 0.5
46-
47-
4823
@patch("controller.sentry.views.cache")
4924
@pytest.mark.django_db
5025
def test_app_view_retrieve_panic(cache: Mock, client):

controller/sentry/utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,12 @@ def invalidate_cache(path=""):
4141
# pylint: disable=unused-argument
4242
def is_panic_activated(request):
4343
return {"PANIC": cache.get(settings.PANIC_KEY)}
44+
45+
46+
class Singleton(type):
47+
_instances = {}
48+
49+
def __call__(cls, *args, **kwargs):
50+
if cls not in cls._instances:
51+
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
52+
return cls._instances[cls]

controller/sentry/webservices/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)