diff --git a/src/sentry/api/endpoints/organization_spans_trace.py b/src/sentry/api/endpoints/organization_spans_trace.py deleted file mode 100644 index 44bc274b23074a..00000000000000 --- a/src/sentry/api/endpoints/organization_spans_trace.py +++ /dev/null @@ -1,126 +0,0 @@ -from datetime import datetime -from typing import Any, TypedDict - -import sentry_sdk -from django.http import HttpRequest, HttpResponse -from rest_framework.request import Request -from rest_framework.response import Response - -from sentry import features -from sentry.api.api_publish_status import ApiPublishStatus -from sentry.api.base import region_silo_endpoint -from sentry.api.bases import NoProjects, OrganizationEventsV2EndpointBase -from sentry.api.paginator import GenericOffsetPaginator -from sentry.api.utils import handle_query_errors, update_snuba_params_with_timestamp -from sentry.models.organization import Organization -from sentry.models.project import Project -from sentry.organizations.services.organization import RpcOrganization -from sentry.search.eap.types import SearchResolverConfig -from sentry.search.events.types import SnubaParams -from sentry.snuba.referrer import Referrer -from sentry.snuba.spans_rpc import run_trace_query - - -class SerializedEvent(TypedDict): - children: list["SerializedEvent"] - event_id: str - parent_span_id: str | None - project_id: int - project_slug: str - start_timestamp: datetime | None - end_timestamp: datetime | None - transaction: str - description: str - duration: float - is_transaction: bool - op: str - event_type: str - - -@region_silo_endpoint -class OrganizationSpansTraceEndpoint(OrganizationEventsV2EndpointBase): - publish_status = { - "GET": ApiPublishStatus.PRIVATE, - } - - def get_projects( - self, - request: HttpRequest, - organization: Organization | RpcOrganization, - force_global_perms: bool = False, - include_all_accessible: bool = False, - project_ids: set[int] | None = None, - project_slugs: set[str] | None = None, - ) -> list[Project]: - """The trace endpoint always wants to get all projects regardless of what's passed into the API - - This is because a trace can span any number of projects in an organization. But we still want to - use the get_projects function to check for any permissions. So we'll just pass project_ids=-1 everytime - which is what would be sent if we wanted all projects""" - return super().get_projects( - request, - organization, - project_ids={-1}, - project_slugs=None, - include_all_accessible=True, - ) - - def serialize_rpc_span(self, span: dict[str, Any]) -> SerializedEvent: - return SerializedEvent( - children=[self.serialize_rpc_span(child) for child in span["children"]], - event_id=span["id"], - project_id=span["project.id"], - project_slug=span["project.slug"], - parent_span_id=None if span["parent_span"] == "0" * 16 else span["parent_span"], - start_timestamp=span["precise.start_ts"], - end_timestamp=span["precise.finish_ts"], - duration=span["span.duration"], - transaction=span["transaction"], - is_transaction=span["is_transaction"], - description=span["description"], - op=span["span.op"], - event_type="span", - ) - - @sentry_sdk.tracing.trace - def query_trace_data(self, snuba_params: SnubaParams, trace_id: str) -> list[SerializedEvent]: - trace_data = run_trace_query( - trace_id, snuba_params, Referrer.API_TRACE_VIEW_GET_EVENTS.value, SearchResolverConfig() - ) - result = [] - id_to_event = {event["id"]: event for event in trace_data} - for span in trace_data: - if span["parent_span"] in id_to_event: - parent = id_to_event[span["parent_span"]] - parent["children"].append(span) - else: - result.append(span) - return [self.serialize_rpc_span(root) for root in result] - - def has_feature(self, organization: Organization, request: Request) -> bool: - return bool( - features.has("organizations:trace-spans-format", organization, actor=request.user) - ) - - def get(self, request: Request, organization: Organization, trace_id: str) -> HttpResponse: - if not self.has_feature(organization, request): - return Response(status=404) - - try: - # The trace view isn't useful without global views, so skipping the check here - snuba_params = self.get_snuba_params(request, organization, check_global_views=False) - except NoProjects: - return Response(status=404) - - update_snuba_params_with_timestamp(request, snuba_params) - - def data_fn(offset: int, limit: int) -> list[SerializedEvent]: - """offset and limit don't mean anything on this endpoint currently""" - with handle_query_errors(): - spans = self.query_trace_data(snuba_params, trace_id) - return spans - - return self.paginate( - request=request, - paginator=GenericOffsetPaginator(data_fn=data_fn), - ) diff --git a/src/sentry/api/endpoints/organization_trace.py b/src/sentry/api/endpoints/organization_trace.py new file mode 100644 index 00000000000000..da1a0d2896fc82 --- /dev/null +++ b/src/sentry/api/endpoints/organization_trace.py @@ -0,0 +1,203 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from typing import Any, TypedDict + +import sentry_sdk +from django.http import HttpRequest, HttpResponse +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry import features +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases import NoProjects, OrganizationEventsV2EndpointBase +from sentry.api.paginator import GenericOffsetPaginator +from sentry.api.utils import handle_query_errors, update_snuba_params_with_timestamp +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.organizations.services.organization import RpcOrganization +from sentry.search.eap.types import SearchResolverConfig +from sentry.search.events.builder.discover import DiscoverQueryBuilder +from sentry.search.events.types import QueryBuilderConfig, SnubaParams +from sentry.snuba.dataset import Dataset +from sentry.snuba.referrer import Referrer +from sentry.snuba.spans_rpc import run_trace_query + +# 1 worker each for spans, errors, performance issues +_query_thread_pool = ThreadPoolExecutor(max_workers=3) + + +class SerializedEvent(TypedDict): + description: str + event_id: str + event_type: str + project_id: int + project_slug: str + start_timestamp: datetime + transaction: str + + +class SerializedSpan(SerializedEvent): + children: list["SerializedEvent"] + errors: list["SerializedEvent"] + duration: float + end_timestamp: datetime + op: str + parent_span_id: str | None + is_transaction: bool + + +@region_silo_endpoint +class OrganizationTraceEndpoint(OrganizationEventsV2EndpointBase): + """Replaces OrganizationEventsTraceEndpoint""" + + publish_status = { + "GET": ApiPublishStatus.PRIVATE, + } + + def get_projects( + self, + request: HttpRequest, + organization: Organization | RpcOrganization, + force_global_perms: bool = False, + include_all_accessible: bool = False, + project_ids: set[int] | None = None, + project_slugs: set[str] | None = None, + ) -> list[Project]: + """The trace endpoint always wants to get all projects regardless of what's passed into the API + + This is because a trace can span any number of projects in an organization. But we still want to + use the get_projects function to check for any permissions. So we'll just pass project_ids=-1 everytime + which is what would be sent if we wanted all projects""" + return super().get_projects( + request, + organization, + project_ids={-1}, + project_slugs=None, + include_all_accessible=True, + ) + + def serialize_rpc_event(self, event: dict[str, Any]) -> SerializedEvent: + if event.get("event_type") == "error": + return SerializedEvent( + event_id=event["id"], + project_id=event["project.id"], + project_slug=event["project.name"], + start_timestamp=event["timestamp"], + transaction=event["transaction"], + description=event["message"], + event_type="error", + ) + elif event.get("event_type") == "span": + return SerializedSpan( + children=[self.serialize_rpc_event(child) for child in event["children"]], + errors=[self.serialize_rpc_event(error) for error in event["errors"]], + event_id=event["id"], + project_id=event["project.id"], + project_slug=event["project.slug"], + parent_span_id=None if event["parent_span"] == "0" * 16 else event["parent_span"], + start_timestamp=event["precise.start_ts"], + end_timestamp=event["precise.finish_ts"], + duration=event["span.duration"], + transaction=event["transaction"], + is_transaction=event["is_transaction"], + description=event["description"], + op=event["span.op"], + event_type="span", + ) + else: + raise Exception(f"Unknown event encountered in trace: {event.get('event_type')}") + + def run_errors_query(self, snuba_params: SnubaParams, trace_id: str): + """Run an error query, getting all the errors for a given trace id""" + # TODO: replace this with EAP calls, this query is copied from the old trace view + error_query = DiscoverQueryBuilder( + Dataset.Events, + params={}, + snuba_params=snuba_params, + query=f"trace:{trace_id}", + selected_columns=[ + "id", + "project.name", + "project.id", + "timestamp", + "trace.span", + "transaction", + "issue", + "title", + "message", + "tags[level]", + ], + # Don't add timestamp to this orderby as snuba will have to split the time range up and make multiple queries + orderby=["id"], + limit=10_000, + config=QueryBuilderConfig( + auto_fields=True, + ), + ) + result = error_query.run_query(Referrer.API_TRACE_VIEW_GET_EVENTS.value) + error_data = error_query.process_results(result)["data"] + for event in error_data: + event["event_type"] = "error" + return error_data + + @sentry_sdk.tracing.trace + def query_trace_data(self, snuba_params: SnubaParams, trace_id: str) -> list[SerializedEvent]: + """Queries span/error data for a given trace""" + # This is a hack, long term EAP will store both errors and performance_issues eventually but is not ready + # currently. But we want to move performance data off the old tables immediately. To keep the code simpler I'm + # parallelizing the queries here, but ideally this parallelization lives in the spans_rpc module instead + spans_future = _query_thread_pool.submit( + run_trace_query, + trace_id, + snuba_params, + Referrer.API_TRACE_VIEW_GET_EVENTS.value, + SearchResolverConfig(), + ) + errors_future = _query_thread_pool.submit(self.run_errors_query, snuba_params, trace_id) + spans_data = spans_future.result() + errors_data = errors_future.result() + + result = [] + id_to_span = {event["id"]: event for event in spans_data} + id_to_error = {event["trace.span"]: event for event in errors_data} + for span in spans_data: + if span["parent_span"] in id_to_span: + parent = id_to_span[span["parent_span"]] + parent["children"].append(span) + else: + result.append(span) + if span["id"] in id_to_error: + error = id_to_error.pop(span["id"]) + span["errors"].append(error) + for error in id_to_error.values(): + result.append(error) + return [self.serialize_rpc_event(root) for root in result] + + def has_feature(self, organization: Organization, request: Request) -> bool: + return bool( + features.has("organizations:trace-spans-format", organization, actor=request.user) + ) + + def get(self, request: Request, organization: Organization, trace_id: str) -> HttpResponse: + if not self.has_feature(organization, request): + return Response(status=404) + + try: + # The trace view isn't useful without global views, so skipping the check here + snuba_params = self.get_snuba_params(request, organization, check_global_views=False) + except NoProjects: + return Response(status=404) + + update_snuba_params_with_timestamp(request, snuba_params) + + def data_fn(offset: int, limit: int) -> list[SerializedEvent]: + """offset and limit don't mean anything on this endpoint currently""" + with handle_query_errors(): + spans = self.query_trace_data(snuba_params, trace_id) + return spans + + return self.paginate( + request=request, + paginator=GenericOffsetPaginator(data_fn=data_fn), + ) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 6ecce99ca2ea3a..2bae17ad69d148 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -573,12 +573,12 @@ OrganizationSpansFieldValuesEndpoint, ) from .endpoints.organization_spans_fields_stats import OrganizationSpansFieldsStatsEndpoint -from .endpoints.organization_spans_trace import OrganizationSpansTraceEndpoint from .endpoints.organization_stats import OrganizationStatsEndpoint from .endpoints.organization_stats_v2 import OrganizationStatsEndpointV2 from .endpoints.organization_tagkey_values import OrganizationTagKeyValuesEndpoint from .endpoints.organization_tags import OrganizationTagsEndpoint from .endpoints.organization_teams import OrganizationTeamsEndpoint +from .endpoints.organization_trace import OrganizationTraceEndpoint from .endpoints.organization_traces import ( OrganizationTracesEndpoint, OrganizationTraceSpansEndpoint, @@ -1607,8 +1607,8 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: ), re_path( r"^(?P<organization_id_or_slug>[^\/]+)/trace/(?P<trace_id>(?:\d+|[A-Fa-f0-9-]{32,36}))/$", - OrganizationSpansTraceEndpoint.as_view(), - name="sentry-api-0-organization-spans-trace", + OrganizationTraceEndpoint.as_view(), + name="sentry-api-0-organization-trace", ), re_path( r"^(?P<organization_id_or_slug>[^\/]+)/measurements-meta/$", diff --git a/src/sentry/snuba/spans_rpc.py b/src/sentry/snuba/spans_rpc.py index f86ba3aa5e9064..d6e16c173db0ef 100644 --- a/src/sentry/snuba/spans_rpc.py +++ b/src/sentry/snuba/spans_rpc.py @@ -445,7 +445,12 @@ def run_trace_query( columns_by_name = {col.proto_definition.name: col for col in columns} for item_group in response.item_groups: for span_item in item_group.items: - span: dict[str, Any] = {"id": span_item.id, "children": []} + span: dict[str, Any] = { + "id": span_item.id, + "children": [], + "errors": [], + "event_type": "span", + } for attribute in span_item.attributes: resolved_column = columns_by_name[attribute.key.name] if resolved_column.proto_definition.type == STRING: diff --git a/tests/snuba/api/endpoints/test_organization_spans_trace.py b/tests/snuba/api/endpoints/test_organization_trace.py similarity index 76% rename from tests/snuba/api/endpoints/test_organization_spans_trace.py rename to tests/snuba/api/endpoints/test_organization_trace.py index 8bd426b7a49d38..e332fbd5308886 100644 --- a/tests/snuba/api/endpoints/test_organization_spans_trace.py +++ b/tests/snuba/api/endpoints/test_organization_trace.py @@ -2,13 +2,14 @@ from django.urls import reverse +from sentry.utils.samples import load_data from tests.snuba.api.endpoints.test_organization_events_trace import ( OrganizationEventsTraceEndpointBase, ) class OrganizationEventsTraceEndpointTest(OrganizationEventsTraceEndpointBase): - url_name = "sentry-api-0-organization-spans-trace" + url_name = "sentry-api-0-organization-trace" FEATURES = ["organizations:trace-spans-format"] def assert_event(self, result, event_data, message): @@ -23,7 +24,7 @@ def get_transaction_children(self, event): for child in event["children"]: if child["is_transaction"]: children.append(child) - else: + elif child["event_type"] == "span": children.extend(child["children"]) return sorted(children, key=lambda event: event["description"]) @@ -112,3 +113,33 @@ def test_ignore_project_param(self): data = response.data assert len(data) == 1 self.assert_trace_data(data[0]) + + def test_with_errors_data(self): + self.load_trace(is_eap=True) + start, _ = self.get_start_end_from_day_ago(1000) + error_data = load_data( + "javascript", + timestamp=start, + ) + error_data["contexts"]["trace"] = { + "type": "trace", + "trace_id": self.trace_id, + "span_id": self.root_event.data["contexts"]["trace"]["span_id"], + } + error_data["tags"] = [["transaction", "/transaction/gen1-0"]] + error = self.store_event(error_data, project_id=self.gen1_project.id) + + with self.feature(self.FEATURES): + response = self.client_get( + data={}, + ) + assert response.status_code == 200, response.content + data = response.data + assert len(data) == 1 + self.assert_trace_data(data[0]) + error_event = None + assert len(data[0]["errors"]) == 1 + error_event = data[0]["errors"][0] + assert error_event is not None + assert error_event["event_id"] == error.data["event_id"] + assert error_event["project_slug"] == self.gen1_project.slug