Просмотр исходного кода

feat(events-v2): Add an organization event details endpoint (#13553)

This is a prototype of an organization event details endpoint
which adds support for finding a next and previous event based
on a custom list of conditions provided via the events v2 format.
Lyn Nagara 5 лет назад
Родитель
Сommit
822f85fbcd

+ 60 - 0
src/sentry/api/bases/organization_events.py

@@ -2,11 +2,14 @@ from __future__ import absolute_import
 
 from copy import deepcopy
 from rest_framework.exceptions import PermissionDenied
+import six
+from enum import Enum
 
 from sentry import features
 from sentry.api.bases import OrganizationEndpoint, OrganizationEventsError
 from sentry.api.event_search import get_snuba_query_args, InvalidSearchQuery
 from sentry.models.project import Project
+from sentry.utils import snuba
 
 # We support 4 "special fields" on the v2 events API which perform some
 # additional calculations over aggregated event data
@@ -28,6 +31,11 @@ SPECIAL_FIELDS = {
 ALLOWED_GROUPINGS = frozenset(('issue.id', 'project.id'))
 
 
+class Direction(Enum):
+    NEXT = 0
+    PREV = 1
+
+
 class OrganizationEventsEndpointBase(OrganizationEndpoint):
 
     def get_snuba_query_args(self, request, organization):
@@ -137,3 +145,55 @@ class OrganizationEventsEndpointBase(OrganizationEndpoint):
             raise OrganizationEventsError(
                 'Boolean search operator OR and AND not allowed in this search.')
         return snuba_args
+
+    def next_event_id(self, *args):
+        """
+        Returns the next event ID if there is a subsequent event matching the
+        conditions provided
+        """
+        return self._get_next_or_prev_id(Direction.NEXT, *args)
+
+    def prev_event_id(self, *args):
+        """
+        Returns the previous event ID if there is a previous event matching the
+        conditions provided
+        """
+        return self._get_next_or_prev_id(Direction.PREV, *args)
+
+    def _get_next_or_prev_id(self, direction, request, organization, snuba_args, event):
+        if (direction == Direction.NEXT):
+            time_condition = [
+                ['timestamp', '>=', event.timestamp],
+                [['timestamp', '>', event.timestamp], ['event_id', '>', event.event_id]]
+            ]
+            orderby = ['timestamp', 'event_id']
+            start = max(event.datetime, snuba_args['start'])
+            end = snuba_args['end']
+
+        else:
+            time_condition = [
+                ['timestamp', '<=', event.timestamp],
+                [['timestamp', '<', event.timestamp], ['event_id', '<', event.event_id]]
+            ]
+            orderby = ['-timestamp', '-event_id']
+            start = snuba_args['start']
+            end = min(event.datetime, snuba_args['end'])
+
+        conditions = snuba_args['conditions'][:]
+        conditions.extend(time_condition)
+
+        result = snuba.raw_query(
+            start=start,
+            end=end,
+            selected_columns=['event_id'],
+            conditions=conditions,
+            filter_keys=snuba_args['filter_keys'],
+            orderby=orderby,
+            limit=1,
+            referrer='api.organization-events.next-or-prev-id',
+        )
+
+        if 'error' in result or len(result['data']) == 0:
+            return None
+
+        return six.text_type(result['data'][0]['event_id'])

+ 119 - 0
src/sentry/api/endpoints/organization_event_details.py

@@ -0,0 +1,119 @@
+from __future__ import absolute_import
+
+from rest_framework.response import Response
+import six
+from enum import Enum
+
+from sentry.api.bases import OrganizationEventsEndpointBase, OrganizationEventsError, NoProjects
+from sentry import features
+from sentry.models import SnubaEvent
+from sentry.models.project import Project
+from sentry.api.serializers import serialize
+from sentry.utils.snuba import raw_query
+
+
+class EventOrdering(Enum):
+    LATEST = 0
+    OLDEST = 1
+
+
+class OrganizationEventDetailsEndpoint(OrganizationEventsEndpointBase):
+    def get(self, request, organization, project_slug, event_id):
+        if not features.has('organizations:events-v2', organization, actor=request.user):
+            return Response(status=404)
+
+        try:
+            params = self.get_filter_params(request, organization)
+            snuba_args = self.get_snuba_query_args_v2(request, organization, params)
+        except OrganizationEventsError as exc:
+            return Response({'detail': exc.message}, status=400)
+        except NoProjects:
+            return Response(status=404)
+
+        try:
+            project = Project.objects.get(
+                slug=project_slug,
+                organization_id=organization.id
+            )
+        except Project.DoesNotExist:
+            return Response(status=404)
+
+        # We return the requested event if we find a match regardless of whether
+        # it occurred within the range specified
+        event = SnubaEvent.objects.from_event_id(event_id, project.id)
+
+        if event is None:
+            return Response({'detail': 'Event not found'}, status=404)
+
+        data = serialize(event)
+
+        data['nextEventID'] = self.next_event_id(request, organization, snuba_args, event)
+        data['previousEventID'] = self.prev_event_id(request, organization, snuba_args, event)
+        data['projectSlug'] = project_slug
+
+        return Response(data)
+
+
+class OrganizationEventsLatestOrOldest(OrganizationEventsEndpointBase):
+    def get(self, latest_or_oldest, request, organization):
+        if not features.has('organizations:events-v2', organization, actor=request.user):
+            return Response(status=404)
+
+        try:
+            params = self.get_filter_params(request, organization)
+            snuba_args = self.get_snuba_query_args_v2(request, organization, params)
+        except OrganizationEventsError as exc:
+            return Response({'detail': exc.message}, status=400)
+        except NoProjects:
+            return Response(status=404)
+
+        if latest_or_oldest == EventOrdering.LATEST:
+            orderby = ['-timestamp', '-event_id']
+        else:
+            orderby = ['timestamp', 'event_id']
+
+        result = raw_query(
+            start=snuba_args['start'],
+            end=snuba_args['end'],
+            selected_columns=SnubaEvent.selected_columns,
+            conditions=snuba_args['conditions'],
+            filter_keys=snuba_args['filter_keys'],
+            orderby=orderby,
+            limit=2,
+            referrer='api.organization-event-details-latest-or-oldest',
+        )
+
+        if 'error' in result or len(result['data']) == 0:
+            return Response({'detail': 'Event not found'}, status=404)
+
+        try:
+            project_id = result['data'][0]['project_id']
+            project_slug = Project.objects.get(
+                organization=organization, id=project_id).slug
+        except Project.DoesNotExist:
+            project_slug = None
+
+        data = serialize(SnubaEvent(result['data'][0]))
+        data['previousEventID'] = None
+        data['nextEventID'] = None
+        data['projectSlug'] = project_slug
+
+        if latest_or_oldest == EventOrdering.LATEST and len(result['data']) == 2:
+            data['previousEventID'] = six.text_type(result['data'][1]['event_id'])
+
+        if latest_or_oldest == EventOrdering.OLDEST and len(result['data']) == 2:
+            data['nextEventID'] = six.text_type(result['data'][1]['event_id'])
+
+        return Response(data)
+
+
+class OrganizationEventDetailsLatestEndpoint(OrganizationEventsLatestOrOldest):
+    def get(self, request, organization):
+        return super(OrganizationEventDetailsLatestEndpoint, self).get(
+            EventOrdering.LATEST, request, organization)
+
+
+class OrganizationEventDetailsOldestEndpoint(OrganizationEventsLatestOrOldest):
+    def get(self, request, organization):
+        return super(OrganizationEventDetailsOldestEndpoint, self).get(
+            EventOrdering.OLDEST, request, organization)

+ 16 - 0
src/sentry/api/urls.py

@@ -72,6 +72,7 @@ from .endpoints.organization_discover_query import OrganizationDiscoverQueryEndp
 from .endpoints.organization_discover_saved_queries import OrganizationDiscoverSavedQueriesEndpoint
 from .endpoints.organization_discover_saved_query_detail import OrganizationDiscoverSavedQueryDetailEndpoint
 from .endpoints.organization_events import OrganizationEventsEndpoint, OrganizationEventsMetaEndpoint, OrganizationEventsStatsEndpoint, OrganizationEventsHeatmapEndpoint
+from .endpoints.organization_event_details import OrganizationEventDetailsEndpoint, OrganizationEventDetailsLatestEndpoint, OrganizationEventDetailsOldestEndpoint
 from .endpoints.organization_group_index import OrganizationGroupIndexEndpoint
 from .endpoints.organization_dashboard_details import OrganizationDashboardDetailsEndpoint
 from .endpoints.organization_dashboard_widget_details import OrganizationDashboardWidgetDetailsEndpoint
@@ -605,6 +606,21 @@ urlpatterns = patterns(
         OrganizationEventsEndpoint.as_view(),
         name='sentry-api-0-organization-events'
     ),
+    url(
+        r'^organizations/(?P<organization_slug>[^\/]+)/events/(?P<project_slug>[^\/]+):(?P<event_id>(?:\d+|[A-Fa-f0-9]{32}))/$',
+        OrganizationEventDetailsEndpoint.as_view(),
+        name='sentry-api-0-organization-event-details'
+    ),
+    url(
+        r'^organizations/(?P<organization_slug>[^\/]+)/events/latest/$',
+        OrganizationEventDetailsLatestEndpoint.as_view(),
+        name='sentry-api-0-organization-event-details-latest'
+    ),
+    url(
+        r'^organizations/(?P<organization_slug>[^\/]+)/events/oldest/$',
+        OrganizationEventDetailsOldestEndpoint.as_view(),
+        name='sentry-api-0-organization-event-details-oldest'
+    ),
     url(
         r'^organizations/(?P<organization_slug>[^\/]+)/events-stats/$',
         OrganizationEventsStatsEndpoint.as_view(),

+ 223 - 0
tests/snuba/api/endpoints/test_organization_event_details.py

@@ -0,0 +1,223 @@
+from __future__ import absolute_import
+
+from datetime import timedelta
+from django.utils import timezone
+from django.core.urlresolvers import reverse
+from sentry.testutils import APITestCase, SnubaTestCase
+from sentry.models import Group
+
+
+class OrganizationEventDetailsTestBase(APITestCase, SnubaTestCase):
+    def setUp(self):
+        super(OrganizationEventDetailsTestBase, self).setUp()
+        min_ago = (timezone.now() - timedelta(minutes=1)).isoformat()[:19]
+        two_min_ago = (timezone.now() - timedelta(minutes=2)).isoformat()[:19]
+        three_min_ago = (timezone.now() - timedelta(minutes=3)).isoformat()[:19]
+
+        self.login_as(user=self.user)
+        self.project = self.create_project()
+
+        self.store_event(
+            data={
+                'event_id': 'a' * 32,
+                'timestamp': three_min_ago,
+                'fingerprint': ['group-1'],
+
+            },
+            project_id=self.project.id,
+        )
+        self.store_event(
+            data={
+                'event_id': 'b' * 32,
+                'timestamp': two_min_ago,
+                'fingerprint': ['group-1'],
+            },
+            project_id=self.project.id,
+        )
+        self.store_event(
+            data={
+                'event_id': 'c' * 32,
+                'timestamp': min_ago,
+                'fingerprint': ['group-2'],
+            },
+            project_id=self.project.id,
+        )
+        self.groups = Group.objects.all()
+
+
+class OrganizationEventDetailsEndpointTest(OrganizationEventDetailsTestBase):
+    def test_simple(self):
+        url = reverse(
+            'sentry-api-0-organization-event-details',
+            kwargs={
+                'organization_slug': self.project.organization.slug,
+                'project_slug': self.project.slug,
+                'event_id': 'a' * 32,
+            }
+        )
+
+        with self.feature('organizations:events-v2'):
+            response = self.client.get(url, format='json')
+
+        assert response.status_code == 200, response.content
+        assert response.data['id'] == 'a' * 32
+        assert response.data['previousEventID'] is None
+        assert response.data['nextEventID'] == 'b' * 32
+        assert response.data['projectSlug'] == self.project.slug
+
+    def test_no_access(self):
+        url = reverse(
+            'sentry-api-0-organization-event-details',
+            kwargs={
+                'organization_slug': self.project.organization.slug,
+                'project_slug': self.project.slug,
+                'event_id': 'a' * 32,
+            }
+        )
+
+        response = self.client.get(url, format='json')
+
+        assert response.status_code == 404, response.content
+
+    def test_no_event(self):
+        url = reverse(
+            'sentry-api-0-organization-event-details',
+            kwargs={
+                'organization_slug': self.project.organization.slug,
+                'project_slug': self.project.slug,
+                'event_id': 'd' * 32,
+            }
+        )
+
+        with self.feature('organizations:events-v2'):
+            response = self.client.get(url, format='json')
+
+        assert response.status_code == 404, response.content
+
+
+class OrganizationEventDetailsLatestEndpointTest(OrganizationEventDetailsTestBase):
+    def test_simple(self):
+        url = reverse(
+            'sentry-api-0-organization-event-details-latest',
+            kwargs={
+                'organization_slug': self.project.organization.slug,
+            }
+        )
+
+        with self.feature('organizations:events-v2'):
+            response = self.client.get(url, format='json')
+
+        assert response.status_code == 200, response.content
+        assert response.data['id'] == 'c' * 32
+        assert response.data['previousEventID'] == 'b' * 32
+        assert response.data['nextEventID'] is None
+        assert response.data['projectSlug'] == self.project.slug
+
+    def test_no_access(self):
+        url = reverse(
+            'sentry-api-0-organization-event-details-latest',
+            kwargs={
+                'organization_slug': self.project.organization.slug,
+            }
+        )
+
+        response = self.client.get(url, format='json')
+
+        assert response.status_code == 404, response.content
+
+    def test_no_event(self):
+        new_org = self.create_organization(owner=self.user)
+        self.create_project(organization=new_org)
+        url = reverse(
+            'sentry-api-0-organization-event-details-latest',
+            kwargs={
+                'organization_slug': new_org.slug,
+            }
+        )
+
+        with self.feature('organizations:events-v2'):
+            response = self.client.get(url, format='json')
+
+        assert response.status_code == 404, response.content
+
+    def test_query_with_issue_id(self):
+        url = reverse(
+            'sentry-api-0-organization-event-details-latest',
+            kwargs={
+                'organization_slug': self.project.organization.slug,
+            }
+        )
+        query = {'query': 'issue.id:{}'.format(self.groups[1].id)}
+
+        with self.feature('organizations:events-v2'):
+            response = self.client.get(url, query, format='json')
+
+        assert response.status_code == 200, response.content
+        assert response.data['id'] == 'c' * 32
+        assert response.data['previousEventID'] is None
+        assert response.data['nextEventID'] is None
+        assert response.data['projectSlug'] == self.project.slug
+
+
+class OrganizationEventDetailsOldestEndpointTest(OrganizationEventDetailsTestBase):
+    def test_simple(self):
+        url = reverse(
+            'sentry-api-0-organization-event-details-oldest',
+            kwargs={
+                'organization_slug': self.project.organization.slug,
+            }
+        )
+
+        with self.feature('organizations:events-v2'):
+            response = self.client.get(url, format='json')
+
+        assert response.status_code == 200, response.content
+        assert response.data['id'] == 'a' * 32
+        assert response.data['previousEventID'] is None
+        assert response.data['nextEventID'] == 'b' * 32
+        assert response.data['projectSlug'] == self.project.slug
+
+    def test_no_access(self):
+        url = reverse(
+            'sentry-api-0-organization-event-details-oldest',
+            kwargs={
+                'organization_slug': self.project.organization.slug,
+            }
+        )
+
+        response = self.client.get(url, format='json')
+
+        assert response.status_code == 404, response.content
+
+    def test_no_event(self):
+        new_org = self.create_organization(owner=self.user)
+        self.create_project(organization=new_org)
+        url = reverse(
+            'sentry-api-0-organization-event-details-oldest',
+            kwargs={
+                'organization_slug': new_org.slug,
+            }
+        )
+
+        with self.feature('organizations:events-v2'):
+            response = self.client.get(url, format='json')
+
+        assert response.status_code == 404, response.content
+
+    def test_query_with_issue_id(self):
+        url = reverse(
+            'sentry-api-0-organization-event-details-oldest',
+            kwargs={
+                'organization_slug': self.project.organization.slug,
+            }
+        )
+        query = {'query': 'issue.id:{}'.format(self.groups[1].id)}
+
+        with self.feature('organizations:events-v2'):
+            response = self.client.get(url, query, format='json')
+
+        assert response.status_code == 200, response.content
+        assert response.data['id'] == 'c' * 32
+        assert response.data['previousEventID'] is None
+        assert response.data['nextEventID'] is None
+        assert response.data['projectSlug'] == self.project.slug