99from rest_framework import serializers
1010from rest_framework .request import Request
1111from rest_framework .response import Response
12+ from sentry_conventions .attributes import ATTRIBUTE_METADATA
1213from sentry_protos .snuba .v1 .endpoint_trace_item_attributes_pb2 import (
1314 TraceItemAttributeNamesRequest ,
1415 TraceItemAttributeNamesResponse ,
3435from sentry .api .base import cell_silo_endpoint
3536from sentry .api .bases import NoProjects , OrganizationEventsEndpointBase
3637from sentry .api .endpoints .organization_trace_item_attributes_types import (
38+ TraceItemAttributeContext ,
3739 TraceItemAttributeKey ,
3840 TraceItemAttributeSource ,
3941)
@@ -185,6 +187,23 @@ def get_result(self, limit, cursor=None):
185187 description = "Sentry [search syntax](https://docs.sentry.io/concepts/search/) to filter trace items before computing attributes." ,
186188)
187189
190+ EXPAND_QUERY_PARAM = OpenApiParameter (
191+ name = "expand" ,
192+ location = "query" ,
193+ required = False ,
194+ many = True ,
195+ type = str ,
196+ enum = ["context" ],
197+ # Internal-only for now: gated behind the data-browsing-attribute-context
198+ # feature, so exclude it from the public OpenAPI spec.
199+ exclude = True ,
200+ description = (
201+ "Optional fields to expand. Pass `context` to include the sentry "
202+ "conventions metadata (brief, examples, deprecation, etc.) for "
203+ "attributes that map to a known convention."
204+ ),
205+ )
206+
188207
189208class OrganizationTraceItemAttributesEndpointBase (OrganizationEventsEndpointBase ):
190209 publish_status = {
@@ -221,6 +240,7 @@ class OrganizationTraceItemAttributesEndpointSerializer(serializers.Serializer):
221240 )
222241 substringMatch = serializers .CharField (required = False , source = "substring_match" )
223242 query = serializers .CharField (required = False )
243+ expand = serializers .MultipleChoiceField (choices = ["context" ], required = False )
224244
225245 def validate (self , attrs : Any ) -> Any :
226246 if attrs .get ("item_type" ) is None and attrs .get ("dataset" ) is None :
@@ -277,11 +297,60 @@ def resolve_attribute_values_referrer(item_type: str) -> Referrer:
277297 raise ValueError (f"Invalid item type: { item_type } " )
278298
279299
300+ def build_sentry_convention_context (
301+ public_name : str , internal_name : str
302+ ) -> TraceItemAttributeContext | None :
303+ """
304+ Build the sentry conventions context for an attribute, if it maps to a known
305+ convention. Only fields actually present in the conventions metadata are
306+ included.
307+
308+ A convention may be keyed in ``ATTRIBUTE_METADATA`` by either the public
309+ alias or the internal name (see
310+ ``_update_attribute_definitions_with_deprecations`` in
311+ ``search/eap/spans/attributes.py``), so we try the public name first and
312+ fall back to the internal name.
313+ """
314+ metadata = ATTRIBUTE_METADATA .get (public_name ) or ATTRIBUTE_METADATA .get (internal_name )
315+ if metadata is None :
316+ return None
317+
318+ deprecation = metadata .deprecation
319+
320+ # brief and isDeprecated are always present for a known convention.
321+ context : TraceItemAttributeContext = {
322+ "brief" : metadata .brief ,
323+ "isDeprecated" : bool (
324+ deprecation is not None and (deprecation .status is not None or deprecation .replacement )
325+ ),
326+ }
327+
328+ if metadata .additional_context :
329+ context ["details" ] = (
330+ list (metadata .additional_context )
331+ if isinstance (metadata .additional_context , (list , tuple ))
332+ else [metadata .additional_context ]
333+ )
334+
335+ if metadata .example is not None :
336+ context ["examples" ] = (
337+ list (metadata .example )
338+ if isinstance (metadata .example , (list , tuple ))
339+ else [metadata .example ]
340+ )
341+
342+ if deprecation is not None and deprecation .replacement :
343+ context ["replacementAttribute" ] = deprecation .replacement
344+
345+ return context
346+
347+
280348def as_attribute_key (
281349 name : str ,
282350 attr_type : Literal ["string" , "number" , "boolean" ],
283351 item_type : SupportedTraceItemType ,
284352 is_proxy : bool = False ,
353+ include_context : bool = False ,
285354) -> TraceItemAttributeKey :
286355 public_key , public_name , attribute_source = translate_internal_to_public_alias (
287356 name , attr_type , item_type
@@ -326,6 +395,11 @@ def as_attribute_key(
326395 if secondary_aliases :
327396 attribute_key ["secondaryAliases" ] = sorted (secondary_aliases )
328397
398+ if include_context and serialized_source ["source_type" ] == AttributeSourceType .SENTRY .value :
399+ context = build_sentry_convention_context (public_name , name )
400+ if context is not None :
401+ attribute_key ["context" ] = context
402+
329403 return attribute_key
330404
331405
@@ -365,6 +439,7 @@ class OrganizationTraceItemAttributesEndpoint(OrganizationTraceItemAttributesEnd
365439 ATTRIBUTE_TYPE_QUERY_PARAM ,
366440 SUBSTRING_MATCH_QUERY_PARAM ,
367441 SEARCH_QUERY_PARAM ,
442+ EXPAND_QUERY_PARAM ,
368443 CursorQueryParam ,
369444 ],
370445 responses = {
@@ -438,6 +513,15 @@ def get(
438513 debug = request .user .is_superuser and request .GET .get ("debug" , False )
439514 debug_infos : list [dict ] = []
440515
516+ # Only expand the sentry conventions context when explicitly requested
517+ # via `expand=context` and the feature is enabled for the org. When the
518+ # feature is disabled this is a no-op even if `expand=context` is passed.
519+ include_context = "context" in serialized .get ("expand" , set ()) and features .has (
520+ "organizations:data-browsing-attribute-context" ,
521+ organization ,
522+ actor = request .user ,
523+ )
524+
441525 def data_fn (offset : int , limit : int ) -> list [TraceItemAttributeKey ]:
442526 futures = []
443527 with ContextPropagatingThreadPoolExecutor (
@@ -457,6 +541,7 @@ def data_fn(offset: int, limit: int) -> list[TraceItemAttributeKey]:
457541 column_definitions ,
458542 trace_item_type ,
459543 include_internal ,
544+ include_context ,
460545 debug = debug ,
461546 )
462547 )
@@ -490,6 +575,7 @@ def query_trace_attributes(
490575 column_definitions : ColumnDefinitions ,
491576 trace_item_type : SupportedTraceItemType ,
492577 include_internal : bool ,
578+ include_context : bool = False ,
493579 debug : str | bool = False ,
494580 ) -> tuple [list [TraceItemAttributeKey ], dict | None ]:
495581 debug_info : dict | None = None
@@ -580,6 +666,7 @@ def query_trace_attributes(
580666 substring_match ,
581667 aliased_attributes ,
582668 all_aliased_attributes ,
669+ include_context ,
583670 )
584671
585672 sentry_sdk .set_context ("api_response" , {"attributes" : attributes })
@@ -597,6 +684,7 @@ def serialize_trace_attributes(
597684 substring_match : str ,
598685 aliased_attributes : list [ResolvedAttribute | ProxyResolvedAttribute ],
599686 exclude_attributes : list [ResolvedAttribute | ProxyResolvedAttribute ],
687+ include_context : bool = False ,
600688 ) -> list [TraceItemAttributeKey ]:
601689 attribute_keys = {}
602690 for attribute in rpc_response .attributes :
@@ -609,6 +697,7 @@ def serialize_trace_attributes(
609697 attribute .name ,
610698 attribute_type ,
611699 trace_item_type ,
700+ include_context = include_context ,
612701 )
613702 if (
614703 not is_sentry_convention_replacement_attribute (
@@ -645,6 +734,7 @@ def serialize_trace_attributes(
645734 attribute_type ,
646735 trace_item_type ,
647736 is_proxy = isinstance (aliased_attr , ProxyResolvedAttribute ),
737+ include_context = include_context ,
648738 )
649739 if can_expose_attribute_to_api (
650740 aliased_attr .internal_name ,
0 commit comments