Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/sentry/api/endpoints/organization_trace_item_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -185,6 +187,23 @@ 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"],
# 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 "
"attributes that map to a known convention."
),
)


class OrganizationTraceItemAttributesEndpointBase(OrganizationEventsEndpointBase):
publish_status = {
Expand Down Expand Up @@ -221,6 +240,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:
Expand Down Expand Up @@ -277,11 +297,60 @@ def resolve_attribute_values_referrer(item_type: str) -> Referrer:
raise ValueError(f"Invalid item type: {item_type}")


def build_sentry_convention_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.

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

deprecation = metadata.deprecation

# 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)
if isinstance(metadata.additional_context, (list, tuple))
else [metadata.additional_context]
)

if metadata.example is not None:
context["examples"] = (
list(metadata.example)
if isinstance(metadata.example, (list, tuple))
else [metadata.example]
)

if deprecation is not None and deprecation.replacement:
context["replacementAttribute"] = deprecation.replacement

return context


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
Expand Down Expand Up @@ -326,6 +395,11 @@ def as_attribute_key(
if secondary_aliases:
attribute_key["secondaryAliases"] = sorted(secondary_aliases)

if include_context and serialized_source["source_type"] == AttributeSourceType.SENTRY.value:
context = build_sentry_convention_context(public_name, name)
if context is not None:
attribute_key["context"] = context
Comment thread
cursor[bot] marked this conversation as resolved.

return attribute_key


Expand Down Expand Up @@ -365,6 +439,7 @@ class OrganizationTraceItemAttributesEndpoint(OrganizationTraceItemAttributesEnd
ATTRIBUTE_TYPE_QUERY_PARAM,
SUBSTRING_MATCH_QUERY_PARAM,
SEARCH_QUERY_PARAM,
EXPAND_QUERY_PARAM,
CursorQueryParam,
],
responses={
Expand Down Expand Up @@ -438,6 +513,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(
Expand All @@ -457,6 +541,7 @@ def data_fn(offset: int, limit: int) -> list[TraceItemAttributeKey]:
column_definitions,
trace_item_type,
include_internal,
include_context,
debug=debug,
)
)
Expand Down Expand Up @@ -490,6 +575,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
Expand Down Expand Up @@ -580,6 +666,7 @@ def query_trace_attributes(
substring_match,
aliased_attributes,
all_aliased_attributes,
include_context,
)

sentry_sdk.set_context("api_response", {"attributes": attributes})
Expand All @@ -597,6 +684,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:
Expand All @@ -609,6 +697,7 @@ def serialize_trace_attributes(
attribute.name,
attribute_type,
trace_item_type,
include_context=include_context,
)
if (
not is_sentry_convention_replacement_attribute(
Expand Down Expand Up @@ -645,6 +734,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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,45 @@
from typing import Literal, NotRequired, TypedDict
from typing import Any, Literal, NotRequired, TypedDict


class TraceItemAttributeSource(TypedDict):
source_type: Literal["sentry", "user"]
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. 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]]
# The attribute that replaces this one, when deprecated.
replacementAttribute: 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]
104 changes: 104 additions & 0 deletions tests/snuba/api/endpoints/test_organization_trace_item_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_sentry_convention_context
from sentry.api.endpoints.organization_trace_item_attributes_types import (
TraceItemAttributeKey,
)
Expand All @@ -27,6 +29,35 @@
from sentry.testutils.helpers.options import override_options


class TestBuildAttributeContext:
def test_lookup_by_public_name(self) -> None:
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

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_sentry_convention_context("span.op", "sentry.op")
assert context == {
"brief": "The operation of a span.",
"examples": ["http.client"],
"isDeprecated": False,
}

def test_deprecated_attribute_includes_replacement(self) -> None:
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_sentry_convention_context("not.a.convention", "also.not.a.convention") is None


class OrganizationTraceItemAttributesEndpointTestBase(APITestCase, SnubaTestCase):
feature_flags: dict[str, bool]
item_type: SupportedTraceItemType
Expand Down Expand Up @@ -461,6 +492,79 @@ 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,
organization_id=self.organization.id,
timestamp=before_now(days=0, minutes=10).replace(microsecond=0),
# `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:
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 + 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"],
"isDeprecated": False,
}
# A deprecated convention also surfaces the replacement attribute.
assert attributes["transaction"]["context"] == {
"brief": "The sentry transaction (segment name).",
"examples": ["GET /"],
"isDeprecated": True,
"replacementAttribute": "sentry.segment.name",
}
# 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()

# 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(
Expand Down
Loading