diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 7a865707418c6d..c1fbd5c9149c8f 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -70,6 +70,7 @@ rpc_get_issues_for_transaction, rpc_get_profiles_for_trace, rpc_get_trace_for_transaction, + rpc_get_trace_from_id, rpc_get_transactions_for_project, ) from sentry.seer.fetch_issues import by_error_type, by_function_name, by_text_query, utils @@ -999,6 +1000,7 @@ def send_seer_webhook(*, event_name: str, organization_id: int, payload: dict) - "get_trace_for_transaction": rpc_get_trace_for_transaction, "get_profiles_for_trace": rpc_get_profiles_for_trace, "get_issues_for_transaction": rpc_get_issues_for_transaction, + "get_trace_from_id": rpc_get_trace_from_id, # # Replays "get_replay_summary_logs": rpc_get_replay_summary_logs, diff --git a/src/sentry/seer/explorer/index_data.py b/src/sentry/seer/explorer/index_data.py index 67e0324618147e..05f5be26943bde 100644 --- a/src/sentry/seer/explorer/index_data.py +++ b/src/sentry/seer/explorer/index_data.py @@ -6,10 +6,14 @@ from django.contrib.auth.models import AnonymousUser from sentry import search +from sentry.api.endpoints.organization_traces import TracesExecutor from sentry.api.event_search import SearchFilter from sentry.api.helpers.group_index.index import parse_and_convert_issue_search_query from sentry.api.serializers.base import serialize from sentry.api.serializers.models.event import EventSerializer +from sentry.api.utils import handle_query_errors +from sentry.constants import ObjectStatus +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import SnubaParams @@ -29,8 +33,10 @@ ) from sentry.services.eventstore import backend as eventstore from sentry.services.eventstore.models import Event, GroupEvent +from sentry.snuba.dataset import Dataset from sentry.snuba.referrer import Referrer from sentry.snuba.spans_rpc import Spans +from sentry.snuba.trace import SerializedEvent, query_trace_data logger = logging.getLogger(__name__) @@ -548,6 +554,68 @@ def get_issues_for_transaction(transaction_name: str, project_id: int) -> Transa ) +def get_trace_from_id(trace_id: str, organization_id: int) -> list[SerializedEvent] | None: + """ + Get a trace from an ID. + + Args: + trace_id: The ID of the trace to fetch. Can be shortened to the first 8 characters. + organization_id: The ID of the trace's organization + + Returns: + Trace details + """ + + try: + organization = Organization.objects.get(id=organization_id) + except Organization.DoesNotExist: + logger.exception( + "Organization does not exist; cannot fetch trace", + extra={"organization_id": organization_id, "trace_id": trace_id}, + ) + return None + + projects = list(Project.objects.filter(organization=organization, status=ObjectStatus.ACTIVE)) + end = datetime.now(UTC) + start = end - timedelta(hours=24) + snuba_params = SnubaParams( + start=start, + end=end, + projects=projects, + organization=organization, + ) + + if len(trace_id) < 32: + with handle_query_errors(): + executor = TracesExecutor( + dataset=Dataset.SpansIndexed, + snuba_params=snuba_params, + user_queries=[f"trace:{trace_id}"], + sort=None, + limit=1, + breakdown_slices=1, + get_all_projects=lambda: projects, + ) + subquery_result = executor.execute(0, 1) + full_trace_id = subquery_result.get("data", [{}])[0].get("trace") + else: + full_trace_id = trace_id + + if isinstance(full_trace_id, str): + trace_data = query_trace_data(snuba_params, full_trace_id, referrer=Referrer.SEER_RPC) + if trace_data: + return trace_data + + logger.info( + "Trace not found", + extra={ + "organization_id": organization_id, + "trace_id": trace_id, + }, + ) + return None + + # RPC wrappers @@ -570,3 +638,8 @@ def rpc_get_profiles_for_trace(trace_id: str, project_id: int) -> dict[str, Any] def rpc_get_issues_for_transaction(transaction_name: str, project_id: int) -> dict[str, Any]: issues = get_issues_for_transaction(transaction_name, project_id) return issues.dict() if issues else {} + + +def rpc_get_trace_from_id(trace_id: str, project_id: int) -> dict[str, Any]: + trace = get_trace_from_id(trace_id, project_id) + return {"trace": trace} if trace else {} diff --git a/src/sentry/snuba/trace.py b/src/sentry/snuba/trace.py index 1d32df40d5c53a..eb97c0083858a7 100644 --- a/src/sentry/snuba/trace.py +++ b/src/sentry/snuba/trace.py @@ -448,6 +448,7 @@ def query_trace_data( error_id: str | None = None, additional_attributes: list[str] | None = None, include_uptime: bool = False, + referrer: Referrer = Referrer.API_TRACE_VIEW_GET_EVENTS, ) -> 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 @@ -470,7 +471,7 @@ def query_trace_data( Spans.run_trace_query, trace_id=trace_id, params=snuba_params, - referrer=Referrer.API_TRACE_VIEW_GET_EVENTS.value, + referrer=referrer.value, config=SearchResolverConfig(), additional_attributes=additional_attributes, ) diff --git a/tests/sentry/seer/explorer/test_index_data.py b/tests/sentry/seer/explorer/test_index_data.py index eae57d354b2bbe..dbc28b30253de4 100644 --- a/tests/sentry/seer/explorer/test_index_data.py +++ b/tests/sentry/seer/explorer/test_index_data.py @@ -9,6 +9,7 @@ get_issues_for_transaction, get_profiles_for_trace, get_trace_for_transaction, + get_trace_from_id, get_transactions_for_project, ) from sentry.testutils.cases import APITransactionTestCase, SnubaTestCase, SpanTestCase @@ -775,3 +776,98 @@ def test_get_issues_for_transaction_with_quotes(self) -> None: issue2 = result2.issues[0] assert issue2.id == event2.group.id assert issue2.transaction == transaction_name_in + + def _test_get_trace_from_id(self, use_short_id: bool) -> None: + transaction_name = "api/users/profile" + trace_id = uuid.uuid4().hex + spans: list[dict] = [] + for i in range(5): + # Create a span tree for this trace + span = self.create_span( + { + **({"description": f"span-{i}"} if i != 4 else {}), + "sentry_tags": {"transaction": transaction_name}, + "trace_id": trace_id, + "parent_span_id": (None if i == 0 else spans[i // 2]["span_id"]), + "is_segment": i == 0, # First span is the root + }, + start_ts=self.ten_mins_ago + timedelta(minutes=i), + ) + spans.append(span) + + self.store_spans(spans, is_eap=True) + result = get_trace_from_id(trace_id[:8] if use_short_id else trace_id, self.organization.id) + assert isinstance(result, list) + + seen_span_ids = [] + root_span_ids = [] + + def check(e): + assert "event_id" in e + assert e["transaction"] == transaction_name + assert e["project_id"] == self.project.id + + # TODO: handle non-span events + + # Is a span + assert "op" in e + assert "description" in e + assert "parent_span_id" in e + assert "children" in e + + desc = e["description"] + assert isinstance(desc, str) + if desc: + assert desc.startswith("span-") + + # TODO: test connected errors/occurrences + + seen_span_ids.append(e["event_id"]) + + if e["parent_span_id"] is None: + # Is root + assert e["is_transaction"] + root_span_ids.append(e["event_id"]) + + # Recurse + for child in e["children"]: + check(child) + + for event in result: + check(event) + + assert set(seen_span_ids) == {s["span_id"] for s in spans} + assert len(root_span_ids) == 1 + + def test_get_trace_from_short_id(self) -> None: + self._test_get_trace_from_id(use_short_id=True) + + def test_get_trace_from_id_full_id(self) -> None: + self._test_get_trace_from_id(use_short_id=False) + + def test_get_trace_from_id_wrong_project(self) -> None: + transaction_name = "api/users/profile" + trace_id = uuid.uuid4().hex + other_org = self.create_organization() + other_project = self.create_project(organization=other_org) + + spans = [] + for i in range(2): + span = self.create_span( + { + "project_id": other_project.id, + "description": f"span-{i}", + "sentry_tags": {"transaction": transaction_name}, + "trace_id": trace_id, + "parent_span_id": None if i == 0 else spans[0]["span_id"], + "is_segment": i == 0, + }, + start_ts=self.ten_mins_ago + timedelta(minutes=i), + ) + spans.append(span) + + self.store_spans(spans, is_eap=True) + + # Call with short ID + result = get_trace_from_id(trace_id[:8], self.project.id) + assert result is None