Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0414de5
Revert "fix(batcher): Add global flush trigger based on the span numb…
alexander-alderman-webb Aug 11, 2026
dd57e21
Revert "feat(batcher): Add global flush trigger based on summed size …
alexander-alderman-webb Aug 11, 2026
8669777
Revert "feat(batcher): Add hard span limit (#7143)"
alexander-alderman-webb Aug 11, 2026
46cf5ce
ref: Flush trace bucket when segment span finishes
alexander-alderman-webb Aug 11, 2026
51a768e
make mypy happy
alexander-alderman-webb Aug 11, 2026
379b8c4
simplify and fix tests
alexander-alderman-webb Aug 11, 2026
9241cee
.
alexander-alderman-webb Aug 11, 2026
b3da36f
add span to pending bucket
alexander-alderman-webb Aug 11, 2026
7652579
modify pending flush inside lock
alexander-alderman-webb Aug 11, 2026
78b2893
flush async
alexander-alderman-webb Aug 11, 2026
0478e71
fix race on 3.14t
alexander-alderman-webb Aug 12, 2026
602a412
clean up comments
alexander-alderman-webb Aug 12, 2026
bd5a977
conftest update
alexander-alderman-webb Aug 12, 2026
02c0a67
work on fixture changes
alexander-alderman-webb Aug 12, 2026
8057cac
add re-entrancy guard
alexander-alderman-webb Aug 12, 2026
5653f6c
edit comment
alexander-alderman-webb Aug 12, 2026
5ee9fb6
resolve django test failures
alexander-alderman-webb Aug 12, 2026
40dfb01
second iteration django tests
alexander-alderman-webb Aug 12, 2026
c29c865
resolve flask test failures
alexander-alderman-webb Aug 12, 2026
14d342d
resolve common test failures
alexander-alderman-webb Aug 12, 2026
e0150a4
merge master
alexander-alderman-webb Aug 12, 2026
54334a3
clean up comment
alexander-alderman-webb Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion sentry_sdk/_span_batcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ def add(self, span: "SpanJSON") -> None:
self._running_size[span["trace_id"]] += self._estimate_size(span)

if (
size + 1 >= self.MAX_BEFORE_FLUSH
span["is_segment"] is True
or size + 1 >= self.MAX_BEFORE_FLUSH
or self._running_size[span["trace_id"]]
>= self.MAX_BYTES_BEFORE_FLUSH
):
Expand Down
61 changes: 61 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import os
import socket
import threading
import warnings
from collections import namedtuple
from contextlib import contextmanager
Expand Down Expand Up @@ -293,11 +294,71 @@ def inner(identifier):
return inner


def _install_flush_completion_handshake(client: "sentry_sdk.Client") -> None:
"""Make batcher.flush() wait for the flusher thread to drain.

Otherwise, test assertions can run before envelopes are captured.
The span batcher flushes pending items asynchronously with the main thread.
Flushes triggered by segments finishing are asynchronous, and can collect buckets
that would have otherwise been flushed synchronously by `sentry_sdk.flush()`.
Comment on lines +301 to +303

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm finding this comment a little tricky to follow. My understanding of this section is:

  • The span batcher sends out pending items in the background, separately from the main thread.
  • When a segment finishes, that triggers a background flush too
  • The background flush in point 2 can end up sending buckets that sentry_sdk.flush() would otherwise have sent immediately.

Do I have this right? And why is point 3 problematic? Is it because we need those buckets immediately when invoking sentry_sdk.flush() and we may not get them because those buckets are in the process of being flushed by the background process?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that's correct! To add some links:

Each batcher has a flushing thread that runs the _flush_loop() method.
When a segment span finishes, _flush() is triggered inside the thread here:

self._flush(only_pending=True)

This is what I called "asynchronous" in the comment.
When you call sentry_sdk.flush(), it triggers the flush here

which is not in the flushing thread. This runs synchronously with the user code.

"""
batcher = client.span_batcher
if batcher is None:
return

orig_flush_raw = batcher._flush
orig_flush = batcher.flush
lock = threading.Lock()
drained_count = 0
wake = threading.Event()

def _flush(*args: "Any", **kwargs: "Any") -> "Any":
nonlocal drained_count
try:
return orig_flush_raw(*args, **kwargs)
finally:
with lock:
drained_count += 1
wake.set()

def flush() -> None:
nonlocal drained_count
# Re-entrancy guard: if `flush()` is invoked from within a drain (e.g. a
# custom transport), waiting on the flusher thread would deadlock, because
# the flusher is blocked inside our own handler.
if getattr(getattr(batcher, "_active", None), "flag", False):
orig_flush()
return

# If the background flusher thread was never started (no spans have
# been added), there is no thread to drain and the counter will never
# advance. Fall back to the original synchronous flush.
if batcher._flusher is None or not batcher._flusher.is_alive():
orig_flush()
return

with lock:
target = drained_count

batcher._flush_event.set()
while True:
with lock:
if drained_count > target:
break
wake.wait()
wake.clear()
orig_flush()
Comment thread
alexander-alderman-webb marked this conversation as resolved.

object.__setattr__(batcher, "_flush", _flush)
object.__setattr__(batcher, "flush", flush)


@pytest.fixture
def sentry_init(request):
def inner(*a, **kw):
kw.setdefault("transport", TestTransport())
client = sentry_sdk.Client(*a, **kw)
_install_flush_completion_handshake(client)
sentry_sdk.get_global_scope().set_client(client)

if request.node.get_closest_marker("forked"):
Expand Down
7 changes: 6 additions & 1 deletion tests/integrations/django/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -2499,12 +2499,17 @@ def test_transaction_http_method_custom(

client.get("/nomessage")
client.options("/nomessage")
client.head("/nomessage")

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

assert spans[2]["attributes"][SPANDATA.HTTP_REQUEST_METHOD] == "OPTIONS"

client.head("/nomessage")

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

assert spans[5]["attributes"][SPANDATA.HTTP_REQUEST_METHOD] == "HEAD"
else:
events = capture_events()
Expand Down
6 changes: 5 additions & 1 deletion tests/integrations/django/test_cache_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,6 @@ def test_cache_spans_middleware(
if span_streaming:
items = capture_items("span")

client.get(reverse("not_cached_view"))
client.get(reverse("not_cached_view"))

sentry_sdk.flush()
Expand All @@ -269,6 +268,11 @@ def test_cache_spans_middleware(
)
assert "cache.hit" not in spans[1]["attributes"]
assert spans[1]["attributes"]["cache.item_size"] == 2

client.get(reverse("not_cached_view"))

sentry_sdk.flush()
spans = [item.payload for item in items]
# second_event - cache.get
assert spans[4]["attributes"]["sentry.op"] == "cache.get"
assert spans[4]["name"].startswith("views.decorators.cache.cache_header.")
Expand Down
25 changes: 18 additions & 7 deletions tests/integrations/flask/test_flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -1211,20 +1211,31 @@ def test_transaction_or_segment_http_method_custom(
response = client.options("/nomessage")
assert response.status_code == 200

response = client.head("/nomessage")
assert response.status_code == 200

if span_streaming:
sentry_sdk.flush()
spans = [i.payload for i in items]
assert len(spans) == 2
(options_segment, head_segment) = spans
(options_segment,) = spans
assert options_segment["attributes"]["http.request.method"] == "OPTIONS"

response = client.head("/nomessage")
assert response.status_code == 200

sentry_sdk.flush()
spans = [i.payload for i in items]
assert len(spans) == 2
(_, head_segment) = spans

assert head_segment["attributes"]["http.request.method"] == "HEAD"
else:
assert len(events) == 2
(event1, event2) = events
(event1,) = events
assert event1["request"]["method"] == "OPTIONS"

response = client.head("/nomessage")
assert response.status_code == 200

assert len(events) == 2
(_, event2) = events

assert event2["request"]["method"] == "HEAD"


Expand Down
Loading
Loading