From 459f308c6a86140c796602ae18ed0af5c575d5c3 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 22 May 2026 07:29:47 -0300 Subject: [PATCH 01/14] ref(search): Add EAP API attribute visibility checks Add a shared helper for hiding internal Sentry convention attributes from API surfaces and let SearchResolver track attributes hidden by API visibility configuration. This keeps default resolver behavior unchanged unless an API caller opts into visibility enforcement. --- src/sentry/search/eap/resolver.py | 27 ++++++++- src/sentry/search/eap/types.py | 4 ++ src/sentry/search/eap/utils.py | 72 ++++++++++++++++++++++ tests/sentry/search/eap/test_spans.py | 86 ++++++++++++++++++++++++++- 4 files changed, 187 insertions(+), 2 deletions(-) diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index a036cba06a0b80..93709122e2d679 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -60,7 +60,7 @@ from sentry.search.eap.rpc_utils import and_trace_item_filters from sentry.search.eap.sampling import validate_sampling from sentry.search.eap.spans.attributes import SPANS_INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS -from sentry.search.eap.types import EAPResponse, SearchResolverConfig +from sentry.search.eap.types import EAPResponse, SearchResolverConfig, SupportedTraceItemType from sentry.search.events import constants as qb_constants from sentry.search.events import fields from sentry.search.events import filter as event_filter @@ -105,9 +105,16 @@ class SearchResolver: VirtualColumnDefinition | None, ], ] = field(default_factory=dict) + _hidden_api_attributes: set[str] = field(default_factory=set, repr=False) qualified_short_id_to_group_id_cache: dict[int, dict[str, int]] = field(default_factory=dict) _internal_name_to_column: dict[str, ResolvedAttribute] = field(default_factory=dict, repr=False) + def has_hidden_api_attributes(self) -> bool: + return bool(self._hidden_api_attributes) + + def is_hidden_api_attribute(self, column: str) -> bool: + return column in self._hidden_api_attributes + def _find_column_by_internal_name(self, internal_name: str) -> ResolvedAttribute | None: """Look up a column definition by its internal name (e.g. 'sentry.item_id' -> 'id' column). @@ -1043,7 +1050,9 @@ def resolve_attribute( if public_alias_override is not None: alias = public_alias_override + is_public_defined_attribute = False if column in self.definitions.contexts: + is_public_defined_attribute = True column_context = self.definitions.contexts[column] column_definition = ResolvedAttribute( public_alias=alias, @@ -1052,6 +1061,7 @@ def resolve_attribute( processor=column_context.processor, ) elif column in self.definitions.columns: + is_public_defined_attribute = True column_context = None column_definition = self.definitions.columns[column] if column_definition.private and column not in self.config.fields_acl.attributes: @@ -1111,6 +1121,21 @@ def resolve_attribute( column_context = None if column_definition: + if self.config.api_attribute_visibility_item_type is not None: + from sentry.search.eap.utils import can_expose_attribute_to_api + + item_type = SupportedTraceItemType(self.config.api_attribute_visibility_item_type) + visibility_attribute = ( + column_definition.public_alias + if is_public_defined_attribute + else column_definition.internal_name + ) + if not can_expose_attribute_to_api( + visibility_attribute, + item_type, + include_internal=self.config.api_attribute_visibility_include_internal, + ): + self._hidden_api_attributes.add(column) self._resolved_attribute_cache[column] = (column_definition, column_context) return self._resolved_attribute_cache[column] else: diff --git a/src/sentry/search/eap/types.py b/src/sentry/search/eap/types.py index 24e5e56476d3db..94d9901a60d8b2 100644 --- a/src/sentry/search/eap/types.py +++ b/src/sentry/search/eap/types.py @@ -39,6 +39,10 @@ class SearchResolverConfig: # When True, ResolvedAttributes whose internal_type is ARRAY are silently dropped based on # feature flag organizations:trace-item-details-array-fields disable_array_attributes: bool = True + # API-only visibility enforcement. Non-API callers should leave this as None + # so backend resolution semantics remain unchanged. + api_attribute_visibility_item_type: str | None = None + api_attribute_visibility_include_internal: bool = False def extra_conditions( self, diff --git a/src/sentry/search/eap/utils.py b/src/sentry/search/eap/utils.py index f6d61014606d7d..3d595d60978d73 100644 --- a/src/sentry/search/eap/utils.py +++ b/src/sentry/search/eap/utils.py @@ -2,6 +2,7 @@ from typing import Literal from google.protobuf.timestamp_pb2 import Timestamp +from sentry_conventions.attributes import ATTRIBUTE_METADATA from sentry_protos.snuba.v1.endpoint_time_series_pb2 import TimeSeriesRequest from sentry.search.eap.columns import ColumnDefinitions, ResolvedAttribute @@ -237,6 +238,77 @@ def can_expose_attribute( return True +def _has_internal_convention_visibility(attribute: str) -> bool: + metadata = ATTRIBUTE_METADATA.get(attribute) + if metadata is None: + return False + + visibility = metadata.visibility + return getattr(visibility, "value", visibility) == "internal" + + +def _get_sentry_convention_visibility_candidates( + attribute: str, item_type: SupportedTraceItemType +) -> set[str]: + candidates = {attribute} + + if item_type == SupportedTraceItemType.SPANS and attribute.startswith(("dsc.", "_internal.")): + candidates.add(f"sentry.{attribute}") + + resolved_attribute = PUBLIC_ALIAS_TO_INTERNAL_MAPPING.get(item_type, {}).get(attribute) + if resolved_attribute is not None: + candidates.add(resolved_attribute.public_alias) + candidates.add(resolved_attribute.internal_name) + if resolved_attribute.replacement: + candidates.add(resolved_attribute.replacement) + + for mapping in INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS.get(item_type, {}).values(): + public_alias = mapping.get(attribute) + if public_alias is not None: + candidates.add(public_alias) + + replacement_map = SENTRY_CONVENTIONS_REPLACEMENT_MAPPINGS.get(item_type, {}) + seen: set[str] = set() + pending = list(candidates) + while pending: + candidate = pending.pop() + if candidate in seen: + continue + seen.add(candidate) + replacement = replacement_map.get(candidate) + if replacement is not None and replacement not in candidates: + candidates.add(replacement) + pending.append(replacement) + + return candidates + + +def is_internal_sentry_convention_attribute( + attribute: str, item_type: SupportedTraceItemType +) -> bool: + return any( + _has_internal_convention_visibility(candidate) + for candidate in _get_sentry_convention_visibility_candidates(attribute, item_type) + ) + + +def can_expose_attribute_to_api( + attribute: str, item_type: SupportedTraceItemType, include_internal: bool = False +) -> bool: + candidates = _get_sentry_convention_visibility_candidates(attribute, item_type) + + for candidate in candidates: + if not can_expose_attribute(candidate, item_type, include_internal=include_internal): + return False + + if include_internal: + return True + + return not any( + is_internal_sentry_convention_attribute(candidate, item_type) for candidate in candidates + ) + + def is_sentry_convention_replacement_attribute( public_alias: str, item_type: SupportedTraceItemType ) -> bool: diff --git a/tests/sentry/search/eap/test_spans.py b/tests/sentry/search/eap/test_spans.py index f9d50180525958..7b20836377af71 100644 --- a/tests/sentry/search/eap/test_spans.py +++ b/tests/sentry/search/eap/test_spans.py @@ -1,5 +1,7 @@ import os from datetime import datetime +from types import SimpleNamespace +from unittest import mock import pytest from sentry_protos.snuba.v1.endpoint_trace_item_table_pb2 import ( @@ -28,6 +30,7 @@ ) from sentry.exceptions import InvalidSearchQuery +from sentry.search.eap import utils as eap_utils from sentry.search.eap.columns import ResolvedAttribute from sentry.search.eap.occurrences.definitions import OCCURRENCE_DEFINITIONS from sentry.search.eap.resolver import SearchResolver @@ -40,13 +43,94 @@ ) from sentry.search.eap.spans.definitions import SPAN_DEFINITIONS from sentry.search.eap.spans.sentry_conventions import SENTRY_CONVENTIONS_DIRECTORY -from sentry.search.eap.types import SearchResolverConfig +from sentry.search.eap.types import SearchResolverConfig, SupportedTraceItemType +from sentry.search.eap.utils import can_expose_attribute_to_api from sentry.search.events.types import SnubaParams from sentry.testutils.cases import TestCase from sentry.testutils.helpers.datetime import freeze_time from sentry.utils import json +class AttributeVisibilityTest(TestCase): + def test_public_convention_attribute_visible_to_everyone(self) -> None: + with mock.patch.dict( + eap_utils.ATTRIBUTE_METADATA, + {"public.attr": SimpleNamespace(visibility="public")}, + ): + assert can_expose_attribute_to_api("public.attr", SupportedTraceItemType.SPANS) + + def test_internal_convention_attribute_hidden_unless_included(self) -> None: + with mock.patch.dict( + eap_utils.ATTRIBUTE_METADATA, + {"internal.attr": SimpleNamespace(visibility="internal")}, + ): + assert not can_expose_attribute_to_api("internal.attr", SupportedTraceItemType.SPANS) + assert can_expose_attribute_to_api( + "internal.attr", SupportedTraceItemType.SPANS, include_internal=True + ) + + def test_internal_convention_public_alias_is_hidden(self) -> None: + with ( + mock.patch.dict( + eap_utils.ATTRIBUTE_METADATA, + {"internal.attr": SimpleNamespace(visibility="internal")}, + ), + mock.patch.dict( + eap_utils.PUBLIC_ALIAS_TO_INTERNAL_MAPPING[SupportedTraceItemType.SPANS], + { + "public.alias": ResolvedAttribute( + public_alias="public.alias", + internal_name="internal.attr", + search_type="string", + ) + }, + ), + ): + assert not can_expose_attribute_to_api("public.alias", SupportedTraceItemType.SPANS) + + def test_internal_convention_translated_public_alias_is_hidden(self) -> None: + with ( + mock.patch.dict( + eap_utils.ATTRIBUTE_METADATA, + {"public.alias": SimpleNamespace(visibility="internal")}, + ), + mock.patch.dict( + eap_utils.INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS[SupportedTraceItemType.SPANS]["string"], + {"internal.attr": "public.alias"}, + ), + ): + assert not can_expose_attribute_to_api("internal.attr", SupportedTraceItemType.SPANS) + + def test_internal_convention_replacement_is_hidden(self) -> None: + with ( + mock.patch.dict( + eap_utils.ATTRIBUTE_METADATA, + {"replacement.attr": SimpleNamespace(visibility="internal")}, + ), + mock.patch.dict( + eap_utils.SENTRY_CONVENTIONS_REPLACEMENT_MAPPINGS[SupportedTraceItemType.SPANS], + {"deprecated.attr": "replacement.attr"}, + ), + ): + assert not can_expose_attribute_to_api("deprecated.attr", SupportedTraceItemType.SPANS) + + def test_stripped_internal_prefix_alias_is_hidden(self) -> None: + assert not can_expose_attribute_to_api( + "_internal.normalized_description", SupportedTraceItemType.SPANS + ) + assert can_expose_attribute_to_api( + "_internal.normalized_description", + SupportedTraceItemType.SPANS, + include_internal=True, + ) + + def test_stripped_dsc_convention_alias_is_hidden(self) -> None: + assert not can_expose_attribute_to_api("dsc.trace_id", SupportedTraceItemType.SPANS) + assert can_expose_attribute_to_api( + "dsc.trace_id", SupportedTraceItemType.SPANS, include_internal=True + ) + + class SearchResolverQueryTest(TestCase): def setUp(self) -> None: self.resolver = SearchResolver( From 85f1e75818e19fab543312e6a97b439ae38abbfc Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 22 May 2026 10:52:28 -0300 Subject: [PATCH 02/14] fix(typing): Explicitly re-export ATTRIBUTE_METADATA from eap utils The `as ATTRIBUTE_METADATA` pattern tells mypy this is an intentional re-export, fixing the attr-defined errors in tests that access it via `eap_utils.ATTRIBUTE_METADATA`. --- src/sentry/search/eap/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/search/eap/utils.py b/src/sentry/search/eap/utils.py index 3d595d60978d73..6f345ff366462c 100644 --- a/src/sentry/search/eap/utils.py +++ b/src/sentry/search/eap/utils.py @@ -2,7 +2,7 @@ from typing import Literal from google.protobuf.timestamp_pb2 import Timestamp -from sentry_conventions.attributes import ATTRIBUTE_METADATA +from sentry_conventions.attributes import ATTRIBUTE_METADATA as ATTRIBUTE_METADATA from sentry_protos.snuba.v1.endpoint_time_series_pb2 import TimeSeriesRequest from sentry.search.eap.columns import ColumnDefinitions, ResolvedAttribute From 3d585d7cc71ba35603820321b166a1f125abaa44 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 22 May 2026 13:16:42 -0300 Subject: [PATCH 03/14] ref(search): Move can_expose_attribute_to_api import to top of module --- src/sentry/search/eap/resolver.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index 93709122e2d679..ea93623e9a34b5 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -61,6 +61,7 @@ from sentry.search.eap.sampling import validate_sampling from sentry.search.eap.spans.attributes import SPANS_INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS from sentry.search.eap.types import EAPResponse, SearchResolverConfig, SupportedTraceItemType +from sentry.search.eap.utils import can_expose_attribute_to_api from sentry.search.events import constants as qb_constants from sentry.search.events import fields from sentry.search.events import filter as event_filter @@ -1122,8 +1123,6 @@ def resolve_attribute( if column_definition: if self.config.api_attribute_visibility_item_type is not None: - from sentry.search.eap.utils import can_expose_attribute_to_api - item_type = SupportedTraceItemType(self.config.api_attribute_visibility_item_type) visibility_attribute = ( column_definition.public_alias From 1c0058e4469cf6030be4719555917b6ed7d85c7d Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 22 May 2026 13:18:05 -0300 Subject: [PATCH 04/14] ref(search): Remove redundant seen set in visibility candidates The candidates set already prevents duplicates since we check `replacement not in candidates` before adding to pending. --- src/sentry/search/eap/utils.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/sentry/search/eap/utils.py b/src/sentry/search/eap/utils.py index 6f345ff366462c..3812e245536ab9 100644 --- a/src/sentry/search/eap/utils.py +++ b/src/sentry/search/eap/utils.py @@ -268,13 +268,9 @@ def _get_sentry_convention_visibility_candidates( candidates.add(public_alias) replacement_map = SENTRY_CONVENTIONS_REPLACEMENT_MAPPINGS.get(item_type, {}) - seen: set[str] = set() pending = list(candidates) while pending: candidate = pending.pop() - if candidate in seen: - continue - seen.add(candidate) replacement = replacement_map.get(candidate) if replacement is not None and replacement not in candidates: candidates.add(replacement) From c002e60f97b7f5e1ab1b6170efd7f828008ccff2 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 22 May 2026 13:31:59 -0300 Subject: [PATCH 05/14] ref(search): Move can_expose_attribute_to_api import back to local scope Reverts the import move to avoid a circular import risk between resolver and utils modules. --- src/sentry/search/eap/resolver.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index ea93623e9a34b5..93709122e2d679 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -61,7 +61,6 @@ from sentry.search.eap.sampling import validate_sampling from sentry.search.eap.spans.attributes import SPANS_INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS from sentry.search.eap.types import EAPResponse, SearchResolverConfig, SupportedTraceItemType -from sentry.search.eap.utils import can_expose_attribute_to_api from sentry.search.events import constants as qb_constants from sentry.search.events import fields from sentry.search.events import filter as event_filter @@ -1123,6 +1122,8 @@ def resolve_attribute( if column_definition: if self.config.api_attribute_visibility_item_type is not None: + from sentry.search.eap.utils import can_expose_attribute_to_api + item_type = SupportedTraceItemType(self.config.api_attribute_visibility_item_type) visibility_attribute = ( column_definition.public_alias From 4859bb3d42eaba657dbda385946d7862347e1ae1 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 22 May 2026 13:33:46 -0300 Subject: [PATCH 06/14] fix(search): Use original column name for API visibility checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When public_alias_override is set (e.g. equation resolution), the column_definition.public_alias becomes a synthetic label like "equation|…" which can_expose_attribute_to_api cannot evaluate against conventions, prefixes, or mappings. --- src/sentry/search/eap/resolver.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index 93709122e2d679..c38a692480fb70 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -1126,9 +1126,7 @@ def resolve_attribute( item_type = SupportedTraceItemType(self.config.api_attribute_visibility_item_type) visibility_attribute = ( - column_definition.public_alias - if is_public_defined_attribute - else column_definition.internal_name + column if is_public_defined_attribute else column_definition.internal_name ) if not can_expose_attribute_to_api( visibility_attribute, From 409b9a90ec50b832f22f45e43cfa3434d3488ab8 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 25 May 2026 13:24:02 -0300 Subject: [PATCH 07/14] Add in description comment --- src/sentry/search/eap/utils.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/sentry/search/eap/utils.py b/src/sentry/search/eap/utils.py index 3812e245536ab9..52366736e2dea3 100644 --- a/src/sentry/search/eap/utils.py +++ b/src/sentry/search/eap/utils.py @@ -291,12 +291,23 @@ def is_internal_sentry_convention_attribute( def can_expose_attribute_to_api( attribute: str, item_type: SupportedTraceItemType, include_internal: bool = False ) -> bool: + """Return whether an attribute may be exposed by public API surfaces. + + The visibility check expands the requested attribute to its related public + aliases, internal names, and replacement attributes because any of those may + carry the metadata that marks the underlying convention as internal. + `include_internal` only allows those Sentry-owned internal convention + attributes. It does not bypass `can_expose_attribute`, which still filters + private attributes first. + """ candidates = _get_sentry_convention_visibility_candidates(attribute, item_type) for candidate in candidates: if not can_expose_attribute(candidate, item_type, include_internal=include_internal): return False + # Private attributes are rejected above before this internal-only override + # is applied. if include_internal: return True From 62217a5d8e42a28998f49c8579d9f324084a43eb Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 25 May 2026 13:49:47 -0300 Subject: [PATCH 08/14] test(search): Use real convention attributes Update EAP attribute visibility tests to exercise real sentry_conventions metadata instead of mocked convention metadata. Keep alias and replacement edge-case coverage while pointing those mappings at real internal DSC attributes. Co-Authored-By: Codex --- tests/sentry/search/eap/test_spans.py | 93 +++++++++++++-------------- 1 file changed, 43 insertions(+), 50 deletions(-) diff --git a/tests/sentry/search/eap/test_spans.py b/tests/sentry/search/eap/test_spans.py index 7b20836377af71..65e0e48cbea797 100644 --- a/tests/sentry/search/eap/test_spans.py +++ b/tests/sentry/search/eap/test_spans.py @@ -1,9 +1,9 @@ import os from datetime import datetime -from types import SimpleNamespace from unittest import mock import pytest +from sentry_conventions.attributes import ATTRIBUTE_METADATA, ATTRIBUTE_NAMES from sentry_protos.snuba.v1.endpoint_trace_item_table_pb2 import ( AggregationAndFilter, AggregationComparisonFilter, @@ -53,67 +53,55 @@ class AttributeVisibilityTest(TestCase): def test_public_convention_attribute_visible_to_everyone(self) -> None: - with mock.patch.dict( - eap_utils.ATTRIBUTE_METADATA, - {"public.attr": SimpleNamespace(visibility="public")}, - ): - assert can_expose_attribute_to_api("public.attr", SupportedTraceItemType.SPANS) + assert can_expose_attribute_to_api( + ATTRIBUTE_NAMES.SENTRY_ENVIRONMENT, SupportedTraceItemType.SPANS + ) def test_internal_convention_attribute_hidden_unless_included(self) -> None: - with mock.patch.dict( - eap_utils.ATTRIBUTE_METADATA, - {"internal.attr": SimpleNamespace(visibility="internal")}, - ): - assert not can_expose_attribute_to_api("internal.attr", SupportedTraceItemType.SPANS) - assert can_expose_attribute_to_api( - "internal.attr", SupportedTraceItemType.SPANS, include_internal=True - ) + assert not can_expose_attribute_to_api( + ATTRIBUTE_NAMES.SENTRY_DSC_ENVIRONMENT, SupportedTraceItemType.SPANS + ) + assert can_expose_attribute_to_api( + ATTRIBUTE_NAMES.SENTRY_DSC_ENVIRONMENT, + SupportedTraceItemType.SPANS, + include_internal=True, + ) def test_internal_convention_public_alias_is_hidden(self) -> None: - with ( - mock.patch.dict( - eap_utils.ATTRIBUTE_METADATA, - {"internal.attr": SimpleNamespace(visibility="internal")}, - ), - mock.patch.dict( - eap_utils.PUBLIC_ALIAS_TO_INTERNAL_MAPPING[SupportedTraceItemType.SPANS], - { - "public.alias": ResolvedAttribute( - public_alias="public.alias", - internal_name="internal.attr", - search_type="string", - ) - }, - ), + with mock.patch.dict( + eap_utils.PUBLIC_ALIAS_TO_INTERNAL_MAPPING[SupportedTraceItemType.SPANS], + { + "public.alias": ResolvedAttribute( + public_alias="public.alias", + internal_name=ATTRIBUTE_NAMES.SENTRY_DSC_ENVIRONMENT, + search_type="string", + ) + }, ): assert not can_expose_attribute_to_api("public.alias", SupportedTraceItemType.SPANS) def test_internal_convention_translated_public_alias_is_hidden(self) -> None: - with ( - mock.patch.dict( - eap_utils.ATTRIBUTE_METADATA, - {"public.alias": SimpleNamespace(visibility="internal")}, - ), - mock.patch.dict( - eap_utils.INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS[SupportedTraceItemType.SPANS]["string"], - {"internal.attr": "public.alias"}, - ), + with mock.patch.dict( + eap_utils.INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS[SupportedTraceItemType.SPANS]["string"], + {ATTRIBUTE_NAMES.SENTRY_DSC_ENVIRONMENT: "public.alias"}, ): - assert not can_expose_attribute_to_api("internal.attr", SupportedTraceItemType.SPANS) + assert not can_expose_attribute_to_api( + ATTRIBUTE_NAMES.SENTRY_DSC_ENVIRONMENT, SupportedTraceItemType.SPANS + ) def test_internal_convention_replacement_is_hidden(self) -> None: - with ( - mock.patch.dict( - eap_utils.ATTRIBUTE_METADATA, - {"replacement.attr": SimpleNamespace(visibility="internal")}, - ), - mock.patch.dict( - eap_utils.SENTRY_CONVENTIONS_REPLACEMENT_MAPPINGS[SupportedTraceItemType.SPANS], - {"deprecated.attr": "replacement.attr"}, - ), + with mock.patch.dict( + eap_utils.SENTRY_CONVENTIONS_REPLACEMENT_MAPPINGS[SupportedTraceItemType.SPANS], + {"deprecated.attr": ATTRIBUTE_NAMES.SENTRY_DSC_ENVIRONMENT}, ): assert not can_expose_attribute_to_api("deprecated.attr", SupportedTraceItemType.SPANS) + def test_public_convention_deprecated_alias_visible_to_everyone(self) -> None: + aliases = ATTRIBUTE_METADATA[ATTRIBUTE_NAMES.SENTRY_ENVIRONMENT].aliases + + assert aliases is not None + assert can_expose_attribute_to_api(aliases[0], SupportedTraceItemType.SPANS) + def test_stripped_internal_prefix_alias_is_hidden(self) -> None: assert not can_expose_attribute_to_api( "_internal.normalized_description", SupportedTraceItemType.SPANS @@ -125,9 +113,14 @@ def test_stripped_internal_prefix_alias_is_hidden(self) -> None: ) def test_stripped_dsc_convention_alias_is_hidden(self) -> None: - assert not can_expose_attribute_to_api("dsc.trace_id", SupportedTraceItemType.SPANS) + assert not can_expose_attribute_to_api( + ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix("sentry."), + SupportedTraceItemType.SPANS, + ) assert can_expose_attribute_to_api( - "dsc.trace_id", SupportedTraceItemType.SPANS, include_internal=True + ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix("sentry."), + SupportedTraceItemType.SPANS, + include_internal=True, ) From 91d8b48bea8f3b7830076e357e784aac8cd1f587 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 25 May 2026 14:56:43 -0300 Subject: [PATCH 09/14] ref(search): Filter hidden API attributes without resolver state Apply API attribute visibility while resolving selected attributes instead of storing hidden attribute names on the SearchResolver instance. Keep the API visibility config as the switch for this behavior and add coverage for hidden internal attributes and the include-internal override. Co-Authored-By: Codex --- src/sentry/search/eap/resolver.py | 49 ++++++++++++++------------- tests/sentry/search/eap/test_spans.py | 42 +++++++++++++++++++++++ 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index c38a692480fb70..70e50ae27cd332 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -105,16 +105,9 @@ class SearchResolver: VirtualColumnDefinition | None, ], ] = field(default_factory=dict) - _hidden_api_attributes: set[str] = field(default_factory=set, repr=False) qualified_short_id_to_group_id_cache: dict[int, dict[str, int]] = field(default_factory=dict) _internal_name_to_column: dict[str, ResolvedAttribute] = field(default_factory=dict, repr=False) - def has_hidden_api_attributes(self) -> bool: - return bool(self._hidden_api_attributes) - - def is_hidden_api_attribute(self, column: str) -> bool: - return column in self._hidden_api_attributes - def _find_column_by_internal_name(self, internal_name: str) -> ResolvedAttribute | None: """Look up a column definition by its internal name (e.g. 'sentry.item_id' -> 'id' column). @@ -975,6 +968,10 @@ def resolve_columns( match = fields.is_function(column) has_aggregates = has_aggregates or match is not None resolved_column, context = self.resolve_column(column, match) + if isinstance(resolved_column, ResolvedAttribute) and self._should_hide_api_attribute( + column, resolved_column + ): + continue if ( self.config.disable_array_attributes and isinstance(resolved_column, ResolvedAttribute) @@ -1031,12 +1028,34 @@ def resolve_attributes( resolved_contexts = [] for column in columns: col, context = self.resolve_attribute(column) + if self._should_hide_api_attribute(column, col): + continue if self.config.disable_array_attributes and col.internal_type == constants.ARRAY: continue resolved_columns.append(col) resolved_contexts.append(context) return resolved_columns, resolved_contexts + def _should_hide_api_attribute( + self, column: str, resolved_attribute: ResolvedAttribute + ) -> bool: + if self.config.api_attribute_visibility_item_type is None: + return False + + from sentry.search.eap.utils import can_expose_attribute_to_api + + item_type = SupportedTraceItemType(self.config.api_attribute_visibility_item_type) + visibility_attribute = ( + column + if column in self.definitions.contexts or column in self.definitions.columns + else resolved_attribute.internal_name + ) + return not can_expose_attribute_to_api( + visibility_attribute, + item_type, + include_internal=self.config.api_attribute_visibility_include_internal, + ) + def resolve_attribute( self, column: str, public_alias_override: str | None = None ) -> tuple[ResolvedAttribute, VirtualColumnDefinition | None]: @@ -1050,9 +1069,7 @@ def resolve_attribute( if public_alias_override is not None: alias = public_alias_override - is_public_defined_attribute = False if column in self.definitions.contexts: - is_public_defined_attribute = True column_context = self.definitions.contexts[column] column_definition = ResolvedAttribute( public_alias=alias, @@ -1061,7 +1078,6 @@ def resolve_attribute( processor=column_context.processor, ) elif column in self.definitions.columns: - is_public_defined_attribute = True column_context = None column_definition = self.definitions.columns[column] if column_definition.private and column not in self.config.fields_acl.attributes: @@ -1121,19 +1137,6 @@ def resolve_attribute( column_context = None if column_definition: - if self.config.api_attribute_visibility_item_type is not None: - from sentry.search.eap.utils import can_expose_attribute_to_api - - item_type = SupportedTraceItemType(self.config.api_attribute_visibility_item_type) - visibility_attribute = ( - column if is_public_defined_attribute else column_definition.internal_name - ) - if not can_expose_attribute_to_api( - visibility_attribute, - item_type, - include_internal=self.config.api_attribute_visibility_include_internal, - ): - self._hidden_api_attributes.add(column) self._resolved_attribute_cache[column] = (column_definition, column_context) return self._resolved_attribute_cache[column] else: diff --git a/tests/sentry/search/eap/test_spans.py b/tests/sentry/search/eap/test_spans.py index 65e0e48cbea797..b4883ae114e07d 100644 --- a/tests/sentry/search/eap/test_spans.py +++ b/tests/sentry/search/eap/test_spans.py @@ -822,6 +822,48 @@ def test_simple_number_tag(self) -> None: ) assert virtual_context is None + def test_resolve_columns_hides_internal_api_attributes(self) -> None: + resolver = SearchResolver( + params=SnubaParams(projects=[self.project]), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS + ), + definitions=SPAN_DEFINITIONS, + ) + + resolved_columns, resolved_contexts = resolver.resolve_columns( + [ + "span.op", + f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]", + ] + ) + + assert [column.public_alias for column in resolved_columns] == ["span.op"] + assert resolved_contexts == [None] + + def test_resolve_columns_includes_internal_api_attributes_when_configured(self) -> None: + resolver = SearchResolver( + params=SnubaParams(projects=[self.project]), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS, + api_attribute_visibility_include_internal=True, + ), + definitions=SPAN_DEFINITIONS, + ) + + resolved_columns, resolved_contexts = resolver.resolve_columns( + [ + "span.op", + f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]", + ] + ) + + assert [column.public_alias for column in resolved_columns] == [ + "span.op", + f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]", + ] + assert resolved_contexts == [None, None] + def test_sum_function(self) -> None: resolved_column, virtual_context = self.resolver.resolve_column("sum(span.self_time)") assert resolved_column.proto_definition == AttributeAggregation( From 9b8b7b2eb514c38b006022c84a396e0477457a62 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 25 May 2026 15:22:02 -0300 Subject: [PATCH 10/14] fix(search): Hide API-invisible EAP attributes in expressions Apply API attribute visibility filtering to aggregate and equation operands so hidden attributes cannot be embedded in resolved RPC columns. Reject orderby fallback resolution when the selected column was filtered out for API visibility. Co-Authored-By: GPT-5 Codex --- src/sentry/search/eap/resolver.py | 40 +++++++++++++++++-- src/sentry/snuba/rpc_dataset_common.py | 4 ++ tests/sentry/search/eap/test_spans.py | 34 ++++++++++++++++ tests/sentry/snuba/test_rpc_dataset_common.py | 33 ++++++++++++++- 4 files changed, 107 insertions(+), 4 deletions(-) diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index 70e50ae27cd332..8cfd2f37fb60b9 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -83,6 +83,10 @@ def collect_issue_short_ids_from_parsed_terms(terms: Sequence[object]) -> set[st return out +class _HiddenApiAttribute(InvalidSearchQuery): + pass + + @dataclass(frozen=True) class SearchResolver: """The only attributes are things we want to cache and params @@ -967,7 +971,10 @@ def resolve_columns( for column in stripped_columns: match = fields.is_function(column) has_aggregates = has_aggregates or match is not None - resolved_column, context = self.resolve_column(column, match) + try: + resolved_column, context = self.resolve_column(column, match) + except _HiddenApiAttribute: + continue if isinstance(resolved_column, ResolvedAttribute) and self._should_hide_api_attribute( column, resolved_column ): @@ -1036,6 +1043,13 @@ def resolve_attributes( resolved_contexts.append(context) return resolved_columns, resolved_contexts + def should_hide_api_column( + self, column: str, resolved_column: ResolvedAttribute | ResolvedFunction + ) -> bool: + if not isinstance(resolved_column, ResolvedAttribute): + return False + return self._should_hide_api_attribute(column, resolved_column) + def _should_hide_api_attribute( self, column: str, resolved_attribute: ResolvedAttribute ) -> bool: @@ -1056,6 +1070,12 @@ def _should_hide_api_attribute( include_internal=self.config.api_attribute_visibility_include_internal, ) + def _raise_if_hidden_api_attribute( + self, column: str, resolved_attribute: ResolvedAttribute + ) -> None: + if self._should_hide_api_attribute(column, resolved_attribute): + raise _HiddenApiAttribute(f"The field {column} is not allowed for this query") + def resolve_attribute( self, column: str, public_alias_override: str | None = None ) -> tuple[ResolvedAttribute, VirtualColumnDefinition | None]: @@ -1152,7 +1172,10 @@ def resolve_functions( """Helper function to resolve a list of functions instead of 1 attribute at a time""" resolved_functions, resolved_contexts = [], [] for column in columns: - function, context = self.resolve_function(column) + try: + function, context = self.resolve_function(column) + except _HiddenApiAttribute: + continue resolved_functions.append(function) resolved_contexts.append(context) return resolved_functions, resolved_contexts @@ -1208,6 +1231,9 @@ def resolve_function( parsed_args.append(argument_definition.default_arg) else: parsed_argument, _ = self.resolve_attribute(argument_definition.default_arg) + self._raise_if_hidden_api_attribute( + argument_definition.default_arg, parsed_argument + ) parsed_args.append(parsed_argument) missing_args -= 1 continue @@ -1222,6 +1248,7 @@ def resolve_function( ) if isinstance(argument_definition, AttributeArgumentDefinition): parsed_argument, _ = self.resolve_attribute(argument) + self._raise_if_hidden_api_attribute(argument, parsed_argument) parsed_args.append(parsed_argument) else: if argument_definition.argument_types is None: @@ -1308,7 +1335,10 @@ def resolve_equations( formulas = [] contexts = [] for equation in equations: - formula, context = self.resolve_equation(equation) + try: + formula, context = self.resolve_equation(equation) + except _HiddenApiAttribute: + continue formulas.append(formula) contexts.extend(context) return formulas, contexts @@ -1329,6 +1359,8 @@ def resolve_equation( col, context = self.resolve_column( operation, public_alias_override=f"equation|{equation}" ) + if isinstance(col, ResolvedAttribute): + self._raise_if_hidden_api_attribute(operation, col) return col, [context] if context else [] elif isinstance(operation, float): return ( @@ -1404,6 +1436,8 @@ def _resolve_operation( # Resolve the column, and turn it into a RPC Column so it can be used in a BinaryFormula # Columns in equations must pass default_value=0 otherwise they may become a null and ruin the entire formula col, context = self.resolve_column(operation, default_value=0) + if isinstance(col, ResolvedAttribute): + self._raise_if_hidden_api_attribute(operation, col) contexts = [context] if context is not None else [] proto_definition = col.proto_definition diff --git a/src/sentry/snuba/rpc_dataset_common.py b/src/sentry/snuba/rpc_dataset_common.py index 37bcdc4dade70d..2a4256999cf6fa 100644 --- a/src/sentry/snuba/rpc_dataset_common.py +++ b/src/sentry/snuba/rpc_dataset_common.py @@ -311,6 +311,10 @@ def get_table_rpc_request(cls, query: TableQuery) -> TableRequest: raise InvalidSearchQuery("orderby must also be in the selected columns or groupby") else: resolved_column = resolver.resolve_column(stripped_orderby)[0] + if resolver.should_hide_api_column(stripped_orderby, resolved_column): + raise InvalidSearchQuery( + "orderby must also be in the selected columns or groupby" + ) # Virtual context columns transform values (e.g. "1" -> "low") which # can produce an undesirable alphabetical sort order. When a sort_column diff --git a/tests/sentry/search/eap/test_spans.py b/tests/sentry/search/eap/test_spans.py index b4883ae114e07d..cf173648810610 100644 --- a/tests/sentry/search/eap/test_spans.py +++ b/tests/sentry/search/eap/test_spans.py @@ -841,6 +841,40 @@ def test_resolve_columns_hides_internal_api_attributes(self) -> None: assert [column.public_alias for column in resolved_columns] == ["span.op"] assert resolved_contexts == [None] + def test_resolve_columns_hides_functions_with_internal_api_attributes(self) -> None: + resolver = SearchResolver( + params=SnubaParams(projects=[self.project]), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS + ), + definitions=SPAN_DEFINITIONS, + ) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + resolved_columns, resolved_contexts = resolver.resolve_columns( + ["span.op", f"count_unique({hidden_attribute})"] + ) + + assert [column.public_alias for column in resolved_columns] == ["span.op"] + assert resolved_contexts == [None] + + def test_resolve_equations_hides_internal_api_attributes(self) -> None: + resolver = SearchResolver( + params=SnubaParams(projects=[self.project]), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS + ), + definitions=SPAN_DEFINITIONS, + ) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + resolved_equations, resolved_contexts = resolver.resolve_equations( + [f"count_unique({hidden_attribute}) / count()", "count() / 1"] + ) + + assert [column.public_alias for column in resolved_equations] == ["equation|count() / 1"] + assert resolved_contexts == [] + def test_resolve_columns_includes_internal_api_attributes_when_configured(self) -> None: resolver = SearchResolver( params=SnubaParams(projects=[self.project]), diff --git a/tests/sentry/snuba/test_rpc_dataset_common.py b/tests/sentry/snuba/test_rpc_dataset_common.py index b9586755bc2680..8301599bcde6f9 100644 --- a/tests/sentry/snuba/test_rpc_dataset_common.py +++ b/tests/sentry/snuba/test_rpc_dataset_common.py @@ -1,9 +1,11 @@ from datetime import datetime, timedelta, timezone import pytest +from sentry_conventions.attributes import ATTRIBUTE_NAMES from sentry_protos.snuba.v1.downsampled_storage_pb2 import DownsampledStorageConfig -from sentry.search.eap.types import SearchResolverConfig +from sentry.exceptions import InvalidSearchQuery +from sentry.search.eap.types import SearchResolverConfig, SupportedTraceItemType from sentry.search.events.types import SnubaParams from sentry.snuba.occurrences_rpc import Occurrences from sentry.snuba.ourlogs import OurLogs @@ -131,6 +133,35 @@ def test_force_sampling_mode_in_table(dataset, query, mode): assert rpc_request.rpc_request.meta.downsampled_storage_config.mode == mode +@django_db_all +def test_table_orderby_rejects_hidden_api_attribute() -> None: + owner = Factories.create_user() + organization = Factories.create_organization(owner=owner) + project = Factories.create_project(organization=organization) + end = datetime.now(timezone.utc) + start = end - timedelta(days=1) + snuba_params = SnubaParams(start=start, end=end, organization=organization, projects=[project]) + config = SearchResolverConfig(api_attribute_visibility_item_type=SupportedTraceItemType.SPANS) + resolver = Spans.get_resolver(snuba_params, config) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + table_query = TableQuery( + "", + [hidden_attribute], + [hidden_attribute], + 0, + 1, + "TestReferrer", + None, + resolver, + ) + + with pytest.raises( + InvalidSearchQuery, match="orderby must also be in the selected columns or groupby" + ): + RPCBase.get_table_rpc_request(table_query) + + @pytest.mark.parametrize( ["dataset"], [ From a01633fc22c299317e5488bc52d81761247fae99 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 25 May 2026 15:54:16 -0300 Subject: [PATCH 11/14] fix(search): Hide EAP API attributes in filters Reject API-hidden attributes when resolving EAP WHERE terms, HAVING aggregate terms, and nested query combinator filters. This keeps api_attribute_visibility_item_type from exposing internal attributes through filter-only query paths. Co-Authored-By: Codex --- src/sentry/search/eap/resolver.py | 9 ++++++ tests/sentry/search/eap/test_spans.py | 40 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index 8cfd2f37fb60b9..0449ef1d89918e 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -535,12 +535,14 @@ def _resolve_term( self, term: event_search.SearchFilter ) -> tuple[TraceItemFilter, VirtualColumnDefinition | None]: resolved_column, context_definition = self.resolve_column(term.key.name) + self._raise_if_hidden_resolved_attribute(term.key.name, resolved_column) value = term.value.value if self.params.is_timeseries_request and context_definition is not None: resolved_column, value = self.map_search_term_context_to_original_column( term, context_definition ) + self._raise_if_hidden_api_attribute(term.key.name, resolved_column) context_definition = None if not isinstance(resolved_column.proto_definition, AttributeKey): @@ -816,6 +818,7 @@ def resolve_aggregate_term( self, term: event_search.AggregateFilter ) -> tuple[AggregationFilter, VirtualColumnDefinition | None]: resolved_column, context = self.resolve_column(term.key.name) + self._raise_if_hidden_resolved_attribute(term.key.name, resolved_column) proto_definition = resolved_column.proto_definition if not isinstance( @@ -1076,6 +1079,12 @@ def _raise_if_hidden_api_attribute( if self._should_hide_api_attribute(column, resolved_attribute): raise _HiddenApiAttribute(f"The field {column} is not allowed for this query") + def _raise_if_hidden_resolved_attribute( + self, column: str, resolved_column: ResolvedAttribute | ResolvedFunction + ) -> None: + if isinstance(resolved_column, ResolvedAttribute): + self._raise_if_hidden_api_attribute(column, resolved_column) + def resolve_attribute( self, column: str, public_alias_override: str | None = None ) -> tuple[ResolvedAttribute, VirtualColumnDefinition | None]: diff --git a/tests/sentry/search/eap/test_spans.py b/tests/sentry/search/eap/test_spans.py index cf173648810610..12e7848e1a2210 100644 --- a/tests/sentry/search/eap/test_spans.py +++ b/tests/sentry/search/eap/test_spans.py @@ -43,6 +43,7 @@ ) from sentry.search.eap.spans.definitions import SPAN_DEFINITIONS from sentry.search.eap.spans.sentry_conventions import SENTRY_CONVENTIONS_DIRECTORY +from sentry.search.eap.trace_metrics.definitions import TRACE_METRICS_DEFINITIONS from sentry.search.eap.types import SearchResolverConfig, SupportedTraceItemType from sentry.search.eap.utils import can_expose_attribute_to_api from sentry.search.events.types import SnubaParams @@ -525,6 +526,45 @@ def test_aggregate_query_on_custom_attributes(self) -> None: ) ) + def test_query_hides_internal_api_attributes_in_where(self) -> None: + resolver = SearchResolver( + params=SnubaParams(), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS + ), + definitions=SPAN_DEFINITIONS, + ) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + with pytest.raises(InvalidSearchQuery, match="not allowed"): + resolver.resolve_query(f"{hidden_attribute}:foo") + + def test_query_hides_internal_api_attributes_in_having(self) -> None: + resolver = SearchResolver( + params=SnubaParams(), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS + ), + definitions=SPAN_DEFINITIONS, + ) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + with pytest.raises(InvalidSearchQuery, match="not allowed"): + resolver.resolve_query(f"count_unique({hidden_attribute}):>0") + + def test_query_hides_internal_api_attributes_in_if_subquery(self) -> None: + resolver = SearchResolver( + params=SnubaParams(), + config=SearchResolverConfig( + api_attribute_visibility_item_type=SupportedTraceItemType.SPANS + ), + definitions=TRACE_METRICS_DEFINITIONS, + ) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + with pytest.raises(InvalidSearchQuery, match="not allowed"): + resolver.resolve_query(f"count_if(`{hidden_attribute}:foo`, value):>0") + def test_aggregate_query_on_attributes_with_units(self) -> None: for value in ["1000", "1s", "1000ms"]: where, having, _ = self.resolver.resolve_query(f"avg(measurements.lcp):>{value}") From 661211e741e4e33109db01f123d80610bd3fb18d Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 25 May 2026 16:02:01 -0300 Subject: [PATCH 12/14] fix(search): Reject hidden EAP group by attributes Reject API-hidden attributes when resolving required EAP attributes. This prevents timeseries group_by requests from silently dropping hidden fields and becoming ungrouped aggregate queries. Co-Authored-By: Codex --- src/sentry/search/eap/resolver.py | 3 +- tests/sentry/snuba/test_rpc_dataset_common.py | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index 0449ef1d89918e..93b841e3623f28 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -1038,8 +1038,7 @@ def resolve_attributes( resolved_contexts = [] for column in columns: col, context = self.resolve_attribute(column) - if self._should_hide_api_attribute(column, col): - continue + self._raise_if_hidden_api_attribute(column, col) if self.config.disable_array_attributes and col.internal_type == constants.ARRAY: continue resolved_columns.append(col) diff --git a/tests/sentry/snuba/test_rpc_dataset_common.py b/tests/sentry/snuba/test_rpc_dataset_common.py index 8301599bcde6f9..4b3c6e92633009 100644 --- a/tests/sentry/snuba/test_rpc_dataset_common.py +++ b/tests/sentry/snuba/test_rpc_dataset_common.py @@ -162,6 +162,36 @@ def test_table_orderby_rejects_hidden_api_attribute() -> None: RPCBase.get_table_rpc_request(table_query) +@django_db_all +def test_timeseries_groupby_rejects_hidden_api_attribute() -> None: + owner = Factories.create_user() + organization = Factories.create_organization(owner=owner) + project = Factories.create_project(organization=organization) + end = datetime.now(timezone.utc) + start = end - timedelta(days=1) + snuba_params = SnubaParams( + start=start, + end=end, + granularity_secs=60, + organization=organization, + projects=[project], + ) + config = SearchResolverConfig(api_attribute_visibility_item_type=SupportedTraceItemType.SPANS) + resolver = Spans.get_resolver(snuba_params, config) + hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" + + with pytest.raises(InvalidSearchQuery, match="not allowed"): + RPCBase.get_timeseries_query( + search_resolver=resolver, + params=snuba_params, + query_string="", + y_axes=["count()"], + groupby=[hidden_attribute], + referrer="TestReferrer", + sampling_mode=None, + ) + + @pytest.mark.parametrize( ["dataset"], [ From 97a9cdefac10d6e502ebfdc025f0b8cc55984278 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Tue, 26 May 2026 07:19:46 -0300 Subject: [PATCH 13/14] fix(eap): Check remapped API attribute visibility Validate API attribute visibility against remapped storage attributes for virtual contexts. This prevents hidden backing attributes from being exposed through timeseries group-bys and order-bys that use virtual context aliases. Co-Authored-By: Codex --- src/sentry/search/eap/resolver.py | 13 ++-- src/sentry/snuba/rpc_dataset_common.py | 4 ++ tests/sentry/snuba/test_rpc_dataset_common.py | 67 +++++++++++++++++++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index 93b841e3623f28..d93f99ca53b8eb 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -761,6 +761,8 @@ def map_context_to_original_column( if not isinstance(resolved_column.proto_definition, AttributeKey): raise ValueError(f"{resolved_column.public_alias} is not valid search term") + self._raise_if_hidden_api_attribute(context.to_column_name, resolved_column) + return resolved_column def map_search_term_context_to_original_column( @@ -1061,11 +1063,12 @@ def _should_hide_api_attribute( from sentry.search.eap.utils import can_expose_attribute_to_api item_type = SupportedTraceItemType(self.config.api_attribute_visibility_item_type) - visibility_attribute = ( - column - if column in self.definitions.contexts or column in self.definitions.columns - else resolved_attribute.internal_name - ) + if column in self.definitions.contexts and resolved_attribute.internal_name != column: + visibility_attribute = resolved_attribute.internal_name + elif column in self.definitions.contexts or column in self.definitions.columns: + visibility_attribute = column + else: + visibility_attribute = resolved_attribute.internal_name return not can_expose_attribute_to_api( visibility_attribute, item_type, diff --git a/src/sentry/snuba/rpc_dataset_common.py b/src/sentry/snuba/rpc_dataset_common.py index 2a4256999cf6fa..9317598719fb71 100644 --- a/src/sentry/snuba/rpc_dataset_common.py +++ b/src/sentry/snuba/rpc_dataset_common.py @@ -328,6 +328,10 @@ def get_table_rpc_request(cls, query: TableQuery) -> TableRequest: internal_name=context_def.sort_column, search_type="string", ) + if resolver.should_hide_api_column(stripped_orderby, sort_col): + raise InvalidSearchQuery( + "orderby must also be in the selected columns or groupby" + ) orderby_resolved = sort_col all_columns.append(sort_col) sort_column_aliases.add(sort_alias) diff --git a/tests/sentry/snuba/test_rpc_dataset_common.py b/tests/sentry/snuba/test_rpc_dataset_common.py index 4b3c6e92633009..4b921bdf65cbc8 100644 --- a/tests/sentry/snuba/test_rpc_dataset_common.py +++ b/tests/sentry/snuba/test_rpc_dataset_common.py @@ -1,4 +1,6 @@ from datetime import datetime, timedelta, timezone +from typing import Any +from unittest import mock import pytest from sentry_conventions.attributes import ATTRIBUTE_NAMES @@ -162,6 +164,38 @@ def test_table_orderby_rejects_hidden_api_attribute() -> None: RPCBase.get_table_rpc_request(table_query) +def test_table_orderby_rejects_hidden_remapped_virtual_context_sort_attribute() -> None: + organization = mock.Mock(id=1) + project = mock.Mock(id=1, slug="project-slug", organization=organization) + end = datetime.now(timezone.utc) + start = end - timedelta(days=1) + snuba_params = SnubaParams(start=start, end=end, organization=organization, projects=[project]) + config = SearchResolverConfig(api_attribute_visibility_item_type=SupportedTraceItemType.SPANS) + resolver = Spans.get_resolver(snuba_params, config) + + table_query = TableQuery( + "", + ["device.class", "count()"], + ["device.class"], + 0, + 1, + "TestReferrer", + None, + resolver, + ) + + def can_expose(attribute: str, *_args: Any, **_kwargs: Any) -> bool: + return attribute != "sentry.device.class" + + with ( + mock.patch("sentry.search.eap.utils.can_expose_attribute_to_api", can_expose), + pytest.raises( + InvalidSearchQuery, match="orderby must also be in the selected columns or groupby" + ), + ): + RPCBase.get_table_rpc_request(table_query) + + @django_db_all def test_timeseries_groupby_rejects_hidden_api_attribute() -> None: owner = Factories.create_user() @@ -192,6 +226,39 @@ def test_timeseries_groupby_rejects_hidden_api_attribute() -> None: ) +def test_timeseries_groupby_rejects_hidden_remapped_virtual_context_attribute() -> None: + organization = mock.Mock(id=1) + project = mock.Mock(id=1, slug="project-slug", organization=organization) + end = datetime.now(timezone.utc) + start = end - timedelta(days=1) + snuba_params = SnubaParams( + start=start, + end=end, + granularity_secs=60, + organization=organization, + projects=[project], + ) + config = SearchResolverConfig(api_attribute_visibility_item_type=SupportedTraceItemType.SPANS) + resolver = Spans.get_resolver(snuba_params, config) + + def can_expose(attribute: str, *_args: Any, **_kwargs: Any) -> bool: + return attribute != "sentry.category" + + with ( + mock.patch("sentry.search.eap.utils.can_expose_attribute_to_api", can_expose), + pytest.raises(InvalidSearchQuery, match="not allowed"), + ): + RPCBase.get_timeseries_query( + search_resolver=resolver, + params=snuba_params, + query_string="", + y_axes=["count()"], + groupby=["span.module"], + referrer="TestReferrer", + sampling_mode=None, + ) + + @pytest.mark.parametrize( ["dataset"], [ From 23016fc4433645fef0487dc0957790727eb54094 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Tue, 26 May 2026 07:38:22 -0300 Subject: [PATCH 14/14] fix(eap): Use generic error for hidden attributes and broaden prefix check Use the same "Could not parse" error message for hidden API attributes as for genuinely unknown fields, preventing attribute enumeration via distinct error messages. Remove the spans-only guard on the dsc./\_internal. prefix expansion in candidate generation so the visibility check applies to all item types. Co-Authored-By: Claude Opus 4.6 --- src/sentry/search/eap/resolver.py | 2 +- src/sentry/search/eap/utils.py | 2 +- tests/sentry/search/eap/test_spans.py | 6 +++--- tests/sentry/snuba/test_rpc_dataset_common.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index d93f99ca53b8eb..1f9cae0d74afae 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -1079,7 +1079,7 @@ def _raise_if_hidden_api_attribute( self, column: str, resolved_attribute: ResolvedAttribute ) -> None: if self._should_hide_api_attribute(column, resolved_attribute): - raise _HiddenApiAttribute(f"The field {column} is not allowed for this query") + raise _HiddenApiAttribute(f"Could not parse {column}") def _raise_if_hidden_resolved_attribute( self, column: str, resolved_column: ResolvedAttribute | ResolvedFunction diff --git a/src/sentry/search/eap/utils.py b/src/sentry/search/eap/utils.py index 52366736e2dea3..3d05577c8c580c 100644 --- a/src/sentry/search/eap/utils.py +++ b/src/sentry/search/eap/utils.py @@ -252,7 +252,7 @@ def _get_sentry_convention_visibility_candidates( ) -> set[str]: candidates = {attribute} - if item_type == SupportedTraceItemType.SPANS and attribute.startswith(("dsc.", "_internal.")): + if attribute.startswith(("dsc.", "_internal.")): candidates.add(f"sentry.{attribute}") resolved_attribute = PUBLIC_ALIAS_TO_INTERNAL_MAPPING.get(item_type, {}).get(attribute) diff --git a/tests/sentry/search/eap/test_spans.py b/tests/sentry/search/eap/test_spans.py index 12e7848e1a2210..dce7f999618568 100644 --- a/tests/sentry/search/eap/test_spans.py +++ b/tests/sentry/search/eap/test_spans.py @@ -536,7 +536,7 @@ def test_query_hides_internal_api_attributes_in_where(self) -> None: ) hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" - with pytest.raises(InvalidSearchQuery, match="not allowed"): + with pytest.raises(InvalidSearchQuery, match="Could not parse"): resolver.resolve_query(f"{hidden_attribute}:foo") def test_query_hides_internal_api_attributes_in_having(self) -> None: @@ -549,7 +549,7 @@ def test_query_hides_internal_api_attributes_in_having(self) -> None: ) hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" - with pytest.raises(InvalidSearchQuery, match="not allowed"): + with pytest.raises(InvalidSearchQuery, match="Could not parse"): resolver.resolve_query(f"count_unique({hidden_attribute}):>0") def test_query_hides_internal_api_attributes_in_if_subquery(self) -> None: @@ -562,7 +562,7 @@ def test_query_hides_internal_api_attributes_in_if_subquery(self) -> None: ) hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" - with pytest.raises(InvalidSearchQuery, match="not allowed"): + with pytest.raises(InvalidSearchQuery, match="Could not parse"): resolver.resolve_query(f"count_if(`{hidden_attribute}:foo`, value):>0") def test_aggregate_query_on_attributes_with_units(self) -> None: diff --git a/tests/sentry/snuba/test_rpc_dataset_common.py b/tests/sentry/snuba/test_rpc_dataset_common.py index 4b921bdf65cbc8..390927a1365aeb 100644 --- a/tests/sentry/snuba/test_rpc_dataset_common.py +++ b/tests/sentry/snuba/test_rpc_dataset_common.py @@ -214,7 +214,7 @@ def test_timeseries_groupby_rejects_hidden_api_attribute() -> None: resolver = Spans.get_resolver(snuba_params, config) hidden_attribute = f"tags[{ATTRIBUTE_NAMES.SENTRY_DSC_TRACE_ID.removeprefix('sentry.')}]" - with pytest.raises(InvalidSearchQuery, match="not allowed"): + with pytest.raises(InvalidSearchQuery, match="Could not parse"): RPCBase.get_timeseries_query( search_resolver=resolver, params=snuba_params, @@ -246,7 +246,7 @@ def can_expose(attribute: str, *_args: Any, **_kwargs: Any) -> bool: with ( mock.patch("sentry.search.eap.utils.can_expose_attribute_to_api", can_expose), - pytest.raises(InvalidSearchQuery, match="not allowed"), + pytest.raises(InvalidSearchQuery, match="Could not parse"), ): RPCBase.get_timeseries_query( search_resolver=resolver,