From 2cc9487e4b3ff234b78ab6100a22376c3eac8768 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 31 Jul 2026 15:52:16 -0400 Subject: [PATCH 1/2] feat(seer): Return custom attribute context from get_attribute_names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RPC dropped user-authored attribute context: anything that wasn't a sentry convention or a Sentry-defined field was filtered out before reaching Seer, so custom attributes arrived with no descriptions. Route those attributes into a new `custom_fields` list instead. Keeping them out of `built_in_fields` preserves that list's meaning — attributes that are part of Sentry's schema — while custom context, which is specific to an organization, travels alongside it. Custom attributes without authored context are unchanged and appear only in `fields`. Context for these attributes is served by the /trace-items/attributes/ endpoint and gated behind data-browsing-attribute-context, so `custom_fields` is empty until an organization authors some. Refs BROWSE-596 Co-Authored-By: Claude Opus 5 --- .../seer/assisted_query/traces_tools.py | 51 +++--- src/sentry/seer/sentry_data_models.py | 14 +- .../seer/assisted_query/test_traces_tools.py | 145 ++++++++++++++++++ .../api/endpoints/test_seer_attributes.py | 4 + 4 files changed, 188 insertions(+), 26 deletions(-) create mode 100644 tests/sentry/seer/assisted_query/test_traces_tools.py diff --git a/src/sentry/seer/assisted_query/traces_tools.py b/src/sentry/seer/assisted_query/traces_tools.py index 4de64c903bfe..f5d720c16495 100644 --- a/src/sentry/seer/assisted_query/traces_tools.py +++ b/src/sentry/seer/assisted_query/traces_tools.py @@ -124,8 +124,9 @@ def get_attribute_names( item_type: Type of trace item (default: "spans", can be "spans", "logs", etc.) include_context: When True, include the context metadata (brief, examples, deprecation, etc.) for each attribute and attach it to the - matching built-in fields in the response. Today the metadata comes - from the sentry conventions; custom attribute context is planned. + matching field in the response. Sentry-owned attributes get their + metadata from the sentry conventions or a column definition; custom + attributes get user-authored metadata. Returns: Dictionary with attributes: @@ -138,25 +139,28 @@ def get_attribute_names( {"key": "span.op", "type": "string", "context": {...}}, {"key": "span.duration", "type": "number", "context": None}, ... + ], + "custom_fields": [ + {"key": "custom_field_1", "type": "string", "context": {...}}, + ... ] } - Each built-in field's "context" is only populated when expand="context" - is requested (and the attribute maps to a known convention); otherwise it - is None. Convention-backed attributes that aren't hardcoded built-ins - (e.g. http.route) are also appended to "built_in_fields" so their context - isn't lost. + A field's "context" is only populated when expand="context" is requested + and metadata exists for the attribute; otherwise it is None. + Convention-backed attributes that aren't hardcoded built-ins (e.g. + http.route) are appended to "built_in_fields" so their context isn't lost. + Custom attributes go to "custom_fields" when they have authored context, + and appear only in "fields" otherwise. """ organization = Organization.objects.get(id=org_id) api_key = ApiKey(organization_id=org_id, scope_list=API_KEY_SCOPES) fields: dict[str, list[str]] = {"string": [], "number": []} - # Maps an attribute name to its context, populated only when the caller - # passes include_context=True. Used below to attach context to the built-in - # fields (which is where Seer reads attribute context from). Today the - # context comes from the sentry conventions, but custom attribute context is - # planned, at which point user-defined attributes will be populated too. + # Maps an attribute name to its context, populated only when the caller passes + # include_context=True. Attached below to the built-in and custom fields, which + # is where Seer reads attribute context from. context_by_name: dict[str, dict[str, Any]] = {} # Maps an attribute name to its type ("string"/"number"), so context-bearing # attributes that aren't hardcoded built-ins can still be emitted as fields. @@ -207,20 +211,23 @@ def get_attribute_names( ] # Context-bearing attributes that aren't hardcoded built-ins (e.g. the - # convention http.route, or Sentry-defined fields like span.description) would - # otherwise be dropped. Surface them through built_in_fields too, since that's - # where Seer reads attribute context from. We promote conventions - # (isConvention=True) and Sentry-defined attributes (source=sentry); - # user-authored custom context is intentionally left out. + # convention http.route) would otherwise be dropped. Conventions and + # Sentry-defined fields join built_in_fields; user-authored context goes to + # custom_fields, being org-specific rather than part of Sentry's schema. hardcoded_keys = {f["key"] for f in hardcoded_fields} + custom_fields = [] for name, context in context_by_name.items(): if name in hardcoded_keys: continue - if not (context.get("isConvention") or source_by_name.get(name) == "sentry"): - continue - built_in_fields.append(BuiltInField(key=name, type=type_by_name[name], context=context)) - - return AttributeNamesResponse(fields=fields, built_in_fields=built_in_fields) + field = BuiltInField(key=name, type=type_by_name[name], context=context) + if context.get("isConvention") or source_by_name.get(name) == "sentry": + built_in_fields.append(field) + elif context.get("isCustom"): + custom_fields.append(field) + + return AttributeNamesResponse( + fields=fields, built_in_fields=built_in_fields, custom_fields=custom_fields + ) def get_attribute_values_with_substring( diff --git a/src/sentry/seer/sentry_data_models.py b/src/sentry/seer/sentry_data_models.py index 35e37ed6587a..988cc7638428 100644 --- a/src/sentry/seer/sentry_data_models.py +++ b/src/sentry/seer/sentry_data_models.py @@ -210,16 +210,22 @@ class BuiltInField(BaseModel): type: str # Attribute metadata (brief, examples, isDeprecated, replacementAttribute, # ...) for the attribute, populated when the caller requests - # `expand="context"`; otherwise None. Today the metadata comes from the - # sentry conventions, so only attributes that map to a known convention - # carry it, but custom attribute context is planned and will populate this - # for user-defined attributes too. + # `expand="context"`; otherwise None. Sentry-owned attributes carry metadata + # from the sentry conventions or their column definition; user-defined + # attributes carry user-authored metadata (see + # AttributeNamesResponse.custom_fields). context: dict[str, Any] | None = None class AttributeNamesResponse(BaseModel): fields: dict[str, list[str]] + # Sentry-owned attributes (conventions and Sentry-defined fields), whose + # context comes from the sentry conventions or a column definition. built_in_fields: list[BuiltInField] + # Custom (user-defined) attributes that carry user-authored context. Only + # populated when the caller requests context and the organization has + # authored some; attributes without context are still listed in `fields`. + custom_fields: list[BuiltInField] = [] class AttributeBucket(BaseModel): diff --git a/tests/sentry/seer/assisted_query/test_traces_tools.py b/tests/sentry/seer/assisted_query/test_traces_tools.py new file mode 100644 index 000000000000..db4a647dbcca --- /dev/null +++ b/tests/sentry/seer/assisted_query/test_traces_tools.py @@ -0,0 +1,145 @@ +from unittest.mock import MagicMock, patch + +from sentry.seer.assisted_query.traces_tools import get_attribute_names +from sentry.testutils.cases import TestCase + + +def _attribute(name, attr_type="string", source="user", context=None): + attribute = { + "key": name, + "name": name, + "attributeType": attr_type, + "attributeSource": {"source_type": source}, + } + if context is not None: + attribute["context"] = context + return attribute + + +class TestGetAttributeNames(TestCase): + def setUp(self) -> None: + super().setUp() + self.org = self.create_organization() + self.project = self.create_project(organization=self.org) + + def _mock_responses(self, mock_client_cls, string_attrs, number_attrs=None): + """Stub the two per-type calls get_attribute_names makes (string, then number).""" + mock_client = mock_client_cls.return_value + string_response, number_response = MagicMock(), MagicMock() + string_response.data = string_attrs + number_response.data = number_attrs or [] + mock_client.get.side_effect = [string_response, number_response] + return mock_client + + @patch("sentry.seer.assisted_query.traces_tools.ApiClient") + def test_custom_context_goes_to_custom_fields(self, mock_client_cls: MagicMock) -> None: + self._mock_responses( + mock_client_cls, + [ + # A convention, which belongs in built_in_fields. + _attribute( + "device.class", + context={"isConvention": True, "brief": "Device class"}, + ), + # User-authored context, which belongs in custom_fields. + _attribute( + "my_custom_attr", + context={ + "isCustom": True, + "brief": "Set by the checkout service", + "details": ["Only on payment spans."], + "examples": ["visa"], + }, + ), + # No context at all, so it appears only in `fields`. + _attribute("undescribed_attr", context={}), + ], + ) + + result = get_attribute_names( + org_id=self.org.id, + project_ids=[self.project.id], + stats_period="7d", + include_context=True, + ) + + assert [field.key for field in result.custom_fields] == ["my_custom_attr"] + assert result.custom_fields[0].type == "string" + assert result.custom_fields[0].context == { + "isCustom": True, + "brief": "Set by the checkout service", + "details": ["Only on payment spans."], + "examples": ["visa"], + } + + built_in_keys = {field.key for field in result.built_in_fields} + assert "device.class" in built_in_keys + # Custom context must never leak into the Sentry-owned fields. + assert "my_custom_attr" not in built_in_keys + assert "undescribed_attr" not in built_in_keys + + # Every attribute is still listed in `fields`, with or without context. + assert result.fields["string"] == [ + "device.class", + "my_custom_attr", + "undescribed_attr", + ] + + @patch("sentry.seer.assisted_query.traces_tools.ApiClient") + def test_number_typed_custom_context(self, mock_client_cls: MagicMock) -> None: + self._mock_responses( + mock_client_cls, + [], + [_attribute("cart_total", "number", context={"isCustom": True, "brief": "In cents"})], + ) + + result = get_attribute_names( + org_id=self.org.id, + project_ids=[self.project.id], + stats_period="7d", + include_context=True, + ) + + assert len(result.custom_fields) == 1 + assert result.custom_fields[0].key == "cart_total" + assert result.custom_fields[0].type == "number" + + @patch("sentry.seer.assisted_query.traces_tools.ApiClient") + def test_sentry_source_attribute_stays_built_in(self, mock_client_cls: MagicMock) -> None: + # A Sentry-defined attribute carries isConvention=False, so it is routed by + # its `sentry` source rather than being mistaken for custom context. + self._mock_responses( + mock_client_cls, + [ + _attribute( + "span.description", + source="sentry", + context={"isConvention": False, "brief": "Span description"}, + ) + ], + ) + + result = get_attribute_names( + org_id=self.org.id, + project_ids=[self.project.id], + stats_period="7d", + include_context=True, + ) + + assert "span.description" in {field.key for field in result.built_in_fields} + assert result.custom_fields == [] + + @patch("sentry.seer.assisted_query.traces_tools.ApiClient") + def test_no_context_requested(self, mock_client_cls: MagicMock) -> None: + mock_client = self._mock_responses(mock_client_cls, [_attribute("my_custom_attr")]) + + result = get_attribute_names( + org_id=self.org.id, + project_ids=[self.project.id], + stats_period="7d", + ) + + assert result.custom_fields == [] + # Context is only requested from the endpoint when asked for. + _args, kwargs = mock_client.get.call_args + assert "expand" not in kwargs["params"] diff --git a/tests/snuba/api/endpoints/test_seer_attributes.py b/tests/snuba/api/endpoints/test_seer_attributes.py index ccf9bdc0bcd2..4da9c06b7623 100644 --- a/tests/snuba/api/endpoints/test_seer_attributes.py +++ b/tests/snuba/api/endpoints/test_seer_attributes.py @@ -70,6 +70,7 @@ def test_get_attribute_names(self) -> None: {"key": "span.duration", "type": "number", "context": None}, {"key": "span.self_time", "type": "number", "context": None}, ], + "custom_fields": [], } def test_get_attribute_names_with_context(self) -> None: @@ -133,6 +134,9 @@ def test_get_attribute_names_with_context(self) -> None: for field in result.built_in_fields: assert field.context is None or field.context != {} + # This org has authored no context, so there are no custom fields. + assert result.custom_fields == [] + def test_get_attribute_values_with_substring(self) -> None: for transaction in ["foo", "bar", "baz"]: self.store_segment( From bdfb02bb112be588e5cd1db45aeae10bb281b4f3 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 31 Jul 2026 16:33:32 -0400 Subject: [PATCH 2/2] ref(seer): Rename BuiltInField to AttributeMeta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `custom_fields: list[BuiltInField]` contradicted itself, and the class name is not internal — the seer-side codegen reads each RPC's return annotation into a JSON-schema manifest, where it surfaces as `#/definitions/BuiltInField`. Rename to `AttributeMeta`, which fits both lists and stays accurate for the hardcoded built-ins that carry no context. The wire format is unchanged, since pydantic serializes field names rather than the class name. Also add the end-to-end coverage that was blocked on the endpoint change, now that #120994 has landed on master. Refs BROWSE-596 Co-Authored-By: Claude Opus 5 --- .../seer/assisted_query/traces_tools.py | 6 +- src/sentry/seer/sentry_data_models.py | 21 ++-- .../api/endpoints/test_seer_attributes.py | 98 +++++++++++++++++++ 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/src/sentry/seer/assisted_query/traces_tools.py b/src/sentry/seer/assisted_query/traces_tools.py index f5d720c16495..e769455c3fdf 100644 --- a/src/sentry/seer/assisted_query/traces_tools.py +++ b/src/sentry/seer/assisted_query/traces_tools.py @@ -6,9 +6,9 @@ from sentry.models.apikey import ApiKey from sentry.models.organization import Organization from sentry.seer.sentry_data_models import ( + AttributeMeta, AttributeNamesResponse, AttributeValuesResponse, - BuiltInField, ) logger = logging.getLogger(__name__) @@ -207,7 +207,7 @@ def get_attribute_names( hardcoded_fields = _get_built_in_fields(item_type) built_in_fields = [ - BuiltInField(**f, context=context_by_name.get(f["key"])) for f in hardcoded_fields + AttributeMeta(**f, context=context_by_name.get(f["key"])) for f in hardcoded_fields ] # Context-bearing attributes that aren't hardcoded built-ins (e.g. the @@ -219,7 +219,7 @@ def get_attribute_names( for name, context in context_by_name.items(): if name in hardcoded_keys: continue - field = BuiltInField(key=name, type=type_by_name[name], context=context) + field = AttributeMeta(key=name, type=type_by_name[name], context=context) if context.get("isConvention") or source_by_name.get(name) == "sentry": built_in_fields.append(field) elif context.get("isCustom"): diff --git a/src/sentry/seer/sentry_data_models.py b/src/sentry/seer/sentry_data_models.py index 988cc7638428..1f29a560ad03 100644 --- a/src/sentry/seer/sentry_data_models.py +++ b/src/sentry/seer/sentry_data_models.py @@ -205,15 +205,16 @@ class SpanAttributesResponse(BaseModel): attributes: list[SpanAttribute] -class BuiltInField(BaseModel): +class AttributeMeta(BaseModel): + """An attribute's name, type, and optional context, as listed in + ``AttributeNamesResponse``.""" + key: str type: str - # Attribute metadata (brief, examples, isDeprecated, replacementAttribute, - # ...) for the attribute, populated when the caller requests - # `expand="context"`; otherwise None. Sentry-owned attributes carry metadata - # from the sentry conventions or their column definition; user-defined - # attributes carry user-authored metadata (see - # AttributeNamesResponse.custom_fields). + # Metadata (brief, examples, isDeprecated, replacementAttribute, ...), + # populated when the caller requests `expand="context"`; otherwise None. + # Sourced from the sentry conventions or a column definition for Sentry-owned + # attributes, and authored by the org for custom ones. context: dict[str, Any] | None = None @@ -221,11 +222,11 @@ class AttributeNamesResponse(BaseModel): fields: dict[str, list[str]] # Sentry-owned attributes (conventions and Sentry-defined fields), whose # context comes from the sentry conventions or a column definition. - built_in_fields: list[BuiltInField] + built_in_fields: list[AttributeMeta] # Custom (user-defined) attributes that carry user-authored context. Only # populated when the caller requests context and the organization has # authored some; attributes without context are still listed in `fields`. - custom_fields: list[BuiltInField] = [] + custom_fields: list[AttributeMeta] = [] class AttributeBucket(BaseModel): @@ -244,7 +245,7 @@ class MetricMetadataRow(BaseModel): count: int # Authored context (brief, details) for the metric, populated only when the # caller passes include_context=True (and the metric has context); otherwise - # None. Mirrors the attributes context shape (see BuiltInField.context). + # None. Mirrors the attributes context shape (see AttributeMeta.context). context: dict[str, Any] | None = None diff --git a/tests/snuba/api/endpoints/test_seer_attributes.py b/tests/snuba/api/endpoints/test_seer_attributes.py index 4da9c06b7623..c0a984b67dc3 100644 --- a/tests/snuba/api/endpoints/test_seer_attributes.py +++ b/tests/snuba/api/endpoints/test_seer_attributes.py @@ -1,11 +1,17 @@ from uuid import uuid4 +from sentry.explore.models import ( + TraceItemAttributeContext, + TraceItemAttributeTypes, + TraceItemTypes, +) from sentry.seer.assisted_query.traces_tools import ( _get_built_in_fields, get_attribute_names, get_attribute_values_with_substring, ) from sentry.seer.endpoints.seer_rpc import get_attributes_and_values +from sentry.seer.sentry_data_models import AttributeMeta from sentry.testutils.cases import BaseSpansTestCase from sentry.testutils.helpers.datetime import before_now from tests.snuba.api.endpoints.test_organization_trace_item_attributes import ( @@ -137,6 +143,98 @@ def test_get_attribute_names_with_context(self) -> None: # This org has authored no context, so there are no custom fields. assert result.custom_fields == [] + def test_get_attribute_names_with_custom_context(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={"my_custom_attr": "value", "undescribed_attr": "value"}, + ) + TraceItemAttributeContext.objects.create( + organization=self.organization, + project=None, + attribute_key="my_custom_attr", + item_type=TraceItemTypes.SPANS, + attribute_type=TraceItemAttributeTypes.STRING, + brief="Set by the checkout service", + additional_context="Only present on payment spans.", + examples=["visa", "amex"], + ) + + with self.feature( + [ + "organizations:visibility-explore-view", + "organizations:data-browsing-attribute-context", + ] + ): + result = get_attribute_names( + org_id=self.organization.id, + project_ids=[self.project.id], + stats_period="7d", + include_context=True, + ) + + # A described custom attribute is surfaced separately from the + # Sentry-owned built_in_fields. + assert result.custom_fields == [ + AttributeMeta( + key="my_custom_attr", + type="string", + context={ + "isCustom": True, + "brief": "Set by the checkout service", + "details": ["Only present on payment spans."], + "examples": ["visa", "amex"], + }, + ) + ] + assert "my_custom_attr" not in {field.key for field in result.built_in_fields} + # Custom attributes are listed in `fields` whether described or not. + assert "my_custom_attr" in result.fields["string"] + assert "undescribed_attr" in result.fields["string"] + + def test_get_attribute_names_custom_context_requires_feature(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={"my_custom_attr": "value"}, + ) + TraceItemAttributeContext.objects.create( + organization=self.organization, + project=None, + attribute_key="my_custom_attr", + item_type=TraceItemTypes.SPANS, + attribute_type=TraceItemAttributeTypes.STRING, + brief="Set by the checkout service", + ) + + # Custom context is gated, so without the flag there's nothing to surface. + with self.feature(["organizations:visibility-explore-view"]): + result = get_attribute_names( + org_id=self.organization.id, + project_ids=[self.project.id], + stats_period="7d", + include_context=True, + ) + + assert result.custom_fields == [] + assert "my_custom_attr" in result.fields["string"] + def test_get_attribute_values_with_substring(self) -> None: for transaction in ["foo", "bar", "baz"]: self.store_segment(