From bfe9623a23cb9166be9a92d9b73c5db26cd16d28 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Mon, 20 Jul 2026 14:16:14 -0400 Subject: [PATCH 1/8] fix(tracing): Handle traces_sampler raising by falling back When a user-provided traces_sampler callback raises, catch the exception, log a warning, and fall back to the parent sample rate or the configured traces_sample_rate instead of propagating the error. Refs PY-2617 Refs #6849 --- sentry_sdk/tracing.py | 18 ++++--- sentry_sdk/tracing_utils.py | 25 +++++++--- tests/tracing/test_sampling.py | 90 ++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 14 deletions(-) diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 1790e13fbf..de651e2d56 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1200,16 +1200,22 @@ def _set_initial_sampling_decision( # we would have bailed already if neither `traces_sampler` nor # `traces_sample_rate` were defined, so one of these should work; prefer # the hook if so - sample_rate = ( - client.options["traces_sampler"](sampling_context) - if callable(client.options.get("traces_sampler")) - # default inheritance behavior - else ( + if callable(client.options.get("traces_sampler")): + try: + sample_rate = client.options["traces_sampler"](sampling_context) + except Exception: + logger.warning("[Tracing] traces_sampler raised; falling back to parent sample rate or traces_sample_rate", exc_info=True) + sample_rate = ( + sampling_context["parent_sampled"] + if sampling_context["parent_sampled"] is not None + else client.options["traces_sample_rate"] + ) + else: + sample_rate = ( sampling_context["parent_sampled"] if sampling_context["parent_sampled"] is not None else client.options["traces_sample_rate"] ) - ) # Since this is coming from the user (or from a function provided by the # user), who knows what we might get. (The only valid values are diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index ff4c03829e..87117823e3 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -42,6 +42,7 @@ from typing import Any, Dict, Generator, Iterator, Optional, Tuple, Union from sentry_sdk._types import Attributes + from sentry_sdk.client import BaseClient SENTRY_TRACE_REGEX = re.compile( @@ -1574,6 +1575,16 @@ def add_sentry_baggage_to_headers( stripped_existing_baggage + separator + sentry_baggage ) +def _get_fallback_sample_rate(client: "BaseClient", propagation_context: "PropagationContext") -> "Union[float, bool]": + if propagation_context.parent_sampled is not None: + propagation_context_sample_rate = propagation_context._sample_rate() + + if propagation_context_sample_rate is not None: + return propagation_context_sample_rate + else: + return propagation_context.parent_sampled + else: + return client.options["traces_sample_rate"] def _make_sampling_decision( name: str, @@ -1630,15 +1641,13 @@ def _make_sampling_decision( if propagation_context.custom_sampling_context: sampling_context.update(propagation_context.custom_sampling_context) - sample_rate = client.options["traces_sampler"](sampling_context) + try: + sample_rate = client.options["traces_sampler"](sampling_context) + except Exception: + logger.warning("[Tracing] traces_sampler raised; falling back to parent sample rate or traces_sample_rate", exc_info=True) + sample_rate = _get_fallback_sample_rate(client=client, propagation_context=propagation_context) else: - if propagation_context.parent_sampled is not None: - if propagation_context._sample_rate() is not None: - sample_rate = propagation_context._sample_rate() - else: - sample_rate = propagation_context.parent_sampled - else: - sample_rate = client.options["traces_sample_rate"] + sample_rate = _get_fallback_sample_rate(client=client, propagation_context=propagation_context) # Validate whether the sample_rate we got is actually valid. Since # traces_sampler is user-provided, it could return anything. diff --git a/tests/tracing/test_sampling.py b/tests/tracing/test_sampling.py index 16394f3843..1753b296db 100644 --- a/tests/tracing/test_sampling.py +++ b/tests/tracing/test_sampling.py @@ -204,6 +204,96 @@ def test_tolerates_traces_sampler_returning_a_boolean_span_streaming( assert span.sampled is traces_sampler_return_value +@pytest.mark.parametrize( + "traces_sample_rate,expected_decision", + [(0.0, False), (0.25, False), (0.75, True), (1.00, True)], +) +def test_traces_sampler_raising_falls_back_to_traces_sample_rate( + sentry_init, + traces_sample_rate, + expected_decision, +): + sentry_init( + traces_sampler=mock.Mock(side_effect=ValueError("boom")), + traces_sample_rate=traces_sample_rate, + ) + + baggage = Baggage(sentry_items={"sample_rand": "0.500000"}) + transaction = start_transaction(name="dogpark", baggage=baggage) + assert transaction.sampled is expected_decision + + +@pytest.mark.parametrize("parent_sampling_decision", [True, False]) +def test_traces_sampler_raising_falls_back_to_parent_sampling_decision( + sentry_init, parent_sampling_decision +): + # set traces_sample_rate to produce the opposite of the parent decision, + # to prove the parent's decision takes precedence in the fallback + sentry_init( + traces_sampler=mock.Mock(side_effect=ValueError("boom")), + traces_sample_rate=0.0 if parent_sampling_decision else 1.0, + ) + + baggage = Baggage(sentry_items={"sample_rand": "0.500000"}) + transaction = start_transaction( + name="dogpark", baggage=baggage, parent_sampled=parent_sampling_decision + ) + assert transaction.sampled is parent_sampling_decision + + +@pytest.mark.parametrize( + "traces_sample_rate,expected_decision", + [(0.0, False), (0.25, False), (0.75, True), (1.00, True)], +) +def test_traces_sampler_raising_falls_back_to_traces_sample_rate_span_streaming( + sentry_init, + traces_sample_rate, + expected_decision, +): + sentry_init( + traces_sampler=mock.Mock(side_effect=ValueError("boom")), + traces_sample_rate=traces_sample_rate, + _experiments={ + "trace_lifecycle": "stream", + }, + ) + + sentry_sdk.traces.continue_trace( + { + "sentry-trace": "0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331", + "baggage": "sentry-sample_rand=0.500000", + } + ) + + with sentry_sdk.traces.start_span(name="dogpark") as span: + assert span.sampled is expected_decision + + +def test_traces_sampler_raising_falls_back_to_propagated_sample_rate_span_streaming( + sentry_init, +): + # parent said "don't sample", but its propagated sample rate of 0.75 + # combined with sample_rand 0.5 should win over both the flag and + # traces_sample_rate=0.0 + sentry_init( + traces_sampler=mock.Mock(side_effect=ValueError("boom")), + traces_sample_rate=0.0, + _experiments={ + "trace_lifecycle": "stream", + }, + ) + + sentry_sdk.traces.continue_trace( + { + "sentry-trace": "0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-0", + "baggage": "sentry-sample_rate=0.75,sentry-sample_rand=0.500000", + } + ) + + with sentry_sdk.traces.start_span(name="dogpark") as span: + assert span.sampled is True + + @pytest.mark.parametrize("sampling_decision", [True, False]) def test_only_captures_transaction_when_sampled_is_true( sentry_init, sampling_decision, capture_events From 7bf8fd79893eafcfd76b98dff87687556f2850a9 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Mon, 20 Jul 2026 14:38:40 -0400 Subject: [PATCH 2/8] fix(client): Capture exceptions raised by before_send callbacks Wrap the before_send callback invocation in capture_internal_exceptions() so that exceptions raised by user-provided before_send_log, before_send_metric, and before_send_span callbacks do not crash the application. When a callback raises, the original, unmodified telemetry item is sent. Refs PY-2617 Refs #6849 --- sentry_sdk/client.py | 3 ++- sentry_sdk/tracing.py | 5 ++++- sentry_sdk/tracing_utils.py | 21 +++++++++++++++----- tests/test_logs.py | 26 +++++++++++++++++++++++++ tests/test_metrics.py | 23 ++++++++++++++++++++++ tests/tracing/test_span_streaming.py | 29 ++++++++++++++++++++++++++++ 6 files changed, 100 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index abad953de3..93980f55ef 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -1244,7 +1244,8 @@ def _capture_telemetry( serialized = telemetry._to_json() # type: ignore[union-attr] if before_send is not None: - serialized = before_send(serialized, {}) # type: ignore[arg-type] + with capture_internal_exceptions(): + serialized = before_send(serialized, {}) # type: ignore[arg-type] if ty in ("log", "metric"): # Logs and metrics can be dropped in their respective diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index de651e2d56..df999733c1 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1204,7 +1204,10 @@ def _set_initial_sampling_decision( try: sample_rate = client.options["traces_sampler"](sampling_context) except Exception: - logger.warning("[Tracing] traces_sampler raised; falling back to parent sample rate or traces_sample_rate", exc_info=True) + logger.warning( + "[Tracing] traces_sampler raised; falling back to parent sample rate or traces_sample_rate", + exc_info=True, + ) sample_rate = ( sampling_context["parent_sampled"] if sampling_context["parent_sampled"] is not None diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 87117823e3..d33a3c288a 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1575,7 +1575,10 @@ def add_sentry_baggage_to_headers( stripped_existing_baggage + separator + sentry_baggage ) -def _get_fallback_sample_rate(client: "BaseClient", propagation_context: "PropagationContext") -> "Union[float, bool]": + +def _get_fallback_sample_rate( + client: "BaseClient", propagation_context: "PropagationContext" +) -> "Union[float, bool]": if propagation_context.parent_sampled is not None: propagation_context_sample_rate = propagation_context._sample_rate() @@ -1586,6 +1589,7 @@ def _get_fallback_sample_rate(client: "BaseClient", propagation_context: "Propag else: return client.options["traces_sample_rate"] + def _make_sampling_decision( name: str, attributes: "Optional[Attributes]", @@ -1644,10 +1648,17 @@ def _make_sampling_decision( try: sample_rate = client.options["traces_sampler"](sampling_context) except Exception: - logger.warning("[Tracing] traces_sampler raised; falling back to parent sample rate or traces_sample_rate", exc_info=True) - sample_rate = _get_fallback_sample_rate(client=client, propagation_context=propagation_context) + logger.warning( + "[Tracing] traces_sampler raised; falling back to parent sample rate or traces_sample_rate", + exc_info=True, + ) + sample_rate = _get_fallback_sample_rate( + client=client, propagation_context=propagation_context + ) else: - sample_rate = _get_fallback_sample_rate(client=client, propagation_context=propagation_context) + sample_rate = _get_fallback_sample_rate( + client=client, propagation_context=propagation_context + ) # Validate whether the sample_rate we got is actually valid. Since # traces_sampler is user-provided, it could return anything. @@ -1655,7 +1666,7 @@ def _make_sampling_decision( logger.warning(f"[Tracing] Discarding {name} because of invalid sample rate.") return False, None, None, "sample_rate" - sample_rate = float(sample_rate) # type: ignore[arg-type] + sample_rate = float(sample_rate) if not sample_rate: if traces_sampler_defined: reason = "traces_sampler returned 0 or False" diff --git a/tests/test_logs.py b/tests/test_logs.py index ebd7e7f969..9985ab01a1 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -162,6 +162,32 @@ def _before_log(record, hint): assert before_log_called is True +@minimum_python_37 +@pytest.mark.tests_internal_exceptions +def test_logs_before_send_log_raises_does_not_crash_application( + sentry_init, capture_items +): + def _before_log(record, hint): + raise ValueError("before_send_log error") + + sentry_init( + enable_logs=True, + before_send_log=_before_log, + ) + items = capture_items("log") + + sentry_sdk.logger.error("This is an error log...") + + get_client().flush() + logs = [item.payload for item in items] + + # The exception in before_send_log is swallowed and the original, + # unmodified log is sent. + assert len(logs) == 1 + assert logs[0]["body"] == "This is an error log..." + assert logs[0]["attributes"]["sentry.severity_text"] == "error" + + @minimum_python_37 def test_logs_attributes(sentry_init, capture_items): """ diff --git a/tests/test_metrics.py b/tests/test_metrics.py index e62a868dbe..de9548a261 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -247,6 +247,29 @@ def _before_metric(record, hint): assert before_metric_called +@pytest.mark.tests_internal_exceptions +def test_metrics_before_send_raises_does_not_crash_application( + sentry_init, capture_items +): + def _before_metric(record, hint): + raise ValueError("before_send_metric error") + + sentry_init( + before_send_metric=_before_metric, + ) + items = capture_items("trace_metric") + + sentry_sdk.metrics.count("test.keep", 1) + + get_client().flush() + + # The exception in before_send_metric is swallowed and the original, + # unmodified metric is sent. + metrics = [item.payload for item in items] + assert len(metrics) == 1 + assert metrics[0]["name"] == "test.keep" + + def test_transport_format(sentry_init, capture_envelopes): sentry_init(server_name="test-server", release="1.0.0") diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 5702710f75..8a20265373 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -359,6 +359,35 @@ def before_send_span(span, hint): assert not before_send_span_called +@pytest.mark.tests_internal_exceptions +def test_before_send_span_raises_does_not_crash_application(sentry_init, capture_items): + def before_send_span(span, hint): + raise ValueError("before_send_span error") + + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "before_send_span": before_send_span, + "trace_lifecycle": "stream", + }, + ) + + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="span"): + ... + + sentry_sdk.get_client().flush() + spans = [item.payload for item in items] + + # The exception in before_send_span is swallowed and the original, + # unmodified span is sent. + assert len(spans) == 1 + (span,) = spans + + assert span["name"] == "span" + + def test_span_attributes(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, From 0f8a64a446654dca2d22abb6ce5676dc699e4120 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 21 Jul 2026 11:00:45 -0400 Subject: [PATCH 3/8] address CR comments --- sentry_sdk/client.py | 10 ++++- sentry_sdk/tracing_utils.py | 6 +-- tests/test_logs.py | 7 +--- tests/test_metrics.py | 7 ++-- tests/tracing/test_sampling.py | 59 ++++++++++++++++++---------- tests/tracing/test_span_streaming.py | 8 +--- 6 files changed, 58 insertions(+), 39 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 93980f55ef..a9570f11e1 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -1244,8 +1244,16 @@ def _capture_telemetry( serialized = telemetry._to_json() # type: ignore[union-attr] if before_send is not None: + exception_raised_in_before_send_func = False with capture_internal_exceptions(): - serialized = before_send(serialized, {}) # type: ignore[arg-type] + try: + serialized = before_send(serialized, {}) # type: ignore[arg-type] + except Exception: + exception_raised_in_before_send_func = True + raise + + if exception_raised_in_before_send_func: + return if ty in ("log", "metric"): # Logs and metrics can be dropped in their respective diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index d33a3c288a..6088097281 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1576,7 +1576,7 @@ def add_sentry_baggage_to_headers( ) -def _get_fallback_sample_rate( +def _get_effective_sample_rate( client: "BaseClient", propagation_context: "PropagationContext" ) -> "Union[float, bool]": if propagation_context.parent_sampled is not None: @@ -1652,11 +1652,11 @@ def _make_sampling_decision( "[Tracing] traces_sampler raised; falling back to parent sample rate or traces_sample_rate", exc_info=True, ) - sample_rate = _get_fallback_sample_rate( + sample_rate = _get_effective_sample_rate( client=client, propagation_context=propagation_context ) else: - sample_rate = _get_fallback_sample_rate( + sample_rate = _get_effective_sample_rate( client=client, propagation_context=propagation_context ) diff --git a/tests/test_logs.py b/tests/test_logs.py index 9985ab01a1..82b5072a01 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -181,11 +181,8 @@ def _before_log(record, hint): get_client().flush() logs = [item.payload for item in items] - # The exception in before_send_log is swallowed and the original, - # unmodified log is sent. - assert len(logs) == 1 - assert logs[0]["body"] == "This is an error log..." - assert logs[0]["attributes"]["sentry.severity_text"] == "error" + # The exception in before_send_log is swallowed and the log is dropped. + assert not logs @minimum_python_37 diff --git a/tests/test_metrics.py b/tests/test_metrics.py index de9548a261..23e11f0135 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -263,11 +263,10 @@ def _before_metric(record, hint): get_client().flush() - # The exception in before_send_metric is swallowed and the original, - # unmodified metric is sent. + # The exception in before_send_metric is swallowed and the metric is + # dropped. metrics = [item.payload for item in items] - assert len(metrics) == 1 - assert metrics[0]["name"] == "test.keep" + assert not metrics def test_transport_format(sentry_init, capture_envelopes): diff --git a/tests/tracing/test_sampling.py b/tests/tracing/test_sampling.py index 1753b296db..5cce0cba4e 100644 --- a/tests/tracing/test_sampling.py +++ b/tests/tracing/test_sampling.py @@ -195,9 +195,7 @@ def test_tolerates_traces_sampler_returning_a_boolean_span_streaming( ): sentry_init( traces_sampler=mock.Mock(return_value=traces_sampler_return_value), - _experiments={ - "trace_lifecycle": "stream", - }, + trace_lifecycle="stream", ) with sentry_sdk.traces.start_span(name="dogpark") as span: @@ -253,9 +251,7 @@ def test_traces_sampler_raising_falls_back_to_traces_sample_rate_span_streaming( sentry_init( traces_sampler=mock.Mock(side_effect=ValueError("boom")), traces_sample_rate=traces_sample_rate, - _experiments={ - "trace_lifecycle": "stream", - }, + trace_lifecycle="stream", ) sentry_sdk.traces.continue_trace( @@ -269,29 +265,52 @@ def test_traces_sampler_raising_falls_back_to_traces_sample_rate_span_streaming( assert span.sampled is expected_decision -def test_traces_sampler_raising_falls_back_to_propagated_sample_rate_span_streaming( +@pytest.mark.parametrize( + "traces_sample_rate,expected_decision", + [(0.0, False), (0.25, False), (0.75, True), (1.00, True)], +) +def test_traces_sampler_raising_no_incoming_trace_falls_back_to_traces_sample_rate_span_streaming( sentry_init, + traces_sample_rate, + expected_decision, + monkeypatch, ): - # parent said "don't sample", but its propagated sample rate of 0.75 - # combined with sample_rand 0.5 should win over both the flag and - # traces_sample_rate=0.0 sentry_init( traces_sampler=mock.Mock(side_effect=ValueError("boom")), - traces_sample_rate=0.0, - _experiments={ - "trace_lifecycle": "stream", - }, + traces_sample_rate=traces_sample_rate, + trace_lifecycle="stream", ) - sentry_sdk.traces.continue_trace( - { - "sentry-trace": "0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-0", - "baggage": "sentry-sample_rate=0.75,sentry-sample_rand=0.500000", - } + # no continue_trace, so no propagated sample_rand; make it deterministic + monkeypatch.setattr( + "sentry_sdk.tracing_utils._generate_sample_rand", lambda *a, **kw: 0.5 ) with sentry_sdk.traces.start_span(name="dogpark") as span: - assert span.sampled is True + assert span.sampled is expected_decision + + +def test_traces_sampler_raising_no_incoming_trace_and_no_traces_sample_rate( + sentry_init, +): + sentry_init( + traces_sampler=mock.Mock(side_effect=ValueError("boom")), + ) + + transaction = start_transaction(name="dogpark") + assert transaction.sampled is False + + +def test_traces_sampler_raising_no_incoming_trace_and_no_traces_sample_rate_span_streaming( + sentry_init, +): + sentry_init( + traces_sampler=mock.Mock(side_effect=ValueError("boom")), + trace_lifecycle="stream", + ) + + with sentry_sdk.traces.start_span(name="dogpark") as span: + assert span.sampled is False @pytest.mark.parametrize("sampling_decision", [True, False]) diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 8a20265373..0fad80a2cb 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -380,12 +380,8 @@ def before_send_span(span, hint): sentry_sdk.get_client().flush() spans = [item.payload for item in items] - # The exception in before_send_span is swallowed and the original, - # unmodified span is sent. - assert len(spans) == 1 - (span,) = spans - - assert span["name"] == "span" + # The exception in before_send_span is swallowed and the span is dropped. + assert not spans def test_span_attributes(sentry_init, capture_items): From 99fe440a2ae335f0cbfda05b74ff9aeaa9995813 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 21 Jul 2026 11:27:58 -0400 Subject: [PATCH 4/8] actually, do not drop spans --- sentry_sdk/client.py | 8 +++++--- tests/tracing/test_span_streaming.py | 8 ++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index a9570f11e1..05cfc188d2 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -1252,10 +1252,12 @@ def _capture_telemetry( exception_raised_in_before_send_func = True raise - if exception_raised_in_before_send_func: - return - if ty in ("log", "metric"): + # We are ok with dropping metrics and logs when an exception is raised + # because we allow users to drop them in their respect before_send_* + # functions. + if exception_raised_in_before_send_func: + return # Logs and metrics can be dropped in their respective # before_send, so if we get None, don't queue them for sending. if serialized is None: diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 0fad80a2cb..8a20265373 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -380,8 +380,12 @@ def before_send_span(span, hint): sentry_sdk.get_client().flush() spans = [item.payload for item in items] - # The exception in before_send_span is swallowed and the span is dropped. - assert not spans + # The exception in before_send_span is swallowed and the original, + # unmodified span is sent. + assert len(spans) == 1 + (span,) = spans + + assert span["name"] == "span" def test_span_attributes(sentry_init, capture_items): From 6f95c1f60668bcb9a55fe1f9ad7842ae62047ab1 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 21 Jul 2026 11:35:02 -0400 Subject: [PATCH 5/8] fix(tracing): Remove BaseClient type import breaking docs build The TYPE_CHECKING import of BaseClient from sentry_sdk.client in tracing_utils.py created a circular import when Sphinx evaluated type annotations during the API docs build (sphinx_autodoc_typehints evaluates TYPE_CHECKING imports at runtime). A cold 'import sentry_sdk.client' then cycled through scope -> tracing -> tracing_utils -> client -> integrations.dedupe -> partially-initialized scope. Annotate the client parameter as Any instead. --- sentry_sdk/tracing_utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 6088097281..84ad225a79 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -42,7 +42,6 @@ from typing import Any, Dict, Generator, Iterator, Optional, Tuple, Union from sentry_sdk._types import Attributes - from sentry_sdk.client import BaseClient SENTRY_TRACE_REGEX = re.compile( @@ -1577,7 +1576,7 @@ def add_sentry_baggage_to_headers( def _get_effective_sample_rate( - client: "BaseClient", propagation_context: "PropagationContext" + client: "Any", propagation_context: "PropagationContext" ) -> "Union[float, bool]": if propagation_context.parent_sampled is not None: propagation_context_sample_rate = propagation_context._sample_rate() From 227a4c4a8382439f2a3c90f55c09df7d81fb48b8 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 21 Jul 2026 11:50:22 -0400 Subject: [PATCH 6/8] fix(client): Send pre-callback span when before_send_span raises Reset the serialized span to its original value when before_send_span raises, so partial mutations made by the callback before the exception are discarded and the unmodified span is sent. Strengthen the raise test to mutate the span before raising, asserting the mutation does not leak into the sent span. --- sentry_sdk/client.py | 5 +++++ tests/tracing/test_span_streaming.py | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 05cfc188d2..3a73a4d2ea 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -1264,6 +1264,11 @@ def _capture_telemetry( return elif ty == "span" and isinstance(telemetry, StreamedSpan): + # Reset the span to its original value before we attempted + # to call the `before_send_span` callback + if exception_raised_in_before_send_func: + serialized = telemetry._to_json() + # Spans can't be dropped in before_send_span by design. They can # be altered though (e.g. to sanitize). Only allow changes to # name and attributes. diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 8a20265373..c9c04c02e3 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -362,6 +362,10 @@ def before_send_span(span, hint): @pytest.mark.tests_internal_exceptions def test_before_send_span_raises_does_not_crash_application(sentry_init, capture_items): def before_send_span(span, hint): + # Mutate the span before raising to ensure the partial mutation + # is discarded when the exception is raised. + span["name"] = "Mutated span name" + span["attributes"]["mutated"] = True raise ValueError("before_send_span error") sentry_init( @@ -374,7 +378,7 @@ def before_send_span(span, hint): items = capture_items("span") - with sentry_sdk.traces.start_span(name="span"): + with sentry_sdk.traces.start_span(name="span", attributes={"original": "value"}): ... sentry_sdk.get_client().flush() @@ -386,6 +390,8 @@ def before_send_span(span, hint): (span,) = spans assert span["name"] == "span" + assert span["attributes"]["original"] == "value" + assert "mutated" not in span["attributes"] def test_span_attributes(sentry_init, capture_items): From f3e440eecb926fc411e0986816276adb17b3f203 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 21 Jul 2026 11:59:58 -0400 Subject: [PATCH 7/8] improve reason --- sentry_sdk/tracing_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 84ad225a79..b1a9bec516 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1668,7 +1668,7 @@ def _make_sampling_decision( sample_rate = float(sample_rate) if not sample_rate: if traces_sampler_defined: - reason = "traces_sampler returned 0 or False" + reason = "traces_sampler returned 0 or False, or is using a fallback sample rate that is 0 or False" else: reason = "traces_sample_rate is set to 0" From 99fb778a66596a72527447c22601921b2cda7dd5 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 21 Jul 2026 12:47:29 -0400 Subject: [PATCH 8/8] . --- sentry_sdk/tracing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index df999733c1..5534d1f478 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1244,7 +1244,7 @@ def _set_initial_sampling_decision( "[Tracing] Discarding {transaction_description} because {reason}".format( transaction_description=transaction_description, reason=( - "traces_sampler returned 0 or False" + "traces_sampler returned 0 or False, or is using a fallback sample rate that is 0 or False" if callable(client.options.get("traces_sampler")) else "traces_sample_rate is set to 0" ),