Skip to content

Commit ba83ef1

Browse files
authored
feat(trace-explorer): Add trace breakdowns to trace response (#69042)
To give an overview where a trace is spending it's time, this adds a breakdown of the trace by project.
1 parent f8e8949 commit ba83ef1

File tree

3 files changed

+703
-22
lines changed

3 files changed

+703
-22
lines changed

src/sentry/api/endpoints/organization_traces.py

Lines changed: 180 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from collections import defaultdict
22
from collections.abc import Mapping
33
from datetime import datetime, timedelta
4-
from typing import Any, TypedDict, cast
4+
from typing import Any, Literal, TypedDict, cast
55

6+
import sentry_sdk
67
from rest_framework import serializers
78
from rest_framework.request import Request
89
from rest_framework.response import Response
10+
from snuba_sdk import Column, Condition, Op
911

1012
from sentry import options
1113
from sentry.api.api_owners import ApiOwner
@@ -19,13 +21,24 @@
1921
from sentry.search.events.types import ParamsType, QueryBuilderConfig
2022
from sentry.snuba.dataset import Dataset
2123
from sentry.snuba.referrer import Referrer
24+
from sentry.utils.snuba import bulk_snql_query
25+
26+
27+
class TraceInterval(TypedDict):
28+
project: str | None
29+
start: int
30+
end: int
31+
kind: Literal["project", "missing", "unknown"]
2232

2333

2434
class TraceResult(TypedDict):
2535
trace: str
2636
numSpans: int
2737
name: str | None
2838
duration: int
39+
start: int
40+
end: int
41+
breakdowns: list[TraceInterval]
2942
spans: list[Mapping[str, Any]]
3043

3144

@@ -118,34 +131,90 @@ def data_fn(offset: int, limit: int):
118131
snuba_params.start = min_timestamp - buffer
119132
snuba_params.end = max_timestamp + buffer
120133

134+
trace_condition = f"trace:[{', '.join(spans_by_trace.keys())}]"
135+
121136
with handle_query_errors():
122-
builder = SpansIndexedQueryBuilder(
137+
breakdowns_query = SpansIndexedQueryBuilder(
138+
Dataset.SpansIndexed,
139+
cast(ParamsType, params),
140+
snuba_params=snuba_params,
141+
query=f"{trace_condition}",
142+
selected_columns=[
143+
"trace",
144+
"project",
145+
"transaction",
146+
"first_seen()",
147+
"last_seen()",
148+
],
149+
orderby=["first_seen()", "last_seen()"],
150+
# limit the number of segments we fetch per trace so a single
151+
# large trace does not result in the rest being blank
152+
limitby=("trace", int(10_000 / len(spans_by_trace))),
153+
config=QueryBuilderConfig(
154+
functions_acl=["trace_name", "first_seen", "last_seen"],
155+
transform_alias_to_input_format=True,
156+
),
157+
)
158+
# TODO: this should be `is_transaction:1` but there's some
159+
# boolean mapping that's not working for this field
160+
breakdowns_query.add_conditions(
161+
[
162+
Condition(Column("is_segment"), Op.EQ, 1),
163+
]
164+
)
165+
166+
with handle_query_errors():
167+
traces_meta_query = SpansIndexedQueryBuilder(
123168
Dataset.SpansIndexed,
124169
cast(ParamsType, params),
125170
snuba_params=snuba_params,
126-
query=f"trace:[{', '.join(spans_by_trace.keys())}]",
127-
selected_columns=["trace", "count()", "trace_name()", "elapsed()"],
171+
query=trace_condition,
172+
selected_columns=[
173+
"trace",
174+
"count()",
175+
"trace_name()",
176+
"first_seen()",
177+
"last_seen()",
178+
],
128179
limit=len(spans_by_trace),
129180
config=QueryBuilderConfig(
130-
functions_acl=["trace_name", "elapsed"],
181+
functions_acl=["trace_name", "first_seen", "last_seen"],
131182
transform_alias_to_input_format=True,
132183
),
133184
)
134-
trace_results = builder.run_query(Referrer.API_TRACE_EXPLORER_TRACES_META.value)
135-
trace_results = builder.process_results(trace_results)
185+
186+
with handle_query_errors():
187+
results = bulk_snql_query(
188+
[
189+
breakdowns_query.get_snql_query(),
190+
traces_meta_query.get_snql_query(),
191+
],
192+
Referrer.API_TRACE_EXPLORER_TRACES_META.value,
193+
)
194+
breakdowns_results = breakdowns_query.process_results(results[0])
195+
traces_meta_results = traces_meta_query.process_results(results[1])
196+
197+
try:
198+
breakdowns = process_breakdowns(breakdowns_results["data"])
199+
except Exception as e:
200+
sentry_sdk.capture_exception(e)
201+
breakdowns = defaultdict(list)
136202

137203
traces: list[TraceResult] = [
138204
{
139205
"trace": row["trace"],
140206
"numSpans": row["count()"],
141207
"name": row["trace_name()"],
142-
"duration": row["elapsed()"],
208+
"duration": row["last_seen()"] - row["first_seen()"],
209+
"start": row["first_seen()"],
210+
"end": row["last_seen()"],
211+
"breakdowns": breakdowns[row["trace"]],
143212
"spans": [
144213
{field: span[field] for field in serialized["field"]}
145214
for span in spans_by_trace[row["trace"]]
146215
],
147216
}
148-
for row in trace_results["data"]
217+
for row in traces_meta_results["data"]
149218
]
150219

151220
return {"data": traces, "meta": meta}
@@ -162,3 +231,105 @@ def data_fn(offset: int, limit: int):
162231
dataset=Dataset.SpansIndexed,
163232
),
164233
)
234+
235+
236+
def process_breakdowns(data):
237+
trace_stacks: Mapping[str, list[TraceInterval]] = defaultdict(list)
238+
breakdowns_by_trace: Mapping[str, list[TraceInterval]] = defaultdict(list)
239+
240+
def breakdown_append(breakdown, interval):
241+
breakdown.append(interval)
242+
243+
def stack_pop(stack):
244+
interval = stack.pop()
245+
if stack:
246+
# when popping an interval off the stack, it means that we've
247+
# consumed up to this point in time, so we update the transaction
248+
# above it to the remaining interval
249+
stack[-1]["start"] = interval["end"]
250+
return interval
251+
252+
def stack_clear(breakdown, stack, until=None):
253+
interval = None
254+
while stack:
255+
if until is not None and stack[-1]["end"] > until:
256+
break
257+
258+
item = stack_pop(stack)
259+
260+
# While popping the stack, we adjust the start
261+
# of the intervals in the stack, so it's possible
262+
# we produce an invalid interval. Make sure to
263+
# filter them out here
264+
if item["start"] <= item["end"]:
265+
interval = item
266+
breakdown_append(breakdown, interval)
267+
return interval
268+
269+
for row in data:
270+
stack = trace_stacks[row["trace"]]
271+
cur: TraceInterval = {
272+
"project": row["project"],
273+
"start": row["first_seen()"],
274+
"end": row["last_seen()"],
275+
"kind": "project",
276+
}
277+
278+
# nothing on the stack yet, so directly push onto
279+
# stack and wait for next item to come so an
280+
# interval can be determined
281+
if not stack:
282+
stack.append(cur)
283+
continue
284+
285+
breakdown = breakdowns_by_trace[row["trace"]]
286+
prev = stack[-1]
287+
if prev["end"] <= cur["start"]:
288+
# This implies there's no overlap between this transaction
289+
# and the previous transaction. So we're done with the whole stack
290+
291+
last = stack_clear(breakdown, stack, until=cur["start"])
292+
293+
# if there's a gap between prev end and cur start
294+
if not stack or stack[-1]["end"] < cur["start"]:
295+
if last is not None and last["end"] < cur["start"]:
296+
breakdown_append(
297+
breakdown,
298+
{
299+
"project": None,
300+
"start": prev["end"],
301+
"end": cur["start"],
302+
"kind": "missing",
303+
},
304+
)
305+
elif prev["project"] == cur["project"]:
306+
# If the intervals are in the same project,
307+
# we need to merge them into the same interval.
308+
new_end = max(prev["end"], cur["end"])
309+
prev["end"] = new_end
310+
continue
311+
else:
312+
# This imples that there is some overlap between this transaction
313+
# and the previous transaction. So we need push the interval up to
314+
# the current item to the breakdown.
315+
316+
breakdown_append(
317+
breakdown,
318+
{
319+
"project": prev["project"],
320+
"start": prev["start"],
321+
"end": cur["start"],
322+
"kind": "project",
323+
},
324+
)
325+
326+
if prev["end"] <= cur["end"]:
327+
stack_pop(stack)
328+
329+
stack.append(cur)
330+
331+
for trace, stack in trace_stacks.items():
332+
breakdown = breakdowns_by_trace[trace]
333+
stack_clear(breakdown, stack)
334+
335+
return breakdowns_by_trace

src/sentry/search/events/datasets/spans_indexed.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -254,19 +254,20 @@ def function_converter(self) -> Mapping[str, SnQLFunction]:
254254
private=True,
255255
),
256256
SnQLFunction(
257-
"elapsed",
257+
"first_seen",
258258
snql_aggregate=lambda args, alias: Function(
259-
"minus",
260-
[
261-
Function(
262-
"max",
263-
[self._resolve_timestamp_with_ms("end_timestamp", "end_ms")],
264-
),
265-
Function(
266-
"min",
267-
[self._resolve_timestamp_with_ms("start_timestamp", "start_ms")],
268-
),
269-
],
259+
"min",
260+
[self._resolve_timestamp_with_ms("start_timestamp", "start_ms")],
261+
alias,
262+
),
263+
default_result_type="duration",
264+
private=True,
265+
),
266+
SnQLFunction(
267+
"last_seen",
268+
snql_aggregate=lambda args, alias: Function(
269+
"max",
270+
[self._resolve_timestamp_with_ms("end_timestamp", "end_ms")],
270271
alias,
271272
),
272273
default_result_type="duration",

0 commit comments

Comments
 (0)