From 9e64ab31c380f89cdd9e13e34053873c492db257 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 24 Jul 2026 14:49:04 +0200 Subject: [PATCH 1/3] feat(streaming): Promote `before_send_span` to top-level option Add `before_send_span` as a top-level `sentry_sdk.init()` parameter alongside `before_send_log` and `before_send_metric`, while keeping backward compatibility with the `_experiments` dict. Warn when span streaming is not enabled. --- sentry_sdk/client.py | 6 ++ sentry_sdk/consts.py | 6 ++ sentry_sdk/utils.py | 4 +- tests/tracing/test_span_streaming.py | 114 +++++++++++++++++++++------ 4 files changed, 106 insertions(+), 24 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index c10daad6ed..5f05f506d4 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -397,6 +397,12 @@ def _get_options(*args: "Optional[str]", **kwargs: "Any") -> "Dict[str, Any]": stacklevel=2, ) + if rv["before_send_span"] and not has_span_streaming_enabled(rv): + warnings.warn( + "The `before_send_span` parameter only works when `trace_lifecycle` is set to `stream`.", + stacklevel=2, + ) + return rv diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index dff264f9f8..c08fba496d 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1336,6 +1336,7 @@ def __init__( trace_ignore_status_codes: "AbstractSet[int]" = frozenset(), enable_metrics: bool = True, before_send_metric: "Optional[Callable[[Metric, Hint], Optional[Metric]]]" = None, + before_send_span: "Optional[Callable[[SpanJSON, Hint], Optional[SpanJSON]]]" = None, org_id: "Optional[str]" = None, strict_trace_continuation: bool = False, stream_gen_ai_spans: bool = True, @@ -1756,6 +1757,11 @@ def __init__( but you can provide it explicitly for self-hosted and Relay setups. This value is used for trace propagation and for features like `strict_trace_continuation`. + :param before_send_span: An optional function to modify spans before they're sent to Sentry. + Any modifications to the span in this function will be retained. Unlike ``before_send_log`` + and ``before_send_metric``, spans cannot be dropped by returning None. Only works when + ``trace_lifecycle="stream"`` is enabled. + :param stream_gen_ai_spans: When set, generative AI spans are sent in a new transport format to reduce downstream data loss. diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 5aa533dea0..a6ece4faf1 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2122,7 +2122,9 @@ def get_before_send_span( if options is None: return None - return options["_experiments"].get("before_send_span") + return options.get("before_send_span") or options["_experiments"].get( + "before_send_span" + ) def format_attribute(val: "Any") -> "AttributeValue": diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 74e4744066..2efacc177c 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -235,10 +235,8 @@ def before_send_span(span, hint): sentry_init( traces_sample_rate=1.0, - _experiments={ - "before_send_span": before_send_span, - "trace_lifecycle": "stream", - }, + trace_lifecycle="stream", + before_send_span=before_send_span, ) items = capture_items("span") @@ -278,10 +276,8 @@ def before_send_span(span, hint): sentry_init( traces_sample_rate=1.0, - _experiments={ - "before_send_span": before_send_span, - "trace_lifecycle": "stream", - }, + trace_lifecycle="stream", + before_send_span=before_send_span, ) items = capture_items("span") @@ -307,10 +303,8 @@ def before_send_span(span, hint): sentry_init( traces_sample_rate=1.0, - _experiments={ - "before_send_span": before_send_span, - "trace_lifecycle": "stream", - }, + trace_lifecycle="stream", + before_send_span=before_send_span, ) items = capture_items("span") @@ -338,13 +332,11 @@ def before_send_span(span, hint): sentry_init( traces_sample_rate=1.0, - _experiments={ - "before_send_span": before_send_span, - "trace_lifecycle": "stream", - "ignore_spans": [ - "ignored", - ], - }, + trace_lifecycle="stream", + before_send_span=before_send_span, + ignore_spans=[ + "ignored", + ], ) items = capture_items("span") @@ -370,10 +362,8 @@ def before_send_span(span, hint): sentry_init( traces_sample_rate=1.0, - _experiments={ - "before_send_span": before_send_span, - "trace_lifecycle": "stream", - }, + trace_lifecycle="stream", + before_send_span=before_send_span, ) items = capture_items("span") @@ -394,6 +384,84 @@ def before_send_span(span, hint): assert "mutated" not in span["attributes"] +def test_before_send_span_set_in_experiments(sentry_init, capture_items): + def before_send_span(span, hint): + span["name"] = "from experiments" + return span + + 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] + + assert len(spans) == 1 + (span,) = spans + + assert span["name"] == "from experiments" + + +def test_before_send_span_top_level_takes_precedence_over_experiments( + sentry_init, capture_items +): + def top_level(span, hint): + span["name"] = "top-level" + return span + + def experimental(span, hint): + span["name"] = "experimental" + return span + + sentry_init( + traces_sample_rate=1.0, + trace_lifecycle="stream", + before_send_span=top_level, + _experiments={ + "before_send_span": experimental, + }, + ) + + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="span"): + ... + + sentry_sdk.get_client().flush() + spans = [item.payload for item in items] + + assert len(spans) == 1 + (span,) = spans + + assert span["name"] == "top-level" + + +def test_before_send_span_warns_without_span_streaming(sentry_init): + import warnings + + def before_send_span(span, hint): + return span + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + sentry_init( + traces_sample_rate=1.0, + before_send_span=before_send_span, + ) + + (warning,) = [x for x in w if "before_send_span" in str(x.message)] + assert "trace_lifecycle" in str(warning.message) + + def test_span_attributes(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, From c8f627036f2a7b1a885730c7b1253888ea1e08d7 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 24 Jul 2026 14:51:47 +0200 Subject: [PATCH 2/3] . --- tests/tracing/test_span_streaming.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 2efacc177c..cae8e181a0 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -391,9 +391,9 @@ def before_send_span(span, hint): sentry_init( traces_sample_rate=1.0, + trace_lifecycle="stream", _experiments={ "before_send_span": before_send_span, - "trace_lifecycle": "stream", }, ) From dac9204f4e94057c620b21584e15a38f7dbff485 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 24 Jul 2026 14:52:50 +0200 Subject: [PATCH 3/3] . --- sentry_sdk/consts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index c08fba496d..951ea66783 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1758,7 +1758,7 @@ def __init__( trace propagation and for features like `strict_trace_continuation`. :param before_send_span: An optional function to modify spans before they're sent to Sentry. - Any modifications to the span in this function will be retained. Unlike ``before_send_log`` + Modifications to the span's attributes and name will be retained. Unlike ``before_send_log`` and ``before_send_metric``, spans cannot be dropped by returning None. Only works when ``trace_lifecycle="stream"`` is enabled.