From b429f5f23be4cb8d1122f893229d08cb1a789e8e Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 10 Aug 2026 08:37:32 +0200 Subject: [PATCH 1/5] feat(batcher): Add hard span limit --- sentry_sdk/_span_batcher.py | 14 ++++++++- tests/tracing/test_span_batcher.py | 46 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index 79285c3386..a7c8778f83 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -25,7 +25,10 @@ class SpanBatcher(Batcher["SpanJSON"]): # The max limits are all per trace (per bucket). MAX_ENVELOPE_SIZE = 1000 # spans MAX_BEFORE_FLUSH = 1000 + MAX_BEFORE_DROP = 2000 + GLOBAL_MAX_BEFORE_DROP = 10_000 + MAX_BYTES_BEFORE_FLUSH = 5 * 1024 * 1024 # 5 MB FLUSH_WAIT_TIME = 5.0 @@ -44,6 +47,8 @@ def __init__( # envelope. # trace_id -> span buffer self._span_buffer: dict[str, list["SpanJSON"]] = defaultdict(list) + self._span_number: int = 0 + self._running_size: dict[str, int] = defaultdict(lambda: 0) self._capture_func = capture_func self._record_lost_func = record_lost_func @@ -71,6 +76,8 @@ def _reset_in_child() -> None: def _reset_thread_state(self) -> None: self._span_buffer = defaultdict(list) + self._span_number = 0 + self._running_size = defaultdict(lambda: 0) self._running = True @@ -117,7 +124,10 @@ def add(self, span: "SpanJSON") -> None: with self._lock: size = len(self._span_buffer[span["trace_id"]]) - if size >= self.MAX_BEFORE_DROP: + if ( + size >= self.MAX_BEFORE_DROP + or self._span_number >= self.GLOBAL_MAX_BEFORE_DROP + ): self._record_lost_func( reason="queue_overflow", data_category="span", @@ -126,6 +136,8 @@ def add(self, span: "SpanJSON") -> None: return None self._span_buffer[span["trace_id"]].append(span) + self._span_number += 1 + self._running_size[span["trace_id"]] += self._estimate_size(span) if ( diff --git a/tests/tracing/test_span_batcher.py b/tests/tracing/test_span_batcher.py index c21d6cdea4..c112b965be 100644 --- a/tests/tracing/test_span_batcher.py +++ b/tests/tracing/test_span_batcher.py @@ -171,6 +171,47 @@ def test_drop_isolated_per_bucket( assert record_lost_event_calls.count(("queue_overflow", "span", None, 1)) == 1 +def test_drop_after_global_max_reached( + sentry_init, capture_envelopes, capture_record_lost_event_calls, monkeypatch +): + """New spans are dropped if the buffer reaches GLOBAL_MAX_BEFORE_DROP spans.""" + monkeypatch.setattr(SpanBatcher, "GLOBAL_MAX_BEFORE_DROP", 2) + # set the time-based flush limit to something huge so that we're not flushing + # prematurely + monkeypatch.setattr(SpanBatcher, "FLUSH_WAIT_TIME", 100000) + + sentry_init( + traces_sample_rate=1.0, + trace_lifecycle="stream", + ) + + envelopes = capture_envelopes() + record_lost_event_calls = capture_record_lost_event_calls() + + with sentry_sdk.traces.start_span(name="span 1"): + pass + with sentry_sdk.traces.start_span(name="span 2"): + pass + with sentry_sdk.traces.start_span(name="span 3"): + pass + + sentry_sdk.traces.new_trace() + with sentry_sdk.traces.start_span(name="span 4"): + pass + + sentry_sdk.flush() + + assert len(envelopes) == 1 + + print("items are", envelopes[0].items[0].payload.json["items"]) + + assert len(envelopes[0].items[0].payload.json["items"]) == 2 + assert envelopes[0].items[0].payload.json["items"][0]["name"] == "span 1" + assert envelopes[0].items[0].payload.json["items"][1]["name"] == "span 2" + + assert record_lost_event_calls.count(("queue_overflow", "span", None, 1)) == 2 + + def test_length_based_flushing(sentry_init, capture_items, monkeypatch): """A flush event is triggered when a bucket contains MAX_BEFORE_FLUSH spans.""" monkeypatch.setattr(SpanBatcher, "MAX_BEFORE_FLUSH", 1) @@ -460,6 +501,8 @@ def test_span_batcher_lock_reset_in_child_after_fork(sentry_init): original_lock.acquire() batcher._span_buffer["test-trace-id"].append(object()) + batcher._span_number = 1 + batcher._running_size["test-trace-id"] = 42 batcher._active.flag = True batcher._flush_event.set() @@ -472,6 +515,8 @@ def test_span_batcher_lock_reset_in_child_after_fork(sentry_init): flusher_reset = batcher._flusher is None and batcher._flusher_pid is None span_buffer_reset = len(batcher._span_buffer) == 0 + span_number_reset = batcher._span_number == 0 + running_size_reset = len(batcher._running_size) == 0 active_reset = not getattr(batcher._active, "flag", False) @@ -484,6 +529,7 @@ def test_span_batcher_lock_reset_in_child_after_fork(sentry_init): and unheld and flusher_reset and span_buffer_reset + and span_number_reset and running_size_reset and active_reset and event_reset From b30af99a7f2e4f8b1f49700a2085f89c1fed13d4 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 10 Aug 2026 08:39:05 +0200 Subject: [PATCH 2/5] decrement span number --- sentry_sdk/_span_batcher.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index a7c8778f83..c4bacd8c47 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -240,6 +240,8 @@ def _flush(self, only_pending: bool = False) -> None: envelopes.append(envelope) del self._span_buffer[bucket_id] + self._span_number -= 1 + del self._running_size[bucket_id] for envelope in envelopes: From bde3c70ac23357fe613866480cf3886fab379e0b Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 10 Aug 2026 08:50:57 +0200 Subject: [PATCH 3/5] add another test --- sentry_sdk/_span_batcher.py | 2 +- tests/tracing/test_span_batcher.py | 45 ++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index c4bacd8c47..5f2e8576b9 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -239,8 +239,8 @@ def _flush(self, only_pending: bool = False) -> None: envelopes.append(envelope) + self._span_number -= len(self._span_buffer[bucket_id]) del self._span_buffer[bucket_id] - self._span_number -= 1 del self._running_size[bucket_id] diff --git a/tests/tracing/test_span_batcher.py b/tests/tracing/test_span_batcher.py index c112b965be..507fef184b 100644 --- a/tests/tracing/test_span_batcher.py +++ b/tests/tracing/test_span_batcher.py @@ -203,8 +203,6 @@ def test_drop_after_global_max_reached( assert len(envelopes) == 1 - print("items are", envelopes[0].items[0].payload.json["items"]) - assert len(envelopes[0].items[0].payload.json["items"]) == 2 assert envelopes[0].items[0].payload.json["items"][0]["name"] == "span 1" assert envelopes[0].items[0].payload.json["items"][1]["name"] == "span 2" @@ -212,6 +210,49 @@ def test_drop_after_global_max_reached( assert record_lost_event_calls.count(("queue_overflow", "span", None, 1)) == 2 +def test_capture_after_flush_with_global_limit( + sentry_init, capture_envelopes, monkeypatch +): + """New spans are captured again after a flush reduces the span number below the global limit.""" + monkeypatch.setattr(SpanBatcher, "GLOBAL_MAX_BEFORE_DROP", 2) + # set the time-based flush limit to something huge so that we're not flushing + # prematurely + monkeypatch.setattr(SpanBatcher, "FLUSH_WAIT_TIME", 100000) + + sentry_init( + traces_sample_rate=1.0, + trace_lifecycle="stream", + ) + + envelopes = capture_envelopes() + + with sentry_sdk.traces.start_span(name="span 1"): + pass + with sentry_sdk.traces.start_span(name="span 2"): + pass + + sentry_sdk.traces.new_trace() + with sentry_sdk.traces.start_span(name="span 3"): + pass + + sentry_sdk.flush() + + # The span is captured even though a span was dropped in the same trace. + with sentry_sdk.traces.start_span(name="span 4"): + pass + + sentry_sdk.flush() + + assert len(envelopes) == 2 + + assert len(envelopes[0].items[0].payload.json["items"]) == 2 + assert envelopes[0].items[0].payload.json["items"][0]["name"] == "span 1" + assert envelopes[0].items[0].payload.json["items"][1]["name"] == "span 2" + + assert len(envelopes[1].items[0].payload.json["items"]) == 1 + assert envelopes[1].items[0].payload.json["items"][0]["name"] == "span 4" + + def test_length_based_flushing(sentry_init, capture_items, monkeypatch): """A flush event is triggered when a bucket contains MAX_BEFORE_FLUSH spans.""" monkeypatch.setattr(SpanBatcher, "MAX_BEFORE_FLUSH", 1) From faf5684ebc7039aeaf8a2a7d208c360524c38a52 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 10 Aug 2026 09:30:39 +0200 Subject: [PATCH 4/5] do not index if global limit is reached --- sentry_sdk/_span_batcher.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index 5f2e8576b9..27c9852c61 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -123,10 +123,9 @@ def add(self, span: "SpanJSON") -> None: return None with self._lock: - size = len(self._span_buffer[span["trace_id"]]) if ( - size >= self.MAX_BEFORE_DROP - or self._span_number >= self.GLOBAL_MAX_BEFORE_DROP + self._span_number >= self.GLOBAL_MAX_BEFORE_DROP + or len(self._span_buffer[span["trace_id"]]) >= self.MAX_BEFORE_DROP ): self._record_lost_func( reason="queue_overflow", @@ -141,7 +140,8 @@ def add(self, span: "SpanJSON") -> None: self._running_size[span["trace_id"]] += self._estimate_size(span) if ( - size + 1 >= self.MAX_BEFORE_FLUSH + len(self._span_buffer[span["trace_id"]]) + 1 + >= self.MAX_BEFORE_FLUSH or self._running_size[span["trace_id"]] >= self.MAX_BYTES_BEFORE_FLUSH ): From 5a295fa8507c4b0494ee0cf25d984ad4894ee616 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Mon, 10 Aug 2026 09:32:55 +0200 Subject: [PATCH 5/5] fix off by one error --- sentry_sdk/_span_batcher.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index 27c9852c61..c1c40044ee 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -140,8 +140,7 @@ def add(self, span: "SpanJSON") -> None: self._running_size[span["trace_id"]] += self._estimate_size(span) if ( - len(self._span_buffer[span["trace_id"]]) + 1 - >= self.MAX_BEFORE_FLUSH + len(self._span_buffer[span["trace_id"]]) >= self.MAX_BEFORE_FLUSH or self._running_size[span["trace_id"]] >= self.MAX_BYTES_BEFORE_FLUSH ):