From 0c23d14b3b1378e5f33b33e3ebb6c72705085fc1 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Wed, 17 Jun 2026 10:05:11 -0400 Subject: [PATCH 01/11] feat(explore): Add sentry conventions context to trace item attributes The trace item attributes endpoint can now expand each attribute with the sentry conventions metadata (brief, details, examples, deprecation, and replacement attribute) sourced from sentry_conventions.attributes. The context is only attached when the caller passes expand=context and the organizations:data-browsing-attribute-context feature is enabled; otherwise the response is unchanged. Only fields actually present in ATTRIBUTE_METADATA are included (e.g. last_received is intentionally omitted). The metadata lookup falls back from the public alias to the internal name, mirroring _update_attribute_definitions_with_deprecations, since a convention may be keyed under either name. Co-Authored-By: Claude --- .../organization_trace_item_attributes.py | 86 +++++++++++++++ ...rganization_trace_item_attributes_types.py | 31 +++++- .../examples/trace_item_attribute_examples.py | 7 ++ src/sentry/features/temporary.py | 2 + ...test_organization_trace_item_attributes.py | 102 ++++++++++++++++++ 5 files changed, 227 insertions(+), 1 deletion(-) diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index d10cfdb1c02e2e..d984abab8d0d7f 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -9,6 +9,7 @@ from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response +from sentry_conventions.attributes import ATTRIBUTE_METADATA from sentry_protos.snuba.v1.endpoint_trace_item_attributes_pb2 import ( TraceItemAttributeNamesRequest, TraceItemAttributeNamesResponse, @@ -34,6 +35,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases import NoProjects, OrganizationEventsEndpointBase from sentry.api.endpoints.organization_trace_item_attributes_types import ( + TraceItemAttributeContext, TraceItemAttributeKey, TraceItemAttributeSource, ) @@ -185,6 +187,20 @@ def get_result(self, limit, cursor=None): description="Sentry [search syntax](https://docs.sentry.io/concepts/search/) to filter trace items before computing attributes.", ) +EXPAND_QUERY_PARAM = OpenApiParameter( + name="expand", + location="query", + required=False, + many=True, + type=str, + enum=["context"], + description=( + "Optional fields to expand. Pass `context` to include the sentry " + "conventions metadata (brief, examples, deprecation, etc.) for " + "attributes that map to a known convention." + ), +) + class OrganizationTraceItemAttributesEndpointBase(OrganizationEventsEndpointBase): publish_status = { @@ -221,6 +237,7 @@ class OrganizationTraceItemAttributesEndpointSerializer(serializers.Serializer): ) substringMatch = serializers.CharField(required=False, source="substring_match") query = serializers.CharField(required=False) + expand = serializers.MultipleChoiceField(choices=["context"], required=False) def validate(self, attrs: Any) -> Any: if attrs.get("item_type") is None and attrs.get("dataset") is None: @@ -277,11 +294,56 @@ def resolve_attribute_values_referrer(item_type: str) -> Referrer: raise ValueError(f"Invalid item type: {item_type}") +def build_attribute_context( + public_name: str, internal_name: str +) -> TraceItemAttributeContext | None: + """ + Build the sentry conventions context for an attribute, if it maps to a known + convention. Only fields actually present in the conventions metadata are + included (e.g. ``last_received`` is intentionally omitted since it does not + live in the metadata). + + A convention may be keyed in ``ATTRIBUTE_METADATA`` by either the public + alias or the internal name (see + ``_update_attribute_definitions_with_deprecations`` in + ``search/eap/spans/attributes.py``), so we try the public name first and + fall back to the internal name. + """ + metadata = ATTRIBUTE_METADATA.get(public_name) or ATTRIBUTE_METADATA.get(internal_name) + if metadata is None: + return None + + context: TraceItemAttributeContext = {} + + if metadata.brief: + context["brief"] = metadata.brief + + if metadata.additional_context: + context["details"] = list(metadata.additional_context) + + if metadata.example is not None: + context["examples"] = ( + list(metadata.example) + if isinstance(metadata.example, (list, tuple)) + else [metadata.example] + ) + + deprecation = metadata.deprecation + context["is_deprecated"] = bool( + deprecation is not None and (deprecation.status is not None or deprecation.replacement) + ) + if deprecation is not None and deprecation.replacement: + context["replacement_attribute"] = deprecation.replacement + + return context or None + + def as_attribute_key( name: str, attr_type: Literal["string", "number", "boolean"], item_type: SupportedTraceItemType, is_proxy: bool = False, + include_context: bool = False, ) -> TraceItemAttributeKey: public_key, public_name, attribute_source = translate_internal_to_public_alias( name, attr_type, item_type @@ -326,6 +388,14 @@ def as_attribute_key( if secondary_aliases: attribute_key["secondaryAliases"] = sorted(secondary_aliases) + # Join in the sentry conventions metadata. Only sentry convention attributes + # are present in ATTRIBUTE_METADATA, keyed by their public convention name + # (e.g. "gen_ai.usage.output_tokens"). + if include_context: + context = build_attribute_context(public_name, name) + if context is not None: + attribute_key["context"] = context + return attribute_key @@ -364,6 +434,7 @@ class OrganizationTraceItemAttributesEndpoint(OrganizationTraceItemAttributesEnd ATTRIBUTE_TYPE_QUERY_PARAM, SUBSTRING_MATCH_QUERY_PARAM, SEARCH_QUERY_PARAM, + EXPAND_QUERY_PARAM, CursorQueryParam, ], responses={ @@ -437,6 +508,15 @@ def get( debug = request.user.is_superuser and request.GET.get("debug", False) debug_infos: list[dict] = [] + # Only expand the sentry conventions context when explicitly requested + # via `expand=context` and the feature is enabled for the org. When the + # feature is disabled this is a no-op even if `expand=context` is passed. + include_context = "context" in serialized.get("expand", set()) and features.has( + "organizations:data-browsing-attribute-context", + organization, + actor=request.user, + ) + def data_fn(offset: int, limit: int) -> list[TraceItemAttributeKey]: futures = [] with ContextPropagatingThreadPoolExecutor( @@ -456,6 +536,7 @@ def data_fn(offset: int, limit: int) -> list[TraceItemAttributeKey]: column_definitions, trace_item_type, include_internal, + include_context, debug=debug, ) ) @@ -489,6 +570,7 @@ def query_trace_attributes( column_definitions: ColumnDefinitions, trace_item_type: SupportedTraceItemType, include_internal: bool, + include_context: bool = False, debug: str | bool = False, ) -> tuple[list[TraceItemAttributeKey], dict | None]: debug_info: dict | None = None @@ -579,6 +661,7 @@ def query_trace_attributes( substring_match, aliased_attributes, all_aliased_attributes, + include_context, ) sentry_sdk.set_context("api_response", {"attributes": attributes}) @@ -596,6 +679,7 @@ def serialize_trace_attributes( substring_match: str, aliased_attributes: list[ResolvedAttribute | ProxyResolvedAttribute], exclude_attributes: list[ResolvedAttribute | ProxyResolvedAttribute], + include_context: bool = False, ) -> list[TraceItemAttributeKey]: attribute_keys = {} for attribute in rpc_response.attributes: @@ -608,6 +692,7 @@ def serialize_trace_attributes( attribute.name, attribute_type, trace_item_type, + include_context=include_context, ) if ( not is_sentry_convention_replacement_attribute( @@ -644,6 +729,7 @@ def serialize_trace_attributes( attribute_type, trace_item_type, is_proxy=isinstance(aliased_attr, ProxyResolvedAttribute), + include_context=include_context, ) if can_expose_attribute_to_api( aliased_attr.internal_name, diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes_types.py b/src/sentry/api/endpoints/organization_trace_item_attributes_types.py index a7c4613e509d18..c2d373f57f690a 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes_types.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes_types.py @@ -1,4 +1,4 @@ -from typing import Literal, NotRequired, TypedDict +from typing import Any, Literal, NotRequired, TypedDict class TraceItemAttributeSource(TypedDict): @@ -6,9 +6,38 @@ class TraceItemAttributeSource(TypedDict): is_transformed_alias: NotRequired[bool] +class TraceItemAttributeContext(TypedDict): + """ + Additional, mostly-static metadata about an attribute sourced from the + sentry conventions (``sentry_conventions.attributes.ATTRIBUTE_METADATA``). + + Only attributes that map to a known sentry convention have context, and + within the context only the fields actually present in the conventions + metadata are included. This is only attached when the caller passes + ``expand=context`` and the ``data-browsing-attribute-context`` feature is + enabled. + """ + + # A short, human-readable description of the attribute. + brief: NotRequired[str] + # Longer-form notes that add nuance beyond the brief (e.g. caveats, + # double-counting warnings). Sourced from the convention's + # ``additional_context``. + details: NotRequired[list[str]] + # Example value(s) for the attribute, normalized to a list. + examples: NotRequired[list[Any]] + # Whether the convention has been deprecated. + is_deprecated: NotRequired[bool] + # The attribute that replaces this one, when deprecated. + replacement_attribute: NotRequired[str] + + class TraceItemAttributeKey(TypedDict): key: str name: str secondaryAliases: NotRequired[list[str]] attributeSource: TraceItemAttributeSource attributeType: Literal["string", "number", "boolean"] + # Sentry conventions metadata, only present when requested via + # ``expand=context`` (and gated behind the feature flag). + context: NotRequired[TraceItemAttributeContext] diff --git a/src/sentry/apidocs/examples/trace_item_attribute_examples.py b/src/sentry/apidocs/examples/trace_item_attribute_examples.py index 755487f6e4598c..098e5fa5635892 100644 --- a/src/sentry/apidocs/examples/trace_item_attribute_examples.py +++ b/src/sentry/apidocs/examples/trace_item_attribute_examples.py @@ -7,6 +7,13 @@ "name": "device.class", "attributeSource": {"source_type": "sentry"}, "attributeType": "string", + "context": { + "brief": ( + "The classification of the device. For example, `low`, `medium`, or `high`. " + "Typically inferred by Relay - SDKs generally do not need to set this directly." + ), + "is_deprecated": False, + }, } BATCH_SIZE: TraceItemAttributeKey = { diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 62c828af584706..fc6c5afaa5151e 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -343,6 +343,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:insights-modules-use-eap", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable data browsing heat map widget manager.add("organizations:data-browsing-heat-map-widget", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable sentry conventions attribute context (brief, examples, deprecation, etc.) on the trace item attributes endpoint + manager.add("organizations:data-browsing-attribute-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable public RPC endpoint for local seer development manager.add("organizations:seer-public-rpc", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Organizations on the old usage-based (v0) Seer plan 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 486b5fc0c5dd11..3b339ce219d827 100644 --- a/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py +++ b/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py @@ -5,8 +5,10 @@ import pytest from django.urls import reverse from rest_framework.exceptions import ErrorDetail +from sentry_conventions.attributes import ATTRIBUTE_METADATA from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey +from sentry.api.endpoints.organization_trace_item_attributes import build_attribute_context from sentry.api.endpoints.organization_trace_item_attributes_types import ( TraceItemAttributeKey, ) @@ -27,6 +29,35 @@ from sentry.testutils.helpers.options import override_options +class TestBuildAttributeContext: + def test_lookup_by_public_name(self) -> None: + context = build_attribute_context("device.class", "sentry.device.class") + assert context is not None + assert context["brief"].startswith("The classification of the device.") + assert context["is_deprecated"] is False + + def test_falls_back_to_internal_name(self) -> None: + # The convention is keyed by the internal name (`sentry.op`), not the + # public alias (`span.op`), so the public-name lookup misses and the + # fallback must resolve it. + assert "span.op" not in ATTRIBUTE_METADATA + context = build_attribute_context("span.op", "sentry.op") + assert context == { + "brief": "The operation of a span.", + "examples": ["http.client"], + "is_deprecated": False, + } + + def test_deprecated_attribute_includes_replacement(self) -> None: + context = build_attribute_context("transaction", "sentry.transaction") + assert context is not None + assert context["is_deprecated"] is True + assert context["replacement_attribute"] == "sentry.segment.name" + + def test_unknown_attribute_returns_none(self) -> None: + assert build_attribute_context("not.a.convention", "also.not.a.convention") is None + + class OrganizationTraceItemAttributesEndpointTestBase(APITestCase, SnubaTestCase): feature_flags: dict[str, bool] item_type: SupportedTraceItemType @@ -461,6 +492,77 @@ def test_no_projects(self) -> None: assert response.status_code == 200, response.content assert response.data == [] + def _store_basic_segment(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={"foo": "foo"}, + ) + + def test_expand_context(self) -> None: + self._store_basic_segment() + + response = self.do_request( + query={"attributeType": "string", "expand": "context"}, + features={ + **self.feature_flags, + "organizations:data-browsing-attribute-context": True, + }, + ) + assert response.status_code == 200, response.data + + attributes = {item["key"]: item for item in response.data} + + # A non-deprecated sentry convention gets brief + examples + is_deprecated. + assert attributes["device.class"]["context"] == { + "brief": ( + "The classification of the device. For example, `low`, `medium`, or `high`. " + "Typically inferred by Relay - SDKs generally do not need to set this directly." + ), + "examples": ["medium"], + "is_deprecated": False, + } + # A deprecated convention also surfaces the replacement attribute. + assert attributes["transaction"]["context"] == { + "brief": "The sentry transaction (segment name).", + "examples": ["GET /"], + "is_deprecated": True, + "replacement_attribute": "sentry.segment.name", + } + # User tags are not sentry conventions, so they have no context. + assert "context" not in attributes["foo"] + + def test_expand_context_without_feature_flag(self) -> None: + self._store_basic_segment() + + # expand=context is requested, but the gating feature is disabled. + response = self.do_request( + query={"attributeType": "string", "expand": "context"}, + ) + assert response.status_code == 200, response.data + assert all("context" not in item for item in response.data) + + def test_context_not_included_without_expand(self) -> None: + self._store_basic_segment() + + response = self.do_request( + query={"attributeType": "string"}, + features={ + **self.feature_flags, + "organizations:data-browsing-attribute-context": True, + }, + ) + assert response.status_code == 200, response.data + assert all("context" not in item for item in response.data) + def test_tags_list_str(self) -> None: for tag in ["foo", "bar", "baz"]: self.store_segment( From 6f13f62d8cbdc307f0d4d09753d3a3c8987a44cd Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Wed, 17 Jun 2026 10:25:18 -0400 Subject: [PATCH 02/11] ref(explore): camelCase trace item attribute context fields Use isDeprecated/replacementAttribute instead of snake_case to match the camelCase response-body convention. Co-Authored-By: Claude --- .../organization_trace_item_attributes.py | 4 ++-- .../organization_trace_item_attributes_types.py | 4 ++-- .../examples/trace_item_attribute_examples.py | 2 +- .../test_organization_trace_item_attributes.py | 16 ++++++++-------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index d984abab8d0d7f..415cf302af2f19 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -329,11 +329,11 @@ def build_attribute_context( ) deprecation = metadata.deprecation - context["is_deprecated"] = bool( + context["isDeprecated"] = bool( deprecation is not None and (deprecation.status is not None or deprecation.replacement) ) if deprecation is not None and deprecation.replacement: - context["replacement_attribute"] = deprecation.replacement + context["replacementAttribute"] = deprecation.replacement return context or None diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes_types.py b/src/sentry/api/endpoints/organization_trace_item_attributes_types.py index c2d373f57f690a..0bb8659a404e81 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes_types.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes_types.py @@ -27,9 +27,9 @@ class TraceItemAttributeContext(TypedDict): # Example value(s) for the attribute, normalized to a list. examples: NotRequired[list[Any]] # Whether the convention has been deprecated. - is_deprecated: NotRequired[bool] + isDeprecated: NotRequired[bool] # The attribute that replaces this one, when deprecated. - replacement_attribute: NotRequired[str] + replacementAttribute: NotRequired[str] class TraceItemAttributeKey(TypedDict): diff --git a/src/sentry/apidocs/examples/trace_item_attribute_examples.py b/src/sentry/apidocs/examples/trace_item_attribute_examples.py index 098e5fa5635892..413b9900b1314a 100644 --- a/src/sentry/apidocs/examples/trace_item_attribute_examples.py +++ b/src/sentry/apidocs/examples/trace_item_attribute_examples.py @@ -12,7 +12,7 @@ "The classification of the device. For example, `low`, `medium`, or `high`. " "Typically inferred by Relay - SDKs generally do not need to set this directly." ), - "is_deprecated": False, + "isDeprecated": False, }, } 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 3b339ce219d827..ae9dd30b36bda6 100644 --- a/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py +++ b/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py @@ -34,7 +34,7 @@ def test_lookup_by_public_name(self) -> None: context = build_attribute_context("device.class", "sentry.device.class") assert context is not None assert context["brief"].startswith("The classification of the device.") - assert context["is_deprecated"] is False + assert context["isDeprecated"] is False def test_falls_back_to_internal_name(self) -> None: # The convention is keyed by the internal name (`sentry.op`), not the @@ -45,14 +45,14 @@ def test_falls_back_to_internal_name(self) -> None: assert context == { "brief": "The operation of a span.", "examples": ["http.client"], - "is_deprecated": False, + "isDeprecated": False, } def test_deprecated_attribute_includes_replacement(self) -> None: context = build_attribute_context("transaction", "sentry.transaction") assert context is not None - assert context["is_deprecated"] is True - assert context["replacement_attribute"] == "sentry.segment.name" + assert context["isDeprecated"] is True + assert context["replacementAttribute"] == "sentry.segment.name" def test_unknown_attribute_returns_none(self) -> None: assert build_attribute_context("not.a.convention", "also.not.a.convention") is None @@ -521,21 +521,21 @@ def test_expand_context(self) -> None: attributes = {item["key"]: item for item in response.data} - # A non-deprecated sentry convention gets brief + examples + is_deprecated. + # A non-deprecated sentry convention gets brief + examples + isDeprecated. assert attributes["device.class"]["context"] == { "brief": ( "The classification of the device. For example, `low`, `medium`, or `high`. " "Typically inferred by Relay - SDKs generally do not need to set this directly." ), "examples": ["medium"], - "is_deprecated": False, + "isDeprecated": False, } # A deprecated convention also surfaces the replacement attribute. assert attributes["transaction"]["context"] == { "brief": "The sentry transaction (segment name).", "examples": ["GET /"], - "is_deprecated": True, - "replacement_attribute": "sentry.segment.name", + "isDeprecated": True, + "replacementAttribute": "sentry.segment.name", } # User tags are not sentry conventions, so they have no context. assert "context" not in attributes["foo"] From ea57e79963c191dfde0eafd6110e9a62b189685a Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Wed, 17 Jun 2026 10:27:49 -0400 Subject: [PATCH 03/11] ref(explore): only attach attribute context to sentry-source attributes A user-supplied tag can share a name with a sentry convention (e.g. gen_ai.request.model). Gate the convention metadata lookup on attributeSource.source_type == sentry so user attributes never pick up convention metadata. Co-Authored-By: Claude --- .../api/endpoints/organization_trace_item_attributes.py | 8 ++++---- .../endpoints/test_organization_trace_item_attributes.py | 9 ++++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 415cf302af2f19..5d7c084a67d37a 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -388,10 +388,10 @@ def as_attribute_key( if secondary_aliases: attribute_key["secondaryAliases"] = sorted(secondary_aliases) - # Join in the sentry conventions metadata. Only sentry convention attributes - # are present in ATTRIBUTE_METADATA, keyed by their public convention name - # (e.g. "gen_ai.usage.output_tokens"). - if include_context: + # Join in the sentry conventions metadata. Only sentry attributes map to a + # convention, so we skip user attributes entirely to avoid a user tag that + # happens to share a name with a convention picking up its metadata. + if include_context and serialized_source["source_type"] == AttributeSourceType.SENTRY.value: context = build_attribute_context(public_name, name) if context is not None: attribute_key["context"] = context 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 ae9dd30b36bda6..1081abc331deee 100644 --- a/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py +++ b/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py @@ -504,7 +504,10 @@ def _store_basic_segment(self) -> None: transaction="foo", duration=100, exclusive_time=100, - tags={"foo": "foo"}, + # `gen_ai.request.model` is a sentry convention name, but as a + # user-supplied tag it resolves to a `user` source. It must not pick + # up convention metadata. + tags={"foo": "foo", "gen_ai.request.model": "gpt-4"}, ) def test_expand_context(self) -> None: @@ -539,6 +542,10 @@ def test_expand_context(self) -> None: } # User tags are not sentry conventions, so they have no context. assert "context" not in attributes["foo"] + # A user tag whose name collides with a sentry convention still gets no + # context, because only `sentry`-source attributes are expanded. + assert attributes["gen_ai.request.model"]["attributeSource"]["source_type"] == "user" + assert "context" not in attributes["gen_ai.request.model"] def test_expand_context_without_feature_flag(self) -> None: self._store_basic_segment() From df73fdc75d18e5d1d98245c2f88a258e6be4f545 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Wed, 17 Jun 2026 10:30:05 -0400 Subject: [PATCH 04/11] ref(explore): drop last_received note from build_attribute_context docstring Co-Authored-By: Claude --- src/sentry/api/endpoints/organization_trace_item_attributes.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 5d7c084a67d37a..d31306d82c9aee 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -300,8 +300,7 @@ def build_attribute_context( """ Build the sentry conventions context for an attribute, if it maps to a known convention. Only fields actually present in the conventions metadata are - included (e.g. ``last_received`` is intentionally omitted since it does not - live in the metadata). + included. A convention may be keyed in ``ATTRIBUTE_METADATA`` by either the public alias or the internal name (see From cb8e550b9f72cdf7c33331f780cb9c910b8d7330 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Wed, 17 Jun 2026 10:31:21 -0400 Subject: [PATCH 05/11] ref(explore): drop duplicate data-browsing-attribute-context flag registration The flag is already registered on master. Co-Authored-By: Claude --- src/sentry/features/temporary.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index fc6c5afaa5151e..62c828af584706 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -343,8 +343,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:insights-modules-use-eap", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable data browsing heat map widget manager.add("organizations:data-browsing-heat-map-widget", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable sentry conventions attribute context (brief, examples, deprecation, etc.) on the trace item attributes endpoint - manager.add("organizations:data-browsing-attribute-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable public RPC endpoint for local seer development manager.add("organizations:seer-public-rpc", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Organizations on the old usage-based (v0) Seer plan From 182ccaa178e3b59ec2c514edef1c759e05ce604b Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Wed, 17 Jun 2026 10:35:14 -0400 Subject: [PATCH 06/11] ref(explore): exclude internal expand param from OpenAPI spec The expand=context param is internal-only (gated behind data-browsing-attribute-context), so hide it from the public spec while keeping it functional. Co-Authored-By: Claude --- src/sentry/api/endpoints/organization_trace_item_attributes.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index e8215affb47208..eb27de8335c5fb 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -194,6 +194,9 @@ def get_result(self, limit, cursor=None): many=True, type=str, enum=["context"], + # Internal-only for now: gated behind the data-browsing-attribute-context + # feature, so exclude it from the public OpenAPI spec. + exclude=True, description=( "Optional fields to expand. Pass `context` to include the sentry " "conventions metadata (brief, examples, deprecation, etc.) for " From 97bc7e1d962359f60628caf1d87af7519141a5c8 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Wed, 17 Jun 2026 10:36:14 -0400 Subject: [PATCH 07/11] ref(explore): drop context from public trace item attribute example context is only returned via the internal expand=context param, so it should not appear in the public response example. Co-Authored-By: Claude --- .../apidocs/examples/trace_item_attribute_examples.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/sentry/apidocs/examples/trace_item_attribute_examples.py b/src/sentry/apidocs/examples/trace_item_attribute_examples.py index 413b9900b1314a..755487f6e4598c 100644 --- a/src/sentry/apidocs/examples/trace_item_attribute_examples.py +++ b/src/sentry/apidocs/examples/trace_item_attribute_examples.py @@ -7,13 +7,6 @@ "name": "device.class", "attributeSource": {"source_type": "sentry"}, "attributeType": "string", - "context": { - "brief": ( - "The classification of the device. For example, `low`, `medium`, or `high`. " - "Typically inferred by Relay - SDKs generally do not need to set this directly." - ), - "isDeprecated": False, - }, } BATCH_SIZE: TraceItemAttributeKey = { From 15718897da87bcbe6ac357d39a224cd44c983d39 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Wed, 17 Jun 2026 10:40:29 -0400 Subject: [PATCH 08/11] ref(explore): make brief and isDeprecated required in attribute context brief is always present for a known convention (0/644 missing) and isDeprecated is always derivable, so type them as required. details, examples, and replacementAttribute remain optional. Co-Authored-By: Claude --- .../organization_trace_item_attributes.py | 17 +++++++++-------- .../organization_trace_item_attributes_types.py | 10 ++++++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index eb27de8335c5fb..611d7cfeb364bd 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -315,10 +315,15 @@ def build_attribute_context( if metadata is None: return None - context: TraceItemAttributeContext = {} + deprecation = metadata.deprecation - if metadata.brief: - context["brief"] = metadata.brief + # brief and isDeprecated are always present for a known convention. + context: TraceItemAttributeContext = { + "brief": metadata.brief, + "isDeprecated": bool( + deprecation is not None and (deprecation.status is not None or deprecation.replacement) + ), + } if metadata.additional_context: context["details"] = list(metadata.additional_context) @@ -330,14 +335,10 @@ def build_attribute_context( else [metadata.example] ) - deprecation = metadata.deprecation - context["isDeprecated"] = bool( - deprecation is not None and (deprecation.status is not None or deprecation.replacement) - ) if deprecation is not None and deprecation.replacement: context["replacementAttribute"] = deprecation.replacement - return context or None + return context def as_attribute_key( diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes_types.py b/src/sentry/api/endpoints/organization_trace_item_attributes_types.py index 0bb8659a404e81..a8d621d22ae913 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes_types.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes_types.py @@ -18,16 +18,18 @@ class TraceItemAttributeContext(TypedDict): enabled. """ - # A short, human-readable description of the attribute. - brief: NotRequired[str] + # A short, human-readable description of the attribute. Always present for a + # known convention. + brief: str + # Whether the convention has been deprecated. Always present for a known + # convention. + isDeprecated: bool # Longer-form notes that add nuance beyond the brief (e.g. caveats, # double-counting warnings). Sourced from the convention's # ``additional_context``. details: NotRequired[list[str]] # Example value(s) for the attribute, normalized to a list. examples: NotRequired[list[Any]] - # Whether the convention has been deprecated. - isDeprecated: NotRequired[bool] # The attribute that replaces this one, when deprecated. replacementAttribute: NotRequired[str] From ff1a26df9d76d0b4e47e75c41d8f8238b7b3df5f Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Wed, 17 Jun 2026 10:43:40 -0400 Subject: [PATCH 09/11] ref(explore): rename build_attribute_context to build_sentry_convention_context Co-Authored-By: Claude --- .../endpoints/organization_trace_item_attributes.py | 7 ++----- .../test_organization_trace_item_attributes.py | 10 +++++----- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 611d7cfeb364bd..5061ac38c1df28 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -297,7 +297,7 @@ def resolve_attribute_values_referrer(item_type: str) -> Referrer: raise ValueError(f"Invalid item type: {item_type}") -def build_attribute_context( +def build_sentry_convention_context( public_name: str, internal_name: str ) -> TraceItemAttributeContext | None: """ @@ -391,11 +391,8 @@ def as_attribute_key( if secondary_aliases: attribute_key["secondaryAliases"] = sorted(secondary_aliases) - # Join in the sentry conventions metadata. Only sentry attributes map to a - # convention, so we skip user attributes entirely to avoid a user tag that - # happens to share a name with a convention picking up its metadata. if include_context and serialized_source["source_type"] == AttributeSourceType.SENTRY.value: - context = build_attribute_context(public_name, name) + context = build_sentry_convention_context(public_name, name) if context is not None: attribute_key["context"] = context 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 1081abc331deee..335183e750b7f8 100644 --- a/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py +++ b/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py @@ -8,7 +8,7 @@ from sentry_conventions.attributes import ATTRIBUTE_METADATA from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey -from sentry.api.endpoints.organization_trace_item_attributes import build_attribute_context +from sentry.api.endpoints.organization_trace_item_attributes import build_sentry_convention_context from sentry.api.endpoints.organization_trace_item_attributes_types import ( TraceItemAttributeKey, ) @@ -31,7 +31,7 @@ class TestBuildAttributeContext: def test_lookup_by_public_name(self) -> None: - context = build_attribute_context("device.class", "sentry.device.class") + context = build_sentry_convention_context("device.class", "sentry.device.class") assert context is not None assert context["brief"].startswith("The classification of the device.") assert context["isDeprecated"] is False @@ -41,7 +41,7 @@ def test_falls_back_to_internal_name(self) -> None: # public alias (`span.op`), so the public-name lookup misses and the # fallback must resolve it. assert "span.op" not in ATTRIBUTE_METADATA - context = build_attribute_context("span.op", "sentry.op") + context = build_sentry_convention_context("span.op", "sentry.op") assert context == { "brief": "The operation of a span.", "examples": ["http.client"], @@ -49,13 +49,13 @@ def test_falls_back_to_internal_name(self) -> None: } def test_deprecated_attribute_includes_replacement(self) -> None: - context = build_attribute_context("transaction", "sentry.transaction") + context = build_sentry_convention_context("transaction", "sentry.transaction") assert context is not None assert context["isDeprecated"] is True assert context["replacementAttribute"] == "sentry.segment.name" def test_unknown_attribute_returns_none(self) -> None: - assert build_attribute_context("not.a.convention", "also.not.a.convention") is None + assert build_sentry_convention_context("not.a.convention", "also.not.a.convention") is None class OrganizationTraceItemAttributesEndpointTestBase(APITestCase, SnubaTestCase): From 50dbdfbe119dc1ecbad10ed110c5ed1c82ffb96a Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Wed, 17 Jun 2026 11:26:15 -0400 Subject: [PATCH 10/11] ref(explore): guard additional_context against scalar string in context builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the example normalization: wrap a non-list additional_context in a list instead of iterating it into characters. Defensive — the field is always a list or None today, but this keeps it consistent with examples. Co-Authored-By: Claude --- .../api/endpoints/organization_trace_item_attributes.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 5061ac38c1df28..13ec1f3e7db051 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -326,7 +326,11 @@ def build_sentry_convention_context( } if metadata.additional_context: - context["details"] = list(metadata.additional_context) + context["details"] = ( + list(metadata.additional_context) + if isinstance(metadata.additional_context, (list, tuple)) + else [metadata.additional_context] + ) if metadata.example is not None: context["examples"] = ( From 7e4aaa493fd965c157553b89a6da6f965a5c7fe8 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Thu, 18 Jun 2026 11:01:29 -0400 Subject: [PATCH 11/11] test(explore): Drop defaulted store_segment args from context test helper The span_id, parent_span_id, transaction, duration, and exclusive_time arguments in _store_basic_segment all match store_segment defaults and are not asserted on, so drop them to keep the helper minimal. Co-Authored-By: Claude Opus 4.8 --- .../api/endpoints/test_organization_trace_item_attributes.py | 5 ----- 1 file changed, 5 deletions(-) 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 335183e750b7f8..4588c558026564 100644 --- a/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py +++ b/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py @@ -497,13 +497,8 @@ def _store_basic_segment(self) -> None: 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, # `gen_ai.request.model` is a sentry convention name, but as a # user-supplied tag it resolves to a `user` source. It must not pick # up convention metadata.