Skip to content

Commit d27ecf0

Browse files
committed
git showfeat: Initial monitors implementation
- Add support for schedule-based push monitors - And endpoint for creating and updating checkins (DSN-based auth supported) - Generate a simplistic message event upon monitor failure
1 parent b7f8f6e commit d27ecf0

20 files changed

+2307
-0
lines changed

requirements-base.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ celery>=3.1.8,<3.1.19
55
cffi>=1.11.5,<2.0
66
click>=5.0,<7.0
77
# 'cryptography>=1.3,<1.4
8+
croniter>=0.3.26,<0.4.0
89
cssutils>=0.9.9,<0.10.0
910
django-crispy-forms>=1.4.0,<1.5.0
1011
django-jsonfield>=0.9.13,<0.9.14
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from __future__ import absolute_import
2+
3+
from django.db import transaction
4+
from django.utils import timezone
5+
from rest_framework import serializers
6+
7+
from sentry import features
8+
from sentry.api.authentication import DSNAuthentication
9+
from sentry.api.base import Endpoint
10+
from sentry.api.exceptions import ResourceDoesNotExist
11+
from sentry.api.bases.project import ProjectPermission
12+
from sentry.api.serializers import serialize
13+
from sentry.models import Monitor, MonitorCheckIn, CheckInStatus, MonitorStatus, Project, ProjectKey, ProjectStatus
14+
from sentry.utils.sdk import configure_scope
15+
16+
17+
class CheckInSerializer(serializers.Serializer):
18+
status = serializers.ChoiceField(
19+
choices=(
20+
('ok', CheckInStatus.OK),
21+
('error', CheckInStatus.ERROR),
22+
('in_progress', CheckInStatus.IN_PROGRESS),
23+
),
24+
)
25+
duration = serializers.IntegerField(required=False)
26+
27+
28+
class MonitorCheckInDetailsEndpoint(Endpoint):
29+
authentication_classes = Endpoint.authentication_classes + (DSNAuthentication,)
30+
permission_classes = (ProjectPermission,)
31+
32+
# TODO(dcramer): this code needs shared with other endpoints as its security focused
33+
# TODO(dcramer): this doesnt handle is_global roles
34+
def convert_args(self, request, monitor_id, checkin_id, *args, **kwargs):
35+
try:
36+
monitor = Monitor.objects.get(
37+
guid=monitor_id,
38+
)
39+
except Monitor.DoesNotExist:
40+
raise ResourceDoesNotExist
41+
42+
project = Project.objects.get_from_cache(id=monitor.project_id)
43+
if project.status != ProjectStatus.VISIBLE:
44+
raise ResourceDoesNotExist
45+
46+
if hasattr(request.auth, 'project_id') and project.id != request.auth.project_id:
47+
return self.respond(status=400)
48+
49+
if not features.has('organizations:monitors',
50+
project.organization, actor=request.user):
51+
raise ResourceDoesNotExist
52+
53+
self.check_object_permissions(request, project)
54+
55+
with configure_scope() as scope:
56+
scope.set_tag("organization", project.organization_id)
57+
scope.set_tag("project", project.id)
58+
59+
try:
60+
checkin = MonitorCheckIn.objects.get(
61+
monitor=monitor,
62+
guid=checkin_id,
63+
)
64+
except MonitorCheckIn.DoesNotExist:
65+
raise ResourceDoesNotExist
66+
67+
request._request.organization = project.organization
68+
69+
kwargs.update({
70+
'checkin': checkin,
71+
'monitor': monitor,
72+
'project': project,
73+
})
74+
return (args, kwargs)
75+
76+
def get(self, request, project, monitor, checkin):
77+
"""
78+
Retrieve a check-in
79+
``````````````````
80+
81+
:pparam string monitor_id: the id of the monitor.
82+
:pparam string checkin_id: the id of the check-in.
83+
:auth: required
84+
"""
85+
# we dont allow read permission with DSNs
86+
if isinstance(request.auth, ProjectKey):
87+
return self.respond(status=401)
88+
89+
return self.respond(serialize(checkin, request.user))
90+
91+
def put(self, request, project, monitor, checkin):
92+
"""
93+
Update a check-in
94+
`````````````````
95+
96+
:pparam string monitor_id: the id of the monitor.
97+
:pparam string checkin_id: the id of the check-in.
98+
:auth: required
99+
"""
100+
if checkin.status in CheckInStatus.FINISHED_VALUES:
101+
return self.respond(status=400)
102+
103+
serializer = CheckInSerializer(
104+
data=request.DATA,
105+
partial=True,
106+
context={
107+
'project': project,
108+
'request': request,
109+
},
110+
)
111+
if not serializer.is_valid():
112+
return self.respond(serializer.errors, status=400)
113+
114+
result = serializer.object
115+
116+
current_datetime = timezone.now()
117+
params = {
118+
'date_updated': current_datetime,
119+
}
120+
if 'duration' in result:
121+
params['duration'] = result['duration']
122+
if 'status' in result:
123+
params['status'] = getattr(CheckInStatus, result['status'].upper())
124+
125+
with transaction.atomic():
126+
checkin.update(**params)
127+
if checkin.status == CheckInStatus.ERROR:
128+
monitor.mark_failed(current_datetime)
129+
else:
130+
monitor_params = {
131+
'last_checkin': current_datetime,
132+
'next_checkin': monitor.get_next_scheduled_checkin(current_datetime),
133+
}
134+
if checkin.status == CheckInStatus.OK:
135+
monitor_params['status'] = MonitorStatus.OK
136+
Monitor.objects.filter(
137+
id=monitor.id,
138+
).exclude(
139+
last_checkin__gt=current_datetime,
140+
).update(**monitor_params)
141+
142+
return self.respond(serialize(checkin, request.user))
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
from __future__ import absolute_import
2+
3+
from django.db import transaction
4+
from rest_framework import serializers
5+
6+
from sentry import features
7+
from sentry.api.authentication import DSNAuthentication
8+
from sentry.api.base import Endpoint
9+
from sentry.api.exceptions import ResourceDoesNotExist
10+
from sentry.api.paginator import OffsetPaginator
11+
from sentry.api.bases.project import ProjectPermission
12+
from sentry.api.serializers import serialize
13+
from sentry.models import Monitor, MonitorCheckIn, MonitorStatus, CheckInStatus, Project, ProjectKey, ProjectStatus
14+
from sentry.utils.sdk import configure_scope
15+
16+
17+
class CheckInSerializer(serializers.Serializer):
18+
status = serializers.ChoiceField(
19+
choices=(
20+
('ok', CheckInStatus.OK),
21+
('error', CheckInStatus.ERROR),
22+
('in_progress', CheckInStatus.IN_PROGRESS),
23+
),
24+
)
25+
duration = serializers.IntegerField(required=False)
26+
27+
28+
class MonitorCheckInsEndpoint(Endpoint):
29+
authentication_classes = Endpoint.authentication_classes + (DSNAuthentication,)
30+
permission_classes = (ProjectPermission,)
31+
32+
# TODO(dcramer): this code needs shared with other endpoints as its security focused
33+
# TODO(dcramer): this doesnt handle is_global roles
34+
def convert_args(self, request, monitor_id, *args, **kwargs):
35+
try:
36+
monitor = Monitor.objects.get(
37+
guid=monitor_id,
38+
)
39+
except Monitor.DoesNotExist:
40+
raise ResourceDoesNotExist
41+
42+
project = Project.objects.get_from_cache(id=monitor.project_id)
43+
if project.status != ProjectStatus.VISIBLE:
44+
raise ResourceDoesNotExist
45+
46+
if hasattr(request.auth, 'project_id') and project.id != request.auth.project_id:
47+
return self.respond(status=400)
48+
49+
if not features.has('organizations:monitors',
50+
project.organization, actor=request.user):
51+
raise ResourceDoesNotExist
52+
53+
self.check_object_permissions(request, project)
54+
55+
with configure_scope() as scope:
56+
scope.set_tag("organization", project.organization_id)
57+
scope.set_tag("project", project.id)
58+
59+
request._request.organization = project.organization
60+
61+
kwargs.update({
62+
'monitor': monitor,
63+
'project': project,
64+
})
65+
return (args, kwargs)
66+
67+
def get(self, request, project, monitor):
68+
"""
69+
Retrieve check-ins for an monitor
70+
`````````````````````````````````
71+
72+
:pparam string monitor_id: the id of the monitor.
73+
:auth: required
74+
"""
75+
# we dont allow read permission with DSNs
76+
if isinstance(request.auth, ProjectKey):
77+
return self.respond(status=401)
78+
79+
queryset = MonitorCheckIn.objects.filter(
80+
monitor_id=monitor.id,
81+
)
82+
83+
return self.paginate(
84+
request=request,
85+
queryset=queryset,
86+
order_by='name',
87+
on_results=lambda x: serialize(x, request.user),
88+
paginator_cls=OffsetPaginator,
89+
)
90+
91+
def post(self, request, project, monitor):
92+
"""
93+
Create a new check-in for a monitor
94+
```````````````````````````````````
95+
96+
:pparam string monitor_id: the id of the monitor.
97+
:auth: required
98+
"""
99+
serializer = CheckInSerializer(
100+
data=request.DATA,
101+
context={
102+
'project': project,
103+
'request': request,
104+
},
105+
)
106+
if not serializer.is_valid():
107+
return self.respond(serializer.errors, status=400)
108+
109+
result = serializer.object
110+
111+
with transaction.atomic():
112+
checkin = MonitorCheckIn.objects.create(
113+
project_id=project.id,
114+
monitor_id=monitor.id,
115+
duration=result.get('duration'),
116+
status=getattr(CheckInStatus, result['status'].upper()),
117+
)
118+
if checkin.status == CheckInStatus.ERROR:
119+
monitor.mark_failed(last_checkin=checkin.date_added)
120+
else:
121+
monitor_params = {
122+
'last_checkin': checkin.date_added,
123+
'next_checkin': monitor.get_next_scheduled_checkin(checkin.date_added),
124+
}
125+
if checkin.status == CheckInStatus.OK:
126+
monitor_params['status'] = MonitorStatus.OK
127+
Monitor.objects.filter(
128+
id=monitor.id,
129+
).exclude(
130+
last_checkin__gt=checkin.date_added,
131+
).update(**monitor_params)
132+
133+
return self.respond(serialize(checkin, request.user))
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from __future__ import absolute_import
2+
3+
import six
4+
5+
from sentry.api.serializers import Serializer, register
6+
from sentry.models import MonitorCheckIn
7+
8+
9+
@register(MonitorCheckIn)
10+
class MonitorCheckInSerializer(Serializer):
11+
def serialize(self, obj, attrs, user):
12+
return {
13+
'id': six.text_type(obj.guid),
14+
'status': obj.get_status_display(),
15+
'duration': obj.duration,
16+
'dateCreated': obj.date_added,
17+
}

src/sentry/api/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
from .endpoints.internal_queue_tasks import InternalQueueTasksEndpoint
5050
from .endpoints.internal_quotas import InternalQuotasEndpoint
5151
from .endpoints.internal_stats import InternalStatsEndpoint
52+
from .endpoints.monitor_checkins import MonitorCheckInsEndpoint
53+
from .endpoints.monitor_checkin_details import MonitorCheckInDetailsEndpoint
5254
from .endpoints.organization_access_request_details import OrganizationAccessRequestDetailsEndpoint
5355
from .endpoints.organization_activity import OrganizationActivityEndpoint
5456
from .endpoints.organization_auditlogs import OrganizationAuditLogsEndpoint
@@ -293,6 +295,11 @@
293295
url(r'^accept-transfer/$', AcceptProjectTransferEndpoint.as_view(),
294296
name='sentry-api-0-accept-project-transfer'),
295297

298+
# Monitors
299+
url(r'^monitors/(?P<monitor_id>[^\/]+)/checkins/$', MonitorCheckInsEndpoint.as_view()),
300+
url(r'^monitors/(?P<monitor_id>[^\/]+)/checkins/(?P<checkin_id>[^\/]+)/$',
301+
MonitorCheckInDetailsEndpoint.as_view()),
302+
296303
# Users
297304
url(r'^users/$', UserIndexEndpoint.as_view(), name='sentry-api-0-user-index'),
298305
url(

src/sentry/conf/server.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,13 @@ def create_partitioned_queues(name):
559559
'expires': 30,
560560
},
561561
},
562+
'check-monitors': {
563+
'task': 'sentry.tasks.check_monitors',
564+
'schedule': timedelta(minutes=1),
565+
'options': {
566+
'expires': 60,
567+
},
568+
},
562569
'clear-expired-snoozes': {
563570
'task': 'sentry.tasks.clear_expired_snoozes',
564571
'schedule': timedelta(minutes=5),

src/sentry/db/models/fields/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@
1616
from .gzippeddict import * # NOQA
1717
from .node import * # NOQA
1818
from .pickle import * # NOQA
19+
from .uuid import * # NOQA

0 commit comments

Comments
 (0)