Skip to content

Commit 3688f4c

Browse files
feat: Add trace item stats endpoint (#103217)
Adds an endpoint that is essentially a wrapper around the TraceItemStats endpoint. This endpoint is meant to give you information about the shape of your data. This will be used to return distributions. As we add more stats types, we will expose them in this endpoint. --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent e7a917f commit 3688f4c

File tree

7 files changed

+233
-2
lines changed

7 files changed

+233
-2
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import logging
2+
3+
from rest_framework import serializers
4+
from rest_framework.request import Request
5+
from rest_framework.response import Response
6+
7+
from sentry.api.api_owners import ApiOwner
8+
from sentry.api.api_publish_status import ApiPublishStatus
9+
from sentry.api.base import region_silo_endpoint
10+
from sentry.api.bases import NoProjects, OrganizationEventsV2EndpointBase
11+
from sentry.models.organization import Organization
12+
from sentry.search.eap.constants import SUPPORTED_STATS_TYPES
13+
from sentry.search.eap.resolver import SearchResolver
14+
from sentry.search.eap.spans.definitions import SPAN_DEFINITIONS
15+
from sentry.search.eap.types import SearchResolverConfig
16+
from sentry.snuba.referrer import Referrer
17+
from sentry.snuba.spans_rpc import Spans
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
class OrganizationTraceItemsStatsSerializer(serializers.Serializer):
23+
query = serializers.CharField(required=False)
24+
statsType = serializers.ListField(
25+
child=serializers.ChoiceField(list(SUPPORTED_STATS_TYPES)), required=True
26+
)
27+
28+
29+
@region_silo_endpoint
30+
class OrganizationTraceItemsStatsEndpoint(OrganizationEventsV2EndpointBase):
31+
publish_status = {
32+
"GET": ApiPublishStatus.PRIVATE,
33+
}
34+
owner = ApiOwner.VISIBILITY
35+
36+
def get(self, request: Request, organization: Organization) -> Response:
37+
try:
38+
snuba_params = self.get_snuba_params(request, organization)
39+
except NoProjects:
40+
return Response({"data": []})
41+
42+
serializer = OrganizationTraceItemsStatsSerializer(data=request.GET)
43+
if not serializer.is_valid():
44+
return Response(serializer.errors, status=400)
45+
serialized = serializer.validated_data
46+
47+
resolver_config = SearchResolverConfig()
48+
resolver = SearchResolver(
49+
params=snuba_params, config=resolver_config, definitions=SPAN_DEFINITIONS
50+
)
51+
52+
stats_results = Spans.run_stats_query(
53+
params=snuba_params,
54+
stats_types=serialized.get("statsType"),
55+
query_string=serialized.get("query", ""),
56+
referrer=Referrer.API_SPANS_FREQUENCY_STATS_RPC.value,
57+
config=resolver_config,
58+
search_resolver=resolver,
59+
)
60+
61+
return Response({"data": stats_results})

src/sentry/api/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from sentry.api.endpoints.organization_trace_item_attributes_ranked import (
4141
OrganizationTraceItemsAttributesRankedEndpoint,
4242
)
43+
from sentry.api.endpoints.organization_trace_item_stats import OrganizationTraceItemsStatsEndpoint
4344
from sentry.api.endpoints.organization_unsubscribe import (
4445
OrganizationUnsubscribeIssue,
4546
OrganizationUnsubscribeProject,
@@ -1724,6 +1725,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
17241725
OrganizationTraceItemsAttributesRankedEndpoint.as_view(),
17251726
name="sentry-api-0-organization-trace-item-attributes-ranked",
17261727
),
1728+
re_path(
1729+
r"^(?P<organization_id_or_slug>[^/]+)/trace-items/stats/$",
1730+
OrganizationTraceItemsStatsEndpoint.as_view(),
1731+
name="sentry-api-0-organization-trace-item-stats",
1732+
),
17271733
re_path(
17281734
r"^(?P<organization_id_or_slug>[^/]+)/spans/fields/$",
17291735
OrganizationSpansFieldsEndpoint.as_view(),

src/sentry/search/eap/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
SupportedTraceItemType.PROFILE_FUNCTIONS: TraceItemType.TRACE_ITEM_TYPE_PROFILE_FUNCTION,
2020
}
2121

22+
SUPPORTED_STATS_TYPES = {"attributeDistributions"}
23+
2224
OPERATOR_MAP = {
2325
"=": ComparisonFilter.OP_EQUALS,
2426
"!=": ComparisonFilter.OP_NOT_EQUALS,

src/sentry/snuba/rpc_dataset_common.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,19 @@ def run_trace_query(
869869
) -> list[dict[str, Any]]:
870870
raise NotImplementedError()
871871

872+
@classmethod
873+
def run_stats_query(
874+
cls,
875+
*,
876+
params: SnubaParams,
877+
stats_types: set[str],
878+
query_string: str,
879+
referrer: str,
880+
config: SearchResolverConfig,
881+
search_resolver: SearchResolver | None = None,
882+
) -> list[dict[str, Any]]:
883+
raise NotImplementedError()
884+
872885

873886
def can_force_highest_accuracy(meta: RequestMeta) -> bool:
874887
# when using MODE_HIGHEST_ACCURACY_FLEXTIME, we cannot force highest accuracy

src/sentry/snuba/spans_rpc.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
11
import logging
2+
from collections import defaultdict
23
from datetime import timedelta
34
from typing import Any
45

56
import sentry_sdk
67
from sentry_protos.snuba.v1.endpoint_get_trace_pb2 import GetTraceRequest
8+
from sentry_protos.snuba.v1.endpoint_trace_item_stats_pb2 import (
9+
AttributeDistributionsRequest,
10+
StatsType,
11+
TraceItemStatsRequest,
12+
)
713
from sentry_protos.snuba.v1.request_common_pb2 import PageToken, TraceItemType
814

915
from sentry.exceptions import InvalidSearchQuery
10-
from sentry.search.eap.constants import DOUBLE, INT, STRING
16+
from sentry.search.eap.constants import DOUBLE, INT, STRING, SUPPORTED_STATS_TYPES
1117
from sentry.search.eap.resolver import SearchResolver
1218
from sentry.search.eap.sampling import events_meta_from_rpc_request_meta
1319
from sentry.search.eap.spans.definitions import SPAN_DEFINITIONS
14-
from sentry.search.eap.types import AdditionalQueries, EAPResponse, SearchResolverConfig
20+
from sentry.search.eap.types import (
21+
AdditionalQueries,
22+
EAPResponse,
23+
SearchResolverConfig,
24+
SupportedTraceItemType,
25+
)
26+
from sentry.search.eap.utils import can_expose_attribute
1527
from sentry.search.events.types import SAMPLING_MODES, EventsMeta, SnubaParams
1628
from sentry.snuba import rpc_dataset_common
1729
from sentry.snuba.discover import zerofill
@@ -237,3 +249,61 @@ def run_trace_query(
237249
)
238250
spans.append(span)
239251
return spans
252+
253+
@classmethod
254+
@sentry_sdk.trace
255+
def run_stats_query(
256+
cls,
257+
*,
258+
params: SnubaParams,
259+
stats_types: set[str],
260+
query_string: str,
261+
referrer: str,
262+
config: SearchResolverConfig,
263+
search_resolver: SearchResolver | None = None,
264+
) -> list[dict[str, Any]]:
265+
search_resolver = search_resolver or cls.get_resolver(params, config)
266+
stats_filter, _, _ = search_resolver.resolve_query(query_string)
267+
meta = search_resolver.resolve_meta(
268+
referrer=referrer,
269+
sampling_mode=params.sampling_mode,
270+
)
271+
stats_request = TraceItemStatsRequest(
272+
filter=stats_filter,
273+
meta=meta,
274+
stats_types=[],
275+
)
276+
277+
if not set(stats_types).intersection(SUPPORTED_STATS_TYPES):
278+
return []
279+
280+
if "attributeDistributions" in stats_types:
281+
stats_request.stats_types.append(
282+
StatsType(
283+
attribute_distributions=AttributeDistributionsRequest(
284+
max_buckets=75,
285+
)
286+
)
287+
)
288+
289+
response = snuba_rpc.trace_item_stats_rpc(stats_request)
290+
stats = []
291+
292+
for result in response.results:
293+
if "attributeDistributions" in stats_types and result.HasField(
294+
"attribute_distributions"
295+
):
296+
attributes = defaultdict(list)
297+
for attribute in result.attribute_distributions.attributes:
298+
if not can_expose_attribute(
299+
attribute.attribute_name, SupportedTraceItemType.SPANS
300+
):
301+
continue
302+
303+
for bucket in attribute.buckets:
304+
attributes[attribute.attribute_name].append(
305+
{"label": bucket.label, "value": bucket.value}
306+
)
307+
stats.append({"attribute_distributions": {"data": attributes}})
308+
309+
return stats

static/app/utils/api/knownSentryApiUrls.generated.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,7 @@ export type KnownSentryApiUrls =
553553
| '/organizations/$organizationIdOrSlug/trace-items/attributes/'
554554
| '/organizations/$organizationIdOrSlug/trace-items/attributes/$key/values/'
555555
| '/organizations/$organizationIdOrSlug/trace-items/attributes/ranked/'
556+
| '/organizations/$organizationIdOrSlug/trace-items/stats/'
556557
| '/organizations/$organizationIdOrSlug/trace-logs/'
557558
| '/organizations/$organizationIdOrSlug/trace-meta/$traceId/'
558559
| '/organizations/$organizationIdOrSlug/trace-summary/'
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from django.urls import reverse
2+
3+
from sentry.testutils.cases import APITransactionTestCase, SnubaTestCase, SpanTestCase
4+
from sentry.testutils.helpers.datetime import before_now
5+
6+
7+
class OrganizationTraceItemsStatsEndpointTest(
8+
APITransactionTestCase,
9+
SnubaTestCase,
10+
SpanTestCase,
11+
):
12+
view = "sentry-api-0-organization-trace-item-stats"
13+
14+
def setUp(self) -> None:
15+
super().setUp()
16+
self.login_as(user=self.user)
17+
self.ten_mins_ago = before_now(minutes=10)
18+
self.ten_mins_ago_iso = self.ten_mins_ago.replace(microsecond=0).isoformat()
19+
20+
def do_request(self, query=None, features=None, **kwargs):
21+
if query:
22+
query.setdefault("sampling", "HIGHEST_ACCURACY")
23+
24+
response = self.client.get(
25+
reverse(
26+
self.view,
27+
kwargs={"organization_id_or_slug": self.organization.slug},
28+
),
29+
query,
30+
format="json",
31+
**kwargs,
32+
)
33+
34+
return response
35+
36+
def _store_span(self, description=None, tags=None, duration=None):
37+
if tags is None:
38+
tags = {"foo": "bar"}
39+
40+
self.store_span(
41+
self.create_span(
42+
{"description": description or "foo", "sentry_tags": tags},
43+
start_ts=self.ten_mins_ago,
44+
duration=duration or 1000,
45+
),
46+
is_eap=True,
47+
)
48+
49+
def test_no_project(self) -> None:
50+
response = self.do_request()
51+
assert response.status_code == 200, response.data
52+
assert response.data == {"data": []}
53+
54+
def test_distribution_values(self) -> None:
55+
tags = [
56+
({"browser": "chrome", "device": "desktop"}, 500),
57+
({"browser": "chrome", "device": "mobile"}, 100),
58+
({"browser": "chrome", "device": "mobile"}, 100),
59+
({"browser": "chrome", "device": "desktop"}, 100),
60+
({"browser": "safari", "device": "mobile"}, 100),
61+
({"browser": "chrome", "device": "desktop"}, 500),
62+
({"browser": "edge", "device": "desktop"}, 500),
63+
]
64+
65+
for tag, duration in tags:
66+
self._store_span(tags=tag, duration=duration)
67+
68+
response = self.do_request(
69+
query={"query": "span.duration:<=100", "statsType": ["attributeDistributions"]}
70+
)
71+
assert response.status_code == 200, response.data
72+
assert len(response.data["data"]) == 1
73+
attribute_distribution = response.data["data"][0]["attribute_distributions"]["data"]
74+
device_data = attribute_distribution["sentry.device"]
75+
assert {"label": "mobile", "value": 3.0} in device_data
76+
assert {"label": "desktop", "value": 1.0} in device_data
77+
78+
assert response.data

0 commit comments

Comments
 (0)