From d2f4ee757d8e35818db16696ba0158143e6c894c Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Mon, 20 Jul 2026 14:38:40 -0400 Subject: [PATCH] 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 1ff4cdf882..44af5d047a 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1544,7 +1544,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() @@ -1555,6 +1558,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]", @@ -1601,10 +1605,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. @@ -1612,7 +1623,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 35c8ec69b2..cd378c859e 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -356,6 +356,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,