diff --git a/src/sentry/api/endpoints/organization_spans_fields.py b/src/sentry/api/endpoints/organization_spans_fields.py index c62c269c172a..914c21f93136 100644 --- a/src/sentry/api/endpoints/organization_spans_fields.py +++ b/src/sentry/api/endpoints/organization_spans_fields.py @@ -29,7 +29,7 @@ from sentry.search.eap.resolver import SearchResolver from sentry.search.eap.spans.definitions import SPAN_DEFINITIONS from sentry.search.eap.types import SearchResolverConfig, SupportedTraceItemType -from sentry.search.eap.utils import can_expose_attribute, translate_internal_to_public_alias +from sentry.search.eap.utils import can_expose_attribute_to_api, translate_internal_to_public_alias from sentry.search.events.types import SnubaParams from sentry.snuba.referrer import Referrer from sentry.tagstore.types import TagValue @@ -125,7 +125,7 @@ def get(self, request: Request, organization: Organization) -> Response: as_tag_key(attribute.name, serialized["type"]) for attribute in rpc_response.attributes if attribute.name - and can_expose_attribute( + and can_expose_attribute_to_api( attribute.name, SupportedTraceItemType.SPANS, include_internal=include_internal, @@ -164,15 +164,25 @@ def get(self, request: Request, organization: Organization, key: str) -> Respons max_span_tag_values = options.get("performance.spans-tags-values.max") - executor = EAPSpanFieldValuesAutocompletionExecutor( - organization=organization, - snuba_params=snuba_params, - key=key, - query=request.GET.get("query"), - max_span_tag_values=max_span_tag_values, - ) - with handle_query_errors(): + executor = EAPSpanFieldValuesAutocompletionExecutor( + organization=organization, + snuba_params=snuba_params, + key=key, + query=request.GET.get("query"), + max_span_tag_values=max_span_tag_values, + include_internal=is_active_superuser(request) or is_active_staff(request), + ) + + if not executor.can_expose_attribute_to_api(): + return self.paginate( + request=request, + paginator=ChainPaginator([]), + on_results=lambda results: serialize(results, request.user), + default_per_page=max_span_tag_values, + max_per_page=max_span_tag_values, + ) + tag_values = executor.execute() tag_values.sort(key=lambda tag: tag.value or "") @@ -245,8 +255,10 @@ def __init__( key: str, query: str | None, max_span_tag_values: int, + include_internal: bool, ): super().__init__(organization, snuba_params, key, query, max_span_tag_values) + self.include_internal = include_internal self.resolver = SearchResolver( params=snuba_params, config=SearchResolverConfig(), definitions=SPAN_DEFINITIONS ) @@ -258,6 +270,23 @@ def resolve_attribute_key( resolved, _ = self.resolver.resolve_attribute(key) return resolved.search_type, resolved.proto_definition + def can_expose_attribute_to_api(self) -> bool: + is_public_defined_attribute = ( + self.key in SPAN_DEFINITIONS.columns or self.key in SPAN_DEFINITIONS.contexts + ) + return can_expose_attribute_to_api( + self.key, + SupportedTraceItemType.SPANS, + include_internal=self.include_internal, + ) and ( + is_public_defined_attribute + or can_expose_attribute_to_api( + self.attribute_key.name, + SupportedTraceItemType.SPANS, + include_internal=self.include_internal, + ) + ) + def execute(self) -> list[TagValue]: if self.key in self.PROJECT_ID_KEYS: return self.project_id_autocomplete_function() diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 0839655d21d0..1e3ef32dcdd0 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -64,7 +64,7 @@ SupportedTraceItemType, ) from sentry.search.eap.utils import ( - can_expose_attribute, + can_expose_attribute_to_api, get_secondary_aliases, is_sentry_convention_replacement_attribute, translate_internal_to_public_alias, @@ -321,12 +321,21 @@ def get(self, request: Request, organization: Organization) -> Response: trace_item_type = SupportedTraceItemType(dataset) referrer = resolve_attribute_referrer(trace_item_type) column_definitions = get_column_definitions(trace_item_type) + include_internal = is_active_superuser(request) or is_active_staff(request) resolver = SearchResolver( params=snuba_params, - config=SearchResolverConfig(), + config=SearchResolverConfig( + api_attribute_visibility_item_type=trace_item_type.value, + api_attribute_visibility_include_internal=include_internal, + ), definitions=column_definitions, ) query_filter, _, _ = resolver.resolve_query(query_string) + if resolver.has_hidden_api_attributes(): + return self.paginate( + request=request, + paginator=ChainPaginator([]), + ) meta = resolver.resolve_meta(referrer=referrer.value) meta.trace_item_type = constants.SUPPORTED_TRACE_ITEM_TYPE_MAP.get( trace_item_type, ProtoTraceItemType.TRACE_ITEM_TYPE_SPAN @@ -338,8 +347,6 @@ def get(self, request: Request, organization: Organization) -> Response: snuba_params.start = adjusted_start_date snuba_params.end = adjusted_end_date - include_internal = is_active_superuser(request) or is_active_staff(request) - def data_fn(offset: int, limit: int) -> list[TraceItemAttributeKey]: futures = [] with ContextPropagatingThreadPoolExecutor( @@ -404,6 +411,11 @@ def query_trace_attributes( and substring_match in column.public_alias and not column.secondary_alias and not column.private + and can_expose_attribute_to_api( + column.public_alias, + trace_item_type, + include_internal=include_internal, + ) ): all_aliased_attributes.append(column) for ( @@ -415,6 +427,11 @@ def query_trace_attributes( and virtual_context.search_type is not None and not virtual_context.secondary_alias and constants.TYPE_MAP[virtual_context.search_type] == attr_type + and can_expose_attribute_to_api( + public_label, + trace_item_type, + include_internal=include_internal, + ) ): all_aliased_attributes.append( ProxyResolvedAttribute( @@ -433,6 +450,11 @@ def query_trace_attributes( and virtual_context.search_type is not None and not virtual_context.secondary_alias and constants.TYPE_MAP[virtual_context.search_type] == attr_type + and can_expose_attribute_to_api( + public_label, + trace_item_type, + include_internal=include_internal, + ) ): all_aliased_attributes.append( ProxyResolvedAttribute( @@ -493,7 +515,7 @@ def serialize_trace_attributes_using_sentry_conventions( ) -> list[TraceItemAttributeKey]: attribute_keys = {} for attribute in rpc_response.attributes: - if attribute.name and can_expose_attribute( + if attribute.name and can_expose_attribute_to_api( attribute.name, trace_item_type, include_internal=include_internal, @@ -523,6 +545,14 @@ def serialize_trace_attributes_using_sentry_conventions( if attr_key["name"] in attribute_keys: del attribute_keys[attr_key["name"]] for aliased_attr in aliased_attributes: + if not ( + can_expose_attribute_to_api( + aliased_attr.public_alias, + trace_item_type, + include_internal=include_internal, + ) + ): + continue attr_key = as_attribute_key( aliased_attr.internal_name, attribute_type, @@ -547,7 +577,7 @@ def serialize_trace_attributes( ) -> list[TraceItemAttributeKey]: attribute_keys = {} for attribute in rpc_response.attributes: - if attribute.name and can_expose_attribute( + if attribute.name and can_expose_attribute_to_api( attribute.name, trace_item_type, include_internal=include_internal, @@ -579,18 +609,21 @@ def serialize_trace_attributes( if attr_key["name"] in attribute_keys: del attribute_keys[attr_key["name"]] for aliased_attr in aliased_attributes: - if can_expose_attribute( - aliased_attr.public_alias, - trace_item_type, - include_internal=include_internal, - ): - attr_key = as_attribute_key( - aliased_attr.internal_name, - attribute_type, + if not ( + can_expose_attribute_to_api( + aliased_attr.public_alias, trace_item_type, - is_proxy=isinstance(aliased_attr, ProxyResolvedAttribute), + include_internal=include_internal, ) - attribute_keys[attr_key["key"]] = attr_key + ): + continue + attr_key = as_attribute_key( + aliased_attr.internal_name, + attribute_type, + trace_item_type, + is_proxy=isinstance(aliased_attr, ProxyResolvedAttribute), + ) + attribute_keys[attr_key["key"]] = attr_key attributes = list(attribute_keys.values()) sentry_sdk.set_context("api_response", {"attributes": attributes}) return attributes @@ -628,19 +661,25 @@ def get(self, request: Request, organization: Organization, key: str) -> Respons max_attribute_values = options.get("explore.trace-items.values.max") definitions = get_column_definitions(SupportedTraceItemType(dataset)) + include_internal = is_active_superuser(request) or is_active_staff(request) def data_fn(offset: int, limit: int): - executor = TraceItemAttributeValuesAutocompletionExecutor( - organization=organization, - snuba_params=snuba_params, - key=key, - query=substring_match, - limit=limit, - offset=offset, - definitions=definitions, - ) - with handle_query_errors(): + executor = TraceItemAttributeValuesAutocompletionExecutor( + organization=organization, + snuba_params=snuba_params, + key=key, + query=substring_match, + limit=limit, + offset=offset, + definitions=definitions, + item_type=SupportedTraceItemType(dataset), + include_internal=include_internal, + ) + + if not executor.can_expose_attribute_to_api(): + return [] + tag_values = executor.execute() tag_values.sort(key=lambda tag: tag.value or "") return tag_values @@ -664,10 +703,15 @@ def __init__( limit: int, offset: int, definitions: ColumnDefinitions, + item_type: SupportedTraceItemType, + include_internal: bool, ): super().__init__(organization, snuba_params, key, query, limit) self.limit = limit self.offset = offset + self.item_type = item_type + self.include_internal = include_internal + self.definitions = definitions self.resolver = SearchResolver( params=snuba_params, config=SearchResolverConfig(), definitions=definitions ) @@ -698,6 +742,23 @@ def resolve_attribute_key( context_definition, ) + def can_expose_attribute_to_api(self) -> bool: + is_public_defined_attribute = ( + self.key in self.definitions.columns or self.key in self.definitions.contexts + ) + return can_expose_attribute_to_api( + self.key, + self.item_type, + include_internal=self.include_internal, + ) and ( + is_public_defined_attribute + or can_expose_attribute_to_api( + self.attribute_key.name, + self.item_type, + include_internal=self.include_internal, + ) + ) + def execute(self) -> list[TagValue]: func = self.autocomplete_function.get(self.key) @@ -1033,6 +1094,7 @@ def post(self, request: Request, organization: Organization) -> Response: item_type = SupportedTraceItemType(query_serializer.validated_data["item_type"]) attribute_names: list[str] = serializer.validated_data["attributes"] + include_internal = is_active_superuser(request) or is_active_staff(request) try: snuba_params = self.get_snuba_params(request, organization) @@ -1045,7 +1107,10 @@ def post(self, request: Request, organization: Organization) -> Response: return Response({"detail": f"Unsupported item type: {item_type.value}"}, status=400) resolver = SearchResolver( params=snuba_params, - config=SearchResolverConfig(), + config=SearchResolverConfig( + api_attribute_visibility_item_type=item_type.value, + api_attribute_visibility_include_internal=include_internal, + ), definitions=definitions, ) @@ -1056,6 +1121,12 @@ def post(self, request: Request, organization: Organization) -> Response: for attr_name in attribute_names: try: resolved, _context = resolver.resolve_attribute(attr_name) + if resolver.is_hidden_api_attribute(attr_name): + results[attr_name] = { + "valid": False, + "error": f"Unknown attribute: {attr_name}", + } + continue if attr_name in definitions.contexts or attr_name in definitions.columns: # Known column or virtual context — always valid results[attr_name] = { diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index a036cba06a0b..93709122e2d6 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 24e5e56476d3..94d9901a60d8 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 f6d61014606d..3d595d60978d 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/api/endpoints/test_organization_spans_fields.py b/tests/sentry/api/endpoints/test_organization_spans_fields.py index 567df7b5bcc9..d2c7616e439c 100644 --- a/tests/sentry/api/endpoints/test_organization_spans_fields.py +++ b/tests/sentry/api/endpoints/test_organization_spans_fields.py @@ -163,6 +163,32 @@ def test_boolean_attributes(self) -> None: assert "tags[is_debug,boolean]" in keys assert "tags[is_production,boolean]" in keys + def test_internal_convention_attributes_are_staff_only(self) -> None: + self.store_spans( + [ + self.create_span( + {"tags": {"normal_attr": "normal_value", "sentry.dsc.trace_id": "abc123"}}, + start_ts=before_now(days=0, minutes=10), + ), + ], + ) + + response = self.do_request(query={"dataset": "spans", "type": "string", "process": 1}) + assert response.status_code == 200, response.data + attribute_names = {attr["name"] for attr in response.data} + assert "normal_attr" in attribute_names + assert "sentry.dsc.trace_id" not in attribute_names + + staff_user = self.create_user(is_staff=True) + self.create_member(user=staff_user, organization=self.organization) + self.login_as(user=staff_user, staff=True) + + response = self.do_request(query={"dataset": "spans", "type": "string", "process": 1}) + assert response.status_code == 200, response.data + attribute_names = {attr["name"] for attr in response.data} + assert "normal_attr" in attribute_names + assert "sentry.dsc.trace_id" in attribute_names + class OrganizationSpansTagKeyValuesEndpointTest(BaseSpansTestCase, APITestCase): view = "sentry-api-0-organization-spans-fields-values" @@ -249,6 +275,33 @@ def test_tags_keys(self) -> None: }, ] + def test_internal_convention_attribute_values_are_staff_only(self) -> None: + self.store_segment( + self.project.id, + uuid4().hex, + uuid4().hex, + span_id=uuid4().hex[:16], + organization_id=self.organization.id, + parent_span_id=None, + timestamp=before_now(days=0, minutes=10).replace(microsecond=0), + transaction="foo", + duration=100, + exclusive_time=100, + tags={"sentry.dsc.trace_id": "abc123"}, + ) + + response = self.do_request("sentry.dsc.trace_id") + assert response.status_code == 200, response.data + assert response.data == [] + + staff_user = self.create_user(is_staff=True) + self.create_member(user=staff_user, organization=self.organization) + self.login_as(user=staff_user, staff=True) + + response = self.do_request("sentry.dsc.trace_id") + assert response.status_code == 200, response.data + assert [item["value"] for item in response.data] == ["abc123"] + def test_transaction_keys_autocomplete(self) -> None: timestamp = before_now(days=0, minutes=10).replace(microsecond=0) for transaction in ["foo", "*bar", "*baz"]: diff --git a/tests/sentry/search/eap/test_spans.py b/tests/sentry/search/eap/test_spans.py index f9d501805259..7b20836377af 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( diff --git a/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py b/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py index f8434be2d27e..112f378ca592 100644 --- a/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py +++ b/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py @@ -953,6 +953,32 @@ def test_sentry_internal_attributes(self) -> None: assert "__sentry_internal_span_buffer_outcome" in attribute_names assert "__sentry_internal_test" in attribute_names + def test_internal_convention_attributes_are_staff_only(self) -> None: + self.store_spans( + [ + self.create_span( + {"tags": {"normal_attr": "normal_value", "sentry.dsc.trace_id": "abc123"}}, + start_ts=before_now(days=0, minutes=10), + ), + ], + ) + + response = self.do_request(query={"attributeType": "string", "substringMatch": ""}) + assert response.status_code == 200 + attribute_names = {attr["name"] for attr in response.data} + assert "normal_attr" in attribute_names + assert "sentry.dsc.trace_id" not in attribute_names + + staff_user = self.create_user(is_staff=True) + self.create_member(user=staff_user, organization=self.organization) + self.login_as(user=staff_user, staff=True) + + response = self.do_request(query={"attributeType": "string", "substringMatch": ""}) + assert response.status_code == 200 + attribute_names = {attr["name"] for attr in response.data} + assert "normal_attr" in attribute_names + assert "sentry.dsc.trace_id" in attribute_names + def test_boolean_attributes(self) -> None: span1 = self.create_span(start_ts=before_now(days=0, minutes=10)) span1["data"] = { @@ -1492,6 +1518,28 @@ def test_tags_keys(self) -> None: }, ] + def test_internal_convention_attribute_values_are_staff_only(self) -> None: + self.store_spans( + [ + self.create_span( + {"tags": {"sentry.dsc.trace_id": "abc123"}}, + start_ts=before_now(days=0, minutes=10), + ), + ], + ) + + response = self.do_request(key="sentry.dsc.trace_id") + assert response.status_code == 200, response.content + assert response.data == [] + + staff_user = self.create_user(is_staff=True) + self.create_member(user=staff_user, organization=self.organization) + self.login_as(user=staff_user, staff=True) + + response = self.do_request(key="sentry.dsc.trace_id") + assert response.status_code == 200, response.content + assert [item["value"] for item in response.data] == ["abc123"] + def test_transaction_keys_autocomplete(self) -> None: timestamp = before_now(days=0, minutes=10).replace(microsecond=0) for transaction in ["foo", "*bar", "*baz"]: @@ -2443,6 +2491,40 @@ def test_well_known_attributes(self): assert attr["valid"] is True assert attr["type"] == "number" + def test_internal_convention_attribute_validation_is_staff_only(self): + self.store_spans( + [ + self.create_span( + {"tags": {"sentry.dsc.trace_id": "abc123"}}, + start_ts=before_now(days=0, minutes=10), + ), + ], + ) + + response = self.do_request( + payload={"attributes": ["sentry.dsc.trace_id"]}, + query_params={"itemType": "spans"}, + ) + assert response.status_code == 200 + attr = response.data["attributes"]["sentry.dsc.trace_id"] + assert attr == { + "valid": False, + "error": "Unknown attribute: sentry.dsc.trace_id", + } + + with mock.patch( + "sentry.api.endpoints.organization_trace_item_attributes.is_active_staff", + return_value=True, + ): + response = self.do_request( + payload={"attributes": ["sentry.dsc.trace_id"]}, + query_params={"itemType": "spans"}, + ) + assert response.status_code == 200 + attr = response.data["attributes"]["sentry.dsc.trace_id"] + assert attr["valid"] is True + assert attr["type"] == "string" + def test_virtual_context_attributes(self): response = self.do_request( payload={"attributes": ["project"]},