From d9970094ca151796dce0907ce6c7d288e2b451e4 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 7 May 2026 13:39:39 +0200 Subject: [PATCH 1/5] feat(span-first): Add before_send_span --- sentry_sdk/_types.py | 15 +++++++++++++++ sentry_sdk/client.py | 2 ++ sentry_sdk/consts.py | 4 ++++ sentry_sdk/utils.py | 9 +++++++++ 4 files changed, 30 insertions(+) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index ad3fa35849..9ee1fe4270 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -317,6 +317,21 @@ class SDKInfo(TypedDict): MetricProcessor = Callable[[Metric, Hint], Optional[Metric]] + SpanSnapshot = TypedDict( + "SpanSnapshot", + { + "trace_id": str, + "span_id": str, + "name": str, + "status": str, + "is_segment": bool, + "start_timestamp": float, + "end_timestamp": float, + "parent_span_id": Optional[str], + "attributes": Attributes, + }, + ) + # TODO: Make a proper type definition for this (PRs welcome!) Breadcrumb = Dict[str, Any] diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 9f795d2489..251cc8c9bb 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -949,6 +949,8 @@ def _capture_telemetry( before_send = get_before_send_log(self.options) elif ty == "metric": before_send = get_before_send_metric(self.options) # type: ignore + elif ty == "span": + before_send = get_before_send_span(self.options) # type: ignore if before_send is not None: telemetry = before_send(telemetry, {}) # type: ignore diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index c35da0e22a..cdb118ecdd 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -46,6 +46,7 @@ class CompressionAlgo(Enum): from typing_extensions import Literal, TypedDict import sentry_sdk + from sentry_sdk.traces import StreamedSpan from sentry_sdk._types import ( BreadcrumbProcessor, ContinuousProfilerMode, @@ -85,6 +86,9 @@ class CompressionAlgo(Enum): "before_send_metric": Optional[Callable[[Metric, Hint], Optional[Metric]]], "trace_lifecycle": Optional[Literal["static", "stream"]], "ignore_spans": Optional[IgnoreSpansConfig], + "before_send_span": Optional[ + Callable[[StreamedSpan, Hint], Optional[StreamedSpan]] + ], "suppress_asgi_chained_exceptions": Optional[bool], }, total=False, diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 5051a3d9d2..33c0b3ea06 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2111,6 +2111,15 @@ def get_before_send_metric( ) +def get_before_send_span( + options: "Optional[dict[str, Any]]", +) -> "Optional[Callable[[Metric, Hint], Optional[StreamedSpan]]]": + if options is None: + return None + + return options["_experiments"].get("before_send_span") + + def format_attribute(val: "Any") -> "AttributeValue": """ Turn unsupported attribute value types into an AttributeValue. From 58caff952d5b0c4add7e76a104c2bc0d82b1a0c9 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 7 May 2026 16:24:29 +0200 Subject: [PATCH 2/5] . --- sentry_sdk/traces.py | 11 ------ tests/tracing/test_span_streaming.py | 54 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 4f96a48920..7336c92156 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -573,17 +573,6 @@ def _set_segment_attributes(self) -> None: self.set_attribute("process.command_args", sys.argv) - def _to_dict(self) -> SpanSnapshot: - res = { - "trace_id": self.trace_id, - "span_id": self.span_id, - "name": self._name if self._name is not None else "", - "status": self._status, - "is_segment": self._is_segment(), - "start_timestamp": self._start_timestamp.timestamp(), - "attributes": self.get_attributes(), - } - if self._timestamp: res["end_timestamp"] = self._timestamp.timestamp() diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 0e095b5147..0b818b1f43 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -270,6 +270,60 @@ def traces_sampler(sampling_context): with sentry_sdk.traces.start_span(name="span", attributes={"first": False}): ... +def test_before_send_span(sentry_init, capture_items): + def before_send_span(span, hint): + return None + + sentry_init( + _experiments={ + "before_send_span": before_send_span. + "trace_lifecycle": "stream", + }, + ) + + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="dropped", attributes={"drop": True}): + ... + with sentry_sdk.traces.start_span(name="retained", attributes={"drop": False}): + ... + + sentry_sdk.get_client().flush() + spans = [item.payload for item in items] + + assert len(spans) == 1 + (span,) = spans + + assert span["name"] == "retained" + assert span["attributes"]["drop"] is False + +def test_before_send_span_invalid_return_value(sentry_init, capture_items): + def before_send_span(span, hint): + # Spans can't be dropped in before_send_span + return None + + sentry_init( + _experiments={ + "before_send_span": before_send_span. + "trace_lifecycle": "stream", + }, + ) + + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="dropped", attributes={"drop": True}): + ... + with sentry_sdk.traces.start_span(name="retained", attributes={"drop": False}): + ... + + sentry_sdk.get_client().flush() + spans = [item.payload for item in items] + + assert len(spans) == 1 + (span,) = spans + + assert span["name"] == "retained" + assert span["attributes"]["drop"] is False def test_span_attributes(sentry_init, capture_items): sentry_init( From 3af5562fa0da5aac3618db9a4c7372915b653d48 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 8 May 2026 10:30:02 +0200 Subject: [PATCH 3/5] . --- tests/tracing/test_span_streaming.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 0b818b1f43..b9a136b4e6 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -270,13 +270,14 @@ def traces_sampler(sampling_context): with sentry_sdk.traces.start_span(name="span", attributes={"first": False}): ... + def test_before_send_span(sentry_init, capture_items): def before_send_span(span, hint): - return None + span.set_attribute("", "") sentry_init( _experiments={ - "before_send_span": before_send_span. + "before_send_span": before_send_span, "trace_lifecycle": "stream", }, ) @@ -297,6 +298,7 @@ def before_send_span(span, hint): assert span["name"] == "retained" assert span["attributes"]["drop"] is False + def test_before_send_span_invalid_return_value(sentry_init, capture_items): def before_send_span(span, hint): # Spans can't be dropped in before_send_span @@ -304,7 +306,7 @@ def before_send_span(span, hint): sentry_init( _experiments={ - "before_send_span": before_send_span. + "before_send_span": before_send_span, "trace_lifecycle": "stream", }, ) @@ -325,6 +327,7 @@ def before_send_span(span, hint): assert span["name"] == "retained" assert span["attributes"]["drop"] is False + def test_span_attributes(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, From 4339f3fbab292e871cfcccc3469efe677230c02f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 8 May 2026 15:07:52 +0200 Subject: [PATCH 4/5] feat(span-first): Support before_send_span --- sentry_sdk/client.py | 43 +++++++++++-- sentry_sdk/traces.py | 8 --- sentry_sdk/utils.py | 3 +- tests/tracing/test_span_streaming.py | 96 ++++++++++++++++++++++++---- 4 files changed, 123 insertions(+), 27 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 3b50d71868..98a5800970 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -3,6 +3,7 @@ import random import socket from collections.abc import Mapping +from copy import deepcopy from datetime import datetime, timezone from importlib import import_module from typing import TYPE_CHECKING, List, Dict, cast, overload @@ -25,10 +26,12 @@ logger, get_before_send_log, get_before_send_metric, + get_before_send_span, has_logs_enabled, has_metrics_enabled, ) from sentry_sdk.serializer import serialize +from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing import trace from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.transport import ( @@ -71,7 +74,6 @@ from sentry_sdk.scope import Scope from sentry_sdk.session import Session from sentry_sdk.spotlight import SpotlightClient - from sentry_sdk.traces import StreamedSpan from sentry_sdk.transport import Transport, Item from sentry_sdk._log_batcher import LogBatcher from sentry_sdk._metrics_batcher import MetricsBatcher @@ -938,25 +940,54 @@ def _capture_telemetry( ty: str, scope: "Scope", ) -> None: - # Capture attributes-based telemetry (logs, metrics, spansV2) + """ + Capture attributes-based telemetry (logs, metrics, streamed spans). + + Apply any attributes set on the scope to it, and run the user's + before_send_{telemetry} on it, if applicable. + """ if telemetry is None: return scope.apply_to_telemetry(telemetry) before_send = None + if ty == "log": before_send = get_before_send_log(self.options) + snapshot = telemetry + elif ty == "metric": before_send = get_before_send_metric(self.options) + snapshot = telemetry + elif ty == "span": before_send = get_before_send_span(self.options) + # We don't want to expose the actual underlying span in + # before_send_span to not allow arbitrary edits. Expose a copy + # instead. + snapshot = deepcopy(telemetry) if before_send is not None: - telemetry = before_send(telemetry, {}) - - if telemetry is None: - return + result = before_send(snapshot, {}) + + # Logs and metrics can be dropped in their respective + # before_send, so if we get None, don't queue them for sending. + if ty in ("log", "metric"): + if result is None: + return + + # Spans can't be dropped in before_send_span by design. They can + # be altered though (name and attributes can be changed, e.g. to + # sanitize). + # + # If we get anything but a StreamedSpan back from before_send_span, + # just ignore it. Otherwise, take the returned StreamedSpan and + # merge it with the original. + elif ty == "span": + if isinstance(result, StreamedSpan): + telemetry._attributes = result._attributes + telemetry._name = result._name batcher = None if ty == "log": diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 0b3f0821da..f49760f03b 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -574,14 +574,6 @@ def _set_segment_attributes(self) -> None: self.set_attribute("process.command_args", sys.argv) - if self._timestamp: - res["end_timestamp"] = self._timestamp.timestamp() - - if self._parent_span_id: - res["parent_span_id"] = self._parent_span_id - - return res - class NoOpStreamedSpan(StreamedSpan): __slots__ = ( diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 33c0b3ea06..76f1919e98 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -77,6 +77,7 @@ Metric, SerializedAttributeValue, ) + from sentry_sdk.traces import StreamedSpan P = ParamSpec("P") R = TypeVar("R") @@ -2113,7 +2114,7 @@ def get_before_send_metric( def get_before_send_span( options: "Optional[dict[str, Any]]", -) -> "Optional[Callable[[Metric, Hint], Optional[StreamedSpan]]]": +) -> "Optional[Callable[[StreamedSpan, Hint], Optional[StreamedSpan]]]": if options is None: return None diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index b9a136b4e6..4e876b7527 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -271,11 +271,19 @@ def traces_sampler(sampling_context): ... -def test_before_send_span(sentry_init, capture_items): +def test_before_send_span_basic(sentry_init, capture_items): def before_send_span(span, hint): - span.set_attribute("", "") + assert isinstance(span, StreamedSpan) + + span.name = "Better span name" + span.remove_attribute("drop") + span.set_attribute("sanitize", "[Removed]") + span.set_attribute("add", "new") + + return span sentry_init( + traces_sample_rate=1.0, _experiments={ "before_send_span": before_send_span, "trace_lifecycle": "stream", @@ -284,9 +292,13 @@ def before_send_span(span, hint): items = capture_items("span") - with sentry_sdk.traces.start_span(name="dropped", attributes={"drop": True}): - ... - with sentry_sdk.traces.start_span(name="retained", attributes={"drop": False}): + with sentry_sdk.traces.start_span( + name="span", + attributes={ + "drop": True, + "sanitize": "myamazingpassword", + }, + ): ... sentry_sdk.get_client().flush() @@ -295,16 +307,20 @@ def before_send_span(span, hint): assert len(spans) == 1 (span,) = spans - assert span["name"] == "retained" - assert span["attributes"]["drop"] is False + assert span["name"] == "Better span name" + assert "drop" not in span["attributes"] + assert span["attributes"]["sanitize"] == "[Removed]" + assert span["attributes"]["add"] == "new" def test_before_send_span_invalid_return_value(sentry_init, capture_items): def before_send_span(span, hint): - # Spans can't be dropped in before_send_span + # Spans can't be dropped in before_send_span, so unsupported return + # values will be ignored return None sentry_init( + traces_sample_rate=1.0, _experiments={ "before_send_span": before_send_span, "trace_lifecycle": "stream", @@ -313,9 +329,34 @@ def before_send_span(span, hint): items = capture_items("span") - with sentry_sdk.traces.start_span(name="dropped", attributes={"drop": True}): + with sentry_sdk.traces.start_span(name="span"): ... - with sentry_sdk.traces.start_span(name="retained", attributes={"drop": False}): + + sentry_sdk.get_client().flush() + spans = [item.payload for item in items] + + assert len(spans) == 1 + (span,) = spans + + assert span["name"] == "span" + + +def test_before_send_span_unsupported_edit(sentry_init, capture_items): + def before_send_span(span, hint): + # Anything beyond attribute and name changes will be ignored + span._trace_id = "my-trace-id" + + 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() @@ -324,8 +365,39 @@ def before_send_span(span, hint): assert len(spans) == 1 (span,) = spans - assert span["name"] == "retained" - assert span["attributes"]["drop"] is False + assert span["name"] == "span" + assert span["trace_id"] != "my-trace-id" + + +def test_before_send_span_doesnt_receive_ignored_spans(sentry_init, capture_items): + before_send_span_called = False + + def before_send_span(span, hint): + nonlocal before_send_span_called + before_send_span_called = True + return span + + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "before_send_span": before_send_span, + "trace_lifecycle": "stream", + "ignore_spans": [ + "ignored", + ], + }, + ) + + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="ignored"): + ... + + sentry_sdk.get_client().flush() + spans = [item.payload for item in items] + + assert not spans + assert not before_send_span_called def test_span_attributes(sentry_init, capture_items): From 0c165af5b542256a41b1dc23691518f2aae1fb0c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 8 May 2026 15:08:29 +0200 Subject: [PATCH 5/5] remove unused type --- sentry_sdk/_types.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 9ee1fe4270..ad3fa35849 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -317,21 +317,6 @@ class SDKInfo(TypedDict): MetricProcessor = Callable[[Metric, Hint], Optional[Metric]] - SpanSnapshot = TypedDict( - "SpanSnapshot", - { - "trace_id": str, - "span_id": str, - "name": str, - "status": str, - "is_segment": bool, - "start_timestamp": float, - "end_timestamp": float, - "parent_span_id": Optional[str], - "attributes": Attributes, - }, - ) - # TODO: Make a proper type definition for this (PRs welcome!) Breadcrumb = Dict[str, Any]