Skip to content

Commit f84413d

Browse files
feat(tracing): Record lost spans in client reports
Also, update existing transport tests so they pass against the changes introduced in this commit. Resolves #3229
1 parent ee84c81 commit f84413d

10 files changed

+102
-21
lines changed

sentry_sdk/_types.py

+1
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@
155155
"profile_chunk",
156156
"metric_bucket",
157157
"monitor",
158+
"span",
158159
]
159160
SessionStatus = Literal["ok", "exited", "crashed", "abnormal"]
160161

sentry_sdk/client.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,7 @@ def _prepare_event(
448448

449449
if scope is not None:
450450
is_transaction = event.get("type") == "transaction"
451+
spans_before = len(event.get("spans", []))
451452
event_ = scope.apply_to_event(event, hint, self.options)
452453

453454
# one of the event/error processors returned None
@@ -457,10 +458,22 @@ def _prepare_event(
457458
"event_processor",
458459
data_category=("transaction" if is_transaction else "error"),
459460
)
461+
if is_transaction:
462+
self.transport.record_lost_event(
463+
"event_processor",
464+
data_category="span",
465+
quantity=spans_before + 1, # +1 for the transaction itself
466+
)
460467
return None
461468

462469
event = event_
463470

471+
spans_delta = spans_before - len(event.get("spans", []))
472+
if is_transaction and spans_delta > 0 and self.transport is not None:
473+
self.transport.record_lost_event(
474+
"event_processor", data_category="span", quantity=spans_delta
475+
)
476+
464477
if (
465478
self.options["attach_stacktrace"]
466479
and "exception" not in event
@@ -541,14 +554,27 @@ def _prepare_event(
541554
and event.get("type") == "transaction"
542555
):
543556
new_event = None
557+
spans_before = len(event.get("spans", []))
544558
with capture_internal_exceptions():
545559
new_event = before_send_transaction(event, hint or {})
546560
if new_event is None:
547561
logger.info("before send transaction dropped event")
548562
if self.transport:
549563
self.transport.record_lost_event(
550-
"before_send", data_category="transaction"
564+
reason="before_send", data_category="transaction"
565+
)
566+
self.transport.record_lost_event(
567+
reason="before_send",
568+
data_category="span",
569+
quantity=spans_before + 1, # +1 for the transaction itself
551570
)
571+
else:
572+
spans_delta = spans_before - len(new_event.get("spans", []))
573+
if spans_delta > 0 and self.transport is not None:
574+
self.transport.record_lost_event(
575+
reason="before_send", data_category="span", quantity=spans_delta
576+
)
577+
552578
event = new_event # type: ignore
553579

554580
return event

sentry_sdk/tracing.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,9 @@ class TransactionKwargs(SpanKwargs, total=False):
119119
},
120120
)
121121

122-
123122
BAGGAGE_HEADER_NAME = "baggage"
124123
SENTRY_TRACE_HEADER_NAME = "sentry-trace"
125124

126-
127125
# Transaction source
128126
# see https://develop.sentry.dev/sdk/event-payloads/transaction/#transaction-annotations
129127
TRANSACTION_SOURCE_CUSTOM = "custom"
@@ -858,6 +856,8 @@ def finish(self, hub=None, end_timestamp=None):
858856

859857
client.transport.record_lost_event(reason, data_category="transaction")
860858

859+
# Only one span (the transaction itself) is discarded, since we did not record any spans here.
860+
client.transport.record_lost_event(reason, data_category="span")
861861
return None
862862

863863
if not self.name:

sentry_sdk/transport.py

+27-3
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,23 @@ def record_lost_event(
133133
reason, # type: str
134134
data_category=None, # type: Optional[EventDataCategory]
135135
item=None, # type: Optional[Item]
136+
*,
137+
quantity=1, # type: int
136138
):
137139
# type: (...) -> None
138140
"""This increments a counter for event loss by reason and
139-
data category.
141+
data category by the given positive-int quantity (default 1).
142+
143+
If an item is provided, the data category and quantity are
144+
extracted from the item, and the values passed for
145+
data_category and quantity are ignored.
146+
147+
When recording a lost transaction via data_category="transaction",
148+
the calling code should also record the lost spans via this method.
149+
When recording lost spans, `quantity` should be set to the number
150+
of contained spans, plus one for the transaction itself. When
151+
passing an Item containing a transaction via the `item` parameter,
152+
this method automatically records the lost spans.
140153
"""
141154
return None
142155

@@ -224,15 +237,26 @@ def record_lost_event(
224237
reason, # type: str
225238
data_category=None, # type: Optional[EventDataCategory]
226239
item=None, # type: Optional[Item]
240+
*,
241+
quantity=1, # type: int
227242
):
228243
# type: (...) -> None
229244
if not self.options["send_client_reports"]:
230245
return
231246

232-
quantity = 1
233247
if item is not None:
234248
data_category = item.data_category
235-
if data_category == "attachment":
249+
quantity = 1 # If an item is provided, we always count it as 1 (except for attachments, handled below).
250+
251+
if data_category == "transaction":
252+
# Also record the lost spans
253+
event = item.get_transaction_event() or {}
254+
255+
# +1 for the transaction itself
256+
span_count = len(event.get("spans") or []) + 1
257+
self.record_lost_event(reason, "span", quantity=span_count)
258+
259+
elif data_category == "attachment":
236260
# quantity of 0 is actually 1 as we do not want to count
237261
# empty attachments as actually empty.
238262
quantity = len(item.get_bytes()) or 1

tests/conftest.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,8 @@ def inner():
253253
calls = []
254254
test_client = sentry_sdk.get_client()
255255

256-
def record_lost_event(reason, data_category=None, item=None):
257-
calls.append((reason, data_category, item))
256+
def record_lost_event(reason, data_category=None, item=None, *, quantity=1):
257+
calls.append((reason, data_category, item, quantity))
258258

259259
monkeypatch.setattr(
260260
test_client.transport, "record_lost_event", record_lost_event

tests/profiler/test_transaction_profiler.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def test_profiles_sample_rate(
162162
elif profile_count:
163163
assert record_lost_event_calls == []
164164
else:
165-
assert record_lost_event_calls == [("sample_rate", "profile", None)]
165+
assert record_lost_event_calls == [("sample_rate", "profile", None, 1)]
166166

167167

168168
@pytest.mark.parametrize(
@@ -231,7 +231,7 @@ def test_profiles_sampler(
231231
if profile_count:
232232
assert record_lost_event_calls == []
233233
else:
234-
assert record_lost_event_calls == [("sample_rate", "profile", None)]
234+
assert record_lost_event_calls == [("sample_rate", "profile", None, 1)]
235235

236236

237237
def test_minimum_unique_samples_required(
@@ -260,7 +260,7 @@ def test_minimum_unique_samples_required(
260260
# because we dont leave any time for the profiler to
261261
# take any samples, it should be not be sent
262262
assert len(items["profile"]) == 0
263-
assert record_lost_event_calls == [("insufficient_data", "profile", None)]
263+
assert record_lost_event_calls == [("insufficient_data", "profile", None, 1)]
264264

265265

266266
@pytest.mark.forked

tests/test_basics.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,7 @@ def test_dedupe_event_processor_drop_records_client_report(
570570

571571
assert event["level"] == "error"
572572
assert "exception" in event
573-
assert lost_event_call == ("event_processor", "error", None)
573+
assert lost_event_call == ("event_processor", "error", None, 1)
574574

575575

576576
def test_event_processor_drop_records_client_report(
@@ -602,8 +602,9 @@ def foo(event, hint):
602602
# Using Counter because order of record_lost_event calls does not matter
603603
assert Counter(record_lost_event_calls) == Counter(
604604
[
605-
("event_processor", "error", None),
606-
("event_processor", "transaction", None),
605+
("event_processor", "error", None, 1),
606+
("event_processor", "transaction", None, 1),
607+
("event_processor", "span", None, 1),
607608
]
608609
)
609610

tests/test_monitor.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import random
2+
from collections import Counter
23
from unittest import mock
34

45
import sentry_sdk
@@ -79,7 +80,12 @@ def test_transaction_uses_downsampled_rate(
7980
assert transaction.sampled is False
8081
assert transaction.sample_rate == 0.5
8182

82-
assert record_lost_event_calls == [("backpressure", "transaction", None)]
83+
assert Counter(record_lost_event_calls) == Counter(
84+
[
85+
("backpressure", "transaction", None, 1),
86+
("backpressure", "span", None, 1),
87+
]
88+
)
8389

8490

8591
def test_monitor_no_thread_on_shutdown_no_errors(sentry_init):

tests/test_transport.py

+19-4
Original file line numberDiff line numberDiff line change
@@ -425,12 +425,17 @@ def intercepting_fetch(*args, **kwargs):
425425

426426
discarded_events = report["discarded_events"]
427427

428-
assert len(discarded_events) == 2
428+
assert len(discarded_events) == 3
429429
assert {
430430
"category": "transaction",
431431
"reason": "ratelimit_backoff",
432432
"quantity": 2,
433433
} in discarded_events
434+
assert {
435+
"category": "span",
436+
"reason": "ratelimit_backoff",
437+
"quantity": 2,
438+
} in discarded_events
434439
assert {
435440
"category": "attachment",
436441
"reason": "ratelimit_backoff",
@@ -454,9 +459,19 @@ def intercepting_fetch(*args, **kwargs):
454459
envelope = capturing_server.captured[1].envelope
455460
assert envelope.items[0].type == "client_report"
456461
report = parse_json(envelope.items[0].get_bytes())
457-
assert report["discarded_events"] == [
458-
{"category": "transaction", "reason": "ratelimit_backoff", "quantity": 1},
459-
]
462+
463+
discarded_events = report["discarded_events"]
464+
assert len(discarded_events) == 2
465+
assert {
466+
"category": "transaction",
467+
"reason": "ratelimit_backoff",
468+
"quantity": 1,
469+
} in discarded_events
470+
assert {
471+
"category": "span",
472+
"reason": "ratelimit_backoff",
473+
"quantity": 1,
474+
} in discarded_events
460475

461476

462477
@pytest.mark.parametrize("response_code", [200, 429])

tests/tracing/test_sampling.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,11 @@ def test_warns_and_sets_sampled_to_false_on_invalid_traces_sampler_return_value(
264264
"traces_sample_rate,sampled_output,expected_record_lost_event_calls",
265265
[
266266
(None, False, []),
267-
(0.0, False, [("sample_rate", "transaction", None)]),
267+
(
268+
0.0,
269+
False,
270+
[("sample_rate", "transaction", None, 1), ("sample_rate", "span", None, 1)],
271+
),
268272
(1.0, True, []),
269273
],
270274
)
@@ -290,7 +294,11 @@ def test_records_lost_event_only_if_traces_sample_rate_enabled(
290294
"traces_sampler,sampled_output,expected_record_lost_event_calls",
291295
[
292296
(None, False, []),
293-
(lambda _x: 0.0, False, [("sample_rate", "transaction", None)]),
297+
(
298+
lambda _x: 0.0,
299+
False,
300+
[("sample_rate", "transaction", None, 1), ("sample_rate", "span", None, 1)],
301+
),
294302
(lambda _x: 1.0, True, []),
295303
],
296304
)

0 commit comments

Comments
 (0)