Browse Source

feat(performance) Add related issues endpoint (#18344)

A first pass at a related issues endpoint to be used by the transaction summary
page to show issues related to that transaction.
evanh 4 years ago
parent
commit
b458e8ef28

+ 64 - 1
src/sentry/api/endpoints/organization_events_meta.py

@@ -1,13 +1,20 @@
 from __future__ import absolute_import
 
+import re
 import six
 
 from rest_framework.response import Response
 from rest_framework.exceptions import ParseError
 
+from sentry import search
+from sentry.api.base import EnvironmentMixin
 from sentry.api.bases import OrganizationEventsEndpointBase, OrganizationEventsError, NoProjects
-from sentry.utils import snuba
+from sentry.api.helpers.group_index import build_query_params_from_request
+from sentry.api.event_search import parse_search_query
+from sentry.api.serializers import serialize
+from sentry.api.serializers.models.group import GroupSerializer
 from sentry.snuba import discover
+from sentry.utils import snuba
 
 
 class OrganizationEventsMetaEndpoint(OrganizationEventsEndpointBase):
@@ -30,3 +37,59 @@ class OrganizationEventsMetaEndpoint(OrganizationEventsEndpointBase):
             raise ParseError(detail=six.text_type(error))
 
         return Response({"count": result["data"][0]["count"]})
+
+
+UNESCAPED_QUOTE_RE = re.compile('(?<!\\\\)"')
+
+
+class OrganizationEventsRelatedIssuesEndpoint(OrganizationEventsEndpointBase, EnvironmentMixin):
+    def get(self, request, organization):
+        try:
+            params = self.get_filter_params(request, organization)
+        except OrganizationEventsError as e:
+            return Response({"detail": six.text_type(e)}, status=400)
+        except NoProjects:
+            return Response([])
+
+        possible_keys = ["transaction"]
+        lookup_keys = {key: request.query_params.get(key) for key in possible_keys}
+
+        if not any(lookup_keys.values()):
+            return Response(
+                {
+                    "detail": "Must provide one of {} in order to find related events".format(
+                        possible_keys
+                    )
+                },
+                status=400,
+            )
+
+        try:
+            projects = self.get_projects(request, organization)
+            query_kwargs = build_query_params_from_request(
+                request, organization, projects, params.get("environment")
+            )
+            query_kwargs["limit"] = 5
+            try:
+                # Need to escape quotes in case some "joker" has a transaction with quotes
+                transaction_name = UNESCAPED_QUOTE_RE.sub('\\"', lookup_keys["transaction"])
+                parsed_terms = parse_search_query('transaction:"{}"'.format(transaction_name))
+            except ParseError:
+                return Response({"detail": "Invalid transaction search"}, status=400)
+
+            if query_kwargs.get("search_filters"):
+                query_kwargs["search_filters"].extend(parsed_terms)
+            else:
+                query_kwargs["search_filters"] = parsed_terms
+
+            results = search.query(**query_kwargs)
+        except discover.InvalidSearchQuery as err:
+            raise ParseError(detail=six.text_type(err))
+
+        context = serialize(
+            list(results),
+            request.user,
+            GroupSerializer(environment_func=self._get_environment_func(request, organization.id)),
+        )
+
+        return Response(context)

+ 9 - 1
src/sentry/api/urls.py

@@ -92,7 +92,10 @@ from .endpoints.organization_event_details import OrganizationEventDetailsEndpoi
 from .endpoints.organization_eventid import EventIdLookupEndpoint
 from .endpoints.organization_events import OrganizationEventsEndpoint, OrganizationEventsV2Endpoint
 from .endpoints.organization_events_facets import OrganizationEventsFacetsEndpoint
-from .endpoints.organization_events_meta import OrganizationEventsMetaEndpoint
+from .endpoints.organization_events_meta import (
+    OrganizationEventsMetaEndpoint,
+    OrganizationEventsRelatedIssuesEndpoint,
+)
 from .endpoints.organization_events_stats import OrganizationEventsStatsEndpoint
 from .endpoints.organization_group_index import OrganizationGroupIndexEndpoint
 from .endpoints.organization_incident_activity_index import (
@@ -675,6 +678,11 @@ urlpatterns = [
                     KeyTransactionStatsEndpoint.as_view(),
                     name="sentry-api-0-organization-key-transactions-stats",
                 ),
+                url(
+                    r"^(?P<organization_slug>[^\/]+)/related-issues/$",
+                    OrganizationEventsRelatedIssuesEndpoint.as_view(),
+                    name="sentry-api-0-organization-related-issues",
+                ),
                 # Dashboards
                 url(
                     r"^(?P<organization_slug>[^\/]+)/dashboards/$",

+ 172 - 0
tests/snuba/api/endpoints/test_organization_events_meta.py

@@ -111,3 +111,175 @@ class OrganizationEventsMetaEndpoint(APITestCase, SnubaTestCase):
                 },
             )
         assert response.status_code == 400
+
+
+class OrganizationEventsRelatedIssuesEndpoint(APITestCase, SnubaTestCase):
+    def setUp(self):
+        super(OrganizationEventsRelatedIssuesEndpoint, self).setUp()
+
+    def test_find_related_issue(self):
+        self.login_as(user=self.user)
+
+        project = self.create_project()
+        event1 = self.store_event(
+            data={"timestamp": iso_format(before_now(minutes=1)), "transaction": "/beth/sanchez"},
+            project_id=project.id,
+        )
+
+        url = reverse(
+            "sentry-api-0-organization-related-issues",
+            kwargs={"organization_slug": project.organization.slug},
+        )
+        response = self.client.get(url, {"transaction": "/beth/sanchez"}, format="json")
+
+        assert response.status_code == 200, response.content
+        assert len(response.data) == 1
+        assert response.data[0]["shortId"] == event1.group.qualified_short_id
+        assert int(response.data[0]["id"]) == event1.group_id
+
+    def test_related_issues_no_transaction(self):
+        self.login_as(user=self.user)
+
+        project = self.create_project()
+        self.store_event(
+            data={"timestamp": iso_format(before_now(minutes=1)), "transaction": "/beth/sanchez"},
+            project_id=project.id,
+        )
+
+        url = reverse(
+            "sentry-api-0-organization-related-issues",
+            kwargs={"organization_slug": project.organization.slug},
+        )
+        response = self.client.get(url, format="json")
+
+        assert response.status_code == 400, response.content
+        assert (
+            response.data["detail"]
+            == "Must provide one of ['transaction'] in order to find related events"
+        )
+
+    def test_related_issues_no_matching_groups(self):
+        self.login_as(user=self.user)
+
+        project = self.create_project()
+        self.store_event(
+            data={"timestamp": iso_format(before_now(minutes=1)), "transaction": "/beth/sanchez"},
+            project_id=project.id,
+        )
+
+        url = reverse(
+            "sentry-api-0-organization-related-issues",
+            kwargs={"organization_slug": project.organization.slug},
+        )
+        response = self.client.get(url, {"transaction": "/morty/sanchez"}, format="json")
+
+        assert response.status_code == 200, response.content
+        assert len(response.data) == 0
+
+    def test_related_issues_only_issues_in_date(self):
+        self.login_as(user=self.user)
+
+        project = self.create_project()
+        self.store_event(
+            data={
+                "event_id": "a" * 32,
+                "timestamp": iso_format(before_now(days=2)),
+                "transaction": "/beth/sanchez",
+            },
+            project_id=project.id,
+        )
+        event2 = self.store_event(
+            data={
+                "event_id": "b" * 32,
+                "timestamp": iso_format(before_now(minutes=1)),
+                "transaction": "/beth/sanchez",
+            },
+            project_id=project.id,
+        )
+
+        url = reverse(
+            "sentry-api-0-organization-related-issues",
+            kwargs={"organization_slug": project.organization.slug},
+        )
+        response = self.client.get(
+            url, {"transaction": "/beth/sanchez", "statsPeriod": "24h"}, format="json"
+        )
+
+        assert response.status_code == 200, response.content
+        assert len(response.data) == 1
+        assert response.data[0]["shortId"] == event2.group.qualified_short_id
+        assert int(response.data[0]["id"]) == event2.group_id
+
+    def test_related_issues_transactions_from_different_projects(self):
+        self.login_as(user=self.user)
+
+        project1 = self.create_project()
+        project2 = self.create_project()
+        event1 = self.store_event(
+            data={
+                "event_id": "a" * 32,
+                "timestamp": iso_format(before_now(minutes=1)),
+                "transaction": "/beth/sanchez",
+            },
+            project_id=project1.id,
+        )
+        self.store_event(
+            data={
+                "event_id": "b" * 32,
+                "timestamp": iso_format(before_now(minutes=1)),
+                "transaction": "/beth/sanchez",
+            },
+            project_id=project2.id,
+        )
+
+        url = reverse(
+            "sentry-api-0-organization-related-issues",
+            kwargs={"organization_slug": project1.organization.slug},
+        )
+        response = self.client.get(
+            url, {"transaction": "/beth/sanchez", "project": project1.id}, format="json",
+        )
+
+        assert response.status_code == 200, response.content
+        assert len(response.data) == 1
+        assert response.data[0]["shortId"] == event1.group.qualified_short_id
+        assert int(response.data[0]["id"]) == event1.group_id
+
+    def test_related_issues_transactions_with_quotes(self):
+        self.login_as(user=self.user)
+
+        project = self.create_project()
+        event = self.store_event(
+            data={
+                "event_id": "a" * 32,
+                "timestamp": iso_format(before_now(minutes=1)),
+                "transaction": '/beth/"sanchez"',
+            },
+            project_id=project.id,
+        )
+
+        url = reverse(
+            "sentry-api-0-organization-related-issues",
+            kwargs={"organization_slug": project.organization.slug},
+        )
+        response = self.client.get(
+            url, {"transaction": '/beth/"sanchez"', "project": project.id}, format="json",
+        )
+
+        assert response.status_code == 200, response.content
+        assert len(response.data) == 1
+        assert response.data[0]["shortId"] == event.group.qualified_short_id
+        assert int(response.data[0]["id"]) == event.group_id
+
+        url = reverse(
+            "sentry-api-0-organization-related-issues",
+            kwargs={"organization_slug": project.organization.slug},
+        )
+        response = self.client.get(
+            url, {"transaction": '/beth/\\"sanchez\\"', "project": project.id}, format="json",
+        )
+
+        assert response.status_code == 200, response.content
+        assert len(response.data) == 1
+        assert response.data[0]["shortId"] == event.group.qualified_short_id
+        assert int(response.data[0]["id"]) == event.group_id