diff --git a/src/sentry/api/bases/organization_events.py b/src/sentry/api/bases/organization_events.py index 97aa2e3bdae245..9b4499411ceba5 100644 --- a/src/sentry/api/bases/organization_events.py +++ b/src/sentry/api/bases/organization_events.py @@ -2,11 +2,14 @@ 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 @@ 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 @@ def get_snuba_query_args_v2(self, request, organization, params): 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']) diff --git a/src/sentry/api/endpoints/organization_event_details.py b/src/sentry/api/endpoints/organization_event_details.py new file mode 100644 index 00000000000000..428770f0a63f1b --- /dev/null +++ b/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) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 1a559f46668c26..81f7e62efdf377 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -72,6 +72,7 @@ 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 @@ OrganizationEventsEndpoint.as_view(), name='sentry-api-0-organization-events' ), + url( + r'^organizations/(?P[^\/]+)/events/(?P[^\/]+):(?P(?:\d+|[A-Fa-f0-9]{32}))/$', + OrganizationEventDetailsEndpoint.as_view(), + name='sentry-api-0-organization-event-details' + ), + url( + r'^organizations/(?P[^\/]+)/events/latest/$', + OrganizationEventDetailsLatestEndpoint.as_view(), + name='sentry-api-0-organization-event-details-latest' + ), + url( + r'^organizations/(?P[^\/]+)/events/oldest/$', + OrganizationEventDetailsOldestEndpoint.as_view(), + name='sentry-api-0-organization-event-details-oldest' + ), url( r'^organizations/(?P[^\/]+)/events-stats/$', OrganizationEventsStatsEndpoint.as_view(), diff --git a/tests/snuba/api/endpoints/test_organization_event_details.py b/tests/snuba/api/endpoints/test_organization_event_details.py new file mode 100644 index 00000000000000..acfce90a343838 --- /dev/null +++ b/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