diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 8af7c1a5129a82..c359eb09e4438c 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -1,18 +1,20 @@ from __future__ import annotations +import concurrent.futures import logging from datetime import datetime, timedelta -from types import SimpleNamespace from typing import Any import orjson import requests +import sentry_sdk from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.utils import timezone from rest_framework.response import Response from sentry import eventstore, features, quotas +from sentry.api.endpoints.organization_trace import OrganizationTraceEndpoint from sentry.api.serializers import EventSerializer, serialize from sentry.constants import DataCategory, ObjectStatus from sentry.eventstore.models import Event, GroupEvent @@ -26,7 +28,6 @@ from sentry.seer.signed_seer_api import sign_with_seer_secret from sentry.snuba.ourlogs import OurLogs from sentry.snuba.referrer import Referrer -from sentry.snuba.spans_rpc import Spans from sentry.tasks.autofix import check_autofix_status from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser @@ -146,77 +147,6 @@ def _get_logs_for_event( } -def build_spans_tree(spans_data: list[dict]) -> list[dict]: - """ - Builds a hierarchical tree structure from a flat list of spans. - - Handles multiple potential roots and preserves parent-child relationships. - A span is considered a root if: - 1. It has no parent_span_id, or - 2. Its parent_span_id doesn't match any span_id in the provided data - - Each node in the tree contains the span data and a list of children. - The tree is sorted by duration (longest spans first) at each level. - """ - # Maps for quick lookup - spans_by_id: dict[str, dict] = {} - children_by_parent_id: dict[str, list[dict]] = {} - root_spans: list[dict] = [] - - # First pass: organize spans by ID and parent_id - for span in spans_data: - span_id = span.get("span_id") - if not span_id: - continue - - # Deep copy the span to avoid modifying the original - span_with_children = span.copy() - span_with_children["children"] = [] - spans_by_id[span_id] = span_with_children - - parent_id = span.get("parent_span_id") - if parent_id: - if parent_id not in children_by_parent_id: - children_by_parent_id[parent_id] = [] - children_by_parent_id[parent_id].append(span_with_children) - - # Second pass: identify root spans - # A root span is either: - # 1. A span without a parent_span_id - # 2. A span whose parent_span_id doesn't match any span_id in our data - for span_id, span in spans_by_id.items(): - parent_id = span.get("parent_span_id") - if not parent_id or parent_id not in spans_by_id: - root_spans.append(span) - - # Third pass: build the tree by connecting children to parents - for parent_id, children in children_by_parent_id.items(): - if parent_id in spans_by_id: - parent = spans_by_id[parent_id] - for child in children: - # Only add if not already a child - if child not in parent["children"]: - parent["children"].append(child) - - # Function to sort children in each node by duration - def sort_span_tree(node): - if node["children"]: - # Sort children by duration (in descending order to show longest spans first) - node["children"].sort( - key=lambda x: float(x.get("duration", "0").split("s")[0]), reverse=True - ) - # Recursively sort each child's children - for child in node["children"]: - sort_span_tree(child) - del node["parent_span_id"] - return node - - # Sort the root spans by duration - root_spans.sort(key=lambda x: float(x.get("duration", "0").split("s")[0]), reverse=True) - # Apply sorting to the whole tree - return [sort_span_tree(root) for root in root_spans] - - def _get_serialized_event( event_id: str, group: Group, user: User | RpcUser | AnonymousUser ) -> tuple[dict[str, Any] | None, Event | GroupEvent | None]: @@ -231,405 +161,92 @@ def _get_serialized_event( def _get_trace_tree_for_event(event: Event | GroupEvent, project: Project) -> dict[str, Any] | None: """ - Returns a tree of errors and transactions in the trace for a given event. Does not include non-transaction/non-error spans to reduce noise. + Returns the full trace for the given issue event with a 15-second timeout. + Returns None if the timeout expires or if the trace cannot be fetched. """ trace_id = event.trace_id if not trace_id: return None - projects_qs = Project.objects.filter( - organization=project.organization, status=ObjectStatus.ACTIVE - ) - projects = list(projects_qs) - project_ids = [p.id for p in projects] - start = event.datetime - timedelta(days=1) - end = event.datetime + timedelta(days=1) - - # 1) Query for all spans in the trace using direct span query - snuba_params = SnubaParams( - start=start, - end=end, - projects=projects, - organization=project.organization, - ) - config = SearchResolverConfig( - auto_fields=True, - ) - all_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", - "precise.start_ts", - "precise.finish_ts", - "is_transaction", - "transaction", - "project.id", - "platform", - "profile.id", - "profiler.id", - ], - orderby=["precise.start_ts"], - offset=0, - limit=1000, - referrer=Referrer.API_GROUP_AI_AUTOFIX, - config=config, - sampling_mode="NORMAL", - ) - - # 2) Query for all errors using existing eventstore approach - error_event_filter = eventstore.Filter( - project_ids=project_ids, - conditions=[ - ["trace", "=", trace_id], - ], - organization_id=project.organization_id, - start=start, - end=end, - ) - errors = eventstore.backend.get_events( - filter=error_event_filter, - referrer=Referrer.API_GROUP_AI_AUTOFIX, - tenant_ids={"organization_id": project.organization_id}, - ) - - # 3) Separate out transaction spans and non-transaction spans - all_spans_data = all_spans_result.get("data", []) - transaction_spans = [] - non_transaction_spans = [] - - for span_row in all_spans_data: - if span_row.get("is_transaction", False): - transaction_spans.append(span_row) - else: - non_transaction_spans.append(span_row) - - # 4) Create trees of non-transaction spans and attach them to transactions - # Build a lookup of all spans by span_id - spans_by_id = {} - transaction_span_ids = set() - - for span_row in non_transaction_spans: - span_id = span_row.get("span_id") - if span_id: - spans_by_id[span_id] = { - "span_id": span_id, - "parent_span_id": span_row.get("parent_span"), - "op": span_row.get("span.op"), - "description": span_row.get("span.description"), - "start_timestamp": span_row.get("precise.start_ts"), - "timestamp": span_row.get("precise.finish_ts"), - "data": {}, # empty, but we can add fields we want later - } - - for tx_span in transaction_spans: - span_id = tx_span.get("span_id") - if span_id: - transaction_span_ids.add(span_id) - - def find_transaction_parent(span_id: str) -> str | None: - """Recursively find which transaction this span belongs to by following parent_span_id chain""" - if not span_id: - return None - - # If this span_id is itself a transaction, return it - if span_id in transaction_span_ids: - return span_id - - # If this span exists in our spans lookup, check its parent - if span_id in spans_by_id: - parent_id = spans_by_id[span_id]["parent_span_id"] - if parent_id: - return find_transaction_parent(parent_id) - - return None + def _fetch_trace(): + projects_qs = Project.objects.filter( + organization=project.organization, status=ObjectStatus.ACTIVE + ) + projects = list(projects_qs) + start = event.datetime - timedelta(days=1) + end = event.datetime + timedelta(days=1) + + snuba_params = SnubaParams( + start=start, + end=end, + projects=projects, + organization=project.organization, + ) - # Group non-transaction spans by their parent transaction - spans_by_transaction: dict[str, list] = {} - for span_data in spans_by_id.values(): - span_id = span_data["span_id"] - transaction_parent = find_transaction_parent(span_id) - - if transaction_parent: - if transaction_parent not in spans_by_transaction: - spans_by_transaction[transaction_parent] = [] - spans_by_transaction[transaction_parent].append(span_data) - - # Convert transaction spans to event-like objects - transactions = [] - for tx_span in transaction_spans: - event_id = tx_span.get("span_id") - project_id = tx_span.get("project.id") - tx_span_id: str | None = tx_span.get("span_id") - parent_span_id = tx_span.get("parent_span") - transaction_name = tx_span.get("transaction") - start_ts = tx_span.get("precise.start_ts") - finish_ts = tx_span.get("precise.finish_ts") - span_op = tx_span.get("span.op") - platform_name = tx_span.get("platform") - profile_id = tx_span.get("profile.id") - profiler_id = tx_span.get("profiler.id") - - if not tx_span_id: - continue - - # Get nested spans for this transaction - nested_spans = spans_by_transaction.get(tx_span_id, []) - - # Create a transaction-like event object - transactions.append( - SimpleNamespace( - event_id=event_id, - project_id=project_id, - platform=platform_name, - title=transaction_name, - project=next((p for p in projects if p.id == project_id), None), - data={ - "start_timestamp": start_ts, - "precise_start_ts": start_ts, - "precise_finish_ts": finish_ts, - "contexts": { - "trace": { - "span_id": tx_span_id, - "parent_span_id": parent_span_id, - "op": span_op, - }, - "profile": { - "profile_id": profile_id or profiler_id, - "is_continuous": bool(profiler_id and not profile_id), - }, - }, - "spans": nested_spans, - "breakdowns": { - "span_ops": { - "total.time": { - "value": ( - ((finish_ts - start_ts) * 1000) - if (finish_ts and start_ts) - else 0 - ), - "unit": "millisecond", - } - } - }, + trace_endpoint = OrganizationTraceEndpoint() + trace = trace_endpoint.query_trace_data(snuba_params, trace_id) + + if not trace: + logger.info( + "[Autofix] No trace found for event", + extra={ + "event_id": event.event_id, + "trace_id": trace_id, + "org_slug": project.organization.slug, + "project_slug": project.slug, }, ) - ) - - # 5) Process transaction and error events as before to get expected output - results = transactions + errors - - if not results: - return None + return None - events_by_span_id: dict[str, dict] = {} - events_by_parent_span_id: dict[str, list[dict]] = {} - span_to_transaction: dict[str, dict] = {} # Maps span IDs to their parent transaction events - root_events: list[dict] = [] - all_events: list[dict] = [] # Track all events for orphan detection - - # First pass: collect all events and their metadata - for trace_event in results: - event_data = trace_event.data - # Determine type based on presence of spans in event data - is_transaction = event_data.get("spans") is not None - is_error = not is_transaction - - event_node = { - "event_id": trace_event.event_id, - "datetime": event_data.get("start_timestamp", float("inf")), - "span_id": event_data.get("contexts", {}).get("trace", {}).get("span_id"), - "parent_span_id": event_data.get("contexts", {}).get("trace", {}).get("parent_span_id"), - "is_transaction": is_transaction, - "is_error": is_error, - "is_current_project": trace_event.project_id == project.id, - "project_slug": trace_event.project.slug if trace_event.project else "", - "project_id": trace_event.project_id, - "platform": trace_event.platform, - "children": [], + logger.info( + "[Autofix] Found trace for event", + extra={ + "event_id": event.event_id, + "trace_id": trace_id, + "org_slug": project.organization.slug, + "project_slug": project.slug, + "num_root_nodes": len(trace), + }, + ) + return { + "trace_id": trace_id, + "org_id": project.organization_id, + "trace": trace, } - # Add to all_events for later orphan detection - all_events.append(event_node) - - if is_transaction: - op = event_data.get("contexts", {}).get("trace", {}).get("op") - transaction_title = trace_event.title - duration_obj = ( - event_data.get("breakdowns", {}).get("span_ops", {}).get("total.time", {}) - ) - duration_str = ( - f"{duration_obj.get('value', 0)} {duration_obj.get('unit', 'millisecond')}s" - ) - profile_id = event_data.get("contexts", {}).get("profile", {}).get("profile_id") - is_continuous = event_data.get("contexts", {}).get("profile", {}).get("is_continuous") - precise_start_ts = event_data.get("precise_start_ts") - precise_finish_ts = event_data.get("precise_finish_ts") - - # Store all span IDs from this transaction for later relationship building - spans = event_data.get("spans", []) - span_ids = [span.get("span_id") for span in spans if span.get("span_id")] - - spans_selected_data = [ - { - "span_id": span.get("span_id"), - "parent_span_id": span.get("parent_span_id"), - "title": f"{span.get('op', '')} - {span.get('description', '')}", - "data": span.get("data"), - "duration": f"{span.get('timestamp', 0) - span.get('start_timestamp', 0)}s", - } - for span in spans - ] - selected_spans_tree = build_spans_tree(spans_selected_data) - - event_node.update( - { - "title": f"{op} - {transaction_title}" if op else transaction_title, - "transaction": transaction_title, - "duration": duration_str, - "profile_id": profile_id, - "span_ids": span_ids, # Store for later use - "spans": selected_spans_tree, - "is_continuous": is_continuous, - "precise_start_ts": precise_start_ts, - "precise_finish_ts": precise_finish_ts, - } - ) - - # Register this transaction as the parent for all its spans - for span_id in span_ids: - if span_id: - span_to_transaction[span_id] = event_node - else: - title = trace_event.title - message = trace_event.message if trace_event.message != trace_event.title else None - transaction_name = trace_event.transaction - - error_title = message or "" - if title: - error_title += f"{' - ' if error_title else ''}{title}" - if transaction_name: - error_title += f"{' - ' if error_title else ''}occurred in {transaction_name}" - - event_node.update( - { - "title": error_title, - } - ) + timeout = 15 # seconds - span_id = event_node["span_id"] - parent_span_id = event_node["parent_span_id"] - - # Index events by their span_id - if span_id: - events_by_span_id[span_id] = event_node - - # Index events by their parent_span_id for easier lookup - if parent_span_id: - if parent_span_id not in events_by_parent_span_id: - events_by_parent_span_id[parent_span_id] = [] - events_by_parent_span_id[parent_span_id].append(event_node) - else: - # This is a potential root node (no parent) - root_events.append(event_node) - - # Second pass: establish parent-child relationships based on the three rules - for event_node in list(events_by_span_id.values()): - span_id = event_node["span_id"] - parent_span_id = event_node["parent_span_id"] - - # Rule 1: An event whose span_id is X is a parent of an event whose parent_span_id is X - if span_id and span_id in events_by_parent_span_id: - for child_event in events_by_parent_span_id[span_id]: - if child_event not in event_node["children"]: - event_node["children"].append(child_event) - # If this child was previously considered a root, remove it - if child_event in root_events: - root_events.remove(child_event) - - # Handle case where this event has a parent based on parent_span_id - if parent_span_id: - # Rule 1 (other direction): This event's parent_span_id matches another event's span_id - if parent_span_id in events_by_span_id: - parent_event = events_by_span_id[parent_span_id] - if event_node not in parent_event["children"]: - parent_event["children"].append(event_node) - # If this event was previously considered a root, remove it - if event_node in root_events: - root_events.remove(event_node) - - # Rule 2: A transaction event that contains a span with span_id X is a parent - # of an event whose parent_span_id is X - elif parent_span_id in span_to_transaction: - parent_event = span_to_transaction[parent_span_id] - if event_node not in parent_event["children"]: - parent_event["children"].append(event_node) - # If this event was previously considered a root, remove it - if event_node in root_events: - root_events.remove(event_node) - - # Rule 3: A transaction event that contains a span with span_id X is a parent - # of an event whose span_id is X - if span_id and span_id in span_to_transaction: - parent_event = span_to_transaction[span_id] - # Only establish this relationship if there's no more direct relationship - # (i.e., the event doesn't already have a parent through rules 1 or 2) - if event_node in root_events: - if event_node not in parent_event["children"]: - parent_event["children"].append(event_node) - # Remove from root events since it now has a parent - root_events.remove(event_node) - - # Third pass: find orphaned events and add them to root_events - # These are events with parent_span_id that don't match any span_id - # and didn't get connected through any of our relationship rules - for event_node in all_events: - has_parent = False - # Check if this event is already a child of any other event - for other_event in all_events: - if event_node in other_event["children"]: - has_parent = True - break - - # If not a child of any event and not already in root_events, add it - if not has_parent and event_node not in root_events: - root_events.append(event_node) - - # Function to recursively sort children by datetime - def sort_tree(node): - if node["children"]: - # Sort children by datetime - node["children"].sort(key=lambda x: x["datetime"]) - # Recursively sort each child's children - for child in node["children"]: - sort_tree(child) - return node - - # Sort root events by datetime - root_events.sort(key=lambda x: x["datetime"]) - # Sort children at each level - sorted_tree = [sort_tree(root) for root in root_events] - - return { - "trace_id": event.trace_id, - "org_id": project.organization_id, - "events": sorted_tree, - } + try: + with sentry_sdk.start_span(op="seer.autofix.get_trace_tree_for_event"): + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(_fetch_trace) + return future.result(timeout=timeout) + except concurrent.futures.TimeoutError: + logger.warning( + "[Autofix] Timeout expired while fetching trace tree for event", + extra={ + "event_id": event.event_id, + "trace_id": trace_id, + "project_id": project.id, + "timeout": timeout, + }, + ) + return None + except Exception: + logger.exception("Error fetching trace tree for event") + return None def _get_profile_from_trace_tree( trace_tree: dict[str, Any] | None, event: Event | GroupEvent | None, project: Project ) -> dict[str, Any] | None: """ - Finds the profile for the transaction that contains our error event. + Finds a profile for a span that contains our error event. """ if not trace_tree or not event: return None - events = trace_tree.get("events", []) + events = trace_tree.get("trace", []) event_transaction_name = event.transaction if not event_transaction_name: @@ -638,50 +255,46 @@ def _get_profile_from_trace_tree( # Flatten all events in the tree for easier traversal all_events = [] - def collect_all_events(node): + def _collect_all_events(node): all_events.append(node) for child in node.get("children", []): - collect_all_events(child) + _collect_all_events(child) for root_node in events: - collect_all_events(root_node) + _collect_all_events(root_node) - # Find the first transaction that matches the event's transaction name and has a profile + # Find the first span that matches the event's transaction name and has a profile matching_transaction = None for node in all_events: - if node.get("is_transaction", False): - if node.get("transaction") == event_transaction_name and node.get("profile_id"): - matching_transaction = node - break + if node.get("description") == event_transaction_name and ( + node.get("profile_id") or node.get("profiler_id") + ): + matching_transaction = node + break - if not matching_transaction or not matching_transaction.get("profile_id"): + if not matching_transaction: logger.info( - "[Autofix] No matching transaction with profile_id found for event", + "[Autofix] No matching transaction found for event; could not find a profile", extra={ - "trace_to_search_id": event.trace_id, - "event_transaction_name": event_transaction_name, - "matching_transaction": matching_transaction, + "event_id": event.event_id, + "trace_id": trace_tree.get("trace_id"), + "org_slug": project.organization.slug, "project_slug": project.slug, - "organization_slug": project.organization.slug, - "profile_id": ( - matching_transaction.get("profile_id") if matching_transaction else None - ), - "profiler_id": ( - matching_transaction.get("profiler_id") if matching_transaction else None - ), + "available_descriptions": [node.get("description") for node in all_events], + "event_transaction_name": event_transaction_name, }, ) return None - profile_id = matching_transaction.get("profile_id") - is_continuous = matching_transaction.get("is_continuous", False) + raw_profile_id = matching_transaction.get("profile_id") + raw_profiler_id = matching_transaction.get("profiler_id") + profile_id = raw_profile_id or raw_profiler_id + is_continuous = raw_profiler_id and not raw_profile_id if not profile_id: return None - - # Get precise timestamps for continuous profiles - start_ts = matching_transaction.get("precise_start_ts") - end_ts = matching_transaction.get("precise_finish_ts") + start_ts = matching_transaction.get("start_timestamp") + end_ts = matching_transaction.get("end_timestamp") profile = fetch_profile_data( profile_id=profile_id, diff --git a/tests/sentry/seer/autofix/test_autofix.py b/tests/sentry/seer/autofix/test_autofix.py index 920b79638fb400..b8d39bc84d641f 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -12,11 +12,10 @@ _get_profile_from_trace_tree, _get_trace_tree_for_event, _respond_with_error, - build_spans_tree, trigger_autofix, ) from sentry.seer.explorer.utils import _convert_profile_to_execution_tree -from sentry.testutils.cases import APITestCase, SnubaTestCase, SpanTestCase, TestCase +from sentry.testutils.cases import APITestCase, SnubaTestCase, TestCase from sentry.testutils.helpers.datetime import before_now from sentry.testutils.helpers.features import with_feature from sentry.testutils.skips import requires_snuba @@ -290,727 +289,135 @@ def test_convert_profile_to_execution_tree_with_timestamp(self) -> None: assert abs(child["duration_ns"] - 10000000) < 100 -@requires_snuba @pytest.mark.django_db -class TestGetTraceTreeForEvent(APITestCase, SnubaTestCase, SpanTestCase): - def test_get_trace_tree_for_event(self) -> None: - """ - Tests that a trace tree is correctly created with the expected structure: - - trace (1234567890abcdef1234567890abcdef) - ├── another-root-id (09:59:00Z) "browser - Earlier Transaction" - └── root-tx-id (10:00:00Z) "http.server - Root Transaction" - ├── child1-tx-id (10:00:10Z) "db - Database Query" - │ └── grandchild1-error-id (10:00:15Z) "Database Error" - └── child2-error-id (10:00:20Z) "Division by zero" - - Note: Events are ordered chronologically at each level. - """ - event_data = load_data("python") +class TestGetTraceTreeForEvent(APITestCase): + @patch("sentry.api.endpoints.organization_trace.OrganizationTraceEndpoint.query_trace_data") + def test_get_trace_tree_basic(self, mock_query_trace_data) -> None: + """Test that we can get a basic trace tree.""" trace_id = "1234567890abcdef1234567890abcdef" - test_span_id = "abcdef0123456789" - event_data.update({"contexts": {"trace": {"trace_id": trace_id, "span_id": test_span_id}}}) + event_data = load_data("python") + event_data.update( + {"contexts": {"trace": {"trace_id": trace_id, "span_id": "abcdef0123456789"}}} + ) event = self.store_event(data=event_data, project_id=self.project.id) - # Root event (a transaction) - root_tx_span_id = "aaaaaaaaaaaaaaaa" - root_tx_event_data = { - "event_id": "root-tx-id", - "start_timestamp": (event.datetime - timedelta(seconds=60)).timestamp(), - "spans": [{"span_id": "child1-span-id"}, {"span_id": "child2-span-id"}], - "contexts": { - "trace": {"trace_id": trace_id, "span_id": root_tx_span_id, "op": "http.server"} - }, - "title": "Root Transaction", - "platform": "python", - "project_id": self.project.id, - } - - # Child 1 - transaction that happens before child 2 - child1_span_id = "bbbbbbbbbbbbbbbb" - child1_tx_event_data = { - "event_id": "child1-tx-id", - "start_timestamp": (event.datetime - timedelta(seconds=50)).timestamp(), - "spans": [{"span_id": "dddddddddddddddd"}], - "contexts": { - "trace": { - "trace_id": trace_id, - "span_id": child1_span_id, - "parent_span_id": root_tx_span_id, - "op": "db", - } - }, - "title": "Database Query", - "platform": "python", - "project_id": self.project.id, - } - - # Child 2 - error that happens after child 1 - child2_span_id = "cccccccccccccccc" - child2_error_event_data = { - "event_id": "child2-error-id", - "start_timestamp": (event.datetime - timedelta(seconds=40)).timestamp(), - "contexts": { - "trace": { - "trace_id": trace_id, - "span_id": child2_span_id, - "parent_span_id": root_tx_span_id, - } - }, - "title": "Division by zero", - "platform": "python", - "project_id": self.project.id, - } - - # Grandchild 1 - error event (child of child1) - grandchild1_span_id = "dddddddddddddddd" - grandchild1_error_event_data = { - "event_id": "grandchild1-error-id", - "start_timestamp": (event.datetime - timedelta(seconds=45)).timestamp(), - "contexts": { - "trace": { - "trace_id": trace_id, - "span_id": grandchild1_span_id, - "parent_span_id": child1_span_id, - } - }, - "title": "Database Error", - "platform": "python", - "project_id": self.project.id, - } - - # Add another root event that happens earlier - another_root_span_id = "eeeeeeeeeeeeeeee" - another_root_tx_event_data = { - "event_id": "another-root-id", - "start_timestamp": (event.datetime - timedelta(seconds=120)).timestamp(), - "spans": [], - "contexts": { - "trace": {"trace_id": trace_id, "span_id": another_root_span_id, "op": "browser"} - }, - "title": "Earlier Transaction", - "platform": "javascript", - "project_id": self.project.id, - } - - # Create proper event objects instead of just mocks - tx_events = [] - error_events = [] - - # Create transaction events - for event_data in [root_tx_event_data, child1_tx_event_data, another_root_tx_event_data]: - mock_event = Mock() - # Set attributes directly instead of using data property - mock_event.event_id = event_data["event_id"] - mock_event.start_timestamp = event_data["start_timestamp"] - mock_event.data = event_data - mock_event.title = event_data["title"] - mock_event.platform = event_data["platform"] - mock_event.project_id = event_data["project_id"] - mock_event.trace_id = trace_id - tx_events.append(mock_event) - - # Create error events - for event_data in [child2_error_event_data, grandchild1_error_event_data]: - mock_event = Mock() - # Set attributes directly instead of using data property - mock_event.event_id = event_data["event_id"] - mock_event.start_timestamp = event_data["start_timestamp"] - mock_event.data = event_data - mock_event.title = event_data["title"] - mock_event.platform = event_data["platform"] - mock_event.project_id = event_data["project_id"] - mock_event.trace_id = trace_id - mock_event.message = event_data.get("message", event_data["title"]) - mock_event.transaction = event_data.get("transaction", None) - error_events.append(mock_event) - - # Sort root events by start_timestamp - root_events = [ - event - for event in tx_events + error_events - if event.event_id in ["root-tx-id", "another-root-id"] + # Mock the trace data response + mock_trace_data = [ + { + "id": "aaaaaaaaaaaaaaaa", + "description": "Test Transaction", + "is_transaction": True, + "children": [], + "errors": [], + "occurrences": [], + } ] - root_events.sort(key=lambda x: x.start_timestamp) - - # Function to recursively sort children by start_timestamp - def sort_tree(node): - if node["children"]: - # Sort children by start_timestamp - node["children"].sort(key=lambda x: x["start_timestamp"]) - # Recursively sort each child's children - for child in node["children"]: - sort_tree(child) - return node - - spans = [] - - # Create transaction spans - for tx_event in tx_events: - span_id = tx_event.data["contexts"]["trace"]["span_id"] - parent_span_id = tx_event.data["contexts"]["trace"].get("parent_span_id") - transaction_name = tx_event.title - op = tx_event.data["contexts"]["trace"].get("op") - - span = self.create_span( - { - "trace_id": trace_id, - "span_id": span_id, - "parent_span_id": parent_span_id, - "description": transaction_name, - "sentry_tags": { - "transaction": transaction_name, - "op": op, - }, - "is_segment": True, # This marks it as a transaction span - }, - start_ts=datetime.fromtimestamp(tx_event.start_timestamp), - ) - span.update( - { - "platform": tx_event.platform, - } - ) - spans.append(span) + mock_query_trace_data.return_value = mock_trace_data - self.store_spans(spans, is_eap=True) + trace_tree = _get_trace_tree_for_event(event, self.project) - # Mock the error events query - with patch("sentry.eventstore.backend.get_events") as mock_get_events: - mock_get_events.return_value = error_events - - # Call the function directly instead of through an endpoint - trace_tree = _get_trace_tree_for_event(event, self.project) - - # Validate the trace tree structure assert trace_tree is not None assert trace_tree["trace_id"] == trace_id + assert trace_tree["trace"] == mock_trace_data - # We should have two root events in chronological order - assert len(trace_tree["events"]) == 2 - - # First root should be the earlier transaction - first_root = trace_tree["events"][0] - assert first_root["event_id"] == "eeeeeeeeeeeeeeee" - assert first_root["title"] == "browser - Earlier Transaction" - assert first_root["datetime"] == (event.datetime - timedelta(seconds=120)).timestamp() - assert first_root["is_transaction"] is True - assert first_root["is_error"] is False - assert len(first_root["children"]) == 0 - - # Second root should be the main root transaction - second_root = trace_tree["events"][1] - assert second_root["event_id"] == "aaaaaaaaaaaaaaaa" - assert second_root["title"] == "http.server - Root Transaction" - assert second_root["datetime"] == (event.datetime - timedelta(seconds=60)).timestamp() - assert second_root["is_transaction"] is True - assert second_root["is_error"] is False - - # Second root should have two children in chronological order - assert len(second_root["children"]) == 2 - - # First child of main root is child1 - child1 = second_root["children"][0] - assert child1["event_id"] == "bbbbbbbbbbbbbbbb" - assert child1["title"] == "db - Database Query" - assert child1["datetime"] == (event.datetime - timedelta(seconds=50)).timestamp() - assert child1["is_transaction"] is True - assert child1["is_error"] is False - - # Child1 should have grandchild1 - assert len(child1["children"]) == 1 - grandchild1 = child1["children"][0] - assert grandchild1["event_id"] == "grandchild1-error-id" - assert grandchild1["title"] == "Database Error" - assert grandchild1["datetime"] == (event.datetime - timedelta(seconds=45)).timestamp() - assert grandchild1["is_transaction"] is False - assert grandchild1["is_error"] is True - assert len(grandchild1["children"]) == 0 - - # Second child of main root is child2 - child2 = second_root["children"][1] - assert child2["event_id"] == "child2-error-id" - assert child2["title"] == "Division by zero" - assert child2["datetime"] == (event.datetime - timedelta(seconds=40)).timestamp() - assert child2["is_transaction"] is False - assert child2["is_error"] is True - assert len(child2["children"]) == 0 - - # Verify that get_events was called once (for errors only) - assert mock_get_events.call_count == 1 - - def test_get_trace_tree_empty_results(self) -> None: - """ - Expected trace structure: - - None (empty trace tree) - - This test checks the behavior when no events are found for a trace. - """ - event_data = load_data("python") - trace_id = "1234567890abcdef1234567890abcdef" - test_span_id = "abcdef0123456789" - event_data.update({"contexts": {"trace": {"trace_id": trace_id, "span_id": test_span_id}}}) - event = self.store_event(data=event_data, project_id=self.project.id) - - # Don't create any spans - this should result in an empty trace tree - # Mock the error events query to return empty results - with patch("sentry.eventstore.backend.get_events") as mock_get_events: - mock_get_events.return_value = [] - - trace_tree = _get_trace_tree_for_event(event, self.project) - - assert trace_tree is None - # Should be called once (for errors only) - assert mock_get_events.call_count == 1 - - def test_get_trace_tree_out_of_order_processing(self) -> None: - """ - Expected trace structure: - - trace (1234567890abcdef1234567890abcdef) - └── parent-id (10:00:00Z) "Parent Last" - └── child-id (10:00:10Z) "Child First" - - This test verifies that the correct tree structure is built even when - events are processed out of order (child before parent). - """ + @patch("sentry.api.endpoints.organization_trace.OrganizationTraceEndpoint.query_trace_data") + def test_get_trace_tree_empty(self, mock_query_trace_data) -> None: + """Test that when no spans exist, trace tree returns None.""" trace_id = "1234567890abcdef1234567890abcdef" - test_span_id = "abcdef0123456789" event_data = load_data("python") - event_data.update({"contexts": {"trace": {"trace_id": trace_id, "span_id": test_span_id}}}) - event = self.store_event(data=event_data, project_id=self.project.id) - - # Child event that references a parent we haven't seen yet - child_span_id = "cccccccccccccccc" - parent_span_id = "ffffffffffffffff" - - # Create parent transaction span - parent_span = self.create_span( - { - "trace_id": trace_id, - "span_id": parent_span_id, - "parent_span_id": None, - "description": "Parent Last", - "sentry_tags": { - "transaction": "Parent Last", - "op": "http.server", - }, - "is_segment": True, - }, - start_ts=event.datetime - timedelta(seconds=10), + event_data.update( + {"contexts": {"trace": {"trace_id": trace_id, "span_id": "abcdef0123456789"}}} ) - parent_span.update( - { - "platform": "python", - } - ) - - self.store_spans([parent_span], is_eap=True) - - # Create proper child error event object - child_event = Mock() - child_event.event_id = "child-id" - child_event.start_timestamp = 1672567210.0 - child_event.data = { - "event_id": "child-id", - "start_timestamp": 1672567210.0, - "contexts": { - "trace": { - "trace_id": trace_id, - "span_id": child_span_id, - "parent_span_id": parent_span_id, - } - }, - "title": "Child First", - "platform": "python", - "project_id": self.project.id, - } - child_event.title = "Child First" - child_event.platform = "python" - child_event.project_id = self.project.id - child_event.trace_id = trace_id - child_event.message = "Child First" - child_event.transaction = None - - # Mock the error events query - with patch("sentry.eventstore.backend.get_events") as mock_get_events: - mock_get_events.return_value = [child_event] + event = self.store_event(data=event_data, project_id=self.project.id) - trace_tree = _get_trace_tree_for_event(event, self.project) + # Mock empty trace data + mock_query_trace_data.return_value = [] - assert trace_tree is not None - assert len(trace_tree["events"]) == 1 + trace_tree = _get_trace_tree_for_event(event, self.project) - # Parent should be the root - root = trace_tree["events"][0] - assert root["event_id"] == "ffffffffffffffff" - assert root["span_id"] == parent_span_id + assert trace_tree is None - # Child should be under parent - assert len(root["children"]) == 1 - child = root["children"][0] - assert child["event_id"] == "child-id" - assert child["span_id"] == child_span_id - - # Verify that get_events was called once (for errors only) - assert mock_get_events.call_count == 1 - - def test_get_trace_tree_with_only_errors(self) -> None: - """ - Tests that when results contain only error events (no transactions), - the function still creates a valid trace tree. - - Expected trace structure with the corrected approach: - trace (1234567890abcdef1234567890abcdef) - ├── error1-id (10:00:00Z) "First Error" (has non-matching parent_span_id) - ├── error2-id (10:00:10Z) "Second Error" (has non-matching parent_span_id) - │ └── error3-id (10:00:20Z) "Child Error" - └── error4-id (10:00:30Z) "Orphaned Error" (has non-matching parent_span_id) - - Note: In real-world scenarios, error events often have parent_span_ids even - when their parent events aren't captured in our trace data. - """ - trace_id = "1234567890abcdef1234567890abcdef" - test_span_id = "abcdef0123456789" + def test_get_trace_tree_no_trace_id(self) -> None: + """Test that events without trace_id return None.""" event_data = load_data("python") - event_data.update({"contexts": {"trace": {"trace_id": trace_id, "span_id": test_span_id}}}) + # Don't set trace_id event = self.store_event(data=event_data, project_id=self.project.id) - # Don't create any transaction spans - this test is for errors only - - # Create error events with parent-child relationships - error1_span_id = "1111111111111111" - error1 = Mock() - error1.event_id = "error1-id" - error1.start_timestamp = 1672567200.0 - error1.data = { - "contexts": { - "trace": { - "trace_id": trace_id, - "span_id": error1_span_id, - "parent_span_id": "non-existent-parent-1", # Parent that doesn't exist in our data - } - }, - "title": "First Error", - } - error1.title = "First Error" - error1.platform = "python" - error1.project_id = self.project.id - error1.trace_id = trace_id - error1.message = "First Error" - error1.transaction = None - - error2_span_id = "2222222222222222" - error2 = Mock() - error2.event_id = "error2-id" - error2.start_timestamp = 1672567210.0 - error2.data = { - "contexts": { - "trace": { - "trace_id": trace_id, - "span_id": error2_span_id, - "parent_span_id": "non-existent-parent-2", # Parent that doesn't exist in our data - } - }, - "title": "Second Error", - } - error2.title = "Second Error" - error2.platform = "python" - error2.project_id = self.project.id - error2.trace_id = trace_id - error2.message = "Second Error" - error2.transaction = None - - # This error is a child of error2 - error3 = Mock() - error3.event_id = "error3-id" - error3.start_timestamp = 1672567220.0 - error3.data = { - "contexts": { - "trace": { - "trace_id": trace_id, - "span_id": "3333333333333333", - "parent_span_id": error2_span_id, # Points to error2 - } - }, - "title": "Child Error", - } - error3.title = "Child Error" - error3.platform = "python" - error3.project_id = self.project.id - error3.trace_id = trace_id - error3.message = "Child Error" - error3.transaction = None - - # Another "orphaned" error with a parent_span_id that doesn't point to anything - error4 = Mock() - error4.event_id = "error4-id" - error4.start_timestamp = 1672567230.0 - error4.data = { - "contexts": { - "trace": { - "trace_id": trace_id, - "span_id": "4444444444444444", - "parent_span_id": "non-existent-parent-3", # Parent that doesn't exist in our data - } - }, - "title": "Orphaned Error", - } - error4.title = "Orphaned Error" - error4.platform = "python" - error4.project_id = self.project.id - error4.trace_id = trace_id - error4.message = "Orphaned Error" - error4.transaction = None - - # Mock the error events query to return all errors - with patch("sentry.eventstore.backend.get_events") as mock_get_events: - mock_get_events.return_value = [error1, error2, error3, error4] + trace_tree = _get_trace_tree_for_event(event, self.project) - trace_tree = _get_trace_tree_for_event(event, self.project) - - # Verify the trace tree structure - assert trace_tree is not None - assert trace_tree["trace_id"] == trace_id + assert trace_tree is None - # We should have three root-level errors in the result (error1, error2, error4) - # In the old logic, this would be empty because all errors have parent_span_ids - assert len(trace_tree["events"]) == 3 - - # Verify all the root events are in chronological order - events = trace_tree["events"] - assert events[0]["event_id"] == "error1-id" - assert events[1]["event_id"] == "error2-id" - assert events[2]["event_id"] == "error4-id" - - # error3 should be a child of error2 - assert len(events[1]["children"]) == 1 - child = events[1]["children"][0] - assert child["event_id"] == "error3-id" - assert child["title"] == "Child Error" - - # Verify get_events was called once (for errors only) - assert mock_get_events.call_count == 1 - - def test_get_trace_tree_all_relationship_rules(self) -> None: - """ - Tests that all three relationship rules are correctly implemented: - 1. An event whose span_id is X is a parent of an event whose parent_span_id is X - 2. A transaction event with a span with span_id X is a parent of an event whose parent_span_id is X - 3. A transaction event with a span with span_id X is a parent of an event whose span_id is X - - Expected trace structure: - trace (1234567890abcdef1234567890abcdef) - └── root-tx-id (10:00:00Z) "Root Transaction" - ├── rule1-child-id (10:00:10Z) "Rule 1 Child" (parent_span_id=root-tx-span-id) - ├── rule2-child-id (10:00:20Z) "Rule 2 Child" (parent_span_id=tx-span-1) - └── rule3-child-id (10:00:30Z) "Rule 3 Child" (span_id=tx-span-2) - """ + @patch("sentry.api.endpoints.organization_trace.OrganizationTraceEndpoint.query_trace_data") + def test_get_trace_tree_with_spans(self, mock_query_trace_data) -> None: + """Test trace tree with multiple spans.""" trace_id = "1234567890abcdef1234567890abcdef" - test_span_id = "abcdef0123456789" event_data = load_data("python") - event_data.update({"contexts": {"trace": {"trace_id": trace_id, "span_id": test_span_id}}}) + event_data.update( + {"contexts": {"trace": {"trace_id": trace_id, "span_id": "abcdef0123456789"}}} + ) event = self.store_event(data=event_data, project_id=self.project.id) - # Root transaction with two spans - root_tx_span_id = "aaaaaaaaaaaaaaaa" - tx_span_1 = "bbbbbbbbbbbbbbbb" - tx_span_2 = "cccccccccccccccc" - - # Create root transaction span - root_span = self.create_span( - { - "trace_id": trace_id, - "span_id": root_tx_span_id, - "parent_span_id": None, - "description": "Root Transaction", - "sentry_tags": { - "transaction": "Root Transaction", - "op": "http.server", - }, - "is_segment": True, - }, - start_ts=event.datetime - timedelta(seconds=20), - ) - root_span.update( + # Mock trace data with parent-child relationship + mock_trace_data = [ { - "platform": "python", + "id": "aaaaaaaaaaaaaaaa", + "description": "Parent Transaction", + "is_transaction": True, + "children": [ + { + "id": "bbbbbbbbbbbbbbbb", + "description": "Child Operation", + "parent_span": "aaaaaaaaaaaaaaaa", + "children": [], + "errors": [], + "occurrences": [], + } + ], + "errors": [], + "occurrences": [], } - ) - - # Create child spans for the transaction - child_span_1 = self.create_span( - { - "trace_id": trace_id, - "span_id": tx_span_1, - "parent_span_id": root_tx_span_id, - "description": "Child Span 1", - "sentry_tags": { - "transaction": "Root Transaction", - "op": "db.query", - }, - "is_segment": False, - }, - start_ts=event.datetime - timedelta(seconds=19), - ) - - child_span_2 = self.create_span( - { - "trace_id": trace_id, - "span_id": tx_span_2, - "parent_span_id": root_tx_span_id, - "description": "Child Span 2", - "sentry_tags": { - "transaction": "Root Transaction", - "op": "http.request", - }, - "is_segment": False, - }, - start_ts=event.datetime - timedelta(seconds=18), - ) - - self.store_spans([root_span, child_span_1, child_span_2], is_eap=True) - - # Rule 1: Child whose parent_span_id matches another event's span_id - rule1_child = Mock() - rule1_child.event_id = "rule1-child-id" - rule1_child.start_timestamp = 1672567210.0 - rule1_child.data = { - "contexts": { - "trace": { - "trace_id": trace_id, - "span_id": "dddddddddddddddd", - "parent_span_id": root_tx_span_id, # Points to root transaction's span_id - } - }, - "title": "Rule 1 Child", - } - rule1_child.title = "Rule 1 Child" - rule1_child.platform = "python" - rule1_child.project_id = self.project.id - rule1_child.trace_id = trace_id - rule1_child.message = "Rule 1 Child" - rule1_child.transaction = None - - # Rule 2: Child whose parent_span_id matches a span in a transaction - rule2_child = Mock() - rule2_child.event_id = "rule2-child-id" - rule2_child.start_timestamp = 1672567220.0 - rule2_child.data = { - "contexts": { - "trace": { - "trace_id": trace_id, - "span_id": "eeeeeeeeeeeeeeee", - "parent_span_id": tx_span_1, # Points to a span in the root transaction - } - }, - "title": "Rule 2 Child", - } - rule2_child.title = "Rule 2 Child" - rule2_child.platform = "python" - rule2_child.project_id = self.project.id - rule2_child.trace_id = trace_id - rule2_child.message = "Rule 2 Child" - rule2_child.transaction = None - - # Rule 3: Child whose span_id matches a span in a transaction - rule3_child = Mock() - rule3_child.event_id = "rule3-child-id" - rule3_child.start_timestamp = 1672567230.0 - rule3_child.data = { - "contexts": { - "trace": { - "trace_id": trace_id, - "span_id": tx_span_2, # Same as one of the spans in the root transaction - } - }, - "title": "Rule 3 Child", - } - rule3_child.title = "Rule 3 Child" - rule3_child.platform = "python" - rule3_child.project_id = self.project.id - rule3_child.trace_id = trace_id - rule3_child.message = "Rule 3 Child" - rule3_child.transaction = None - - # Mock the error events query - with patch("sentry.eventstore.backend.get_events") as mock_get_events: - mock_get_events.return_value = [rule1_child, rule2_child, rule3_child] + ] + mock_query_trace_data.return_value = mock_trace_data - trace_tree = _get_trace_tree_for_event(event, self.project) + trace_tree = _get_trace_tree_for_event(event, self.project) - # Verify the trace tree structure assert trace_tree is not None assert trace_tree["trace_id"] == trace_id - assert len(trace_tree["events"]) == 1 # One root node (the transaction) - - # Verify root transaction - root = trace_tree["events"][0] - assert root["event_id"] == "aaaaaaaaaaaaaaaa" - assert root["title"] == "http.server - Root Transaction" - assert root["is_transaction"] is True - assert root["is_error"] is False - - # Root should have all three children according to the rules - assert len(root["children"]) == 3 + assert len(trace_tree["trace"]) == 1 - # Children should be in chronological order - children = root["children"] + parent_span = trace_tree["trace"][0] + assert parent_span["description"] == "Parent Transaction" + assert len(parent_span["children"]) == 1 - # First child - Rule 1 - assert children[0]["event_id"] == "rule1-child-id" - assert children[0]["title"] == "Rule 1 Child" - - # Second child - Rule 2 - assert children[1]["event_id"] == "rule2-child-id" - assert children[1]["title"] == "Rule 2 Child" - - # Third child - Rule 3 - assert children[2]["event_id"] == "rule3-child-id" - assert children[2]["title"] == "Rule 3 Child" - - # Verify get_events was called once (for errors only) - assert mock_get_events.call_count == 1 + child_span = parent_span["children"][0] + assert child_span["description"] == "Child Operation" + assert child_span["parent_span"] == "aaaaaaaaaaaaaaaa" @requires_snuba @pytest.mark.django_db class TestGetProfileFromTraceTree(APITestCase, SnubaTestCase): @patch("sentry.seer.explorer.utils.get_from_profiling_service") - def test_get_profile_from_trace_tree(self, mock_get_from_profiling_service) -> None: - """ - Test the _get_profile_from_trace_tree method which finds a transaction - that matches the event's transaction name and has a profile. - """ + def test_get_profile_from_trace_tree_basic(self, mock_get_from_profiling_service) -> None: + """Test finding a profile for a matching transaction in trace tree.""" # Setup mock event with transaction name event = Mock() event.event_id = "error-event-id" event.trace_id = "1234567890abcdef1234567890abcdef" event.transaction = "/api/users" - event.data = {"transaction": "/api/users"} - # Create a mock trace tree with a transaction that matches the event's transaction name + # Create a simple trace tree structure with a span that has a profile profile_id = "profile123456789" trace_tree = { "trace_id": "1234567890abcdef1234567890abcdef", - "events": [ + "trace": [ { - "event_id": "tx-root-id", - "span_id": "root-span-id", - "is_transaction": True, - "is_error": False, - "transaction": "/api/users", + "id": "tx-span-id", + "description": "/api/users", # Matches event transaction "profile_id": profile_id, - "children": [ - { - "event_id": "error-event-id", - "span_id": "event-span-id", - "is_transaction": False, - "is_error": True, - "children": [], - } - ], + "start_timestamp": 1672567200.0, + "end_timestamp": 1672567210.0, + "children": [], } ], } @@ -1033,17 +440,14 @@ def test_get_profile_from_trace_tree(self, mock_get_from_profiling_service) -> N } } - # Configure the mock response mock_response = Mock() mock_response.status = 200 mock_response.data = orjson.dumps(mock_profile_data) mock_get_from_profiling_service.return_value = mock_response - # Call the function directly instead of through an endpoint profile_result = _get_profile_from_trace_tree(trace_tree, event, self.project) assert profile_result is not None - assert profile_result["profile_matches_issue"] is True assert "execution_tree" in profile_result assert len(profile_result["execution_tree"]) == 1 assert profile_result["execution_tree"][0]["function"] == "main" @@ -1054,341 +458,139 @@ def test_get_profile_from_trace_tree(self, mock_get_from_profiling_service) -> N params={"format": "sample"}, ) + @patch("sentry.profiles.profile_chunks.get_chunk_ids") @patch("sentry.seer.explorer.utils.get_from_profiling_service") - def test_get_profile_from_trace_tree_matching_transaction_name( - self, mock_get_from_profiling_service + def test_get_profile_from_trace_tree_with_profiler_id( + self, mock_get_from_profiling_service, mock_get_chunk_ids ) -> None: - """ - Test _get_profile_from_trace_tree with a transaction whose name - matches the event's transaction name. - """ - # Setup mock event with transaction name + """Test finding a continuous profile using profiler_id.""" event = Mock() - event.event_id = "error-event-id" - event.trace_id = "1234567890abcdef1234567890abcdef" - event.transaction = "/api/orders" - event.data = {"transaction": "/api/orders"} + event.transaction = "/api/test" - # Create a mock trace tree with a transaction whose name matches event transaction name - profile_id = "profile123456789" + profiler_id = "12345678-1234-1234-1234-123456789abc" trace_tree = { - "trace_id": "1234567890abcdef1234567890abcdef", - "events": [ + "trace": [ { - "event_id": "tx-id", - "span_id": "tx-span-id", - "is_transaction": True, - "is_error": False, - "transaction": "/api/orders", # This matches the event's transaction name - "profile_id": profile_id, - "children": [ - { - "event_id": "error-event-id", - "span_id": "error-span-id", - "is_transaction": False, - "is_error": True, - "children": [], - } - ], + "description": "/api/test", + "profiler_id": profiler_id, + "start_timestamp": 1672567200.0, + "end_timestamp": 1672567210.0, + "children": [], } ], } - # Mock the profile data response + # Mock continuous profile response (note the "chunk" wrapper) mock_profile_data = { - "profile": { - "frames": [ - { - "function": "main", - "module": "app.main", - "filename": "main.py", - "lineno": 10, - "in_app": True, - } - ], - "stacks": [[0]], - "samples": [{"stack_id": 0, "thread_id": "1", "elapsed_since_start_ns": 10000000}], - "thread_metadata": {"1": {"name": "MainThread"}}, - } - } - - # Configure the mock response - mock_response = Mock() - mock_response.status = 200 - mock_response.data = orjson.dumps(mock_profile_data) - mock_get_from_profiling_service.return_value = mock_response - - # Call the function - profile_result = _get_profile_from_trace_tree(trace_tree, event, self.project) - - assert profile_result is not None - assert profile_result["profile_matches_issue"] is True - assert "execution_tree" in profile_result - - mock_get_from_profiling_service.assert_called_once_with( - "GET", - f"/organizations/{self.project.organization_id}/projects/{self.project.id}/profiles/{profile_id}", - params={"format": "sample"}, - ) - - @patch("sentry.seer.explorer.utils.get_chunk_ids", return_value=["chunk1"]) - @patch("sentry.seer.explorer.utils.get_from_profiling_service") - def test_get_profile_from_trace_tree_continuous_e2e( - self, mock_get_from_profiling_service, mock_get_chunk_ids - ) -> None: - """ - End-to-end: build a trace tree from span query results where the transaction has only - profiler.id (continuous profile). Then verify we fetch a continuous profile and return an execution tree. - """ - # Base error event whose transaction name should match the transaction in the trace - trace_id = "1234567890abcdef1234567890abcdef" - error_span_id = "bbbbbbbbbbbbbbbb" # 16-hex span id - tx_span_id = "aaaaaaaaaaaaaaaa" # 16-hex transaction span id - data = load_data("python") - # Set transaction to match what we'll have in the trace tree - data["transaction"] = "/api/test" - data.update({"contexts": {"trace": {"trace_id": trace_id, "span_id": error_span_id}}}) - event = self.store_event(data=data, project_id=self.project.id) - - # Prepare Spans.run_table_query to return one transaction span (with profiler.id only) - # and one non-transaction span which matches the event's span_id - tx_start = (event.datetime - timedelta(seconds=30)).timestamp() - tx_end = (event.datetime - timedelta(seconds=10)).timestamp() - - spans_result = { - "data": [ - { - "span_id": tx_span_id, - "parent_span": None, - "span.op": "http.server", - "span.description": "Root", - "precise.start_ts": tx_start, - "precise.finish_ts": tx_end, - "is_transaction": True, - "transaction": "/api/test", - "project.id": self.project.id, - "platform": "python", - "profile.id": None, - "profiler.id": "prof-123", - }, - { - "span_id": error_span_id, - "parent_span": tx_span_id, - "span.op": "db", - "span.description": "Child", - "precise.start_ts": tx_start + 1, - "precise.finish_ts": tx_end - 1, - "is_transaction": False, - "transaction": None, - "project.id": self.project.id, - "platform": "python", - "profile.id": None, - "profiler.id": None, - }, - ] - } - - # Mock the profiling service response for continuous profiles - continuous_profile = { "chunk": { "profile": { "frames": [ { - "function": "main", - "module": "app.main", - "filename": "main.py", - "lineno": 10, + "function": "test", + "module": "app", + "filename": "test.py", + "lineno": 5, "in_app": True, } ], "stacks": [[0]], "samples": [ - {"stack_id": 0, "thread_id": "1", "elapsed_since_start_ns": 10000000} + {"stack_id": 0, "thread_id": "1", "elapsed_since_start_ns": 5000000} ], "thread_metadata": {"1": {"name": "MainThread"}}, } } } + mock_get_chunk_ids.return_value = ["chunk1"] mock_response = Mock() mock_response.status = 200 - mock_response.data = orjson.dumps(continuous_profile) - mock_response.msg = "OK" + mock_response.data = orjson.dumps(mock_profile_data) mock_get_from_profiling_service.return_value = mock_response - with ( - patch("sentry.snuba.spans_rpc.Spans.run_table_query") as mock_run_table_query, - patch("sentry.eventstore.backend.get_events") as mock_get_events, - ): - mock_run_table_query.return_value = spans_result - mock_get_events.return_value = [] - - # Build the trace tree from spans - trace_tree = _get_trace_tree_for_event(event, self.project) - - # Trace tree should exist and contain our transaction - assert trace_tree is not None - assert trace_tree["trace_id"] == trace_id - - tx_node = next(e for e in trace_tree["events"] if e["is_transaction"]) - assert tx_node["profile_id"] == "prof-123" - assert tx_node["is_continuous"] is True - assert tx_node["precise_start_ts"] == tx_start - assert tx_node["precise_finish_ts"] == tx_end - - # Now fetch the profile from the trace tree; should follow the continuous path profile_result = _get_profile_from_trace_tree(trace_tree, event, self.project) + assert profile_result is not None assert "execution_tree" in profile_result - assert len(profile_result["execution_tree"]) >= 1 - # Verify we called the continuous profile endpoint + # Verify continuous profile endpoint was called mock_get_from_profiling_service.assert_called_once() - _, kwargs = mock_get_from_profiling_service.call_args + args, kwargs = mock_get_from_profiling_service.call_args assert kwargs["method"] == "POST" assert ( f"/organizations/{self.project.organization_id}/projects/{self.project.id}/chunks" in kwargs["path"] ) - assert kwargs["json_data"]["profiler_id"] == "prof-123" - # Start/end should be nanoseconds derived from precise timestamps - assert kwargs["json_data"]["start"] == str(int(tx_start * 1e9)) - assert kwargs["json_data"]["end"] == str(int(tx_end * 1e9)) + assert kwargs["json_data"]["profiler_id"] == profiler_id - @patch("sentry.seer.explorer.utils.get_from_profiling_service") - def test_get_profile_from_trace_tree_api_error(self, mock_get_from_profiling_service) -> None: - """ - Test the behavior when the profiling service API returns an error. - """ - # Setup mock event with transaction name + def test_get_profile_from_trace_tree_no_matching_transaction(self) -> None: + """Test that function returns None when no matching transaction is found.""" event = Mock() - event.event_id = "error-event-id" - event.trace_id = "1234567890abcdef1234567890abcdef" - event.transaction = "/api/test" - event.data = {"transaction": "/api/test"} + event.transaction = "/api/different" - # Create a mock trace tree with a transaction that matches the event transaction name - profile_id = "profile123456789" trace_tree = { - "trace_id": "1234567890abcdef1234567890abcdef", - "events": [ + "trace": [ { - "event_id": "tx-root-id", - "span_id": "root-span-id", - "is_transaction": True, - "is_error": False, - "transaction": "/api/test", - "profile_id": profile_id, - "children": [ - { - "event_id": "error-event-id", - "span_id": "event-span-id", - "is_transaction": False, - "is_error": True, - "children": [], - } - ], + "description": "/api/other", # Doesn't match + "profile_id": "profile123", + "children": [], } ], } - # Configure the mock response to simulate an API error - mock_response = Mock() - mock_response.status = 404 - mock_get_from_profiling_service.return_value = mock_response - - # Call the function directly instead of through an endpoint profile_result = _get_profile_from_trace_tree(trace_tree, event, self.project) - assert profile_result is None - mock_get_from_profiling_service.assert_called_once_with( - "GET", - f"/organizations/{self.project.organization_id}/projects/{self.project.id}/profiles/{profile_id}", - params={"format": "sample"}, - ) - - @patch("sentry.seer.explorer.utils.get_from_profiling_service") - def test_get_profile_from_trace_tree_no_matching_transaction( - self, mock_get_from_profiling_service - ): - """ - Test that the function returns None when no matching transaction is found. - """ - # Setup mock event with transaction name + def test_get_profile_from_trace_tree_no_transaction_name(self) -> None: + """Test that function returns None when event has no transaction name.""" event = Mock() - event.event_id = "error-event-id" - event.trace_id = "1234567890abcdef1234567890abcdef" - event.transaction = "/api/different" - event.data = {"transaction": "/api/different"} + event.transaction = None - # Create a mock trace tree with a transaction that DOESN'T match the event transaction name trace_tree = { - "trace_id": "1234567890abcdef1234567890abcdef", - "events": [ + "trace": [ { - "event_id": "tx-root-id", - "span_id": "root-span-id", - "is_transaction": True, - "is_error": False, - "transaction": "/api/other", # Doesn't match event's transaction name - "profile_id": "profile123456789", - "children": [ - { - "event_id": "error-event-id", - "span_id": "event-span-id", - "is_transaction": False, - "is_error": True, - "children": [], - } - ], + "description": "/api/test", + "profile_id": "profile123", + "children": [], } ], } - # Call the function profile_result = _get_profile_from_trace_tree(trace_tree, event, self.project) + assert profile_result is None + def test_get_profile_from_trace_tree_no_trace_tree(self) -> None: + """Test that function returns None when trace tree is None.""" + event = Mock() + event.transaction = "/api/test" + + profile_result = _get_profile_from_trace_tree(None, event, self.project) assert profile_result is None - # API should not be called if no matching transaction is found - mock_get_from_profiling_service.assert_not_called() @patch("sentry.seer.explorer.utils.get_from_profiling_service") - def test_get_profile_from_trace_tree_no_transaction_name( - self, mock_get_from_profiling_service - ) -> None: - """ - Test the behavior when the event doesn't have a transaction name. - """ - # Setup mock event WITHOUT transaction name + def test_get_profile_from_trace_tree_api_error(self, mock_get_from_profiling_service) -> None: + """Test that function returns None when profiling API returns an error.""" event = Mock() - event.event_id = "error-event-id" - event.trace_id = "1234567890abcdef1234567890abcdef" - event.transaction = None - event.data = {} # No transaction + event.transaction = "/api/test" - # Create a mock trace tree trace_tree = { - "trace_id": "1234567890abcdef1234567890abcdef", - "events": [ + "trace": [ { - "event_id": "tx-id", - "span_id": "tx-span-id", - "is_transaction": True, - "is_error": False, - "transaction": "/api/test", - "profile_id": "profile123456789", + "description": "/api/test", + "profile_id": "profile123", + "start_timestamp": 1672567200.0, + "end_timestamp": 1672567210.0, "children": [], } ], } - # Call the function - profile_result = _get_profile_from_trace_tree(trace_tree, event, self.project) + mock_response = Mock() + mock_response.status = 404 + mock_get_from_profiling_service.return_value = mock_response + profile_result = _get_profile_from_trace_tree(trace_tree, event, self.project) assert profile_result is None - # API should not be called if event has no transaction name - mock_get_from_profiling_service.assert_not_called() @requires_snuba @@ -1663,201 +865,6 @@ def test_respond_with_error(self) -> None: assert response.data["detail"] == "Test error message" -class TestBuildSpansTree(TestCase): - def test_build_spans_tree_basic(self) -> None: - """Test that a simple list of spans is correctly converted to a tree.""" - spans_data: list[dict] = [ - { - "span_id": "root-span", - "parent_span_id": None, - "title": "Root Span", - "duration": "10.0s", - }, - { - "span_id": "child1", - "parent_span_id": "root-span", - "title": "Child 1", - "duration": "5.0s", - }, - { - "span_id": "child2", - "parent_span_id": "root-span", - "title": "Child 2", - "duration": "3.0s", - }, - { - "span_id": "grandchild", - "parent_span_id": "child1", - "title": "Grandchild", - "duration": "2.0s", - }, - ] - - tree = build_spans_tree(spans_data) - - # Should have one root - assert len(tree) == 1 - root = tree[0] - assert root["span_id"] == "root-span" - assert root["title"] == "Root Span" - - # Root should have two children, sorted by duration (child1 first) - assert len(root["children"]) == 2 - assert root["children"][0]["span_id"] == "child1" - assert root["children"][1]["span_id"] == "child2" - - # Child1 should have one child - assert len(root["children"][0]["children"]) == 1 - grandchild = root["children"][0]["children"][0] - assert grandchild["span_id"] == "grandchild" - - def test_build_spans_tree_multiple_roots(self) -> None: - """Test that spans with multiple roots are correctly handled.""" - spans_data: list[dict] = [ - { - "span_id": "root1", - "parent_span_id": None, - "title": "Root 1", - "duration": "10.0s", - }, - { - "span_id": "root2", - "parent_span_id": None, - "title": "Root 2", - "duration": "15.0s", - }, - { - "span_id": "child1", - "parent_span_id": "root1", - "title": "Child of Root 1", - "duration": "5.0s", - }, - { - "span_id": "child2", - "parent_span_id": "root2", - "title": "Child of Root 2", - "duration": "7.0s", - }, - ] - - tree = build_spans_tree(spans_data) - - # Should have two roots, sorted by duration (root2 first) - assert len(tree) == 2 - assert tree[0]["span_id"] == "root2" - assert tree[1]["span_id"] == "root1" - - # Each root should have one child - assert len(tree[0]["children"]) == 1 - assert tree[0]["children"][0]["span_id"] == "child2" - - assert len(tree[1]["children"]) == 1 - assert tree[1]["children"][0]["span_id"] == "child1" - - def test_build_spans_tree_orphaned_parent(self) -> None: - """Test that spans with parent_span_id not in the data are treated as roots.""" - spans_data: list[dict] = [ - { - "span_id": "span1", - "parent_span_id": "non-existent-parent", - "title": "Orphaned Span", - "duration": "10.0s", - }, - { - "span_id": "span2", - "parent_span_id": "span1", - "title": "Child of Orphaned Span", - "duration": "5.0s", - }, - ] - - tree = build_spans_tree(spans_data) - - # span1 should be treated as a root even though it has a parent_span_id - assert len(tree) == 1 - assert tree[0]["span_id"] == "span1" - - # span2 should be a child of span1 - assert len(tree[0]["children"]) == 1 - assert tree[0]["children"][0]["span_id"] == "span2" - - def test_build_spans_tree_empty_input(self) -> None: - """Test handling of empty input.""" - assert build_spans_tree([]) == [] - - def test_build_spans_tree_missing_span_ids(self) -> None: - """Test that spans without span_ids are ignored.""" - spans_data: list[dict] = [ - { - "span_id": "valid-span", - "parent_span_id": None, - "title": "Valid Span", - "duration": "10.0s", - }, - { - "span_id": None, # Missing span_id - "parent_span_id": "valid-span", - "title": "Invalid Span", - "duration": "5.0s", - }, - { - # No span_id key - "parent_span_id": "valid-span", - "title": "Another Invalid Span", - "duration": "3.0s", - }, - ] - - tree = build_spans_tree(spans_data) - - # Only the valid span should be in the tree - assert len(tree) == 1 - assert tree[0]["span_id"] == "valid-span" - # No children since the other spans had invalid/missing span_ids - assert len(tree[0]["children"]) == 0 - - def test_build_spans_tree_duration_sorting(self) -> None: - """Test that spans are correctly sorted by duration.""" - spans_data: list[dict] = [ - { - "span_id": "root", - "parent_span_id": None, - "title": "Root Span", - "duration": "10.0s", - }, - { - "span_id": "fast-child", - "parent_span_id": "root", - "title": "Fast Child", - "duration": "1.0s", - }, - { - "span_id": "medium-child", - "parent_span_id": "root", - "title": "Medium Child", - "duration": "5.0s", - }, - { - "span_id": "slow-child", - "parent_span_id": "root", - "title": "Slow Child", - "duration": "9.0s", - }, - ] - - tree = build_spans_tree(spans_data) - - # Should have one root - assert len(tree) == 1 - root = tree[0] - - # Root should have three children, sorted by duration (slow-child first) - assert len(root["children"]) == 3 - assert root["children"][0]["span_id"] == "slow-child" - assert root["children"][1]["span_id"] == "medium-child" - assert root["children"][2]["span_id"] == "fast-child" - - class TestGetLogsForEvent(TestCase): def setUp(self) -> None: super().setUp()