Skip to content

Commit 1c3b50d

Browse files
ref: Flush trace buckets when segment spans finish (#7170)
Mark the corresponding bucket as pending when a segment span is added in the span buffer. This aims to keep memory pressure low. Segment spans typically finish after their children. Adapt span batcher tests by adding an outer segment span. As a result, the various flush conditions are still exercised since the assertions run before the segment span has finished (finishing the segment span otherwise flushes the buffer as well).
1 parent c200bdf commit 1c3b50d

7 files changed

Lines changed: 261 additions & 133 deletions

File tree

sentry_sdk/_span_batcher.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ def add(self, span: "SpanJSON") -> None:
129129
self._running_size[span["trace_id"]] += self._estimate_size(span)
130130

131131
if (
132-
size + 1 >= self.MAX_BEFORE_FLUSH
132+
span["is_segment"] is True
133+
or size + 1 >= self.MAX_BEFORE_FLUSH
133134
or self._running_size[span["trace_id"]]
134135
>= self.MAX_BYTES_BEFORE_FLUSH
135136
):

tests/conftest.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import json
55
import os
66
import socket
7+
import threading
78
import warnings
89
from collections import namedtuple
910
from contextlib import contextmanager
@@ -293,11 +294,71 @@ def inner(identifier):
293294
return inner
294295

295296

297+
def _install_flush_completion_handshake(client: "sentry_sdk.Client") -> None:
298+
"""Make batcher.flush() wait for the flusher thread to drain.
299+
300+
Otherwise, test assertions can run before envelopes are captured.
301+
The span batcher flushes pending items asynchronously with the main thread.
302+
Flushes triggered by segments finishing are asynchronous, and can collect buckets
303+
that would have otherwise been flushed synchronously by `sentry_sdk.flush()`.
304+
"""
305+
batcher = client.span_batcher
306+
if batcher is None:
307+
return
308+
309+
orig_flush_raw = batcher._flush
310+
orig_flush = batcher.flush
311+
lock = threading.Lock()
312+
drained_count = 0
313+
wake = threading.Event()
314+
315+
def _flush(*args: "Any", **kwargs: "Any") -> "Any":
316+
nonlocal drained_count
317+
try:
318+
return orig_flush_raw(*args, **kwargs)
319+
finally:
320+
with lock:
321+
drained_count += 1
322+
wake.set()
323+
324+
def flush() -> None:
325+
nonlocal drained_count
326+
# Re-entrancy guard: if `flush()` is invoked from within a drain (e.g. a
327+
# custom transport), waiting on the flusher thread would deadlock, because
328+
# the flusher is blocked inside our own handler.
329+
if getattr(getattr(batcher, "_active", None), "flag", False):
330+
orig_flush()
331+
return
332+
333+
# If the background flusher thread was never started (no spans have
334+
# been added), there is no thread to drain and the counter will never
335+
# advance. Fall back to the original synchronous flush.
336+
if batcher._flusher is None or not batcher._flusher.is_alive():
337+
orig_flush()
338+
return
339+
340+
with lock:
341+
target = drained_count
342+
343+
batcher._flush_event.set()
344+
while True:
345+
with lock:
346+
if drained_count > target:
347+
break
348+
wake.wait()
349+
wake.clear()
350+
orig_flush()
351+
352+
object.__setattr__(batcher, "_flush", _flush)
353+
object.__setattr__(batcher, "flush", flush)
354+
355+
296356
@pytest.fixture
297357
def sentry_init(request):
298358
def inner(*a, **kw):
299359
kw.setdefault("transport", TestTransport())
300360
client = sentry_sdk.Client(*a, **kw)
361+
_install_flush_completion_handshake(client)
301362
sentry_sdk.get_global_scope().set_client(client)
302363

303364
if request.node.get_closest_marker("forked"):

tests/integrations/django/test_basic.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2496,12 +2496,17 @@ def test_transaction_http_method_custom(
24962496

24972497
client.get("/nomessage")
24982498
client.options("/nomessage")
2499-
client.head("/nomessage")
25002499

25012500
sentry_sdk.flush()
25022501
spans = [item.payload for item in items]
25032502

25042503
assert spans[2]["attributes"][SPANDATA.HTTP_REQUEST_METHOD] == "OPTIONS"
2504+
2505+
client.head("/nomessage")
2506+
2507+
sentry_sdk.flush()
2508+
spans = [item.payload for item in items]
2509+
25052510
assert spans[5]["attributes"][SPANDATA.HTTP_REQUEST_METHOD] == "HEAD"
25062511
else:
25072512
events = capture_events()

tests/integrations/django/test_cache_module.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,6 @@ def test_cache_spans_middleware(
246246
if span_streaming:
247247
items = capture_items("span")
248248

249-
client.get(reverse("not_cached_view"))
250249
client.get(reverse("not_cached_view"))
251250

252251
sentry_sdk.flush()
@@ -269,6 +268,11 @@ def test_cache_spans_middleware(
269268
)
270269
assert "cache.hit" not in spans[1]["attributes"]
271270
assert spans[1]["attributes"]["cache.item_size"] == 2
271+
272+
client.get(reverse("not_cached_view"))
273+
274+
sentry_sdk.flush()
275+
spans = [item.payload for item in items]
272276
# second_event - cache.get
273277
assert spans[4]["attributes"]["sentry.op"] == "cache.get"
274278
assert spans[4]["name"].startswith("views.decorators.cache.cache_header.")

tests/integrations/flask/test_flask.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1211,20 +1211,31 @@ def test_transaction_or_segment_http_method_custom(
12111211
response = client.options("/nomessage")
12121212
assert response.status_code == 200
12131213

1214-
response = client.head("/nomessage")
1215-
assert response.status_code == 200
1216-
12171214
if span_streaming:
12181215
sentry_sdk.flush()
12191216
spans = [i.payload for i in items]
1220-
assert len(spans) == 2
1221-
(options_segment, head_segment) = spans
1217+
(options_segment,) = spans
12221218
assert options_segment["attributes"]["http.request.method"] == "OPTIONS"
1219+
1220+
response = client.head("/nomessage")
1221+
assert response.status_code == 200
1222+
1223+
sentry_sdk.flush()
1224+
spans = [i.payload for i in items]
1225+
assert len(spans) == 2
1226+
(_, head_segment) = spans
1227+
12231228
assert head_segment["attributes"]["http.request.method"] == "HEAD"
12241229
else:
1225-
assert len(events) == 2
1226-
(event1, event2) = events
1230+
(event1,) = events
12271231
assert event1["request"]["method"] == "OPTIONS"
1232+
1233+
response = client.head("/nomessage")
1234+
assert response.status_code == 200
1235+
1236+
assert len(events) == 2
1237+
(_, event2) = events
1238+
12281239
assert event2["request"]["method"] == "HEAD"
12291240

12301241

0 commit comments

Comments
 (0)