Skip to content

Commit caa841c

Browse files
authored
feat(ourlogs): Add trace items attributes endpoints (#87343)
### Summary Since logs and spans (etc.) are moving towards the generic trace items, this adds an endpoint for use with search autocomplete on the frontend, which we can eventually use for any search relating to eap items. This mostly ports the EAP side of the `/spans/fields/` endpoints (organization_spans_fields.py) and makes both endpoints accept an item_type in lieu of dataset. Returning all attr keys and fetching attr values are not yet supported for EAP trace items (see skipped tests).
1 parent 3743235 commit caa841c

File tree

7 files changed

+545
-3
lines changed

7 files changed

+545
-3
lines changed

requirements-base.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ rfc3986-validator>=0.1.1
6868
sentry-arroyo>=2.20.0
6969
sentry-kafka-schemas>=1.1.2
7070
sentry-ophio==1.0.0
71-
sentry-protos>=0.1.62
71+
sentry-protos>=0.1.63
7272
sentry-redis-tools>=0.1.7
7373
sentry-relay>=0.9.6
7474
sentry-sdk[http2]>=2.23.1

requirements-dev-frozen.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ sentry-forked-djangorestframework-stubs==3.15.3.post1
189189
sentry-forked-email-reply-parser==0.5.12.post1
190190
sentry-kafka-schemas==1.1.2
191191
sentry-ophio==1.0.0
192-
sentry-protos==0.1.62
192+
sentry-protos==0.1.63
193193
sentry-redis-tools==0.1.7
194194
sentry-relay==0.9.6
195195
sentry-sdk==2.23.1

requirements-frozen.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ sentry-arroyo==2.20.0
127127
sentry-forked-email-reply-parser==0.5.12.post1
128128
sentry-kafka-schemas==1.1.2
129129
sentry-ophio==1.0.0
130-
sentry-protos==0.1.62
130+
sentry-protos==0.1.63
131131
sentry-redis-tools==0.1.7
132132
sentry-relay==0.9.6
133133
sentry-sdk==2.23.1
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
from datetime import datetime, timedelta
2+
from enum import Enum
3+
from typing import Literal
4+
5+
import sentry_sdk
6+
from google.protobuf.timestamp_pb2 import Timestamp
7+
from rest_framework import serializers
8+
from rest_framework.request import Request
9+
from rest_framework.response import Response
10+
from sentry_protos.snuba.v1.endpoint_trace_item_attributes_pb2 import (
11+
TraceItemAttributeNamesRequest,
12+
TraceItemAttributeValuesRequest,
13+
)
14+
from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType as ProtoTraceItemType
15+
from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey
16+
from sentry_protos.snuba.v1.trace_item_filter_pb2 import ExistsFilter, TraceItemFilter
17+
18+
from sentry import features, options
19+
from sentry.api.api_owners import ApiOwner
20+
from sentry.api.api_publish_status import ApiPublishStatus
21+
from sentry.api.base import region_silo_endpoint
22+
from sentry.api.bases import NoProjects, OrganizationEventsV2EndpointBase
23+
from sentry.api.endpoints.organization_spans_fields import BaseSpanFieldValuesAutocompletionExecutor
24+
from sentry.api.event_search import translate_escape_sequences
25+
from sentry.api.paginator import ChainPaginator
26+
from sentry.api.serializers import serialize
27+
from sentry.models.organization import Organization
28+
from sentry.search.eap import constants
29+
from sentry.search.eap.columns import ColumnDefinitions
30+
from sentry.search.eap.ourlogs.definitions import OURLOG_DEFINITIONS
31+
from sentry.search.eap.resolver import SearchResolver
32+
from sentry.search.eap.spans.definitions import SPAN_DEFINITIONS
33+
from sentry.search.eap.spans.utils import translate_internal_to_public_alias
34+
from sentry.search.eap.types import SearchResolverConfig
35+
from sentry.search.events.types import SnubaParams
36+
from sentry.snuba.referrer import Referrer
37+
from sentry.tagstore.types import TagValue
38+
from sentry.utils import snuba_rpc
39+
40+
41+
class TraceItemType(str, Enum):
42+
LOGS = "logs"
43+
SPANS = "spans"
44+
45+
46+
# Mapping from our enum types to the protobuf enum types
47+
TRACE_ITEM_TYPE_MAP = {
48+
TraceItemType.LOGS: ProtoTraceItemType.TRACE_ITEM_TYPE_LOG,
49+
TraceItemType.SPANS: ProtoTraceItemType.TRACE_ITEM_TYPE_SPAN,
50+
}
51+
52+
53+
class OrganizationTraceItemAttributesEndpointBase(OrganizationEventsV2EndpointBase):
54+
publish_status = {
55+
"GET": ApiPublishStatus.PRIVATE,
56+
}
57+
owner = ApiOwner.PERFORMANCE
58+
feature_flag = "organizations:ourlogs-enabled" # Can be changed to performance-trace-explorer once spans work.
59+
60+
61+
class OrganizationTraceItemAttributesEndpointSerializer(serializers.Serializer):
62+
item_type = serializers.ChoiceField([e.value for e in TraceItemType], required=True)
63+
attribute_type = serializers.ChoiceField(["string", "number"], required=True)
64+
substring_match = serializers.CharField(required=False)
65+
query = serializers.CharField(required=False)
66+
67+
68+
def is_valid_item_type(item_type: str) -> bool:
69+
return item_type in [e.value for e in TraceItemType]
70+
71+
72+
def resolve_attribute_referrer(item_type: str, attribute_type: str) -> Referrer:
73+
return (
74+
Referrer.API_SPANS_TAG_KEYS_RPC
75+
if item_type == TraceItemType.SPANS.value
76+
else Referrer.API_LOGS_TAG_KEYS_RPC
77+
)
78+
79+
80+
def resolve_attribute_values_referrer(item_type: str) -> Referrer:
81+
return (
82+
Referrer.API_SPANS_TAG_VALUES_RPC
83+
if item_type == TraceItemType.SPANS.value
84+
else Referrer.API_LOGS_TAG_VALUES_RPC
85+
)
86+
87+
88+
def as_attribute_key(name: str, type: Literal["string", "number"]):
89+
key = translate_internal_to_public_alias(name, type)
90+
91+
if key is not None:
92+
name = key
93+
elif type == "number":
94+
key = f"tags[{name},number]"
95+
else:
96+
key = name
97+
98+
return {
99+
# key is what will be used to query the API
100+
"key": key,
101+
# name is what will be used to display the tag nicely in the UI
102+
"name": name,
103+
}
104+
105+
106+
def empty_filter(trace_item_type: TraceItemType):
107+
column_name = "sentry.body" if trace_item_type == TraceItemType.LOGS else "sentry.description"
108+
return TraceItemFilter(
109+
exists_filter=ExistsFilter(
110+
key=AttributeKey(name=column_name),
111+
)
112+
)
113+
114+
115+
@region_silo_endpoint
116+
class OrganizationTraceItemAttributesEndpoint(OrganizationTraceItemAttributesEndpointBase):
117+
def get(self, request: Request, organization: Organization) -> Response:
118+
if not features.has(self.feature_flag, organization, actor=request.user):
119+
return Response(status=404)
120+
121+
serializer = OrganizationTraceItemAttributesEndpointSerializer(data=request.GET)
122+
if not serializer.is_valid():
123+
return Response(serializer.errors, status=400)
124+
125+
try:
126+
snuba_params = self.get_snuba_params(request, organization)
127+
except NoProjects:
128+
return self.paginate(
129+
request=request,
130+
paginator=ChainPaginator([]),
131+
)
132+
133+
serialized = serializer.validated_data
134+
substring_match = serialized.get("substring_match", "")
135+
query_string = serialized.get("query")
136+
attribute_type = serialized.get("attribute_type")
137+
item_type = serialized.get("item_type")
138+
139+
max_attributes = options.get("performance.spans-tags-key.max")
140+
value_substring_match = translate_escape_sequences(substring_match)
141+
item_type_type = TraceItemType(item_type)
142+
referrer = resolve_attribute_referrer(item_type_type, attribute_type)
143+
resolver = SearchResolver(
144+
params=snuba_params, config=SearchResolverConfig(), definitions=SPAN_DEFINITIONS
145+
)
146+
filter, _, _ = resolver.resolve_query(query_string)
147+
meta = resolver.resolve_meta(referrer=referrer.value)
148+
meta.trace_item_type = TRACE_ITEM_TYPE_MAP.get(
149+
item_type_type, ProtoTraceItemType.TRACE_ITEM_TYPE_SPAN
150+
)
151+
152+
adjusted_start_date, adjusted_end_date = adjust_start_end_window(
153+
snuba_params.start_date, snuba_params.end_date
154+
)
155+
snuba_params.start = adjusted_start_date
156+
snuba_params.end = adjusted_end_date
157+
158+
filter = filter or empty_filter(item_type_type)
159+
attr_type = (
160+
AttributeKey.Type.TYPE_DOUBLE
161+
if attribute_type == "number"
162+
else AttributeKey.Type.TYPE_STRING
163+
)
164+
165+
rpc_request = TraceItemAttributeNamesRequest(
166+
meta=meta,
167+
limit=max_attributes,
168+
offset=0,
169+
type=attr_type,
170+
value_substring_match=value_substring_match,
171+
intersecting_attributes_filter=filter,
172+
)
173+
174+
rpc_response = snuba_rpc.attribute_names_rpc(rpc_request)
175+
176+
paginator = ChainPaginator(
177+
[
178+
[
179+
as_attribute_key(attribute.name, serialized["attribute_type"])
180+
for attribute in rpc_response.attributes
181+
if attribute.name
182+
],
183+
],
184+
max_limit=max_attributes,
185+
)
186+
187+
return self.paginate(
188+
request=request,
189+
paginator=paginator,
190+
on_results=lambda results: serialize(results, request.user),
191+
default_per_page=max_attributes,
192+
max_per_page=max_attributes,
193+
)
194+
195+
196+
@region_silo_endpoint
197+
class OrganizationTraceItemAttributeValuesEndpoint(OrganizationTraceItemAttributesEndpointBase):
198+
def get(self, request: Request, organization: Organization, key: str) -> Response:
199+
if not features.has(self.feature_flag, organization, actor=request.user):
200+
return Response(status=404)
201+
202+
serializer = OrganizationTraceItemAttributesEndpointSerializer(data=request.GET)
203+
if not serializer.is_valid():
204+
return Response(serializer.errors, status=400)
205+
206+
try:
207+
snuba_params = self.get_snuba_params(request, organization)
208+
except NoProjects:
209+
return self.paginate(
210+
request=request,
211+
paginator=ChainPaginator([]),
212+
)
213+
214+
sentry_sdk.set_tag("query.attribute_key", key)
215+
216+
serialized = serializer.validated_data
217+
item_type = serialized.get("item_type")
218+
substring_match = serialized.get("substring_match", "")
219+
220+
max_attribute_values = options.get("performance.spans-tags-values.max")
221+
222+
definitions = (
223+
SPAN_DEFINITIONS if item_type == TraceItemType.SPANS.value else OURLOG_DEFINITIONS
224+
)
225+
226+
executor = TraceItemAttributeValuesAutocompletionExecutor(
227+
organization=organization,
228+
snuba_params=snuba_params,
229+
key=key,
230+
query=substring_match,
231+
max_span_tag_values=max_attribute_values,
232+
definitions=definitions,
233+
)
234+
235+
tag_values = executor.execute()
236+
tag_values.sort(key=lambda tag: tag.value)
237+
238+
paginator = ChainPaginator([tag_values], max_limit=max_attribute_values)
239+
240+
return self.paginate(
241+
request=request,
242+
paginator=paginator,
243+
on_results=lambda results: serialize(results, request.user),
244+
default_per_page=max_attribute_values,
245+
max_per_page=max_attribute_values,
246+
)
247+
248+
249+
class TraceItemAttributeValuesAutocompletionExecutor(BaseSpanFieldValuesAutocompletionExecutor):
250+
def __init__(
251+
self,
252+
organization: Organization,
253+
snuba_params: SnubaParams,
254+
key: str,
255+
query: str | None,
256+
max_span_tag_values: int,
257+
definitions: ColumnDefinitions,
258+
):
259+
super().__init__(organization, snuba_params, key, query, max_span_tag_values)
260+
self.resolver = SearchResolver(
261+
params=snuba_params, config=SearchResolverConfig(), definitions=definitions
262+
)
263+
self.search_type, self.attribute_key = self.resolve_attribute_key(key, snuba_params)
264+
265+
def resolve_attribute_key(
266+
self, key: str, snuba_params: SnubaParams
267+
) -> tuple[constants.SearchType, AttributeKey]:
268+
resolved_attr, _ = self.resolver.resolve_attribute(key)
269+
return resolved_attr.search_type, resolved_attr.proto_definition
270+
271+
def execute(self) -> list[TagValue]:
272+
if self.key in self.PROJECT_ID_KEYS:
273+
return self.project_id_autocomplete_function()
274+
275+
if self.key in self.PROJECT_SLUG_KEYS:
276+
return self.project_slug_autocomplete_function()
277+
278+
if self.search_type == "boolean":
279+
return self.boolean_autocomplete_function()
280+
281+
if self.search_type == "string":
282+
return self.string_autocomplete_function()
283+
284+
return []
285+
286+
def boolean_autocomplete_function(self) -> list[TagValue]:
287+
return [
288+
TagValue(
289+
key=self.key,
290+
value="false",
291+
times_seen=None,
292+
first_seen=None,
293+
last_seen=None,
294+
),
295+
TagValue(
296+
key=self.key,
297+
value="true",
298+
times_seen=None,
299+
first_seen=None,
300+
last_seen=None,
301+
),
302+
]
303+
304+
def string_autocomplete_function(self) -> list[TagValue]:
305+
adjusted_start_date, adjusted_end_date = adjust_start_end_window(
306+
self.snuba_params.start_date, self.snuba_params.end_date
307+
)
308+
start_timestamp = Timestamp()
309+
start_timestamp.FromDatetime(adjusted_start_date)
310+
311+
end_timestamp = Timestamp()
312+
end_timestamp.FromDatetime(adjusted_end_date)
313+
314+
query = translate_escape_sequences(self.query)
315+
316+
meta = self.resolver.resolve_meta(referrer=Referrer.API_SPANS_TAG_VALUES_RPC.value)
317+
rpc_request = TraceItemAttributeValuesRequest(
318+
meta=meta,
319+
key=self.attribute_key,
320+
value_substring_match=query,
321+
limit=self.max_span_tag_values,
322+
)
323+
rpc_response = snuba_rpc.attribute_values_rpc(rpc_request)
324+
325+
return [
326+
TagValue(
327+
key=self.key,
328+
value=value,
329+
times_seen=None,
330+
first_seen=None,
331+
last_seen=None,
332+
)
333+
for value in rpc_response.values
334+
if value
335+
]
336+
337+
338+
def adjust_start_end_window(start_date: datetime, end_date: datetime) -> tuple[datetime, datetime]:
339+
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
340+
end_date = end_date.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
341+
return start_date, end_date

src/sentry/api/urls.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
)
2828
from sentry.api.endpoints.organization_spans_aggregation import OrganizationSpansAggregationEndpoint
2929
from sentry.api.endpoints.organization_stats_summary import OrganizationStatsSummaryEndpoint
30+
from sentry.api.endpoints.organization_trace_item_attributes import (
31+
OrganizationTraceItemAttributesEndpoint,
32+
OrganizationTraceItemAttributeValuesEndpoint,
33+
)
3034
from sentry.api.endpoints.organization_unsubscribe import (
3135
OrganizationUnsubscribeIssue,
3236
OrganizationUnsubscribeProject,
@@ -1462,6 +1466,16 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
14621466
OrganizationTracesStatsEndpoint.as_view(),
14631467
name="sentry-api-0-organization-traces-stats",
14641468
),
1469+
re_path(
1470+
r"^(?P<organization_id_or_slug>[^\/]+)/trace-items/attributes/$",
1471+
OrganizationTraceItemAttributesEndpoint.as_view(),
1472+
name="sentry-api-0-organization-trace-item-attributes",
1473+
),
1474+
re_path(
1475+
r"^(?P<organization_id_or_slug>[^\/]+)/trace-items/attributes/(?P<key>[^/]+)/values/$",
1476+
OrganizationTraceItemAttributeValuesEndpoint.as_view(),
1477+
name="sentry-api-0-organization-trace-item-attribute-values",
1478+
),
14651479
re_path(
14661480
r"^(?P<organization_id_or_slug>[^\/]+)/spans/fields/$",
14671481
OrganizationSpansFieldsEndpoint.as_view(),

0 commit comments

Comments
 (0)