From d8de8f2b8b0e73629ea920916dfb74f96219f079 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 3 Oct 2025 23:27:06 -0700 Subject: [PATCH 01/12] feat(seer-explorer): add rpc to fetch trace by id --- src/sentry/seer/endpoints/seer_rpc.py | 2 + src/sentry/seer/explorer/index_data.py | 92 +++++++++++++++++++ tests/sentry/seer/explorer/test_index_data.py | 45 +++++++++ 3 files changed, 139 insertions(+) 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..b847002a760b5c 100644 --- a/src/sentry/seer/explorer/index_data.py +++ b/src/sentry/seer/explorer/index_data.py @@ -186,6 +186,7 @@ def get_trace_for_transaction(transaction_name: str, project_id: int) -> TraceDa "span.op", "span.description", "precise.start_ts", + "transaction", ], orderby=["precise.start_ts"], offset=0, @@ -548,6 +549,92 @@ def get_issues_for_transaction(transaction_name: str, project_id: int) -> Transa ) +def get_trace_from_id(trace_id: str, project_id: int) -> TraceData | None: + """ + Get a trace from an ID. + + Args: + trace_id: The ID of the trace to fetch. This can be shortened as the first 8 chars. + project_id: The ID of the project + + Returns: + TraceData with all spans and relationships, or None if no trace found + """ + try: + project = Project.objects.get(id=project_id) + except Project.DoesNotExist: + logger.exception( + "Project does not exist; cannot fetch trace", + extra={"project_id": project_id, "trace_id": trace_id}, + ) + return None + + end_time = datetime.now(UTC) + start_time = end_time - timedelta(hours=24) + + snuba_params = SnubaParams( + start=start_time, + end=end_time, + projects=[project], + organization=project.organization, + ) + config = SearchResolverConfig( + auto_fields=True, + ) + + # Get all spans in the trace + spans_result = Spans.run_table_query( + params=snuba_params, + query_string=f"trace:{trace_id}", + selected_columns=[ + "span_id", + "parent_span", + "span.op", + "span.description", + "transaction", + "trace", # Full trace ID + "precise.start_ts", + ], + orderby=["precise.start_ts"], + offset=0, + limit=5000, + referrer=Referrer.SEER_RPC, + config=config, + sampling_mode="NORMAL", + ) + + if not spans_result.get("data", []): + logger.info( + "No spans found for trace", + extra={"trace_id": trace_id, "project_id": project_id}, + ) + return None + + # Build span objects + spans = [] + for row in spans_result["data"]: + if span_id := row.get("span_id"): + spans.append( + Span( + span_id=span_id, + parent_span_id=row.get("parent_span"), + span_op=row.get("span.op"), + span_description=row.get("span.description") or "", + ) + ) + + transaction_name = spans_result["data"][0]["transaction"] + full_trace_id = spans_result["data"][0]["trace"] + + return TraceData( + trace_id=full_trace_id, + project_id=project_id, + transaction_name=transaction_name, + total_spans=len(spans), + spans=spans, + ) + + # RPC wrappers @@ -570,3 +657,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.dict() if trace else {} diff --git a/tests/sentry/seer/explorer/test_index_data.py b/tests/sentry/seer/explorer/test_index_data.py index eae57d354b2bbe..7f70bd9a5bf077 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,47 @@ 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) -> None: + transaction_name = "api/users/profile" + trace_id = uuid.uuid4().hex + spans = [] + for i in range(5): + # Create spans 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 f"parent-{i-1}", + "is_segment": i == 0, # First span is the transaction span + }, + start_ts=self.ten_mins_ago + timedelta(minutes=i), + ) + spans.append(span) + + self.store_spans(spans, is_eap=True) + + # Call our function with shortened trace ID + result = get_trace_from_id(trace_id[:8], self.project.id) + + # Verify basic structure + assert result is not None + assert result.transaction_name == transaction_name + assert result.project_id == self.project.id + # assert result.trace_id == trace_id + assert len(result.spans) == len(spans) + + # Verify all spans have correct structure and belong to the chosen trace + for i, result_span in enumerate(result.spans): + assert hasattr(result_span, "span_id") + assert hasattr(result_span, "span_description") + assert hasattr(result_span, "parent_span_id") + assert hasattr(result_span, "span_op") + assert result_span.span_description is not None + if i != 4: + assert result_span.span_description.startswith("span-") + + # Verify parent-child relationships are preserved + root_spans = [s for s in result.spans if s.parent_span_id is None] + assert len(root_spans) == 1 # Should have exactly one root span From f671b3060d32bb6b4c125fc91d40c809cea7c8a7 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 3 Oct 2025 23:30:05 -0700 Subject: [PATCH 02/12] test wrong proj --- tests/sentry/seer/explorer/test_index_data.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/sentry/seer/explorer/test_index_data.py b/tests/sentry/seer/explorer/test_index_data.py index 7f70bd9a5bf077..55fb6afd2a05e1 100644 --- a/tests/sentry/seer/explorer/test_index_data.py +++ b/tests/sentry/seer/explorer/test_index_data.py @@ -820,3 +820,30 @@ def test_get_trace_from_id(self) -> None: # Verify parent-child relationships are preserved root_spans = [s for s in result.spans if s.parent_span_id is None] assert len(root_spans) == 1 # Should have exactly one root span + + def test_get_trace_from_id_wrong_project(self) -> None: + transaction_name = "api/users/profile" + trace_id = uuid.uuid4().hex + other_project = self.create_project(organization=self.organization) + + spans = [] + for i in range(5): + # Create spans in the wrong project + 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 f"parent-{i-1}", + "is_segment": i == 0, # First span is the transaction span + }, + start_ts=self.ten_mins_ago + timedelta(minutes=i), + ) + spans.append(span) + + self.store_spans(spans, is_eap=True) + + # Call our function with shortened trace ID + result = get_trace_from_id(trace_id[:8], self.project.id) + assert result is None From 37c39da7ea4157e36abf385f21426aa209f42d14 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 3 Oct 2025 23:31:04 -0700 Subject: [PATCH 03/12] rev change to other rpc --- src/sentry/seer/explorer/index_data.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sentry/seer/explorer/index_data.py b/src/sentry/seer/explorer/index_data.py index b847002a760b5c..fece6d4e8bcec3 100644 --- a/src/sentry/seer/explorer/index_data.py +++ b/src/sentry/seer/explorer/index_data.py @@ -186,7 +186,6 @@ def get_trace_for_transaction(transaction_name: str, project_id: int) -> TraceDa "span.op", "span.description", "precise.start_ts", - "transaction", ], orderby=["precise.start_ts"], offset=0, From ecf637588afa91ff02fc41acd7e50f02deccfb11 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 3 Oct 2025 23:32:26 -0700 Subject: [PATCH 04/12] less in test 2 --- tests/sentry/seer/explorer/test_index_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sentry/seer/explorer/test_index_data.py b/tests/sentry/seer/explorer/test_index_data.py index 55fb6afd2a05e1..9ff639c2249087 100644 --- a/tests/sentry/seer/explorer/test_index_data.py +++ b/tests/sentry/seer/explorer/test_index_data.py @@ -827,7 +827,7 @@ def test_get_trace_from_id_wrong_project(self) -> None: other_project = self.create_project(organization=self.organization) spans = [] - for i in range(5): + for i in range(2): # Create spans in the wrong project span = self.create_span( { From b34da9c292a34b97b4ec6679a9b74890c9a3de88 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:36:46 -0700 Subject: [PATCH 05/12] New implementation, connected error test todo --- src/sentry/seer/explorer/index_data.py | 115 ++++++++---------- src/sentry/snuba/trace.py | 3 +- tests/sentry/seer/explorer/test_index_data.py | 78 ++++++++---- 3 files changed, 102 insertions(+), 94 deletions(-) diff --git a/src/sentry/seer/explorer/index_data.py b/src/sentry/seer/explorer/index_data.py index fece6d4e8bcec3..c6257d2f833964 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,90 +554,67 @@ def get_issues_for_transaction(transaction_name: str, project_id: int) -> Transa ) -def get_trace_from_id(trace_id: str, project_id: int) -> TraceData | None: +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. This can be shortened as the first 8 chars. - project_id: The ID of the project + 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: - TraceData with all spans and relationships, or None if no trace found + Trace details """ + try: - project = Project.objects.get(id=project_id) - except Project.DoesNotExist: + organization = Organization.objects.get(id=organization_id) + except Organization.DoesNotExist: logger.exception( - "Project does not exist; cannot fetch trace", - extra={"project_id": project_id, "trace_id": trace_id}, + "Organization does not exist; cannot fetch trace", + extra={"organization_id": organization_id, "trace_id": trace_id}, ) return None - end_time = datetime.now(UTC) - start_time = end_time - timedelta(hours=24) - + projects = list(Project.objects.filter(organization=organization, status=ObjectStatus.ACTIVE)) + end = datetime.now(UTC) + start = end - timedelta(hours=24) snuba_params = SnubaParams( - start=start_time, - end=end_time, - projects=[project], - organization=project.organization, - ) - config = SearchResolverConfig( - auto_fields=True, - ) - - # Get all spans in the trace - spans_result = Spans.run_table_query( - params=snuba_params, - query_string=f"trace:{trace_id}", - selected_columns=[ - "span_id", - "parent_span", - "span.op", - "span.description", - "transaction", - "trace", # Full trace ID - "precise.start_ts", - ], - orderby=["precise.start_ts"], - offset=0, - limit=5000, - referrer=Referrer.SEER_RPC, - config=config, - sampling_mode="NORMAL", + start=start, + end=end, + projects=projects, + organization=organization, ) - if not spans_result.get("data", []): - logger.info( - "No spans found for trace", - extra={"trace_id": trace_id, "project_id": project_id}, - ) - return None - - # Build span objects - spans = [] - for row in spans_result["data"]: - if span_id := row.get("span_id"): - spans.append( - Span( - span_id=span_id, - parent_span_id=row.get("parent_span"), - span_op=row.get("span.op"), - span_description=row.get("span.description") or "", - ) + 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 - transaction_name = spans_result["data"][0]["transaction"] - full_trace_id = spans_result["data"][0]["trace"] + if full_trace_id: + trace_data = query_trace_data(snuba_params, full_trace_id, referrer=Referrer.SEER_RPC) + if trace_data: + return trace_data - return TraceData( - trace_id=full_trace_id, - project_id=project_id, - transaction_name=transaction_name, - total_spans=len(spans), - spans=spans, + logger.info( + "Trace not found", + extra={ + "trace_id": trace_id, + "organization_id": organization_id, + "project_ids": [p.id for p in projects], + }, ) + return None # RPC wrappers @@ -660,4 +643,4 @@ def rpc_get_issues_for_transaction(transaction_name: str, project_id: int) -> di 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.dict() if trace else {} + 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 9ff639c2249087..cd7570cc337ed8 100644 --- a/tests/sentry/seer/explorer/test_index_data.py +++ b/tests/sentry/seer/explorer/test_index_data.py @@ -777,58 +777,82 @@ def test_get_issues_for_transaction_with_quotes(self) -> None: assert issue2.id == event2.group.id assert issue2.transaction == transaction_name_in - def test_get_trace_from_id(self) -> None: + def _test_get_trace_from_id(self, use_short_id: bool) -> None: transaction_name = "api/users/profile" trace_id = uuid.uuid4().hex spans = [] for i in range(5): - # Create spans for this trace + # 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 f"parent-{i-1}", - "is_segment": i == 0, # First span is the transaction span + "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) - # Call our function with shortened trace ID - result = get_trace_from_id(trace_id[:8], self.project.id) + seen_span_ids = [] + root_spans = [] - # Verify basic structure - assert result is not None - assert result.transaction_name == transaction_name - assert result.project_id == self.project.id - # assert result.trace_id == trace_id - assert len(result.spans) == len(spans) + def check(e): + assert "event_id" in e + assert e["transaction"] == transaction_name + assert e["project_id"] == self.project.id - # Verify all spans have correct structure and belong to the chosen trace - for i, result_span in enumerate(result.spans): - assert hasattr(result_span, "span_id") - assert hasattr(result_span, "span_description") - assert hasattr(result_span, "parent_span_id") - assert hasattr(result_span, "span_op") - assert result_span.span_description is not None - if i != 4: - assert result_span.span_description.startswith("span-") + # TODO: handle non-span events - # Verify parent-child relationships are preserved - root_spans = [s for s in result.spans if s.parent_span_id is None] - assert len(root_spans) == 1 # Should have exactly one root span + # 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_spans.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_spans) == 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_project = self.create_project(organization=self.organization) + other_org = self.create_organization() + other_project = self.create_project(organization=other_org) spans = [] for i in range(2): - # Create spans in the wrong project span = self.create_span( { "project_id": other_project.id, @@ -844,6 +868,6 @@ def test_get_trace_from_id_wrong_project(self) -> None: self.store_spans(spans, is_eap=True) - # Call our function with shortened trace ID + # Call with short ID result = get_trace_from_id(trace_id[:8], self.project.id) assert result is None From 35e17cab60e1415107c68eb10b1bc30a39f72c99 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:41:06 -0700 Subject: [PATCH 06/12] mypy --- src/sentry/seer/explorer/index_data.py | 5 ++--- tests/sentry/seer/explorer/test_index_data.py | 12 ++++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/sentry/seer/explorer/index_data.py b/src/sentry/seer/explorer/index_data.py index c6257d2f833964..05f5be26943bde 100644 --- a/src/sentry/seer/explorer/index_data.py +++ b/src/sentry/seer/explorer/index_data.py @@ -601,7 +601,7 @@ def get_trace_from_id(trace_id: str, organization_id: int) -> list[SerializedEve else: full_trace_id = trace_id - if full_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 @@ -609,9 +609,8 @@ def get_trace_from_id(trace_id: str, organization_id: int) -> list[SerializedEve logger.info( "Trace not found", extra={ - "trace_id": trace_id, "organization_id": organization_id, - "project_ids": [p.id for p in projects], + "trace_id": trace_id, }, ) return None diff --git a/tests/sentry/seer/explorer/test_index_data.py b/tests/sentry/seer/explorer/test_index_data.py index cd7570cc337ed8..dbc28b30253de4 100644 --- a/tests/sentry/seer/explorer/test_index_data.py +++ b/tests/sentry/seer/explorer/test_index_data.py @@ -780,7 +780,7 @@ def test_get_issues_for_transaction_with_quotes(self) -> None: def _test_get_trace_from_id(self, use_short_id: bool) -> None: transaction_name = "api/users/profile" trace_id = uuid.uuid4().hex - spans = [] + spans: list[dict] = [] for i in range(5): # Create a span tree for this trace span = self.create_span( @@ -800,7 +800,7 @@ def _test_get_trace_from_id(self, use_short_id: bool) -> None: assert isinstance(result, list) seen_span_ids = [] - root_spans = [] + root_span_ids = [] def check(e): assert "event_id" in e @@ -827,7 +827,7 @@ def check(e): if e["parent_span_id"] is None: # Is root assert e["is_transaction"] - root_spans.append(e["event_id"]) + root_span_ids.append(e["event_id"]) # Recurse for child in e["children"]: @@ -837,7 +837,7 @@ def check(e): check(event) assert set(seen_span_ids) == {s["span_id"] for s in spans} - assert len(root_spans) == 1 + 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) @@ -859,8 +859,8 @@ def test_get_trace_from_id_wrong_project(self) -> None: "description": f"span-{i}", "sentry_tags": {"transaction": transaction_name}, "trace_id": trace_id, - "parent_span_id": None if i == 0 else f"parent-{i-1}", - "is_segment": i == 0, # First span is the transaction span + "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), ) From d0e6b33a904ea54db5a1ee8b5b8d322b29e3ace0 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Wed, 8 Oct 2025 15:02:25 -0700 Subject: [PATCH 07/12] tweak --- src/sentry/seer/explorer/index_data.py | 4 +- tests/sentry/seer/explorer/test_index_data.py | 45 +++++++++---------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/sentry/seer/explorer/index_data.py b/src/sentry/seer/explorer/index_data.py index 05f5be26943bde..13f3ac29842f14 100644 --- a/src/sentry/seer/explorer/index_data.py +++ b/src/sentry/seer/explorer/index_data.py @@ -640,6 +640,6 @@ def rpc_get_issues_for_transaction(transaction_name: str, project_id: int) -> di 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) +def rpc_get_trace_from_id(trace_id: str, organization_id: int) -> dict[str, Any]: + trace = get_trace_from_id(trace_id, organization_id) return {"trace": trace} if trace else {} diff --git a/tests/sentry/seer/explorer/test_index_data.py b/tests/sentry/seer/explorer/test_index_data.py index dbc28b30253de4..0e9f6fb51675fb 100644 --- a/tests/sentry/seer/explorer/test_index_data.py +++ b/tests/sentry/seer/explorer/test_index_data.py @@ -802,39 +802,36 @@ def _test_get_trace_from_id(self, use_short_id: bool) -> None: 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"] + def check_span(s): + assert "event_id" in s + assert s["transaction"] == transaction_name + assert s["project_id"] == self.project.id + assert "op" in s + assert "description" in s + assert "parent_span_id" in s + assert "children" in s + + desc = s["description"] assert isinstance(desc, str) if desc: assert desc.startswith("span-") - # TODO: test connected errors/occurrences + seen_span_ids.append(s["event_id"]) - 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"]) + # Is root + if s["parent_span_id"] is None: + assert s["is_transaction"] + root_span_ids.append(s["event_id"]) # Recurse - for child in e["children"]: - check(child) + for child in s["children"]: + check_span(child) for event in result: - check(event) + if "children" in event: + check_span(event) + + # TODO: test connected errors/occurrences assert set(seen_span_ids) == {s["span_id"] for s in spans} assert len(root_span_ids) == 1 From 0d0c134af5f0b1f169a21870a2d1e0f884dde80f Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Wed, 8 Oct 2025 15:36:03 -0700 Subject: [PATCH 08/12] Review --- src/sentry/seer/endpoints/seer_rpc.py | 4 +- src/sentry/seer/explorer/index_data.py | 54 ++++++++++--------- src/sentry/seer/sentry_data_models.py | 10 +++- tests/sentry/seer/explorer/test_index_data.py | 31 ++++++----- 4 files changed, 60 insertions(+), 39 deletions(-) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index c1fbd5c9149c8f..64de095ae330e6 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -67,10 +67,10 @@ from sentry.search.events.types import SnubaParams from sentry.seer.autofix.autofix_tools import get_error_event_details, get_profile_details from sentry.seer.explorer.index_data import ( + rpc_get_full_trace_from_id, 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 @@ -1000,7 +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, + "get_full_trace_from_id": rpc_get_full_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 13f3ac29842f14..b5e680955095be 100644 --- a/src/sentry/seer/explorer/index_data.py +++ b/src/sentry/seer/explorer/index_data.py @@ -11,7 +11,7 @@ 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.api.utils import default_start_end_dates, handle_query_errors from sentry.constants import ObjectStatus from sentry.models.organization import Organization from sentry.models.project import Project @@ -23,6 +23,7 @@ normalize_description, ) from sentry.seer.sentry_data_models import ( + EAPTrace, IssueDetails, ProfileData, Span, @@ -36,7 +37,7 @@ 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 +from sentry.snuba.trace import query_trace_data logger = logging.getLogger(__name__) @@ -62,12 +63,11 @@ def get_transactions_for_project(project_id: int) -> list[Transaction]: ) return [] - end_time = datetime.now(UTC) - start_time = end_time - timedelta(hours=24) + start, end = default_start_end_dates() # Last 90 days. snuba_params = SnubaParams( - start=start_time, - end=end_time, + start=start, + end=end, projects=[project], organization=project.organization, ) @@ -554,16 +554,16 @@ 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: +def get_full_trace_from_id(trace_id: str, organization_id: int) -> EAPTrace: """ - Get a trace from an ID. + Get a trace's spans and errors from a trace 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 + The spans and errors in the trace, along with the full 32-character trace ID. """ try: @@ -597,23 +597,29 @@ def get_trace_from_id(trace_id: str, organization_id: int) -> list[SerializedEve get_all_projects=lambda: projects, ) subquery_result = executor.execute(0, 1) - full_trace_id = subquery_result.get("data", [{}])[0].get("trace") + full_trace_id = ( + subquery_result["data"][0].get("trace") if subquery_result["data"] else None + ) 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 + if not isinstance(full_trace_id, str): + logger.info( + "Trace not found from short id", + extra={ + "organization_id": organization_id, + "trace_id": trace_id, + }, + ) + return None - logger.info( - "Trace not found", - extra={ - "organization_id": organization_id, - "trace_id": trace_id, - }, + events = query_trace_data(snuba_params, full_trace_id, referrer=Referrer.SEER_RPC) + + return EAPTrace( + trace_id=full_trace_id, + org_id=organization_id, + trace=events, ) - return None # RPC wrappers @@ -640,6 +646,6 @@ def rpc_get_issues_for_transaction(transaction_name: str, project_id: int) -> di return issues.dict() if issues else {} -def rpc_get_trace_from_id(trace_id: str, organization_id: int) -> dict[str, Any]: - trace = get_trace_from_id(trace_id, organization_id) - return {"trace": trace} if trace else {} +def rpc_get_full_trace_from_id(trace_id: str, organization_id: int) -> dict[str, Any]: + trace = get_full_trace_from_id(trace_id, organization_id) + return trace.dict() if trace else {} diff --git a/src/sentry/seer/sentry_data_models.py b/src/sentry/seer/sentry_data_models.py index 8093882e9f1b4b..765173f0f0d353 100644 --- a/src/sentry/seer/sentry_data_models.py +++ b/src/sentry/seer/sentry_data_models.py @@ -7,7 +7,7 @@ from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, Field class Transaction(BaseModel): @@ -30,6 +30,14 @@ class TraceData(BaseModel): spans: list[Span] +class EAPTrace(BaseModel): + """Based on the Seer model. Child spans are nested in recursively in span.children. `trace` is a combined list of spans, errors, and issue platform occurrences in the trace.""" + + trace_id: str = Field(..., description="ID of the trace") + org_id: int | None = Field(default=None, description="ID of the organization") + trace: list[dict[str, Any]] = Field(..., description="List of spans and errors in the trace") + + class ExecutionTreeNode(BaseModel): function: str module: str diff --git a/tests/sentry/seer/explorer/test_index_data.py b/tests/sentry/seer/explorer/test_index_data.py index 0e9f6fb51675fb..a94d1504e1518c 100644 --- a/tests/sentry/seer/explorer/test_index_data.py +++ b/tests/sentry/seer/explorer/test_index_data.py @@ -6,12 +6,13 @@ from sentry.search.snuba.backend import EventsDatasetSnubaSearchBackend from sentry.seer.explorer.index_data import ( + get_full_trace_from_id, get_issues_for_transaction, get_profiles_for_trace, get_trace_for_transaction, - get_trace_from_id, get_transactions_for_project, ) +from sentry.seer.sentry_data_models import EAPTrace from sentry.testutils.cases import APITransactionTestCase, SnubaTestCase, SpanTestCase from sentry.testutils.helpers.datetime import before_now from tests.snuba.search.test_backend import SharedSnubaMixin @@ -777,7 +778,7 @@ def test_get_issues_for_transaction_with_quotes(self) -> None: assert issue2.id == event2.group.id assert issue2.transaction == transaction_name_in - def _test_get_trace_from_id(self, use_short_id: bool) -> None: + def _test_get_full_trace_from_id(self, use_short_id: bool) -> None: transaction_name = "api/users/profile" trace_id = uuid.uuid4().hex spans: list[dict] = [] @@ -796,8 +797,14 @@ def _test_get_trace_from_id(self, use_short_id: bool) -> None: 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) + result = get_full_trace_from_id( + trace_id[:8] if use_short_id else trace_id, self.organization.id + ) + assert isinstance(result, EAPTrace) + assert result.trace_id == trace_id + assert result.org_id == self.organization.id + + events = result.trace seen_span_ids = [] root_span_ids = [] @@ -827,7 +834,7 @@ def check_span(s): for child in s["children"]: check_span(child) - for event in result: + for event in events: if "children" in event: check_span(event) @@ -836,13 +843,13 @@ def check_span(s): 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_full_trace_from_short_id(self) -> None: + self._test_get_full_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_full_trace_from_id_full_id(self) -> None: + self._test_get_full_trace_from_id(use_short_id=False) - def test_get_trace_from_id_wrong_project(self) -> None: + def test_get_full_trace_from_id_wrong_project(self) -> None: transaction_name = "api/users/profile" trace_id = uuid.uuid4().hex other_org = self.create_organization() @@ -865,6 +872,6 @@ def test_get_trace_from_id_wrong_project(self) -> None: self.store_spans(spans, is_eap=True) - # Call with short ID - result = get_trace_from_id(trace_id[:8], self.project.id) + # Call with short ID and wrong org + result = get_full_trace_from_id(trace_id[:8], self.organization.id) assert result is None From 60e764f62c2ebe84f7b0e77334f32449f35f3981 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:19:19 -0700 Subject: [PATCH 09/12] tweak test and comments --- src/sentry/seer/sentry_data_models.py | 6 +++++- tests/sentry/seer/explorer/test_index_data.py | 20 +++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/sentry/seer/sentry_data_models.py b/src/sentry/seer/sentry_data_models.py index 765173f0f0d353..9c64392cdb70c2 100644 --- a/src/sentry/seer/sentry_data_models.py +++ b/src/sentry/seer/sentry_data_models.py @@ -31,7 +31,11 @@ class TraceData(BaseModel): class EAPTrace(BaseModel): - """Based on the Seer model. Child spans are nested in recursively in span.children. `trace` is a combined list of spans, errors, and issue platform occurrences in the trace.""" + """ + Based on the Seer model. `trace` can contain both span and error events (see `SerializedEvent`). + Spans contain connected error data in `span.errors` and `span.occurrences`. + Child spans are nested recursively in span.children. + """ trace_id: str = Field(..., description="ID of the trace") org_id: int | None = Field(default=None, description="ID of the organization") diff --git a/tests/sentry/seer/explorer/test_index_data.py b/tests/sentry/seer/explorer/test_index_data.py index a94d1504e1518c..7f932e52c000ca 100644 --- a/tests/sentry/seer/explorer/test_index_data.py +++ b/tests/sentry/seer/explorer/test_index_data.py @@ -804,19 +804,20 @@ def _test_get_full_trace_from_id(self, use_short_id: bool) -> None: assert result.trace_id == trace_id assert result.org_id == self.organization.id - events = result.trace - seen_span_ids = [] root_span_ids = [] def check_span(s): - assert "event_id" in s - assert s["transaction"] == transaction_name - assert s["project_id"] == self.project.id + if "parent_span_id" not in s: + # Not a span. + return + + # Basic assertions and ID collection for returned spans. assert "op" in s assert "description" in s - assert "parent_span_id" in s assert "children" in s + assert s["transaction"] == transaction_name + assert s["project_id"] == self.project.id desc = s["description"] assert isinstance(desc, str) @@ -834,11 +835,8 @@ def check_span(s): for child in s["children"]: check_span(child) - for event in events: - if "children" in event: - check_span(event) - - # TODO: test connected errors/occurrences + for event in result.trace: + check_span(event) assert set(seen_span_ids) == {s["span_id"] for s in spans} assert len(root_span_ids) == 1 From 7c13868d15fda47be49c94674b55cd11ccac7788 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:24:37 -0700 Subject: [PATCH 10/12] tweak --- src/sentry/seer/explorer/index_data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/explorer/index_data.py b/src/sentry/seer/explorer/index_data.py index b5e680955095be..4bc42c27223763 100644 --- a/src/sentry/seer/explorer/index_data.py +++ b/src/sentry/seer/explorer/index_data.py @@ -554,7 +554,7 @@ def get_issues_for_transaction(transaction_name: str, project_id: int) -> Transa ) -def get_full_trace_from_id(trace_id: str, organization_id: int) -> EAPTrace: +def get_full_trace_from_id(trace_id: str, organization_id: int) -> EAPTrace | None: """ Get a trace's spans and errors from a trace ID. @@ -598,7 +598,7 @@ def get_full_trace_from_id(trace_id: str, organization_id: int) -> EAPTrace: ) subquery_result = executor.execute(0, 1) full_trace_id = ( - subquery_result["data"][0].get("trace") if subquery_result["data"] else None + subquery_result["data"][0]["trace"] if subquery_result.get("data") else None ) else: full_trace_id = trace_id From 350112c52619769bf469f59052633abc90fcc85c Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:31:03 -0700 Subject: [PATCH 11/12] rename to 'trace details' --- src/sentry/seer/endpoints/seer_rpc.py | 4 ++-- src/sentry/seer/explorer/index_data.py | 6 +++--- tests/sentry/seer/explorer/test_index_data.py | 20 +++++++++---------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 64de095ae330e6..7fe175c6b1eb18 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -67,9 +67,9 @@ from sentry.search.events.types import SnubaParams from sentry.seer.autofix.autofix_tools import get_error_event_details, get_profile_details from sentry.seer.explorer.index_data import ( - rpc_get_full_trace_from_id, rpc_get_issues_for_transaction, rpc_get_profiles_for_trace, + rpc_get_trace_details, rpc_get_trace_for_transaction, rpc_get_transactions_for_project, ) @@ -1000,7 +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_full_trace_from_id": rpc_get_full_trace_from_id, + "get_trace_details": rpc_get_trace_details, # # 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 4bc42c27223763..9e7ba13411c9ad 100644 --- a/src/sentry/seer/explorer/index_data.py +++ b/src/sentry/seer/explorer/index_data.py @@ -554,7 +554,7 @@ def get_issues_for_transaction(transaction_name: str, project_id: int) -> Transa ) -def get_full_trace_from_id(trace_id: str, organization_id: int) -> EAPTrace | None: +def get_trace_details(trace_id: str, organization_id: int) -> EAPTrace | None: """ Get a trace's spans and errors from a trace ID. @@ -646,6 +646,6 @@ def rpc_get_issues_for_transaction(transaction_name: str, project_id: int) -> di return issues.dict() if issues else {} -def rpc_get_full_trace_from_id(trace_id: str, organization_id: int) -> dict[str, Any]: - trace = get_full_trace_from_id(trace_id, organization_id) +def rpc_get_trace_details(trace_id: str, organization_id: int) -> dict[str, Any]: + trace = get_trace_details(trace_id, organization_id) return trace.dict() if trace else {} diff --git a/tests/sentry/seer/explorer/test_index_data.py b/tests/sentry/seer/explorer/test_index_data.py index 7f932e52c000ca..f517a46f816f8f 100644 --- a/tests/sentry/seer/explorer/test_index_data.py +++ b/tests/sentry/seer/explorer/test_index_data.py @@ -6,9 +6,9 @@ from sentry.search.snuba.backend import EventsDatasetSnubaSearchBackend from sentry.seer.explorer.index_data import ( - get_full_trace_from_id, get_issues_for_transaction, get_profiles_for_trace, + get_trace_details, get_trace_for_transaction, get_transactions_for_project, ) @@ -778,7 +778,7 @@ def test_get_issues_for_transaction_with_quotes(self) -> None: assert issue2.id == event2.group.id assert issue2.transaction == transaction_name_in - def _test_get_full_trace_from_id(self, use_short_id: bool) -> None: + def _test_get_trace_details(self, use_short_id: bool) -> None: transaction_name = "api/users/profile" trace_id = uuid.uuid4().hex spans: list[dict] = [] @@ -797,9 +797,7 @@ def _test_get_full_trace_from_id(self, use_short_id: bool) -> None: spans.append(span) self.store_spans(spans, is_eap=True) - result = get_full_trace_from_id( - trace_id[:8] if use_short_id else trace_id, self.organization.id - ) + result = get_trace_details(trace_id[:8] if use_short_id else trace_id, self.organization.id) assert isinstance(result, EAPTrace) assert result.trace_id == trace_id assert result.org_id == self.organization.id @@ -841,13 +839,13 @@ def check_span(s): assert set(seen_span_ids) == {s["span_id"] for s in spans} assert len(root_span_ids) == 1 - def test_get_full_trace_from_short_id(self) -> None: - self._test_get_full_trace_from_id(use_short_id=True) + def test_get_trace_details_short_id(self) -> None: + self._test_get_trace_details(use_short_id=True) - def test_get_full_trace_from_id_full_id(self) -> None: - self._test_get_full_trace_from_id(use_short_id=False) + def test_get_trace_details_full_id(self) -> None: + self._test_get_trace_details(use_short_id=False) - def test_get_full_trace_from_id_wrong_project(self) -> None: + def test_get_trace_details_wrong_project(self) -> None: transaction_name = "api/users/profile" trace_id = uuid.uuid4().hex other_org = self.create_organization() @@ -871,5 +869,5 @@ def test_get_full_trace_from_id_wrong_project(self) -> None: self.store_spans(spans, is_eap=True) # Call with short ID and wrong org - result = get_full_trace_from_id(trace_id[:8], self.organization.id) + result = get_trace_details(trace_id[:8], self.organization.id) assert result is None From fee3a9a8cd2d72872a7a931796480f50ff845ba8 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:41:22 -0700 Subject: [PATCH 12/12] mypy --- tests/sentry/seer/explorer/test_index_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sentry/seer/explorer/test_index_data.py b/tests/sentry/seer/explorer/test_index_data.py index f517a46f816f8f..bd9d41f8c80fc0 100644 --- a/tests/sentry/seer/explorer/test_index_data.py +++ b/tests/sentry/seer/explorer/test_index_data.py @@ -851,7 +851,7 @@ def test_get_trace_details_wrong_project(self) -> None: other_org = self.create_organization() other_project = self.create_project(organization=other_org) - spans = [] + spans: list[dict] = [] for i in range(2): span = self.create_span( {