From 4ed7e7bd62c3f72cd7e42843e8a7b2b988256c8f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:19:34 +0000 Subject: [PATCH] fix(tracing): Flush pending span buckets in segment finish order SpanBatcher._pending_flush was a set, so when the flusher woke after several segments had finished, it sent their traces in hash order. Use an insertion-ordered dict instead. Fixes #8027 --- sentry_sdk/_span_batcher.py | 8 ++++--- tests/tracing/test_span_batcher.py | 37 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index ea67ff6e01..a90dbb7258 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -57,7 +57,9 @@ def __init__( self._last_full_flush: float = time.monotonic() # drives time-based flushes self._flush_event = threading.Event() - self._pending_flush: set[str] = set() # buckets to be flushed + # Buckets to be flushed, in the order they became ready. A dict, not a + # set, so that traces are sent in the order their segments finished. + self._pending_flush: dict[str, None] = {} self._flusher: "Optional[threading.Thread]" = None self._flusher_pid: "Optional[int]" = None @@ -83,7 +85,7 @@ def _reset_thread_state(self) -> None: self._last_full_flush = time.monotonic() self._flush_event = threading.Event() - self._pending_flush = set() + self._pending_flush = {} self._flusher = None self._flusher_pid = None @@ -143,7 +145,7 @@ def add(self, span: "SpanJSON") -> None: or self._running_size[span["trace_id"]] >= self.MAX_BYTES_BEFORE_FLUSH ): - self._pending_flush.add(span["trace_id"]) + self._pending_flush[span["trace_id"]] = None notify = True else: notify = False diff --git a/tests/tracing/test_span_batcher.py b/tests/tracing/test_span_batcher.py index f249125c90..bf000e7b31 100644 --- a/tests/tracing/test_span_batcher.py +++ b/tests/tracing/test_span_batcher.py @@ -1,5 +1,6 @@ import os import sys +import threading import time from unittest import mock @@ -479,6 +480,42 @@ def test_trace_bucket_flushes_when_segment_ends( assert items[1].payload["name"] == "segment span" +def test_pending_traces_flush_in_segment_finish_order( + sentry_init, capture_envelopes, monkeypatch +): + """Traces whose segments finish before the flusher wakes are sent in finish order.""" + monkeypatch.setattr(SpanBatcher, "FLUSH_WAIT_TIME", 100000) + + # Hold the flusher thread back until every segment below has finished, so + # all traces are pending when it runs. + flusher_may_run = threading.Event() + original_flush_loop = SpanBatcher._flush_loop + + def delayed_flush_loop(self): + flusher_may_run.wait() + original_flush_loop(self) + + monkeypatch.setattr(SpanBatcher, "_flush_loop", delayed_flush_loop) + + sentry_init(traces_sample_rate=1.0, trace_lifecycle="stream") + envelopes = capture_envelopes() + + finished_trace_ids = [] + for i in range(10): + sentry_sdk.traces.new_trace() + with sentry_sdk.traces.start_span(name=f"segment {i}") as segment: + with sentry_sdk.traces.start_span(name="child"): + pass + finished_trace_ids.append(segment.trace_id) + + flusher_may_run.set() + time.sleep(0.1) + + assert [ + envelope.headers["trace"]["trace_id"] for envelope in envelopes + ] == finished_trace_ids + + @pytest.mark.skipif( sys.platform == "win32" or not hasattr(os, "fork")