Skip to content

Commit 4084fa3

Browse files
authored
feat(explore): Omit internal attributes from responses (#97414)
The span buffer adds some internal attributes for tracking purposes, they are prefixed with `__sentry_internal`. We're also adding a new official convention for prefixed attributes, `sentry._internal`. Right now, we hide these in the UI in the trace waterfall, but not anywhere else. Instead, use a more robust approach where we filter those attributes out _on the backend_, but they are still visible to superusers.
1 parent d9466a5 commit 4084fa3

File tree

7 files changed

+125
-13
lines changed

7 files changed

+125
-13
lines changed

src/sentry/api/endpoints/organization_trace_item_attributes.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
from sentry.api.paginator import ChainPaginator, GenericOffsetPaginator
2626
from sentry.api.serializers import serialize
2727
from sentry.api.utils import handle_query_errors
28+
from sentry.auth.staff import is_active_staff
29+
from sentry.auth.superuser import is_active_superuser
2830
from sentry.models.organization import Organization
2931
from sentry.models.release import Release
3032
from sentry.models.releaseenvironment import ReleaseEnvironment
@@ -237,6 +239,8 @@ def get(self, request: Request, organization: Organization) -> Response:
237239
else AttributeKey.Type.TYPE_STRING
238240
)
239241

242+
include_internal = is_active_superuser(request) or is_active_staff(request)
243+
240244
def data_fn(offset: int, limit: int):
241245
rpc_request = TraceItemAttributeNamesRequest(
242246
meta=meta,
@@ -253,7 +257,9 @@ def data_fn(offset: int, limit: int):
253257
if use_sentry_conventions:
254258
attribute_keys = {}
255259
for attribute in rpc_response.attributes:
256-
if attribute.name and can_expose_attribute(attribute.name, trace_item_type):
260+
if attribute.name and can_expose_attribute(
261+
attribute.name, trace_item_type, include_internal=include_internal
262+
):
257263
attr_key = as_attribute_key(
258264
attribute.name, serialized["attribute_type"], trace_item_type
259265
)
@@ -280,7 +286,10 @@ def data_fn(offset: int, limit: int):
280286
attribute.name, serialized["attribute_type"], trace_item_type
281287
)
282288
for attribute in rpc_response.attributes
283-
if attribute.name and can_expose_attribute(attribute.name, trace_item_type)
289+
if attribute.name
290+
and can_expose_attribute(
291+
attribute.name, trace_item_type, include_internal=include_internal
292+
)
284293
],
285294
)
286295
)

src/sentry/api/endpoints/project_trace_item_details.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717
from sentry.api.base import region_silo_endpoint
1818
from sentry.api.bases.project import ProjectEndpoint
1919
from sentry.api.exceptions import BadRequest
20+
from sentry.auth.staff import is_active_staff
21+
from sentry.auth.superuser import is_active_superuser
2022
from sentry.models.project import Project
2123
from sentry.search.eap import constants
2224
from sentry.search.eap.types import SupportedTraceItemType, TraceItemAttribute
2325
from sentry.search.eap.utils import (
24-
PRIVATE_ATTRIBUTES,
26+
can_expose_attribute,
2527
is_sentry_convention_replacement_attribute,
2628
translate_internal_to_public_alias,
2729
translate_to_sentry_conventions,
@@ -34,15 +36,18 @@ def convert_rpc_attribute_to_json(
3436
attributes: list[dict],
3537
trace_item_type: SupportedTraceItemType,
3638
use_sentry_conventions: bool = False,
39+
include_internal: bool = False,
3740
) -> list[TraceItemAttribute]:
3841
result: list[TraceItemAttribute] = []
3942
seen_sentry_conventions: set[str] = set()
4043
for attribute in attributes:
4144
internal_name = attribute["name"]
42-
if internal_name in PRIVATE_ATTRIBUTES.get(trace_item_type, []):
43-
continue
44-
if internal_name.startswith(constants.META_PREFIX):
45+
46+
if not can_expose_attribute(
47+
internal_name, trace_item_type, include_internal=include_internal
48+
):
4549
continue
50+
4651
source = attribute["value"]
4752
if len(source) == 0:
4853
raise BadRequest(f"unknown field in protobuf: {internal_name}")
@@ -127,6 +132,9 @@ def extract_key(key: str) -> str | None:
127132
if field_key is None:
128133
continue
129134

135+
# TODO: This should probably also omit internal attributes. It's not
136+
# clear why it doesn't, but this behavior seems important for logs.
137+
130138
try:
131139
result = json.loads(attribute["value"]["valStr"])
132140
# Map the internal field key name back to its public name
@@ -295,11 +303,16 @@ def get(request: Request, project: Project, item_id: str) -> Response:
295303
actor=request.user,
296304
)
297305

306+
include_internal = is_active_superuser(request) or is_active_staff(request)
307+
298308
resp_dict = {
299309
"itemId": serialize_item_id(resp["itemId"], item_type),
300310
"timestamp": resp["timestamp"],
301311
"attributes": convert_rpc_attribute_to_json(
302-
resp["attributes"], item_type, use_sentry_conventions
312+
resp["attributes"],
313+
item_type,
314+
use_sentry_conventions,
315+
include_internal=include_internal,
303316
),
304317
"meta": serialize_meta(resp["attributes"], item_type),
305318
"links": serialize_links(resp["attributes"]),

src/sentry/search/eap/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,5 @@
178178
META_PREFIX = "sentry._meta"
179179
META_FIELD_PREFIX = f"{META_PREFIX}.fields"
180180
META_ATTRIBUTE_PREFIX = f"{META_FIELD_PREFIX}.attributes"
181+
182+
SENTRY_INTERNAL_PREFIXES = ["__sentry_internal", "sentry._internal."]

src/sentry/search/eap/utils.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from sentry.exceptions import InvalidSearchQuery
1717
from sentry.search.eap.columns import ResolvedAttribute
18-
from sentry.search.eap.constants import SAMPLING_MODE_MAP
18+
from sentry.search.eap.constants import SAMPLING_MODE_MAP, SENTRY_INTERNAL_PREFIXES
1919
from sentry.search.eap.ourlogs.attributes import (
2020
LOGS_INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS,
2121
LOGS_INTERNAL_TO_SECONDARY_ALIASES_MAPPING,
@@ -189,11 +189,22 @@ def get_secondary_aliases(
189189
return mapping.get(internal_alias)
190190

191191

192-
def can_expose_attribute(attribute: str, item_type: SupportedTraceItemType) -> bool:
193-
return attribute not in PRIVATE_ATTRIBUTES.get(item_type, {}) and not any(
192+
def can_expose_attribute(
193+
attribute: str, item_type: SupportedTraceItemType, include_internal: bool = False
194+
) -> bool:
195+
# Always omit private attributes
196+
if attribute in PRIVATE_ATTRIBUTES.get(item_type, {}) or any(
194197
attribute.lower().startswith(prefix.lower())
195198
for prefix in PRIVATE_ATTRIBUTE_PREFIXES.get(item_type, {})
196-
)
199+
):
200+
return False
201+
202+
# Omit internal attributes, unless explicitly requested. Usually, only
203+
# Sentry staff should see these.
204+
if any(attribute.lower().startswith(prefix.lower()) for prefix in SENTRY_INTERNAL_PREFIXES):
205+
return include_internal
206+
207+
return True
197208

198209

199210
def handle_downsample_meta(meta: DownsampledStorageMeta) -> bool:

src/sentry/seer/endpoints/seer_rpc.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,10 @@ def get_attribute_names(*, org_id: int, project_ids: list[int], stats_period: st
320320
SupportedTraceItemType.SPANS,
321321
)["name"]
322322
for attr in fields_resp.attributes
323-
if attr.name and can_expose_attribute(attr.name, SupportedTraceItemType.SPANS)
323+
if attr.name
324+
and can_expose_attribute(
325+
attr.name, SupportedTraceItemType.SPANS, include_internal=False
326+
)
324327
]
325328

326329
fields[type_str].extend(parsed_fields)

tests/snuba/api/endpoints/test_organization_trace_item_attributes.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ def test_attribute_collision(self) -> None:
206206

207207

208208
class OrganizationTraceItemAttributesEndpointSpansTest(
209-
OrganizationTraceItemAttributesEndpointTestBase, BaseSpansTestCase
209+
OrganizationTraceItemAttributesEndpointTestBase, BaseSpansTestCase, SpanTestCase
210210
):
211211
feature_flags = {"organizations:visibility-explore-view": True}
212212
item_type = SupportedTraceItemType.SPANS
@@ -521,6 +521,43 @@ def test_attribute_collision(self) -> None:
521521
{"key": "tags[span.op,string]", "name": "tags[span.op,string]"},
522522
]
523523

524+
def test_sentry_internal_attributes(self) -> None:
525+
self.store_spans(
526+
[
527+
self.create_span(
528+
{
529+
"tags": {
530+
"normal_attr": "normal_value",
531+
"__sentry_internal_span_buffer_outcome": "different",
532+
"__sentry_internal_test": "internal_value",
533+
}
534+
},
535+
start_ts=before_now(days=0, minutes=10),
536+
),
537+
],
538+
is_eap=True,
539+
)
540+
541+
response = self.do_request(query={"substringMatch": ""})
542+
assert response.status_code == 200
543+
544+
attribute_names = {attr["name"] for attr in response.data}
545+
assert "normal_attr" in attribute_names
546+
assert "__sentry_internal_span_buffer_outcome" not in attribute_names
547+
assert "__sentry_internal_test" not in attribute_names
548+
549+
staff_user = self.create_user(is_staff=True)
550+
self.create_member(user=staff_user, organization=self.organization)
551+
self.login_as(user=staff_user, staff=True)
552+
553+
response = self.do_request(query={"substringMatch": ""})
554+
assert response.status_code == 200
555+
556+
attribute_names = {attr["name"] for attr in response.data}
557+
assert "normal_attr" in attribute_names
558+
assert "__sentry_internal_span_buffer_outcome" in attribute_names
559+
assert "__sentry_internal_test" in attribute_names
560+
524561

525562
class OrganizationTraceItemAttributeValuesEndpointBaseTest(APITestCase, SnubaTestCase):
526563
feature_flags: dict[str, bool]

tests/snuba/api/endpoints/test_project_trace_item_details.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,3 +467,40 @@ def test_sentry_links(self) -> None:
467467
],
468468
}
469469
]
470+
471+
def test_sentry_internal_attributes(self) -> None:
472+
span_1 = self.create_span(
473+
{
474+
"description": "test span",
475+
"tags": {
476+
"normal_attr": "normal_value",
477+
"__sentry_internal_span_buffer_outcome": "different",
478+
"__sentry_internal_test": "internal_value",
479+
},
480+
},
481+
start_ts=self.one_min_ago,
482+
)
483+
span_1["trace_id"] = self.trace_uuid
484+
item_id = span_1["span_id"]
485+
486+
self.store_spans([span_1], is_eap=True)
487+
488+
trace_details_response = self.do_request("spans", item_id)
489+
assert trace_details_response.status_code == 200
490+
491+
attribute_names = [attr["name"] for attr in trace_details_response.data["attributes"]]
492+
assert "normal_attr" in attribute_names
493+
assert "__sentry_internal_span_buffer_outcome" not in attribute_names
494+
assert "__sentry_internal_test" not in attribute_names
495+
496+
staff_user = self.create_user(is_staff=True)
497+
self.create_member(user=staff_user, organization=self.organization)
498+
self.login_as(user=staff_user, staff=True)
499+
500+
trace_details_response = self.do_request("spans", item_id)
501+
assert trace_details_response.status_code == 200
502+
503+
attribute_names = [attr["name"] for attr in trace_details_response.data["attributes"]]
504+
assert "normal_attr" in attribute_names
505+
assert "__sentry_internal_span_buffer_outcome" in attribute_names
506+
assert "__sentry_internal_test" in attribute_names

0 commit comments

Comments
 (0)