From c2af3b8b7cba50004c3adb78dce4734514b65b12 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:57:07 +0000 Subject: [PATCH 1/3] feat(seer): Return span links from the trace waterfall Seer's trace waterfall now selects the private sentry.links attribute and returns each span's parsed links under links, in the same shape as the trace item details endpoint. This lets Explorer and Code Mode follow links such as cache_origin from a cached Next.js pageload to the render that produced it. Spans.run_trace_query now uses the config it is given instead of a fresh default, so the waterfall can allow the private attribute without affecting other callers. Refs JSSDK-43 Co-authored-by: Charly Gomez <20254395+chargome@users.noreply.github.com> --- src/sentry/seer/agent/tools.py | 33 ++++++++++++++++++-- src/sentry/snuba/spans_rpc.py | 2 +- src/sentry/snuba/trace.py | 3 +- tests/sentry/seer/agent/test_tools.py | 43 +++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/sentry/seer/agent/tools.py b/src/sentry/seer/agent/tools.py index c213ac146df1..abcf1e8fc196 100644 --- a/src/sentry/seer/agent/tools.py +++ b/src/sentry/seer/agent/tools.py @@ -6,6 +6,7 @@ from datetime import UTC, datetime, timedelta, timezone from typing import Any, Literal, TypedDict, cast +import orjson from django.core.exceptions import BadRequest from django.db import models from django.db.models import Q @@ -17,6 +18,7 @@ from sentry import eventstore, features from sentry.api.client import client from sentry.api.endpoints.organization_events_timeseries import TOP_EVENTS_DATASETS +from sentry.api.endpoints.project_trace_item_details import serialize_link from sentry.api.serializers.base import serialize from sentry.api.serializers.models.activity import ActivitySerializer, ActivitySerializerResponse from sentry.api.serializers.models.commit import CommitSerializer, CommitSerializerResponse @@ -64,7 +66,7 @@ ) from sentry.search.eap.occurrences.query_utils import build_event_id_in_filter from sentry.search.eap.resolver import SearchResolver -from sentry.search.eap.types import SearchResolverConfig +from sentry.search.eap.types import FieldsACL, SearchResolverConfig from sentry.search.events.constants import ISSUE_ID_ALIAS from sentry.search.events.types import SAMPLING_MODES, SnubaParams from sentry.seer.agent.index_data import UNESCAPED_QUOTE_RE @@ -629,6 +631,27 @@ def execute_trace_table_query( raise +SPAN_LINKS_ATTRIBUTE = "sentry.links" + + +def _parse_span_links(items: Sequence[dict[str, Any]]) -> None: + """Replace the raw `sentry.links` JSON on each span with parsed `links`, in place.""" + for item in items: + attributes = item.get("additional_attributes") + raw_links = ( + attributes.pop(SPAN_LINKS_ATTRIBUTE, None) if isinstance(attributes, dict) else None + ) + if isinstance(raw_links, str): + try: + item["links"] = [serialize_link(link) for link in orjson.loads(raw_links)] + except (orjson.JSONDecodeError, KeyError, TypeError): + logger.warning( + "get_trace_waterfall: Could not parse span links", + extra={"span_id": item.get("event_id")}, + ) + _parse_span_links(item.get("children", [])) + + def get_trace_waterfall( trace_id: str, organization_id: int, @@ -644,9 +667,11 @@ def get_trace_waterfall( Returns: The spans and errors in the trace, along with the full 32-character trace ID. + Spans with span links carry them in `links`. """ if additional_attributes is None: additional_attributes = ["span.status_code"] + additional_attributes = [*additional_attributes, SPAN_LINKS_ATTRIBUTE] try: organization = Organization.objects.get(id=organization_id) @@ -688,12 +713,16 @@ def get_trace_waterfall( additional_attributes=additional_attributes, referrer=referrer, organization=organization, + # `sentry.links` is private, so only this caller may select it. + config=SearchResolverConfig(fields_acl=FieldsACL(attributes={SPAN_LINKS_ATTRIBUTE})), ) + trace = cast(list[dict[str, Any]], events) + _parse_span_links(trace) return EAPTrace( trace_id=full_trace_id, org_id=organization_id, - trace=events, + trace=trace, ) diff --git a/src/sentry/snuba/spans_rpc.py b/src/sentry/snuba/spans_rpc.py index df8f7c820d5a..820876757094 100644 --- a/src/sentry/snuba/spans_rpc.py +++ b/src/sentry/snuba/spans_rpc.py @@ -143,7 +143,7 @@ def run_trace_query( "app.vitals.ttfd.value", *additional_attributes, ] - resolver = cls.get_resolver(params=params, config=SearchResolverConfig()) + resolver = cls.get_resolver(params=params, config=config) columns, _ = resolver.resolve_attributes(trace_attributes) meta = resolver.resolve_meta(referrer=referrer) request = GetTraceRequest( diff --git a/src/sentry/snuba/trace.py b/src/sentry/snuba/trace.py index ac7633c9b420..80b647d8a4a9 100644 --- a/src/sentry/snuba/trace.py +++ b/src/sentry/snuba/trace.py @@ -900,6 +900,7 @@ def query_trace_data( additional_attributes: list[str] | None = None, include_uptime: bool = False, organization: Organization | None = None, + config: SearchResolverConfig | None = None, ) -> list[SerializedTraceItem]: """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 @@ -933,7 +934,7 @@ def query_trace_data( trace_id=trace_id, params=snuba_params, referrer=referrer, - config=SearchResolverConfig(), + config=config or SearchResolverConfig(), additional_attributes=all_additional_attributes, ) errors_future = query_thread_pool.submit( diff --git a/tests/sentry/seer/agent/test_tools.py b/tests/sentry/seer/agent/test_tools.py index 9d2a5b75b2fe..aefbd59cf22a 100644 --- a/tests/sentry/seer/agent/test_tools.py +++ b/tests/sentry/seer/agent/test_tools.py @@ -873,6 +873,49 @@ def test_get_trace_waterfall_short_id(self) -> None: def test_get_trace_waterfall_full_id(self) -> None: self._test_get_trace_waterfall(use_short_id=False) + def test_get_trace_waterfall_span_links(self) -> None: + trace_id = uuid.uuid4().hex + root = self.create_span( + { + "description": "GET /pricing", + "trace_id": trace_id, + "parent_span_id": None, + "is_segment": True, + "sentry_tags": { + "links": '[{"trace_id":"d099bf9ad5a143cf8f83a98081d0ed3b","span_id":"8873a98879faf06d","sampled":true,"attributes":{"sentry.link.type":{"type":"string","value":"cache_origin"}}}]', + }, + }, + start_ts=self.ten_mins_ago, + ) + child = self.create_span( + { + "description": "no links", + "trace_id": trace_id, + "parent_span_id": root["span_id"], + "is_segment": False, + }, + start_ts=self.ten_mins_ago, + ) + self.store_spans([root, child]) + + result = get_trace_waterfall(trace_id, self.organization.id) + + assert isinstance(result, EAPTrace) + spans = [result.trace[0], *result.trace[0]["children"]] + by_id = {span["event_id"]: span for span in spans} + assert by_id[root["span_id"]]["links"] == [ + { + "traceId": "d099bf9ad5a143cf8f83a98081d0ed3b", + "itemId": "8873a98879faf06d", + "sampled": True, + "attributes": [ + {"name": "sentry.link.type", "value": "cache_origin", "type": "str"} + ], + } + ] + assert "sentry.links" not in by_id[root["span_id"]]["additional_attributes"] + assert "links" not in by_id[child["span_id"]] + def test_get_trace_waterfall_wrong_project(self) -> None: transaction_name = "api/users/profile" trace_id = uuid.uuid4().hex From 75e5fa5808ca86c5edba4718acd0e888ab5be420 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:42:43 +0000 Subject: [PATCH 2/3] fix(seer): Skip empty and malformed span links in the trace waterfall EAP returns an empty string for spans without sentry.links, which was parsed as JSON and logged a warning for every link-less span. Malformed link attributes also raised AttributeError and failed the whole waterfall. Add tests for both, and for the trace endpoint still rejecting the private attribute. Refs JSSDK-43 Co-authored-by: Charly Gomez <20254395+chargome@users.noreply.github.com> --- src/sentry/seer/agent/tools.py | 5 ++- tests/sentry/seer/agent/test_tools.py | 39 ++++++++++++++++++- .../api/endpoints/test_organization_trace.py | 8 ++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/agent/tools.py b/src/sentry/seer/agent/tools.py index abcf1e8fc196..9356ddd83a92 100644 --- a/src/sentry/seer/agent/tools.py +++ b/src/sentry/seer/agent/tools.py @@ -641,10 +641,11 @@ def _parse_span_links(items: Sequence[dict[str, Any]]) -> None: raw_links = ( attributes.pop(SPAN_LINKS_ATTRIBUTE, None) if isinstance(attributes, dict) else None ) - if isinstance(raw_links, str): + # EAP returns "" for spans without the attribute. + if raw_links and isinstance(raw_links, str): try: item["links"] = [serialize_link(link) for link in orjson.loads(raw_links)] - except (orjson.JSONDecodeError, KeyError, TypeError): + except (orjson.JSONDecodeError, AttributeError, KeyError, TypeError): logger.warning( "get_trace_waterfall: Could not parse span links", extra={"span_id": item.get("event_id")}, diff --git a/tests/sentry/seer/agent/test_tools.py b/tests/sentry/seer/agent/test_tools.py index aefbd59cf22a..9ed36c3c3b3e 100644 --- a/tests/sentry/seer/agent/test_tools.py +++ b/tests/sentry/seer/agent/test_tools.py @@ -32,6 +32,7 @@ EVENT_TIMESERIES_RESOLUTIONS, _get_issue_event_timeseries, _get_recommended_event, + _parse_span_links, execute_table_query, execute_timeseries_query, execute_trace_table_query, @@ -898,7 +899,10 @@ def test_get_trace_waterfall_span_links(self) -> None: ) self.store_spans([root, child]) - result = get_trace_waterfall(trace_id, self.organization.id) + with patch("sentry.seer.agent.tools.logger") as mock_logger: + result = get_trace_waterfall(trace_id, self.organization.id) + # EAP returns "" for the link-less child, which must not count as a bad payload. + mock_logger.warning.assert_not_called() assert isinstance(result, EAPTrace) spans = [result.trace[0], *result.trace[0]["children"]] @@ -915,6 +919,7 @@ def test_get_trace_waterfall_span_links(self) -> None: ] assert "sentry.links" not in by_id[root["span_id"]]["additional_attributes"] assert "links" not in by_id[child["span_id"]] + assert "sentry.links" not in by_id[child["span_id"]]["additional_attributes"] def test_get_trace_waterfall_wrong_project(self) -> None: transaction_name = "api/users/profile" @@ -4491,3 +4496,35 @@ def test_legacy_fallback_only_when_gal_is_unavailable(self) -> None: assert self._activity() == [] with self.feature({"projects:issue-action-log-activity": False}): assert [item["type"] for item in self._activity()] == ["set_resolved"] + + +@pytest.mark.parametrize( + "raw_links", + [ + "not json", + '{"trace_id": "d099bf9ad5a143cf8f83a98081d0ed3b"}', + '[{"trace_id": "d099bf9ad5a143cf8f83a98081d0ed3b"}]', + '[{"trace_id": "d099bf9ad5a143cf8f83a98081d0ed3b", "span_id": "8873a98879faf06d", "attributes": ["x"]}]', + ], +) +def test_parse_span_links_skips_malformed_links(raw_links: str) -> None: + linked_child: dict[str, Any] = { + "event_id": "8873a98879faf06d", + "additional_attributes": { + "sentry.links": '[{"trace_id": "d099bf9ad5a143cf8f83a98081d0ed3b", "span_id": "8873a98879faf06d"}]' + }, + "children": [], + } + span: dict[str, Any] = { + "event_id": "a1b2c3d4e5f60718", + "additional_attributes": {"sentry.links": raw_links, "span.status_code": "200"}, + "children": [linked_child], + } + + _parse_span_links([span]) + + assert "links" not in span + assert span["additional_attributes"] == {"span.status_code": "200"} + assert linked_child["links"] == [ + {"traceId": "d099bf9ad5a143cf8f83a98081d0ed3b", "itemId": "8873a98879faf06d"} + ] diff --git a/tests/snuba/api/endpoints/test_organization_trace.py b/tests/snuba/api/endpoints/test_organization_trace.py index 358fc9ad3011..1e32edc3059d 100644 --- a/tests/snuba/api/endpoints/test_organization_trace.py +++ b/tests/snuba/api/endpoints/test_organization_trace.py @@ -610,6 +610,14 @@ def test_with_additional_attributes(self) -> None: assert data[0]["children"][0]["additional_attributes"]["gen_ai.request.model"] == "gpt-4o" assert data[0]["children"][0]["additional_attributes"]["gen_ai.usage.total_tokens"] == 100 + def test_private_span_links_attribute_rejected(self) -> None: + self.load_trace() + with self.feature(self.FEATURES): + response = self.client_get( + data={"timestamp": self.day_ago, "additional_attributes": ["sentry.links"]}, + ) + assert response.status_code == 400, response.content + def test_with_span_description_in_additional_attrs(self) -> None: self.load_trace() with self.feature(self.FEATURES): From e8fe51160ffbb74480036aae72cb47d53453d174 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:21:56 +0000 Subject: [PATCH 3/3] ref(seer): Share span link parsing with the trace item details endpoint Move JSON parsing for sentry.links into parse_links_json next to serialize_link, and use it from both serialize_links and the Seer trace waterfall. Each link is parsed on its own, so one malformed link no longer drops the valid ones. Refs JSSDK-43 Co-authored-by: Charly Gomez <20254395+chargome@users.noreply.github.com> --- .../endpoints/project_trace_item_details.py | 27 ++++++++++++++----- src/sentry/seer/agent/tools.py | 13 +++------ tests/sentry/seer/agent/test_tools.py | 22 +++++++++++++-- 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/sentry/api/endpoints/project_trace_item_details.py b/src/sentry/api/endpoints/project_trace_item_details.py index f0659087b427..7c9a36372015 100644 --- a/src/sentry/api/endpoints/project_trace_item_details.py +++ b/src/sentry/api/endpoints/project_trace_item_details.py @@ -431,16 +431,29 @@ def serialize_links(attributes: list[dict[str, Any]]) -> list[TraceItemLink] | N if link_attribute is None: return None + value = link_attribute.get("value", {}).get("valStr", None) + if value is None: + return None + return parse_links_json(value) + + +def parse_links_json(raw: str) -> list[TraceItemLink] | None: + """Parse the JSON stored in `sentry.links`, keeping every link that can be serialized.""" try: - value = link_attribute.get("value", {}).get("valStr", None) - if value is not None: - links = json.loads(value) - return [serialize_link(link) for link in links] - else: - return None - except Exception as e: + links = json.loads(raw) + except json.JSONDecodeError as e: sentry_sdk.capture_exception(e) return None + if not isinstance(links, list): + return None + + parsed = [] + for link in links: + try: + parsed.append(serialize_link(link)) + except (AttributeError, KeyError, TypeError) as e: + sentry_sdk.capture_exception(e) + return parsed def serialize_link(link: dict[str, Any]) -> TraceItemLink: diff --git a/src/sentry/seer/agent/tools.py b/src/sentry/seer/agent/tools.py index 9356ddd83a92..a3cd9e078b8a 100644 --- a/src/sentry/seer/agent/tools.py +++ b/src/sentry/seer/agent/tools.py @@ -6,7 +6,6 @@ from datetime import UTC, datetime, timedelta, timezone from typing import Any, Literal, TypedDict, cast -import orjson from django.core.exceptions import BadRequest from django.db import models from django.db.models import Q @@ -18,7 +17,7 @@ from sentry import eventstore, features from sentry.api.client import client from sentry.api.endpoints.organization_events_timeseries import TOP_EVENTS_DATASETS -from sentry.api.endpoints.project_trace_item_details import serialize_link +from sentry.api.endpoints.project_trace_item_details import parse_links_json from sentry.api.serializers.base import serialize from sentry.api.serializers.models.activity import ActivitySerializer, ActivitySerializerResponse from sentry.api.serializers.models.commit import CommitSerializer, CommitSerializerResponse @@ -642,14 +641,8 @@ def _parse_span_links(items: Sequence[dict[str, Any]]) -> None: attributes.pop(SPAN_LINKS_ATTRIBUTE, None) if isinstance(attributes, dict) else None ) # EAP returns "" for spans without the attribute. - if raw_links and isinstance(raw_links, str): - try: - item["links"] = [serialize_link(link) for link in orjson.loads(raw_links)] - except (orjson.JSONDecodeError, AttributeError, KeyError, TypeError): - logger.warning( - "get_trace_waterfall: Could not parse span links", - extra={"span_id": item.get("event_id")}, - ) + if raw_links and isinstance(raw_links, str) and (links := parse_links_json(raw_links)): + item["links"] = links _parse_span_links(item.get("children", [])) diff --git a/tests/sentry/seer/agent/test_tools.py b/tests/sentry/seer/agent/test_tools.py index 9ed36c3c3b3e..8d691aa3c5ac 100644 --- a/tests/sentry/seer/agent/test_tools.py +++ b/tests/sentry/seer/agent/test_tools.py @@ -899,10 +899,12 @@ def test_get_trace_waterfall_span_links(self) -> None: ) self.store_spans([root, child]) - with patch("sentry.seer.agent.tools.logger") as mock_logger: + with patch( + "sentry.api.endpoints.project_trace_item_details.sentry_sdk.capture_exception" + ) as mock_capture: result = get_trace_waterfall(trace_id, self.organization.id) # EAP returns "" for the link-less child, which must not count as a bad payload. - mock_logger.warning.assert_not_called() + mock_capture.assert_not_called() assert isinstance(result, EAPTrace) spans = [result.trace[0], *result.trace[0]["children"]] @@ -4528,3 +4530,19 @@ def test_parse_span_links_skips_malformed_links(raw_links: str) -> None: assert linked_child["links"] == [ {"traceId": "d099bf9ad5a143cf8f83a98081d0ed3b", "itemId": "8873a98879faf06d"} ] + + +def test_parse_span_links_keeps_valid_links_next_to_malformed_ones() -> None: + span: dict[str, Any] = { + "event_id": "a1b2c3d4e5f60718", + "additional_attributes": { + "sentry.links": '[{"trace_id": "d099bf9ad5a143cf8f83a98081d0ed3b"}, {"trace_id": "d099bf9ad5a143cf8f83a98081d0ed3b", "span_id": "8873a98879faf06d"}]' + }, + "children": [], + } + + _parse_span_links([span]) + + assert span["links"] == [ + {"traceId": "d099bf9ad5a143cf8f83a98081d0ed3b", "itemId": "8873a98879faf06d"} + ]