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

feat(related_issues): Trace connected errors (#69237)

Given a group, we look for the recommended event and search for any
other errors in the trace.

These trace-connected groups will be shown under the Related Issues tab.
See the current look (more UI changes will be needed).

<img width="933" alt="image"
src="https://github.com/getsentry/sentry/assets/44410/b3f774d7-dc71-40b9-b144-65f143b6e981">
Armen Zambrano G 10 месяцев назад
Родитель
Сommit
34d92225c8

+ 8 - 1
src/sentry/eventstore/models.py

@@ -90,6 +90,13 @@ class BaseEvent(metaclass=abc.ABCMeta):
     def data(self, value: NodeData | Mapping[str, Any]):
     def data(self, value: NodeData | Mapping[str, Any]):
         pass
         pass
 
 
+    @property
+    def trace_id(self) -> str | None:
+        ret_value = None
+        if self.data:
+            ret_value = self.data.get("contexts", {}).get("trace", {}).get("trace_id")
+        return ret_value
+
     @property
     @property
     def platform(self) -> str | None:
     def platform(self) -> str | None:
         column = self._get_column_name(Columns.PLATFORM)
         column = self._get_column_name(Columns.PLATFORM)
@@ -716,7 +723,7 @@ class GroupEvent(BaseEvent):
         data: NodeData,
         data: NodeData,
         snuba_data: Mapping[str, Any] | None = None,
         snuba_data: Mapping[str, Any] | None = None,
         occurrence: IssueOccurrence | None = None,
         occurrence: IssueOccurrence | None = None,
-    ):
+    ) -> None:
         super().__init__(project_id, event_id, snuba_data=snuba_data)
         super().__init__(project_id, event_id, snuba_data=snuba_data)
         self.group = group
         self.group = group
         self.data = data
         self.data = data

+ 2 - 0
src/sentry/issues/related/__init__.py

@@ -3,11 +3,13 @@
 from sentry.models.group import Group
 from sentry.models.group import Group
 
 
 from .same_root_cause import same_root_cause_analysis
 from .same_root_cause import same_root_cause_analysis
+from .trace_connected import trace_connected_analysis
 
 
 __all__ = ["find_related_issues"]
 __all__ = ["find_related_issues"]
 
 
 RELATED_ISSUES_ALGORITHMS = {
 RELATED_ISSUES_ALGORITHMS = {
     "same_root_cause": same_root_cause_analysis,
     "same_root_cause": same_root_cause_analysis,
+    "trace_connected": trace_connected_analysis,
 }
 }
 
 
 
 

+ 4 - 1
src/sentry/issues/related/same_root_cause.py

@@ -11,7 +11,10 @@ def same_root_cause_analysis(group: Group) -> list[int]:
     """Analyze and create a group set if the group was caused by the same root cause."""
     """Analyze and create a group set if the group was caused by the same root cause."""
     # Querying the data field (which is a GzippedDictField) cannot be done via
     # Querying the data field (which is a GzippedDictField) cannot be done via
     # Django's ORM, thus, we do so via compare_groups
     # Django's ORM, thus, we do so via compare_groups
-    project_groups = RangeQuerySetWrapper(Group.objects.filter(project=group.project_id), limit=100)
+    project_groups = RangeQuerySetWrapper(
+        Group.objects.filter(project=group.project_id).exclude(id=group.id),
+        limit=100,
+    )
     same_error_type_groups = [g.id for g in project_groups if compare_groups(g, group)]
     same_error_type_groups = [g.id for g in project_groups if compare_groups(g, group)]
     return same_error_type_groups or []
     return same_error_type_groups or []
 
 

+ 43 - 0
src/sentry/issues/related/trace_connected.py

@@ -0,0 +1,43 @@
+# Module to evaluate if other errors happened in the same trace.
+#
+# Refer to README in module for more details.
+from sentry.api.utils import default_start_end_dates
+from sentry.models.group import Group
+from sentry.models.project import Project
+from sentry.search.events.builder import QueryBuilder
+from sentry.search.events.types import QueryBuilderConfig
+from sentry.snuba.dataset import Dataset
+from sentry.snuba.referrer import Referrer
+from sentry.utils.snuba import bulk_snuba_queries
+
+
+def trace_connected_analysis(group: Group) -> list[int]:
+    event = group.get_recommended_event_for_environments()
+    if not event or event.trace_id is None:
+        return []
+
+    org_id = group.project.organization_id
+    # XXX: Test without a list and validate the data type
+    project_ids = list(Project.objects.filter(organization_id=org_id).values_list("id", flat=True))
+    start, end = default_start_end_dates()  # Today to 90 days back
+    query = QueryBuilder(
+        Dataset.Events,
+        {"start": start, "end": end, "organization_id": org_id, "project_id": project_ids},
+        query=f"trace:{event.trace_id}",
+        selected_columns=["id", "issue.id"],
+        # Don't add timestamp to this orderby as snuba will have to split the time range up and make multiple queries
+        orderby=["id"],
+        limit=100,
+        config=QueryBuilderConfig(auto_fields=False),
+    )
+    results = bulk_snuba_queries(
+        [query.get_snql_query()], referrer=Referrer.API_ISSUES_RELATED_ISSUES.value
+    )
+    transformed_results = list(
+        {
+            datum["issue.id"]
+            for datum in query.process_results(results[0])["data"]
+            if datum["issue.id"] != group.id  # Exclude itself
+        }
+    )
+    return transformed_results

+ 1 - 0
src/sentry/snuba/referrer.py

@@ -98,6 +98,7 @@ class Referrer(Enum):
     API_GROUP_HASHES_LEVELS_GET_LEVELS_OVERVIEW = "api.group_hashes_levels.get_levels_overview"
     API_GROUP_HASHES_LEVELS_GET_LEVELS_OVERVIEW = "api.group_hashes_levels.get_levels_overview"
     API_GROUP_HASHES = "api.group-hashes"
     API_GROUP_HASHES = "api.group-hashes"
     API_ISSUES_ISSUE_EVENTS = "api.issues.issue_events"
     API_ISSUES_ISSUE_EVENTS = "api.issues.issue_events"
+    API_ISSUES_RELATED_ISSUES = "api.issues.related_issues"
     API_ORGANIZATION_EVENT_STATS_FIND_TOPN = "api.organization-event-stats.find-topn"
     API_ORGANIZATION_EVENT_STATS_FIND_TOPN = "api.organization-event-stats.find-topn"
     API_ORGANIZATION_EVENT_STATS_METRICS_ENHANCED = "api.organization-event-stats.metrics-enhanced"
     API_ORGANIZATION_EVENT_STATS_METRICS_ENHANCED = "api.organization-event-stats.metrics-enhanced"
     API_ORGANIZATION_EVENT_STATS = "api.organization-event-stats"
     API_ORGANIZATION_EVENT_STATS = "api.organization-event-stats"

+ 6 - 3
src/sentry/testutils/cases.py

@@ -3487,8 +3487,8 @@ class TraceTestCase(SpanTestCase):
             )
             )
         ]
         ]
 
 
-    def load_errors(self) -> tuple[Event, Event]:
-        """Generates 2 events for gen1 projects."""
+    def load_errors(self) -> tuple[Event, Event, Event]:
+        """Generates trace with errors across two projects."""
         if not hasattr(self, "gen1_project"):
         if not hasattr(self, "gen1_project"):
             self.populate_project1()
             self.populate_project1()
         start, _ = self.get_start_end_from_day_ago(1000)
         start, _ = self.get_start_end_from_day_ago(1000)
@@ -3505,7 +3505,10 @@ class TraceTestCase(SpanTestCase):
         error = self.store_event(error_data, project_id=self.gen1_project.id)
         error = self.store_event(error_data, project_id=self.gen1_project.id)
         error_data["level"] = "warning"
         error_data["level"] = "warning"
         error1 = self.store_event(error_data, project_id=self.gen1_project.id)
         error1 = self.store_event(error_data, project_id=self.gen1_project.id)
-        return error, error1
+
+        another_project = self.create_project(organization=self.organization)
+        another_project_error = self.store_event(error_data, project_id=another_project.id)
+        return error, error1, another_project_error
 
 
     def load_default(self) -> Event:
     def load_default(self) -> Event:
         start, _ = self.get_start_end_from_day_ago(1000)
         start, _ = self.get_start_end_from_day_ago(1000)

+ 28 - 13
tests/sentry/api/endpoints/issues/test_related_issues.py

@@ -1,39 +1,38 @@
-from typing import Any
-
 from django.urls import reverse
 from django.urls import reverse
 
 
-from sentry.testutils.cases import APITestCase
+from sentry.testutils.cases import APITestCase, SnubaTestCase, TraceTestCase
 
 
 
 
-class RelatedIssuesTest(APITestCase):
+class RelatedIssuesTest(APITestCase, SnubaTestCase, TraceTestCase):
     endpoint = "sentry-api-0-issues-related-issues"
     endpoint = "sentry-api-0-issues-related-issues"
+    FEATURES: list[str] = []
 
 
     def setUp(self) -> None:
     def setUp(self) -> None:
         super().setUp()
         super().setUp()
         self.login_as(user=self.user)
         self.login_as(user=self.user)
         self.organization = self.create_organization(owner=self.user)
         self.organization = self.create_organization(owner=self.user)
-        self.error_type = "ApiTimeoutError"
-        self.error_value = "Timed out attempting to reach host: api.github.com"
         # You need to set this value in your test before calling the API
         # You need to set this value in your test before calling the API
         self.group_id = None
         self.group_id = None
 
 
     def reverse_url(self) -> str:
     def reverse_url(self) -> str:
         return reverse(self.endpoint, kwargs={"issue_id": self.group_id})
         return reverse(self.endpoint, kwargs={"issue_id": self.group_id})
 
 
-    def _data(self, type: str, value: str) -> dict[str, Any]:
+    def _data(self, type: str, value: str) -> dict[str, object]:
         return {"type": "error", "metadata": {"type": type, "value": value}}
         return {"type": "error", "metadata": {"type": type, "value": value}}
 
 
     def test_same_root_related_issues(self) -> None:
     def test_same_root_related_issues(self) -> None:
         # This is the group we're going to query about
         # This is the group we're going to query about
-        group = self.create_group(data=self._data(self.error_type, self.error_value))
+        error_type = "ApiTimeoutError"
+        error_value = "Timed out attempting to reach host: api.github.com"
+        group = self.create_group(data=self._data(error_type, error_value))
         self.group_id = group.id
         self.group_id = group.id
 
 
         groups_data = [
         groups_data = [
-            self._data("ApiError", self.error_value),
-            self._data(self.error_type, "Unreacheable host: api.github.com"),
-            self._data(self.error_type, ""),
+            self._data("ApiError", error_value),
+            self._data(error_type, "Unreacheable host: api.github.com"),
+            self._data(error_type, ""),
             # Only this group will be related
             # Only this group will be related
-            self._data(self.error_type, self.error_value),
+            self._data(error_type, error_value),
         ]
         ]
         # XXX: See if we can get this code to be closer to how save_event generates groups
         # XXX: See if we can get this code to be closer to how save_event generates groups
         for datum in groups_data:
         for datum in groups_data:
@@ -45,6 +44,22 @@ class RelatedIssuesTest(APITestCase):
         # https://us.sentry.io/api/0/organizations/sentry/issues-stats/?groups=4741828952&groups=4489703641&statsPeriod=24h
         # https://us.sentry.io/api/0/organizations/sentry/issues-stats/?groups=4741828952&groups=4489703641&statsPeriod=24h
         assert response.json() == {
         assert response.json() == {
             "data": [
             "data": [
-                {"type": "same_root_cause", "data": [1, 5]},
+                {"type": "same_root_cause", "data": [5]},
+                {"type": "trace_connected", "data": []},
             ],
             ],
         }
         }
+
+    def test_trace_connected_errors(self) -> None:
+        error_event, _, another_proj_event = self.load_errors()
+        self.group_id = error_event.group_id  # type: ignore[assignment]
+        assert error_event.group_id != another_proj_event.group_id
+        assert error_event.project.id != another_proj_event.project.id
+        assert error_event.trace_id == another_proj_event.trace_id
+
+        response = self.get_success_response()
+        assert response.json() == {
+            "data": [
+                {"type": "same_root_cause", "data": []},
+                {"type": "trace_connected", "data": [another_proj_event.group_id]},
+            ]
+        }

+ 4 - 4
tests/snuba/api/endpoints/test_organization_events_trace.py

@@ -1002,7 +1002,7 @@ class OrganizationEventsTraceEndpointTest(OrganizationEventsTraceEndpointBase):
 
 
     def test_with_errors(self):
     def test_with_errors(self):
         self.load_trace()
         self.load_trace()
-        error, error1 = self.load_errors()
+        error, error1, _ = self.load_errors()
 
 
         with self.feature(self.FEATURES):
         with self.feature(self.FEATURES):
             response = self.client_get(
             response = self.client_get(
@@ -1012,7 +1012,7 @@ class OrganizationEventsTraceEndpointTest(OrganizationEventsTraceEndpointBase):
         assert response.status_code == 200, response.content
         assert response.status_code == 200, response.content
         self.assert_trace_data(response.data["transactions"][0])
         self.assert_trace_data(response.data["transactions"][0])
         gen1_event = response.data["transactions"][0]["children"][0]
         gen1_event = response.data["transactions"][0]["children"][0]
-        assert len(gen1_event["errors"]) == 2
+        assert len(gen1_event["errors"]) == 3
         data = {
         data = {
             "event_id": error.event_id,
             "event_id": error.event_id,
             "issue_id": error.group_id,
             "issue_id": error.group_id,
@@ -1537,9 +1537,9 @@ class OrganizationEventsTraceMetaEndpointTest(OrganizationEventsTraceEndpointBas
             )
             )
         assert response.status_code == 200, response.content
         assert response.status_code == 200, response.content
         data = response.data
         data = response.data
-        assert data["projects"] == 4
+        assert data["projects"] == 5
         assert data["transactions"] == 8
         assert data["transactions"] == 8
-        assert data["errors"] == 2
+        assert data["errors"] == 3
         assert data["performance_issues"] == 2
         assert data["performance_issues"] == 2
 
 
     def test_with_default(self):
     def test_with_default(self):