From 946decc9ff9b9f057612f39a297b4df95a5ab0dd Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 12:08:24 +0100 Subject: [PATCH 01/83] ref: Remove flag storage from StreamedSpan --- sentry_sdk/traces.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 531a06b1fd..e0235268f0 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -17,9 +17,6 @@ from sentry_sdk._types import Attributes, AttributeValue -FLAGS_CAPACITY = 10 - - class SpanStatus(str, Enum): OK = "ok" ERROR = "error" @@ -65,10 +62,10 @@ class StreamedSpan: """ A span holds timing information of a block of code. - Spans can have multiple child spans thus forming a span tree. + Spans can have multiple child spans, thus forming a span tree. - This is the Span First span implementation. The original transaction-based - span implementation lives in tracing.Span. + This is the Span First span implementation that streams spans. The original + transaction-based span implementation lives in tracing.Span. """ __slots__ = ( @@ -77,7 +74,6 @@ class StreamedSpan: "_span_id", "_trace_id", "_status", - "_flags", ) def __init__( @@ -99,8 +95,6 @@ def __init__( self.set_status(SpanStatus.OK) self.set_source(SegmentSource.CUSTOM) - self._flags: dict[str, bool] = {} - def get_attributes(self) -> "Attributes": return self._attributes @@ -143,10 +137,6 @@ def get_name(self) -> str: def set_name(self, name: str) -> None: self._name = name - def set_flag(self, flag: str, result: bool) -> None: - if len(self._flags) < FLAGS_CAPACITY: - self._flags[flag] = result - def set_op(self, op: str) -> None: self.set_attribute("sentry.op", op) From f3ee55c909ee080c6807f32fc5943e77b44fde20 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 12:12:51 +0100 Subject: [PATCH 02/83] ref: Tweak StreamedSpan interface --- sentry_sdk/traces.py | 53 ++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index e0235268f0..333a9a5c5f 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -92,8 +92,16 @@ def __init__( self._span_id: "Optional[str]" = None self._trace_id: "Optional[str]" = trace_id - self.set_status(SpanStatus.OK) - self.set_source(SegmentSource.CUSTOM) + self._status = SpanStatus.OK.value + self.set_attribute("sentry.span.source", SegmentSource.CUSTOM.value) + + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__}(" + f"name={self._name}, " + f"trace_id={self.trace_id}, " + f"span_id={self.span_id}>" + ) def get_attributes(self) -> "Attributes": return self._attributes @@ -111,44 +119,31 @@ def remove_attribute(self, key: str) -> None: except KeyError: pass - def get_status(self) -> "Union[SpanStatus, str]": - if self._status in {s.value for s in SpanStatus}: - return SpanStatus(self._status) - + @property + def status(self) -> "str": return self._status - def set_status(self, status: "Union[SpanStatus, str]") -> None: + @status.setter + def status(self, status: "Union[SpanStatus, str]") -> None: if isinstance(status, Enum): status = status.value - self._status = status - - def set_http_status(self, http_status: int) -> None: - self.set_attribute(SPANDATA.HTTP_STATUS_CODE, http_status) + if status not in {e.value for e in SpanStatus}: + logger.debug( + f'Unsupported span status {status}. Expected one of: "ok", "error"' + ) + return - if http_status >= 400: - self.set_status(SpanStatus.ERROR) - else: - self.set_status(SpanStatus.OK) + self._status = status - def get_name(self) -> str: + @property + def name(self) -> str: return self._name - def set_name(self, name: str) -> None: + @name.setter + def name(self, name: str) -> None: self._name = name - def set_op(self, op: str) -> None: - self.set_attribute("sentry.op", op) - - def set_origin(self, origin: str) -> None: - self.set_attribute("sentry.origin", origin) - - def set_source(self, source: "Union[str, SegmentSource]") -> None: - if isinstance(source, Enum): - source = source.value - - self.set_attribute("sentry.span.source", source) - @property def span_id(self) -> str: if not self._span_id: From 47ed910d32d99c66d2a142a21d8494aa02740492 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 12:21:15 +0100 Subject: [PATCH 03/83] Add missing logger --- sentry_sdk/traces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 333a9a5c5f..4a1ad8d396 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING from sentry_sdk.consts import SPANDATA -from sentry_sdk.utils import format_attribute +from sentry_sdk.utils import format_attribute, logger if TYPE_CHECKING: from typing import Optional, Union From 5023c76a6248ab91e23939e7225d093bcad41ac5 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 12:40:02 +0100 Subject: [PATCH 04/83] fixes --- sentry_sdk/traces.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 4a1ad8d396..d733899e4b 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -9,7 +9,6 @@ from enum import Enum from typing import TYPE_CHECKING -from sentry_sdk.consts import SPANDATA from sentry_sdk.utils import format_attribute, logger if TYPE_CHECKING: @@ -100,7 +99,7 @@ def __repr__(self) -> str: f"<{self.__class__.__name__}(" f"name={self._name}, " f"trace_id={self.trace_id}, " - f"span_id={self.span_id}>" + f"span_id={self.span_id})>" ) def get_attributes(self) -> "Attributes": From 644544705c10cb26a5c238ff9475742eb7a132ee Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 12:40:34 +0100 Subject: [PATCH 05/83] ref: Add active to StreamedSpan --- sentry_sdk/traces.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index d733899e4b..768e658b5b 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -70,6 +70,7 @@ class StreamedSpan: __slots__ = ( "_name", "_attributes", + "_active", "_span_id", "_trace_id", "_status", @@ -80,9 +81,11 @@ def __init__( *, name: str, attributes: "Optional[Attributes]" = None, + active: bool = True, trace_id: "Optional[str]" = None, ): self._name: str = name + self._active: bool = active self._attributes: "Attributes" = {} if attributes: for attribute, value in attributes.items(): @@ -99,7 +102,8 @@ def __repr__(self) -> str: f"<{self.__class__.__name__}(" f"name={self._name}, " f"trace_id={self.trace_id}, " - f"span_id={self.span_id})>" + f"span_id={self.span_id}, " + f"active={self._active})>" ) def get_attributes(self) -> "Attributes": From 47e6211f473ebad9caf55762ce83dd3b73136a81 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 12:24:32 +0100 Subject: [PATCH 06/83] Add property --- sentry_sdk/traces.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 768e658b5b..859bebdacd 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -147,6 +147,10 @@ def name(self) -> str: def name(self, name: str) -> None: self._name = name + @property + def active(self) -> bool: + return self._active + @property def span_id(self) -> str: if not self._span_id: From 1e7b694d9f1e9ac9d3ff9b86d20cb9d83178247a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 12:33:20 +0100 Subject: [PATCH 07/83] ref: Add no-op streaming span class --- sentry_sdk/traces.py | 53 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 859bebdacd..4f0807d007 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -164,3 +164,56 @@ def trace_id(self) -> str: self._trace_id = uuid.uuid4().hex return self._trace_id + + +class NoOpStreamedSpan(StreamedSpan): + __slots__ = ( + ) + + def __init__( + self, + ) -> None: + pass + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}(sampled={self.sampled})>" + + def get_attributes(self) -> "Attributes": + return {} + + def set_attribute(self, key: str, value: "AttributeValue") -> None: + pass + + def set_attributes(self, attributes: "Attributes") -> None: + pass + + def remove_attribute(self, key: str) -> None: + pass + + @property + def status(self) -> "str": + return SpanStatus.OK.value + + @status.setter + def status(self, status: "Union[SpanStatus, str]") -> None: + pass + + @property + def name(self) -> str: + return "" + + @name.setter + def name(self, value: str) -> None: + pass + + @property + def active(self) -> bool: + return True + + @property + def span_id(self) -> str: + return "0000000000000000" + + @property + def trace_id(self) -> str: + return "00000000000000000000000000000000" From 80bfe5a2af99d568a51135a842c9398154d48751 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 12:37:30 +0100 Subject: [PATCH 08/83] Remove redundant stuff --- sentry_sdk/traces.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 4f0807d007..e09d7191c3 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -167,17 +167,6 @@ def trace_id(self) -> str: class NoOpStreamedSpan(StreamedSpan): - __slots__ = ( - ) - - def __init__( - self, - ) -> None: - pass - - def __repr__(self) -> str: - return f"<{self.__class__.__name__}(sampled={self.sampled})>" - def get_attributes(self) -> "Attributes": return {} From d77342836cc94dbc98870b63d1d030b586a98ebd Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 13:26:59 +0100 Subject: [PATCH 09/83] ref: Add experimental streaming API --- sentry_sdk/scope.py | 54 ++++++++++++++++++++++++- sentry_sdk/traces.py | 94 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 3bc51c1af0..b6943df634 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -33,7 +33,7 @@ normalize_incoming_data, PropagationContext, ) -from sentry_sdk.traces import StreamedSpan +from sentry_sdk.traces import StreamedSpan, NoOpStreamedSpan from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, @@ -1174,6 +1174,58 @@ def start_span( return span + def start_streamed_span( + self, + name: str, + attributes: "Optional[Attributes]" = None, + parent_span: "Optional[StreamedSpan]" = None, + active: bool = True, + ) -> "StreamedSpan": + # TODO: rename to start_span once we drop the old API + if isinstance(parent_span, NoOpStreamedSpan): + # parent_span is only set if the user explicitly set it + logger.debug( + "Ignored parent span provided. Span will be parented to the " + "currently active span instead." + ) + + if parent_span is None or isinstance(parent_span, NoOpStreamedSpan): + parent_span = self.span or self.get_current_scope().span # type: ignore + + # If no eligible parent_span was provided and there is no currently + # active span, this is a segment + if parent_span is None: + propagation_context = self.get_active_propagation_context() + + return StreamedSpan( + name=name, + attributes=attributes, + active=active, + scope=self, + segment=None, + trace_id=propagation_context.trace_id, + parent_span_id=propagation_context.parent_span_id, + parent_sampled=propagation_context.parent_sampled, + baggage=propagation_context.baggage, + ) + + # This is a child span; take propagation context from the parent span + with new_scope(): + if isinstance(parent_span, NoOpStreamedSpan): + return NoOpStreamedSpan() + + return StreamedSpan( + name=name, + attributes=attributes, + active=active, + scope=self, + segment=parent_span._segment, + trace_id=parent_span.trace_id, + parent_span_id=parent_span.span_id, + parent_sampled=parent_span.sampled, + ) + + def continue_trace( self, environ_or_headers: "Dict[str, Any]", diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index e09d7191c3..30fca8b2f4 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -9,6 +9,8 @@ from enum import Enum from typing import TYPE_CHECKING +import sentry_sdk +from sentry_sdk.tracing_utils import Baggage from sentry_sdk.utils import format_attribute, logger if TYPE_CHECKING: @@ -57,6 +59,66 @@ def __str__(self) -> str: } +def start_span( + name: str, + attributes: "Optional[Attributes]" = None, + parent_span: "Optional[StreamedSpan]" = None, + active: bool = True, +) -> "StreamedSpan": + """ + Start a span. + + The span's parent, unless provided explicitly via the `parent_span` argument, + will be the current active span, if any. If there is none, this span will + become the root of a new span tree. + + `start_span()` can either be used as context manager or you can use the span + object it returns and explicitly end it via `span.end()`. The following is + equivalent: + + ```python + import sentry_sdk + + with sentry_sdk.traces.start_span(name="My Span"): + # do something + + # The span automatically finishes once the `with` block is exited + ``` + + ```python + import sentry_sdk + + span = sentry_sdk.traces.start_span(name="My Span") + # do something + span.end() + ``` + + :param name: The name to identify this span by. + :type name: str + + :param attributes: Key-value attributes to set on the span from the start. + These will also be accessible in the traces sampler. + :type attributes: "Optional[Attributes]" + + :param parent_span: A span instance that the new span should consider its + parent. If not provided, the parent will be set to the currently active + span, if any. + :type parent_span: "Optional[StreamedSpan]" + + :param active: Controls whether spans started while this span is running + will automatically become its children. That's the default behavior. If + you want to create a span that shouldn't have any children (unless + provided explicitly via the `parent_span` argument), set this to `False`. + :type active: bool + + :return: The span that has been started. + :rtype: StreamedSpan + """ + return sentry_sdk.get_current_scope().start_streamed_span( + name, attributes, parent_span, active + ) + + class StreamedSpan: """ A span holds timing information of a block of code. @@ -73,7 +135,12 @@ class StreamedSpan: "_active", "_span_id", "_trace_id", + "_parent_span_id", + "_segment", + "_parent_sampled", "_status", + "_scope", + "_baggage", ) def __init__( @@ -82,7 +149,12 @@ def __init__( name: str, attributes: "Optional[Attributes]" = None, active: bool = True, + scope: "sentry_sdk.Scope", + segment: "Optional[StreamedSpan]" = None, trace_id: "Optional[str]" = None, + parent_span_id: "Optional[str]" = None, + parent_sampled: "Optional[bool]" = None, + baggage: "Optional[Baggage]" = None, ): self._name: str = name self._active: bool = active @@ -91,8 +163,16 @@ def __init__( for attribute, value in attributes.items(): self.set_attribute(attribute, value) - self._span_id: "Optional[str]" = None + self._scope = scope + + self._segment = segment or self + self._trace_id: "Optional[str]" = trace_id + self._parent_span_id = parent_span_id + self._parent_sampled = parent_sampled + self._baggage = baggage + + self._span_id: "Optional[str]" = None self._status = SpanStatus.OK.value self.set_attribute("sentry.span.source", SegmentSource.CUSTOM.value) @@ -103,6 +183,7 @@ def __repr__(self) -> str: f"name={self._name}, " f"trace_id={self.trace_id}, " f"span_id={self.span_id}, " + f"parent_span_id={self._parent_span_id}, " f"active={self._active})>" ) @@ -165,8 +246,15 @@ def trace_id(self) -> str: return self._trace_id + @property + def sampled(self) -> "Optional[bool]": + return True + class NoOpStreamedSpan(StreamedSpan): + def __init__(self) -> None: + pass + def get_attributes(self) -> "Attributes": return {} @@ -206,3 +294,7 @@ def span_id(self) -> str: @property def trace_id(self) -> str: return "00000000000000000000000000000000" + + @property + def sampled(self) -> "Optional[bool]": + return False From 647fa79ed6e5839b1c34cd71f5167fe98c5dbf7a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 13:33:09 +0100 Subject: [PATCH 10/83] reformat --- sentry_sdk/scope.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index b6943df634..708e27ff45 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1225,7 +1225,6 @@ def start_streamed_span( parent_sampled=parent_span.sampled, ) - def continue_trace( self, environ_or_headers: "Dict[str, Any]", From 49bdbe61a58043a88982c38f682a79dec76e7e7f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 13:44:37 +0100 Subject: [PATCH 11/83] Add a __repr__ --- sentry_sdk/traces.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 30fca8b2f4..aa54296d97 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -255,6 +255,9 @@ class NoOpStreamedSpan(StreamedSpan): def __init__(self) -> None: pass + def __repr__(self) -> str: + return f"<{self.__class__.__name__}(sampled={self.sampled})>" + def get_attributes(self) -> "Attributes": return {} From 54f81afad43200d26176527a0de9757687406146 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 13:41:02 +0100 Subject: [PATCH 12/83] ref: Add new_trace, continue_trace to span first --- sentry_sdk/traces.py | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index aa54296d97..f6156e77ac 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -14,7 +14,7 @@ from sentry_sdk.utils import format_attribute, logger if TYPE_CHECKING: - from typing import Optional, Union + from typing import Any, Optional, Union from sentry_sdk._types import Attributes, AttributeValue @@ -93,6 +93,9 @@ def start_span( span.end() ``` + To continue a trace from another service, call + `sentry_sdk.traces.continue_trace()` prior to creating a top-level span. + :param name: The name to identify this span by. :type name: str @@ -119,6 +122,44 @@ def start_span( ) +def continue_trace(incoming: "dict[str, Any]") -> None: + """ + Continue a trace from headers or environment variables. + + This function sets the propagation context on the scope. Any span started + in the updated scope will belong under the trace extracted from the + provided propagation headers or environment variables. + + continue_trace() doesn't start any spans on its own. Use the start_span() + API for that. + """ + # This is set both on the isolation and the current scope for compatibility + # reasons. Conceptually, it belongs on the isolation scope, and it also + # used to be set there in non-span-first mode. But in span first mode, we + # start spans on the current scope, regardless of type, like JS does, so we + # need to set the propagation context there. + sentry_sdk.get_isolation_scope().generate_propagation_context( + incoming, + ) + return sentry_sdk.get_current_scope().generate_propagation_context( + incoming, + ) + + +def new_trace() -> None: + """ + Resets the propagation context, forcing a new trace. + + This function sets the propagation context on the scope. Any span started + in the updated scope will start its own trace. + + new_trace() doesn't start any spans on its own. Use the start_span() API + for that. + """ + sentry_sdk.get_isolation_scope().set_new_propagation_context() + sentry_sdk.get_current_scope().set_new_propagation_context() + + class StreamedSpan: """ A span holds timing information of a block of code. From 941863e2f692f067deaf23e9b21452679f513f53 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 13:57:08 +0100 Subject: [PATCH 13/83] ref: Add streaming trace decorator --- sentry_sdk/traces.py | 76 ++++++++++++++++++++++++++++++++++++- sentry_sdk/tracing_utils.py | 59 ++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index f6156e77ac..09ba250e23 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -14,9 +14,12 @@ from sentry_sdk.utils import format_attribute, logger if TYPE_CHECKING: - from typing import Any, Optional, Union + from typing import Any, Callable, Optional, ParamSpec, TypeVar, Union from sentry_sdk._types import Attributes, AttributeValue + P = ParamSpec("P") + R = TypeVar("R") + class SpanStatus(str, Enum): OK = "ok" @@ -342,3 +345,74 @@ def trace_id(self) -> str: @property def sampled(self) -> "Optional[bool]": return False + + +def trace( + func: "Optional[Callable[P, R]]" = None, + *, + name: "Optional[str]" = None, + attributes: "Optional[dict[str, Any]]" = None, + active: bool = True, +) -> "Union[Callable[P, R], Callable[[Callable[P, R]], Callable[P, R]]]": + """ + Decorator to start a span around a function call. + + This decorator automatically creates a new span when the decorated function + is called, and finishes the span when the function returns or raises an exception. + + :param func: The function to trace. When used as a decorator without parentheses, + this is the function being decorated. When used with parameters (e.g., + ``@trace(op="custom")``, this should be None. + :type func: Callable or None + + :param name: The human-readable name/description for the span. If not provided, + defaults to the function name. This provides more specific details about + what the span represents (e.g., "GET /api/users", "process_user_data"). + :type name: str or None + + :param attributes: A dictionary of key-value pairs to add as attributes to the span. + Attribute values must be strings, integers, floats, or booleans. These + attributes provide additional context about the span's execution. + :type attributes: dict[str, Any] or None + + :param active: Controls whether spans started while this span is running + will automatically become its children. That's the default behavior. If + you want to create a span that shouldn't have any children (unless + provided explicitly via the `parent_span` argument), set this to False. + :type active: bool + + :returns: When used as ``@trace``, returns the decorated function. When used as + ``@trace(...)`` with parameters, returns a decorator function. + :rtype: Callable or decorator function + + Example:: + + import sentry_sdk + + # Simple usage with default values + @sentry_sdk.trace + def process_data(): + # Function implementation + pass + + # With custom parameters + @sentry_sdk.trace( + name="Get user data", + attributes={"postgres": True} + ) + def make_db_query(sql): + # Function implementation + pass + """ + from sentry_sdk.tracing_utils import create_streaming_span_decorator + + decorator = create_streaming_span_decorator( + name=name, + attributes=attributes, + active=active, + ) + + if func: + return decorator(func) + else: + return decorator diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index c1d6c44535..8fe31bb33d 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -942,6 +942,58 @@ def sync_wrapper(*args: "Any", **kwargs: "Any") -> "Any": return span_decorator +def create_streaming_span_decorator( + name: "Optional[str]" = None, + attributes: "Optional[dict[str, Any]]" = None, + active: bool = True, +) -> "Any": + """ + Create a span creating decorator that can wrap both sync and async functions. + """ + from sentry_sdk.scope import should_send_default_pii + + def span_decorator(f: "Any") -> "Any": + """ + Decorator to create a span for the given function. + """ + + @functools.wraps(f) + async def async_wrapper(*args: "Any", **kwargs: "Any") -> "Any": + span_name = name or qualname_from_function(f) or "" + + with start_streaming_span( + name=span_name, attributes=attributes, active=active + ): + result = await f(*args, **kwargs) + return result + + try: + async_wrapper.__signature__ = inspect.signature(f) # type: ignore[attr-defined] + except Exception: + pass + + @functools.wraps(f) + def sync_wrapper(*args: "Any", **kwargs: "Any") -> "Any": + span_name = name or qualname_from_function(f) or "" + + with start_streaming_span( + name=span_name, attributes=attributes, active=active + ): + return f(*args, **kwargs) + + try: + sync_wrapper.__signature__ = inspect.signature(f) # type: ignore[attr-defined] + except Exception: + pass + + if inspect.iscoroutinefunction(f): + return async_wrapper + else: + return sync_wrapper + + return span_decorator + + def get_current_span(scope: "Optional[sentry_sdk.Scope]" = None) -> "Optional[Span]": """ Returns the currently active span if there is one running, otherwise `None` @@ -1320,3 +1372,10 @@ def add_sentry_baggage_to_headers( if TYPE_CHECKING: from sentry_sdk.tracing import Span + + +from sentry_sdk.traces import ( + LOW_QUALITY_SEGMENT_SOURCES, + start_span as start_streaming_span, + StreamedSpan, +) From 4b14e8d534e686b5a18f931655128453f0ecc14b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 14:52:21 +0100 Subject: [PATCH 14/83] Remove redundant code --- sentry_sdk/scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 708e27ff45..9970ea975e 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1190,7 +1190,7 @@ def start_streamed_span( ) if parent_span is None or isinstance(parent_span, NoOpStreamedSpan): - parent_span = self.span or self.get_current_scope().span # type: ignore + parent_span = self.span # type: ignore # If no eligible parent_span was provided and there is no currently # active span, this is a segment From 474f8e6e1e2740043acead83d915b5f1f4ef0a82 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 14:56:00 +0100 Subject: [PATCH 15/83] simplify --- sentry_sdk/traces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index f6156e77ac..1739f8b25c 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -141,7 +141,7 @@ def continue_trace(incoming: "dict[str, Any]") -> None: sentry_sdk.get_isolation_scope().generate_propagation_context( incoming, ) - return sentry_sdk.get_current_scope().generate_propagation_context( + sentry_sdk.get_current_scope().generate_propagation_context( incoming, ) From f2738ff53a93069b1eadc0d920befedde9a970b8 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 14:58:52 +0100 Subject: [PATCH 16/83] reorder imports --- sentry_sdk/tracing_utils.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 8fe31bb33d..9c6f811d85 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1369,13 +1369,11 @@ def add_sentry_baggage_to_headers( LOW_QUALITY_TRANSACTION_SOURCES, SENTRY_TRACE_HEADER_NAME, ) - -if TYPE_CHECKING: - from sentry_sdk.tracing import Span - - from sentry_sdk.traces import ( LOW_QUALITY_SEGMENT_SOURCES, start_span as start_streaming_span, StreamedSpan, ) + +if TYPE_CHECKING: + from sentry_sdk.tracing import Span From 7874a549fdf01633b4227eccb7eb9299dad68095 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 15:06:12 +0100 Subject: [PATCH 17/83] ref: Per-bucket limits, fix envelope chunking --- sentry_sdk/_span_batcher.py | 84 +++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index 947eca3806..ef27da1e05 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -15,10 +15,15 @@ class SpanBatcher(Batcher["StreamedSpan"]): - # TODO[span-first]: size-based flushes - # TODO[span-first]: adjust flush/drop defaults + # MAX_BEFORE_FLUSH should be lower than MAX_BEFORE_DROP, so that there is + # a bit of a buffer for spans that appear between setting the flush event + # and actually flushing the buffer. + # + # The max limits are all per trace. + MAX_ENVELOPE_SIZE = 1000 # spans MAX_BEFORE_FLUSH = 1000 - MAX_BEFORE_DROP = 5000 + MAX_BEFORE_DROP = 2000 + MAX_BYTES_BEFORE_FLUSH = 5 * 1024 * 1024 # 5 MB FLUSH_WAIT_TIME = 5.0 TYPE = "span" @@ -35,6 +40,7 @@ def __init__( # envelope. # trace_id -> span buffer self._span_buffer: dict[str, list["StreamedSpan"]] = defaultdict(list) + self._running_size: dict[str, int] = defaultdict(lambda: 0) self._capture_func = capture_func self._record_lost_func = record_lost_func self._running = True @@ -45,16 +51,12 @@ def __init__( self._flusher: "Optional[threading.Thread]" = None self._flusher_pid: "Optional[int]" = None - def get_size(self) -> int: - # caller is responsible for locking before checking this - return sum(len(buffer) for buffer in self._span_buffer.values()) - def add(self, span: "StreamedSpan") -> None: if not self._ensure_thread() or self._flusher is None: return None with self._lock: - size = self.get_size() + size = len(self._span_buffer[span.trace_id]) if size >= self.MAX_BEFORE_DROP: self._record_lost_func( reason="queue_overflow", @@ -64,8 +66,22 @@ def add(self, span: "StreamedSpan") -> None: return None self._span_buffer[span.trace_id].append(span) + self._running_size[span.trace_id] += self._estimate_size(span) + if size + 1 >= self.MAX_BEFORE_FLUSH: self._flush_event.set() + return + + if self._running_size[span.trace_id] >= self.MAX_BYTES_BEFORE_FLUSH: + self._flush_event.set() + return + + @staticmethod + def _estimate_size(item: "StreamedSpan") -> int: + # Rough estimate of serialized span size that's quick to compute. + # 210 is the rough size of the payload without attributes, and we + # estimate additional 70 bytes on top of that per attribute. + return 210 + 70 * len(item._attributes) @staticmethod def _to_transport_format(item: "StreamedSpan") -> "Any": @@ -95,34 +111,40 @@ def _flush(self) -> None: # dsc = spans[0].dynamic_sampling_context() dsc = None - envelope = Envelope( - headers={ - "sent_at": format_timestamp(datetime.now(timezone.utc)), - "trace": dsc, - } - ) - - envelope.add_item( - Item( - type="span", - content_type="application/vnd.sentry.items.span.v2+json", + # Max per envelope is 1000, so if we happen to have more than + # 1000 spans in one bucket, we'll need to separate them. + for start in range(0, len(spans), self.MAX_ENVELOPE_SIZE): + end = min(start + self.MAX_ENVELOPE_SIZE, len(spans)) + + envelope = Envelope( headers={ - "item_count": len(spans), - }, - payload=PayloadRef( - json={ - "items": [ - self._to_transport_format(span) - for span in spans - ] - } - ), + "sent_at": format_timestamp(datetime.now(timezone.utc)), + "trace": dsc, + } + ) + + envelope.add_item( + Item( + type=self.TYPE, + content_type=self.CONTENT_TYPE, + headers={ + "item_count": end - start, + }, + payload=PayloadRef( + json={ + "items": [ + self._to_transport_format(spans[j]) + for j in range(start, end) + ] + } + ), + ) ) - ) - envelopes.append(envelope) + envelopes.append(envelope) self._span_buffer.clear() + self._running_size.clear() for envelope in envelopes: self._capture_func(envelope) From 63a9396a89ddefbc75025a9a2e84c491213c7fdb Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 15:09:11 +0100 Subject: [PATCH 18/83] . --- sentry_sdk/_span_batcher.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index ef27da1e05..baa67fbd9d 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -87,11 +87,15 @@ def _estimate_size(item: "StreamedSpan") -> int: def _to_transport_format(item: "StreamedSpan") -> "Any": # TODO[span-first] res: "dict[str, Any]" = { + "trace_id": item.trace_id, "span_id": item.span_id, "name": item._name, "status": item._status, } + if item._parent_span_id: + res["parent_span_id"] = item._parent_span_id + if item._attributes: res["attributes"] = { k: serialize_attribute(v) for (k, v) in item._attributes.items() @@ -102,7 +106,7 @@ def _to_transport_format(item: "StreamedSpan") -> "Any": def _flush(self) -> None: with self._lock: if len(self._span_buffer) == 0: - return None + return envelopes = [] for trace_id, spans in self._span_buffer.items(): From c974d3edca3a39d33a4cd9a1869a7521026c3237 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 15:15:15 +0100 Subject: [PATCH 19/83] add dummy __enter__, __exit__ --- sentry_sdk/traces.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index bb924563d5..f02954c949 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -231,6 +231,14 @@ def __repr__(self) -> str: f"active={self._active})>" ) + def __enter__(self) -> "StreamedSpan": + return self + + def __exit__( + self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" + ) -> None: + pass + def get_attributes(self) -> "Attributes": return self._attributes @@ -302,6 +310,14 @@ def __init__(self) -> None: def __repr__(self) -> str: return f"<{self.__class__.__name__}(sampled={self.sampled})>" + def __enter__(self) -> "StreamedSpan": + return self + + def __exit__( + self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" + ) -> None: + pass + def get_attributes(self) -> "Attributes": return {} From 831adae305487de271d9113776a08db111174fe3 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 15:20:26 +0100 Subject: [PATCH 20/83] type hint --- sentry_sdk/traces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index f02954c949..bede851e0c 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -310,7 +310,7 @@ def __init__(self) -> None: def __repr__(self) -> str: return f"<{self.__class__.__name__}(sampled={self.sampled})>" - def __enter__(self) -> "StreamedSpan": + def __enter__(self) -> "NoOpStreamedSpan": return self def __exit__( From 1dcf176a90df631b1002eb0b601d5c80861a9962 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 16:22:49 +0100 Subject: [PATCH 21/83] remove unused import --- sentry_sdk/tracing_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 9c6f811d85..80b4628153 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -950,7 +950,6 @@ def create_streaming_span_decorator( """ Create a span creating decorator that can wrap both sync and async functions. """ - from sentry_sdk.scope import should_send_default_pii def span_decorator(f: "Any") -> "Any": """ From 0a7eae805e2466ee3314e8ef7f81f0ae6c96dfce Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 16:38:52 +0100 Subject: [PATCH 22/83] ref: Allow to start and finish StreamedSpans --- sentry_sdk/traces.py | 140 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 135 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index bede851e0c..1f733d8d12 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -6,12 +6,20 @@ """ import uuid +import warnings +from datetime import datetime, timedelta, timezone from enum import Enum from typing import TYPE_CHECKING import sentry_sdk from sentry_sdk.tracing_utils import Baggage -from sentry_sdk.utils import format_attribute, logger +from sentry_sdk.utils import ( + capture_internal_exceptions, + format_attribute, + logger, + nanosecond_time, + should_be_treated_as_error, +) if TYPE_CHECKING: from typing import Any, Callable, Optional, ParamSpec, TypeVar, Union @@ -182,8 +190,13 @@ class StreamedSpan: "_parent_span_id", "_segment", "_parent_sampled", + "_start_timestamp", + "_start_timestamp_monotonic_ns", + "_finished", + "_timestamp", "_status", "_scope", + "_previous_span_on_scope", "_baggage", ) @@ -216,11 +229,24 @@ def __init__( self._parent_sampled = parent_sampled self._baggage = baggage + self._start_timestamp = datetime.now(timezone.utc) + self._timestamp: "Optional[datetime]" = None + self._finished: bool = False + + try: + # profiling depends on this value and requires that + # it is measured in nanoseconds + self._start_timestamp_monotonic_ns = nanosecond_time() + except AttributeError: + pass + self._span_id: "Optional[str]" = None self._status = SpanStatus.OK.value self.set_attribute("sentry.span.source", SegmentSource.CUSTOM.value) + self._start() + def __repr__(self) -> str: return ( f"<{self.__class__.__name__}(" @@ -237,7 +263,77 @@ def __enter__(self) -> "StreamedSpan": def __exit__( self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" ) -> None: - pass + if value is not None and should_be_treated_as_error(ty, value): + self.status = SpanStatus.ERROR + + self._end() + + def end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: + """ + Finish this span and queue it for sending. + + :param end_timestamp: End timestamp to use instead of current time. + :type end_timestamp: "Optional[Union[float, datetime]]" + """ + try: + if end_timestamp and self._timestamp is None: + if isinstance(end_timestamp, float): + end_timestamp = datetime.fromtimestamp(end_timestamp, timezone.utc) + self._timestamp = end_timestamp + except AttributeError: + logger.debug(f"Failed to set end_timestamp: {end_timestamp}") + + self._end() + + def finish(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: + warnings.warn( + "span.finish() is deprecated. Use span.end() instead.", + stacklevel=2, + category=DeprecationWarning, + ) + + self.end(end_timestamp) + + def _start(self) -> None: + if self._active: + old_span = self._scope.span + self._scope.span = self + self._previous_span_on_scope = old_span + + def _end(self) -> None: + if self._finished is True: + # This span is already finished, ignore. + return + + # Detach from scope + if self._active: + with capture_internal_exceptions(): + old_span = self._previous_span_on_scope + del self._previous_span_on_scope + self._scope.span = old_span + + client = sentry_sdk.get_client() + if not client.is_active(): + return + + # Set attributes from the segment + self.set_attribute("sentry.segment.id", self._segment.span_id) + self.set_attribute("sentry.segment.name", self._segment.name) + + # Set the end timestamp if not set yet (e.g. via span.end()) + if self._timestamp is None: + try: + elapsed = nanosecond_time() - self._start_timestamp_monotonic_ns + self._timestamp = self._start_timestamp + timedelta( + microseconds=elapsed / 1000 + ) + except AttributeError: + self._timestamp = datetime.now(timezone.utc) + + self._finished = True + + # Finally, queue the span for sending to Sentry + self._scope._capture_span(self) def get_attributes(self) -> "Attributes": return self._attributes @@ -302,10 +398,27 @@ def trace_id(self) -> str: def sampled(self) -> "Optional[bool]": return True + @property + def start_timestamp(self) -> "Optional[datetime]": + return self._start_timestamp + + @property + def timestamp(self) -> "Optional[datetime]": + return self._timestamp + class NoOpStreamedSpan(StreamedSpan): - def __init__(self) -> None: - pass + __slots__ = ( + "_scope", + "_previous_span_on_scope", + ) + + def __init__( + self, + scope: "Optional[sentry_sdk.Scope]" = None, + ) -> None: + self._scope = scope # type: ignore[assignment] + self._start() def __repr__(self) -> str: return f"<{self.__class__.__name__}(sampled={self.sampled})>" @@ -316,7 +429,24 @@ def __enter__(self) -> "NoOpStreamedSpan": def __exit__( self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" ) -> None: - pass + self._end() + + def _start(self) -> None: + if self._scope is None: + return self + + old_span = self._scope.span + self._scope.span = self + self._previous_span_on_scope = old_span + + def _end(self) -> None: + if self._scope is None: + return + + with capture_internal_exceptions(): + old_span = self._previous_span_on_scope + del self._previous_span_on_scope + self._scope.span = old_span def get_attributes(self) -> "Attributes": return {} From 6888c565ebebccd9e50d4049141790c17875faf0 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 09:37:46 +0100 Subject: [PATCH 23/83] Add end, finish to noop spans --- sentry_sdk/traces.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 1f733d8d12..a34e367959 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -448,6 +448,18 @@ def _end(self) -> None: del self._previous_span_on_scope self._scope.span = old_span + def end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: + self._end() + + def finish(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: + warnings.warn( + "span.finish() is deprecated. Use span.end() instead.", + stacklevel=2, + category=DeprecationWarning, + ) + + self._end() + def get_attributes(self) -> "Attributes": return {} From 09e5cce00810897e25fb5a2b0428931a4e898b6c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 10:13:00 +0100 Subject: [PATCH 24/83] fixes --- sentry_sdk/_span_batcher.py | 4 ++++ sentry_sdk/traces.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index baa67fbd9d..73ab18277a 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -91,8 +91,12 @@ def _to_transport_format(item: "StreamedSpan") -> "Any": "span_id": item.span_id, "name": item._name, "status": item._status, + "start_timestamp": item._start_timestamp.timestamp(), } + if item._timestamp: + res["end_timestamp"] = item._timestamp.timestamp() + if item._parent_span_id: res["parent_span_id"] = item._parent_span_id diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index a34e367959..47895f92e2 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -418,6 +418,7 @@ def __init__( scope: "Optional[sentry_sdk.Scope]" = None, ) -> None: self._scope = scope # type: ignore[assignment] + self._start() def __repr__(self) -> str: @@ -433,7 +434,7 @@ def __exit__( def _start(self) -> None: if self._scope is None: - return self + return old_span = self._scope.span self._scope.span = self From ae2fd52c5c39fe9526d2ecd4c8e15b8a0d7ace79 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 12:08:44 +0100 Subject: [PATCH 25/83] . --- sentry_sdk/traces.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 47895f92e2..16721834f8 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -505,6 +505,14 @@ def trace_id(self) -> str: def sampled(self) -> "Optional[bool]": return False + @property + def start_timestamp(self) -> "Optional[datetime]": + return None + + @property + def timestamp(self) -> "Optional[datetime]": + return None + def trace( func: "Optional[Callable[P, R]]" = None, From f2235745e492a8429ed4f88cb53cdb2054aef73e Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 13:50:28 +0100 Subject: [PATCH 26/83] Correctly detect user-set parent_span=None --- sentry_sdk/scope.py | 12 +++++++----- sentry_sdk/traces.py | 13 ++++++++++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 9970ea975e..caaac114bf 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -33,7 +33,7 @@ normalize_incoming_data, PropagationContext, ) -from sentry_sdk.traces import StreamedSpan, NoOpStreamedSpan +from sentry_sdk.traces import _DEFAULT_PARENT_SPAN, StreamedSpan, NoOpStreamedSpan from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, @@ -1177,9 +1177,9 @@ def start_span( def start_streamed_span( self, name: str, - attributes: "Optional[Attributes]" = None, - parent_span: "Optional[StreamedSpan]" = None, - active: bool = True, + attributes: "Optional[Attributes]", + parent_span: "Optional[StreamedSpan]", + active: bool, ) -> "StreamedSpan": # TODO: rename to start_span once we drop the old API if isinstance(parent_span, NoOpStreamedSpan): @@ -1189,7 +1189,9 @@ def start_streamed_span( "currently active span instead." ) - if parent_span is None or isinstance(parent_span, NoOpStreamedSpan): + if parent_span is _DEFAULT_PARENT_SPAN or isinstance( + parent_span, NoOpStreamedSpan + ): parent_span = self.span # type: ignore # If no eligible parent_span was provided and there is no currently diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index aa54296d97..615d5d8f2a 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -59,10 +59,15 @@ def __str__(self) -> str: } +# Sentinel value for an unset parent_span to be able to distinguish it from +# a None set by the user +_DEFAULT_PARENT_SPAN = object() + + def start_span( name: str, attributes: "Optional[Attributes]" = None, - parent_span: "Optional[StreamedSpan]" = None, + parent_span: "Optional[StreamedSpan]" = _DEFAULT_PARENT_SPAN, active: bool = True, ) -> "StreamedSpan": """ @@ -70,7 +75,8 @@ def start_span( The span's parent, unless provided explicitly via the `parent_span` argument, will be the current active span, if any. If there is none, this span will - become the root of a new span tree. + become the root of a new span tree. If you explicitly want this span to be + top-level without a parent, set `parent_span=None`. `start_span()` can either be used as context manager or you can use the span object it returns and explicitly end it via `span.end()`. The following is @@ -102,7 +108,8 @@ def start_span( :param parent_span: A span instance that the new span should consider its parent. If not provided, the parent will be set to the currently active - span, if any. + span, if any. If set to `None`, this span will become a new root-level + span. :type parent_span: "Optional[StreamedSpan]" :param active: Controls whether spans started while this span is running From 9e8e60ef87bbd8a2167bbd3310c17b92b84a94e9 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 13:54:47 +0100 Subject: [PATCH 27/83] mypy --- sentry_sdk/traces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 615d5d8f2a..0dcb003581 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -67,7 +67,7 @@ def __str__(self) -> str: def start_span( name: str, attributes: "Optional[Attributes]" = None, - parent_span: "Optional[StreamedSpan]" = _DEFAULT_PARENT_SPAN, + parent_span: "Optional[StreamedSpan]" = _DEFAULT_PARENT_SPAN, # type: ignore[assignment] active: bool = True, ) -> "StreamedSpan": """ From 1006e7b30294e54445a4bc9b996cad895b1f009b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 14:02:47 +0100 Subject: [PATCH 28/83] remove unused imports --- sentry_sdk/tracing_utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 80b4628153..54c3dcc6f5 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1369,9 +1369,7 @@ def add_sentry_baggage_to_headers( SENTRY_TRACE_HEADER_NAME, ) from sentry_sdk.traces import ( - LOW_QUALITY_SEGMENT_SOURCES, start_span as start_streaming_span, - StreamedSpan, ) if TYPE_CHECKING: From ad6e7cc09ccc059ee933f3b97d758d5d8ac9394c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 14:11:31 +0100 Subject: [PATCH 29/83] move where finished is set --- sentry_sdk/traces.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 3359f1f7b6..c8d419761e 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -319,10 +319,6 @@ def _end(self) -> None: del self._previous_span_on_scope self._scope.span = old_span - client = sentry_sdk.get_client() - if not client.is_active(): - return - # Set attributes from the segment self.set_attribute("sentry.segment.id", self._segment.span_id) self.set_attribute("sentry.segment.name", self._segment.name) @@ -339,6 +335,10 @@ def _end(self) -> None: self._finished = True + client = sentry_sdk.get_client() + if not client.is_active(): + return + # Finally, queue the span for sending to Sentry self._scope._capture_span(self) From ba29f0c14ca28e057bbdd143e281fe793eca0539 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 14:17:55 +0100 Subject: [PATCH 30/83] remove finished --- sentry_sdk/traces.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index c8d419761e..26d9468d14 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -199,7 +199,6 @@ class StreamedSpan: "_parent_sampled", "_start_timestamp", "_start_timestamp_monotonic_ns", - "_finished", "_timestamp", "_status", "_scope", @@ -238,7 +237,6 @@ def __init__( self._start_timestamp = datetime.now(timezone.utc) self._timestamp: "Optional[datetime]" = None - self._finished: bool = False try: # profiling depends on this value and requires that @@ -282,15 +280,7 @@ def end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: :param end_timestamp: End timestamp to use instead of current time. :type end_timestamp: "Optional[Union[float, datetime]]" """ - try: - if end_timestamp and self._timestamp is None: - if isinstance(end_timestamp, float): - end_timestamp = datetime.fromtimestamp(end_timestamp, timezone.utc) - self._timestamp = end_timestamp - except AttributeError: - logger.debug(f"Failed to set end_timestamp: {end_timestamp}") - - self._end() + self._end(end_timestamp) def finish(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: warnings.warn( @@ -307,8 +297,8 @@ def _start(self) -> None: self._scope.span = self self._previous_span_on_scope = old_span - def _end(self) -> None: - if self._finished is True: + def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: + if self._timestamp is not None: # This span is already finished, ignore. return @@ -323,7 +313,15 @@ def _end(self) -> None: self.set_attribute("sentry.segment.id", self._segment.span_id) self.set_attribute("sentry.segment.name", self._segment.name) - # Set the end timestamp if not set yet (e.g. via span.end()) + # Set the end timestamp + if end_timestamp is not None: + try: + if isinstance(end_timestamp, float): + end_timestamp = datetime.fromtimestamp(end_timestamp, timezone.utc) + self._timestamp = end_timestamp + except Exception: + logger.debug(f"Failed to set end_timestamp: {end_timestamp}") + if self._timestamp is None: try: elapsed = nanosecond_time() - self._start_timestamp_monotonic_ns @@ -333,8 +331,6 @@ def _end(self) -> None: except AttributeError: self._timestamp = datetime.now(timezone.utc) - self._finished = True - client = sentry_sdk.get_client() if not client.is_active(): return From d6a42b2d9182970f557357ded32aa295a10c0313 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 14:21:52 +0100 Subject: [PATCH 31/83] end_timestamp improvements --- sentry_sdk/traces.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 26d9468d14..07c4610c83 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -315,12 +315,20 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None # Set the end timestamp if end_timestamp is not None: - try: - if isinstance(end_timestamp, float): + if isinstance(end_timestamp, (float, int)): + try: end_timestamp = datetime.fromtimestamp(end_timestamp, timezone.utc) - self._timestamp = end_timestamp - except Exception: - logger.debug(f"Failed to set end_timestamp: {end_timestamp}") + except Exception: + logger.debug( + "Failed to set end_timestamp. Using current time instead." + ) + + if isinstance(end_timestamp, datetime): + self._timestamp = end_timestamp + else: + logger.debug( + "Failed to set end_timestamp. Using current time instead." + ) if self._timestamp is None: try: From 5e20ad36c3a33ac7505b3d88e97b6ac2004a187c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 14:26:03 +0100 Subject: [PATCH 32/83] . --- sentry_sdk/traces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 07c4610c83..51cfd7081b 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -451,7 +451,7 @@ def _start(self) -> None: self._scope.span = self self._previous_span_on_scope = old_span - def _end(self) -> None: + def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: if self._scope is None: return From c70fae4a3f8af904195c232dfde71e3b673c6ac5 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 14:41:47 +0100 Subject: [PATCH 33/83] fix --- sentry_sdk/traces.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 51cfd7081b..382b105d00 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -323,12 +323,10 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None "Failed to set end_timestamp. Using current time instead." ) - if isinstance(end_timestamp, datetime): - self._timestamp = end_timestamp - else: - logger.debug( - "Failed to set end_timestamp. Using current time instead." - ) + if isinstance(end_timestamp, datetime): + self._timestamp = end_timestamp + else: + logger.debug("Failed to set end_timestamp. Using current time instead.") if self._timestamp is None: try: From b995770f185aa1045acf406c5a8577aff9e2a27c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 14:42:06 +0100 Subject: [PATCH 34/83] simplify --- sentry_sdk/traces.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 382b105d00..876b6f6e0b 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -319,9 +319,7 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None try: end_timestamp = datetime.fromtimestamp(end_timestamp, timezone.utc) except Exception: - logger.debug( - "Failed to set end_timestamp. Using current time instead." - ) + pass if isinstance(end_timestamp, datetime): self._timestamp = end_timestamp From 60217e170af2396b6a0d1c61b281aba1679ba91f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 10:07:40 +0100 Subject: [PATCH 35/83] ref: Add warnings to span streaming APIs --- sentry_sdk/scope.py | 9 +++++++++ sentry_sdk/traces.py | 24 +++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index caaac114bf..500be31c79 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1182,6 +1182,15 @@ def start_streamed_span( active: bool, ) -> "StreamedSpan": # TODO: rename to start_span once we drop the old API + if not has_span_streaming_enabled(self.client): + warnings.warn( + "Using span streaming API in non-span-streaming mode. Use " + "sentry_sdk.start_transaction() and sentry_sdk.start_span() " + "instead.", + stacklevel=2, + ) + return NoOpStreamedSpan() + if isinstance(parent_span, NoOpStreamedSpan): # parent_span is only set if the user explicitly set it logger.debug( diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 876b6f6e0b..6b2641d452 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -1,4 +1,6 @@ """ +EXPERIMENTAL. Do not use in production. + The API in this file is only meant to be used in span streaming mode. You can enable span streaming mode via @@ -84,6 +86,9 @@ def start_span( """ Start a span. + EXPERIMENTAL. Use sentry_sdk.start_transaction() and sentry_sdk.start_span() + instead. + The span's parent, unless provided explicitly via the `parent_span` argument, will be the current active span, if any. If there is none, this span will become the root of a new span tree. If you explicitly want this span to be @@ -144,6 +149,8 @@ def continue_trace(incoming: "dict[str, Any]") -> None: """ Continue a trace from headers or environment variables. + EXPERIMENTAL. Use sentry_sdk.continue_trace() instead. + This function sets the propagation context on the scope. Any span started in the updated scope will belong under the trace extracted from the provided propagation headers or environment variables. @@ -168,6 +175,8 @@ def new_trace() -> None: """ Resets the propagation context, forcing a new trace. + EXPERIMENTAL. + This function sets the propagation context on the scope. Any span started in the updated scope will start its own trace. @@ -531,6 +540,8 @@ def trace( """ Decorator to start a span around a function call. + EXPERIMENTAL. Use @sentry_sdk.trace instead. + This decorator automatically creates a new span when the decorated function is called, and finishes the span when the function returns or raises an exception. @@ -578,7 +589,18 @@ def make_db_query(sql): # Function implementation pass """ - from sentry_sdk.tracing_utils import create_streaming_span_decorator + from sentry_sdk.tracing_utils import ( + create_streaming_span_decorator, + has_span_streaming_enabled, + ) + + client = sentry_sdk.get_client() + if not has_span_streaming_enabled(client): + warnings.warn( + "Using span streaming API in non-span-streaming mode. Use " + "@sentry_sdk.trace instead.", + stacklevel=2, + ) decorator = create_streaming_span_decorator( name=name, From d6fa965ddf6e3dec05d3d672a7db2120b4e8151c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 10:41:39 +0100 Subject: [PATCH 36/83] . --- sentry_sdk/traces.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 876b6f6e0b..d24dfb4c3f 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -294,7 +294,7 @@ def finish(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> No def _start(self) -> None: if self._active: old_span = self._scope.span - self._scope.span = self + self._scope.span = self # type: ignore self._previous_span_on_scope = old_span def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: @@ -444,7 +444,7 @@ def _start(self) -> None: return old_span = self._scope.span - self._scope.span = self + self._scope.span = self # type: ignore self._previous_span_on_scope = old_span def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: From 3602f86ecac2e7733fb7cd353a060dba3370926a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 10:42:50 +0100 Subject: [PATCH 37/83] . --- sentry_sdk/scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 500be31c79..8d5f576151 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1182,7 +1182,7 @@ def start_streamed_span( active: bool, ) -> "StreamedSpan": # TODO: rename to start_span once we drop the old API - if not has_span_streaming_enabled(self.client): + if not has_span_streaming_enabled(self.client.options): warnings.warn( "Using span streaming API in non-span-streaming mode. Use " "sentry_sdk.start_transaction() and sentry_sdk.start_span() " From 9f59eb08ada310fe0e1c4d6b9dab675ec94be4a3 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 10:44:17 +0100 Subject: [PATCH 38/83] fix --- sentry_sdk/traces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 6b2641d452..ebb1eb462f 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -595,7 +595,7 @@ def make_db_query(sql): ) client = sentry_sdk.get_client() - if not has_span_streaming_enabled(client): + if not has_span_streaming_enabled(client.options): warnings.warn( "Using span streaming API in non-span-streaming mode. Use " "@sentry_sdk.trace instead.", From 72f0968ee574df34a78877078d3e1eae62debff3 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 11:02:55 +0100 Subject: [PATCH 39/83] move --- sentry_sdk/traces.py | 9 --------- sentry_sdk/tracing_utils.py | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index f806a2a288..4813867f13 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -591,17 +591,8 @@ def make_db_query(sql): """ from sentry_sdk.tracing_utils import ( create_streaming_span_decorator, - has_span_streaming_enabled, ) - client = sentry_sdk.get_client() - if not has_span_streaming_enabled(client.options): - warnings.warn( - "Using span streaming API in non-span-streaming mode. Use " - "@sentry_sdk.trace instead.", - stacklevel=2, - ) - decorator = create_streaming_span_decorator( name=name, attributes=attributes, diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 54c3dcc6f5..3dd6ecef91 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -4,6 +4,7 @@ import os import re import sys +import warnings from collections.abc import Mapping, MutableMapping from datetime import timedelta from random import Random @@ -958,6 +959,14 @@ def span_decorator(f: "Any") -> "Any": @functools.wraps(f) async def async_wrapper(*args: "Any", **kwargs: "Any") -> "Any": + client = sentry_sdk.get_client() + if not has_span_streaming_enabled(client.options): + warnings.warn( + "Using span streaming API in non-span-streaming mode. Use " + "@sentry_sdk.trace instead.", + stacklevel=2, + ) + span_name = name or qualname_from_function(f) or "" with start_streaming_span( @@ -973,6 +982,14 @@ async def async_wrapper(*args: "Any", **kwargs: "Any") -> "Any": @functools.wraps(f) def sync_wrapper(*args: "Any", **kwargs: "Any") -> "Any": + client = sentry_sdk.get_client() + if not has_span_streaming_enabled(client.options): + warnings.warn( + "Using span streaming API in non-span-streaming mode. Use " + "@sentry_sdk.trace instead.", + stacklevel=2, + ) + span_name = name or qualname_from_function(f) or "" with start_streaming_span( From dab1970a511362560c1a7e6e4487ff19173a68f6 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 11:16:47 +0100 Subject: [PATCH 40/83] add a guard --- sentry_sdk/traces.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index d24dfb4c3f..84cc31c663 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -451,6 +451,9 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None if self._scope is None: return + if not hasattr(self._previous_span_on_scope): + return + with capture_internal_exceptions(): old_span = self._previous_span_on_scope del self._previous_span_on_scope From 7daa720a4a767a01fca31a156f50be51767fe76a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 11:17:59 +0100 Subject: [PATCH 41/83] . --- sentry_sdk/scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 8d5f576151..da33187a25 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1182,7 +1182,7 @@ def start_streamed_span( active: bool, ) -> "StreamedSpan": # TODO: rename to start_span once we drop the old API - if not has_span_streaming_enabled(self.client.options): + if not has_span_streaming_enabled(sentry_sdk.get_client().options): warnings.warn( "Using span streaming API in non-span-streaming mode. Use " "sentry_sdk.start_transaction() and sentry_sdk.start_span() " From b59f3cd10293e4261be87ba15480b52b073b062f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 11:19:17 +0100 Subject: [PATCH 42/83] move warnings --- sentry_sdk/scope.py | 9 --------- sentry_sdk/traces.py | 11 +++++++++++ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index da33187a25..caaac114bf 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1182,15 +1182,6 @@ def start_streamed_span( active: bool, ) -> "StreamedSpan": # TODO: rename to start_span once we drop the old API - if not has_span_streaming_enabled(sentry_sdk.get_client().options): - warnings.warn( - "Using span streaming API in non-span-streaming mode. Use " - "sentry_sdk.start_transaction() and sentry_sdk.start_span() " - "instead.", - stacklevel=2, - ) - return NoOpStreamedSpan() - if isinstance(parent_span, NoOpStreamedSpan): # parent_span is only set if the user explicitly set it logger.debug( diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 4813867f13..6a4c8c85bb 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -140,6 +140,17 @@ def start_span( :return: The span that has been started. :rtype: StreamedSpan """ + from sentry_sdk.tracing_utils import has_span_streaming_enabled + + if not has_span_streaming_enabled(sentry_sdk.get_client().options): + warnings.warn( + "Using span streaming API in non-span-streaming mode. Use " + "sentry_sdk.start_transaction() and sentry_sdk.start_span() " + "instead.", + stacklevel=2, + ) + return NoOpStreamedSpan() + return sentry_sdk.get_current_scope().start_streamed_span( name, attributes, parent_span, active ) From 2f0dc01735381357a95b5ebaa098d1af0dbf5cd0 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 11:26:25 +0100 Subject: [PATCH 43/83] . --- sentry_sdk/traces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 84cc31c663..c35d270f13 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -451,7 +451,7 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None if self._scope is None: return - if not hasattr(self._previous_span_on_scope): + if not hasattr(self, "_previous_span_on_scope"): return with capture_internal_exceptions(): From c5fcb3ea418f95a50ca13b2c277c8da6d8e977a9 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 14:19:27 +0100 Subject: [PATCH 44/83] ref: Add sampling to span first --- sentry_sdk/scope.py | 26 ++++++++++- sentry_sdk/traces.py | 15 +++++-- sentry_sdk/tracing_utils.py | 90 +++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index caaac114bf..d0018d7126 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -30,6 +30,7 @@ Baggage, has_tracing_enabled, has_span_streaming_enabled, + make_sampling_decision, normalize_incoming_data, PropagationContext, ) @@ -1199,6 +1200,20 @@ def start_streamed_span( if parent_span is None: propagation_context = self.get_active_propagation_context() + sampled, sample_rate, sample_rand, outcome = make_sampling_decision( + name, + attributes, + self, + ) + if sampled is False: + return NoOpStreamedSpan( + scope=self, + unsampled_reason=outcome, + ) + + if sample_rate is not None: + self._update_sample_rate(sample_rate) + return StreamedSpan( name=name, attributes=attributes, @@ -1214,7 +1229,7 @@ def start_streamed_span( # This is a child span; take propagation context from the parent span with new_scope(): if isinstance(parent_span, NoOpStreamedSpan): - return NoOpStreamedSpan() + return NoOpStreamedSpan(unsampled_reason=parent_span._unsampled_reason) return StreamedSpan( name=name, @@ -1227,6 +1242,15 @@ def start_streamed_span( parent_sampled=parent_span.sampled, ) + def _update_sample_rate(self, sample_rate: float) -> None: + # If we had to adjust the sample rate when setting the sampling decision + # for a span, it needs to be updated in the propagation context too + propagation_context = self.get_active_propagation_context() + baggage = propagation_context.baggage + + if baggage is not None: + baggage.sentry_items["sample_rate"] = str(sample_rate) + def continue_trace( self, environ_or_headers: "Dict[str, Any]", diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 2f6a3e77cd..d4a6807378 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -438,13 +438,16 @@ class NoOpStreamedSpan(StreamedSpan): __slots__ = ( "_scope", "_previous_span_on_scope", + "_unsampled_reason", ) def __init__( self, + unsampled_reason: "Optional[str]" = None, scope: "Optional[sentry_sdk.Scope]" = None, ) -> None: self._scope = scope # type: ignore[assignment] + self._unsampled_reason = unsampled_reason self._start() @@ -468,10 +471,16 @@ def _start(self) -> None: self._previous_span_on_scope = old_span def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: - if self._scope is None: - return + client = sentry_sdk.get_client() + if client.is_active() and client.transport: + logger.debug("Discarding span because sampled = False") + client.transport.record_lost_event( + reason=self._unsampled_reason or "sample_rate", + data_category="span", + quantity=1, + ) - if not hasattr(self, "_previous_span_on_scope"): + if self._scope is None or not hasattr(self, "_previous_span_on_scope"): return with capture_internal_exceptions(): diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 3dd6ecef91..6c4ee1f504 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -23,6 +23,7 @@ to_string, try_convert, is_sentry_url, + is_valid_sample_rate, _is_external_source, _is_in_project_root, _module_in_list, @@ -41,6 +42,8 @@ from types import FrameType + from sentry_sdk._types import Attributes + SENTRY_TRACE_REGEX = re.compile( "^[ \t]*" # whitespace @@ -1379,6 +1382,93 @@ def add_sentry_baggage_to_headers( ) +def make_sampling_decision( + name: str, + attributes: "Optional[Attributes]", + scope: "sentry_sdk.Scope", +) -> "tuple[bool, Optional[float], Optional[float], Optional[str]]": + """ + Decide whether a span should be sampled. + + Returns a tuple with: + 1. the sampling decision + 2. the effective sample rate + 3. the sample rand + 4. the reason for not sampling the span, if unsampled + """ + client = sentry_sdk.get_client() + + if not has_tracing_enabled(client.options): + return False, None, None, None + + propagation_context = scope.get_active_propagation_context() + + sample_rand = None + if propagation_context.baggage is not None: + sample_rand = propagation_context.baggage._sample_rand() + if sample_rand is None: + sample_rand = _generate_sample_rand(propagation_context.trace_id) + + sampling_context = { + "name": name, + "trace_id": propagation_context.trace_id, + "parent_span_id": propagation_context.parent_span_id, + "parent_sampled": propagation_context.parent_sampled, + "attributes": attributes or {}, + } + + # If there's a traces_sampler, use that; otherwise use traces_sample_rate + traces_sampler_defined = callable(client.options.get("traces_sampler")) + if traces_sampler_defined: + sample_rate = client.options["traces_sampler"](sampling_context) + else: + if sampling_context["parent_sampled"] is not None: + sample_rate = sampling_context["parent_sampled"] + else: + sample_rate = client.options["traces_sample_rate"] + + # Validate whether the sample_rate we got is actually valid. Since + # traces_sampler is user-provided, it could return anything. + if not is_valid_sample_rate(sample_rate, source="Tracing"): + logger.warning(f"[Tracing] Discarding {name} because of invalid sample rate.") + return False, None, None, None + + sample_rate = float(sample_rate) + + # Adjust sample rate if we're under backpressure + if client.monitor: + sample_rate /= 2**client.monitor.downsample_factor + + outcome: "Optional[str]" = None + + if not sample_rate: + if traces_sampler_defined: + reason = "traces_sampler returned 0 or False" + else: + reason = "traces_sample_rate is set to 0" + + logger.debug(f"[Tracing] Discarding {name} because {reason}") + if client.monitor and client.monitor.downsample_factor > 0: + outcome = "backpressure" + else: + outcome = "sample_rate" + + return False, 0.0, None, outcome + + sampled = sample_rand < sample_rate + + if sampled: + logger.debug(f"[Tracing] Starting {name}") + outcome = None + else: + logger.debug( + f"[Tracing] Discarding {name} because it's not included in the random sample (sampling rate = {sample_rate})" + ) + outcome = "sample_rate" + + return sampled, sample_rate, sample_rand, outcome + + # Circular imports from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, From 51342fbe2cdaffb16d0b9b11d64a81b491d9042c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 14:30:47 +0100 Subject: [PATCH 45/83] add sample_rate, sample_rate to spans --- sentry_sdk/scope.py | 2 ++ sentry_sdk/traces.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index d0018d7126..2ea8579aa5 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1224,6 +1224,8 @@ def start_streamed_span( parent_span_id=propagation_context.parent_span_id, parent_sampled=propagation_context.parent_sampled, baggage=propagation_context.baggage, + sample_rand=sample_rand, + sample_rate=sample_rate, ) # This is a child span; take propagation context from the parent span diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index d4a6807378..f88b86801e 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -224,6 +224,8 @@ class StreamedSpan: "_scope", "_previous_span_on_scope", "_baggage", + "_sample_rand", + "_sample_rate", ) def __init__( @@ -238,6 +240,8 @@ def __init__( parent_span_id: "Optional[str]" = None, parent_sampled: "Optional[bool]" = None, baggage: "Optional[Baggage]" = None, + sample_rate: "Optional[float]" = None, + sample_rand: "Optional[float]" = None, ): self._name: str = name self._active: bool = active @@ -254,6 +258,8 @@ def __init__( self._parent_span_id = parent_span_id self._parent_sampled = parent_sampled self._baggage = baggage + self._sample_rand = sample_rand + self._sample_rate = sample_rate self._start_timestamp = datetime.now(timezone.utc) self._timestamp: "Optional[datetime]" = None From 336b6434da922fcc09e7828f64bda72286e776e1 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 14:37:25 +0100 Subject: [PATCH 46/83] order --- sentry_sdk/scope.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 2ea8579aa5..3186eed15d 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1205,15 +1205,16 @@ def start_streamed_span( attributes, self, ) + + if sample_rate is not None: + self._update_sample_rate(sample_rate) + if sampled is False: return NoOpStreamedSpan( scope=self, unsampled_reason=outcome, ) - if sample_rate is not None: - self._update_sample_rate(sample_rate) - return StreamedSpan( name=name, attributes=attributes, From aba1b5065a78777523983ae1c5939268e51e10fa Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 14:39:20 +0100 Subject: [PATCH 47/83] make private --- sentry_sdk/scope.py | 4 ++-- sentry_sdk/tracing_utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 3186eed15d..454a82db85 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -30,7 +30,7 @@ Baggage, has_tracing_enabled, has_span_streaming_enabled, - make_sampling_decision, + _make_sampling_decision, normalize_incoming_data, PropagationContext, ) @@ -1200,7 +1200,7 @@ def start_streamed_span( if parent_span is None: propagation_context = self.get_active_propagation_context() - sampled, sample_rate, sample_rand, outcome = make_sampling_decision( + sampled, sample_rate, sample_rand, outcome = _make_sampling_decision( name, attributes, self, diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 6c4ee1f504..f688fc7b1d 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1382,7 +1382,7 @@ def add_sentry_baggage_to_headers( ) -def make_sampling_decision( +def _make_sampling_decision( name: str, attributes: "Optional[Attributes]", scope: "sentry_sdk.Scope", From 1e22ed0192227ca691a30c2cd11b5edfa2c4da4b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 14:58:43 +0100 Subject: [PATCH 48/83] ref: Add _is_segment to streaming spans --- sentry_sdk/_span_batcher.py | 1 + sentry_sdk/traces.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index 73ab18277a..df3598b282 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -91,6 +91,7 @@ def _to_transport_format(item: "StreamedSpan") -> "Any": "span_id": item.span_id, "name": item._name, "status": item._status, + "is_segment": item._is_segment(), "start_timestamp": item._start_timestamp.timestamp(), } diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index f88b86801e..4f5f182ecd 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -439,6 +439,9 @@ def start_timestamp(self) -> "Optional[datetime]": def timestamp(self) -> "Optional[datetime]": return self._timestamp + def _is_segment(self) -> bool: + return self._segment == self + class NoOpStreamedSpan(StreamedSpan): __slots__ = ( @@ -518,6 +521,9 @@ def set_attributes(self, attributes: "Attributes") -> None: def remove_attribute(self, key: str) -> None: pass + def _is_segment(self) -> bool: + return True if self._scope is not None else False + @property def status(self) -> "str": return SpanStatus.OK.value From 8a77d5b0e4a5a1a488b1668e2afb4decb279c545 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 15:03:01 +0100 Subject: [PATCH 49/83] tests: Add span streaming tests --- tests/tracing/test_span_streaming.py | 874 +++++++++++++++++++++++++++ 1 file changed, 874 insertions(+) create mode 100644 tests/tracing/test_span_streaming.py diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py new file mode 100644 index 0000000000..423014be93 --- /dev/null +++ b/tests/tracing/test_span_streaming.py @@ -0,0 +1,874 @@ +import asyncio +import re +import sys +from typing import Any +from unittest import mock + +import pytest + +import sentry_sdk +from sentry_sdk.traces import SegmentSource, SpanStatus, StreamedSpan, NoOpStreamedSpan + +minimum_python_38 = pytest.mark.skipif( + sys.version_info < (3, 8), reason="Asyncio tests need Python >= 3.8" +) + + +def envelopes_to_spans(envelopes): + res: "list[dict[str, Any]]" = [] + for envelope in envelopes: + for item in envelope.items: + if item.type == "span": + for span_json in item.payload.json["items"]: + span = { + "start_timestamp": span_json["start_timestamp"], + "end_timestamp": span_json.get("end_timestamp"), + "trace_id": span_json["trace_id"], + "span_id": span_json["span_id"], + "name": span_json["name"], + "status": span_json["status"], + "is_segment": span_json["is_segment"], + "parent_span_id": span_json.get("parent_span_id"), + "attributes": { + k: v["value"] for (k, v) in span_json["attributes"].items() + }, + } + res.append(span) + return res + + +def test_start_span(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span(name="segment") as segment: + assert segment._is_segment() is True + with sentry_sdk.traces.start_span(name="child") as child: + assert child._is_segment() is False + assert child._segment == segment + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 2 + child, segment = spans + + assert segment["name"] == "segment" + assert segment["attributes"]["sentry.segment.name"] == "segment" + assert child["name"] == "child" + assert child["attributes"]["sentry.segment.name"] == "segment" + + assert segment["is_segment"] is True + assert segment["parent_span_id"] is None + assert child["is_segment"] is False + assert child["parent_span_id"] == segment["span_id"] + assert child["trace_id"] == segment["trace_id"] + + assert segment["start_timestamp"] is not None + assert child["start_timestamp"] is not None + assert segment["end_timestamp"] is not None + assert child["end_timestamp"] is not None + + assert child["status"] == "ok" + assert segment["status"] == "ok" + + +def test_start_span_no_context_manager(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + segment = sentry_sdk.traces.start_span(name="segment") + child = sentry_sdk.traces.start_span(name="child") + assert child._segment == segment + child.end() + segment.end() + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 2 + child, segment = spans + + assert segment["name"] == "segment" + assert segment["attributes"]["sentry.segment.name"] == "segment" + assert child["name"] == "child" + assert child["attributes"]["sentry.segment.name"] == "segment" + + assert segment["is_segment"] is True + assert child["is_segment"] is False + assert child["parent_span_id"] == segment["span_id"] + assert child["trace_id"] == segment["trace_id"] + + assert segment["start_timestamp"] is not None + assert child["start_timestamp"] is not None + assert segment["end_timestamp"] is not None + assert child["end_timestamp"] is not None + + assert child["status"] == "ok" + assert segment["status"] == "ok" + + +def test_span_sampled_when_created(sentry_init, capture_envelopes): + # Test that if a span is created without the context manager, it is sampled + # at start_span() time + + def traces_sampler(sampling_context): + assert "delayed_attribute" not in sampling_context["attributes"] + return 1.0 + + sentry_init( + traces_sampler=traces_sampler, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + segment = sentry_sdk.traces.start_span(name="segment") + segment.set_attribute("delayed_attribute", 12) + segment.end() + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (segment,) = spans + + assert segment["name"] == "segment" + assert segment["attributes"]["delayed_attribute"] == 12 + + +def test_start_span_attributes(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span( + name="segment", attributes={"my_attribute": "my_value"} + ): + ... + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (span,) = spans + + assert span["name"] == "segment" + assert span["attributes"]["my_attribute"] == "my_value" + + +def test_start_span_attributes_in_traces_sampler(sentry_init, capture_envelopes): + def traces_sampler(sampling_context): + assert "attributes" in sampling_context + assert "my_attribute" in sampling_context["attributes"] + assert sampling_context["attributes"]["my_attribute"] == "my_value" + return 1.0 + + sentry_init( + traces_sampler=traces_sampler, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span( + name="segment", attributes={"my_attribute": "my_value"} + ): + ... + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (span,) = spans + + assert span["name"] == "segment" + assert span["attributes"]["my_attribute"] == "my_value" + + +def test_sampling_context(sentry_init, capture_envelopes): + received_trace_id = None + + def traces_sampler(sampling_context): + nonlocal received_trace_id + + assert "trace_id" in sampling_context + received_trace_id = sampling_context["trace_id"] + + assert "parent_span_id" in sampling_context + assert sampling_context["parent_span_id"] is None + + assert "parent_sampled" in sampling_context + assert sampling_context["parent_sampled"] is None + + assert "attributes" in sampling_context + + return 1.0 + + sentry_init( + traces_sampler=traces_sampler, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span(name="span") as span: + trace_id = span._trace_id + + assert received_trace_id == trace_id + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + + +def test_span_attributes(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span( + name="segment", attributes={"attribute1": "value"} + ) as span: + assert span.get_attributes()["attribute1"] == "value" + span.set_attribute("attribute2", 47) + span.remove_attribute("attribute1") + span.set_attributes({"attribute3": 4.5, "attribute4": False}) + assert "attribute1" not in span.get_attributes() + attributes = span.get_attributes() + assert attributes["attribute2"] == 47 + assert attributes["attribute3"] == 4.5 + assert attributes["attribute4"] is False + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (span,) = spans + + assert span["name"] == "segment" + assert "attribute1" not in span["attributes"] + assert span["attributes"]["attribute2"] == 47 + assert span["attributes"]["attribute3"] == 4.5 + assert span["attributes"]["attribute4"] is False + + +def test_span_attributes_serialize_early(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + class Class: + pass + + with sentry_sdk.traces.start_span(name="span") as span: + span.set_attributes( + { + # arrays of different types will be serialized + "attribute1": [123, "text"], + # so will custom class instances + "attribute2": Class(), + } + ) + attributes = span.get_attributes() + assert isinstance(attributes["attribute1"], str) + assert attributes["attribute1"] == "[123, 'text']" + assert isinstance(attributes["attribute2"], str) + assert "Class" in attributes["attribute2"] + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (span,) = spans + + assert span["attributes"]["attribute1"] == "[123, 'text']" + assert "Class" in span["attributes"]["attribute2"] + + +def test_traces_sampler_drops_span(sentry_init, capture_envelopes): + def traces_sampler(sampling_context): + assert "attributes" in sampling_context + assert "drop" in sampling_context["attributes"] + + if sampling_context["attributes"]["drop"] is True: + return 0.0 + + return 1.0 + + sentry_init( + traces_sampler=traces_sampler, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + 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 = envelopes_to_spans(events) + + assert len(spans) == 1 + (span,) = spans + + assert span["name"] == "retained" + assert span["attributes"]["drop"] is False + + +def test_traces_sampler_called_once_per_segment(sentry_init): + traces_sampler_called = 0 + span_name_in_traces_sampler = None + + def traces_sampler(sampling_context): + nonlocal traces_sampler_called, span_name_in_traces_sampler + traces_sampler_called += 1 + span_name_in_traces_sampler = sampling_context["name"] + return 1.0 + + sentry_init( + traces_sampler=traces_sampler, + _experiments={"trace_lifecycle": "stream"}, + ) + + with sentry_sdk.traces.start_span(name="segment") as segment: + with sentry_sdk.traces.start_span(name="child1"): + ... + with sentry_sdk.traces.start_span(name="child2"): + with sentry_sdk.traces.start_span(name="child3"): + ... + + assert traces_sampler_called == 1 + assert span_name_in_traces_sampler == segment.name + + +def test_start_inactive_span(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span(name="segment") as segment: + with sentry_sdk.traces.start_span(name="child1", active=False): + with sentry_sdk.traces.start_span(name="child2"): + # Should have segment as parent since child1 is inactive + pass + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 3 + child2, child1, segment = spans + + assert segment["is_segment"] is True + assert segment["name"] == "segment" + assert segment["attributes"]["sentry.segment.name"] == "segment" + + assert child1["is_segment"] is False + assert child1["name"] == "child1" + assert child1["attributes"]["sentry.segment.name"] == "segment" + assert child1["parent_span_id"] == segment["span_id"] + assert child1["trace_id"] == segment["trace_id"] + + assert child2["is_segment"] is False + assert child2["name"] == "child2" + assert child2["attributes"]["sentry.segment.name"] == "segment" + assert child2["parent_span_id"] == segment["span_id"] + assert child2["trace_id"] == segment["trace_id"] + + +def test_start_span_override_parent(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span(name="segment") as segment: + with sentry_sdk.traces.start_span(name="child1"): + with sentry_sdk.traces.start_span(name="child2", parent_span=segment): + pass + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 3 + child2, child1, segment = spans + + assert segment["name"] == "segment" + assert segment["attributes"]["sentry.segment.name"] == "segment" + + assert child1["name"] == "child1" + assert child1["attributes"]["sentry.segment.name"] == "segment" + + assert child2["name"] == "child2" + assert child2["attributes"]["sentry.segment.name"] == "segment" + + assert segment["is_segment"] is True + + assert child1["is_segment"] is False + assert child1["parent_span_id"] == segment["span_id"] + assert child1["trace_id"] == segment["trace_id"] + + assert child2["is_segment"] is False + assert child2["parent_span_id"] == segment["span_id"] + assert child2["trace_id"] == segment["trace_id"] + + +def test_sibling_segments(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span(name="segment1"): + ... + + with sentry_sdk.traces.start_span(name="segment2"): + ... + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 2 + segment1, segment2 = spans + + assert segment1["name"] == "segment1" + assert segment1["attributes"]["sentry.segment.name"] == "segment1" + assert segment1["is_segment"] is True + assert segment1["parent_span_id"] is None + + assert segment2["name"] == "segment2" + assert segment2["attributes"]["sentry.segment.name"] == "segment2" + assert segment2["is_segment"] is True + assert segment2["parent_span_id"] is None + + assert segment1["trace_id"] == segment2["trace_id"] + + +def test_sibling_segments_new_trace(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span(name="segment1"): + ... + + sentry_sdk.traces.new_trace() + + with sentry_sdk.traces.start_span(name="segment2"): + ... + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 2 + segment1, segment2 = spans + + assert segment1["name"] == "segment1" + assert segment1["attributes"]["sentry.segment.name"] == "segment1" + assert segment1["is_segment"] is True + assert segment1["parent_span_id"] is None + + assert segment2["name"] == "segment2" + assert segment2["attributes"]["sentry.segment.name"] == "segment2" + assert segment2["is_segment"] is True + assert segment2["parent_span_id"] is None + + assert segment1["trace_id"] != segment2["trace_id"] + + +def test_continue_trace_sampled(sentry_init, capture_envelopes): + sentry_init( + # parent sampling decision takes precedence over traces_sample_rate + traces_sample_rate=0.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + trace_id = "0af7651916cd43dd8448eb211c80319c" + parent_span_id = "b7ad6b7169203331" + sample_rand = "0.222222" + sampled = "1" + + sentry_sdk.traces.continue_trace( + { + "sentry-trace": f"{trace_id}-{parent_span_id}-{sampled}", + "baggage": f"sentry-trace_id={trace_id},sentry-sample_rate=0.5,sentry-sample_rand={sample_rand}", + } + ) + + with sentry_sdk.traces.start_span(name="segment") as span: + ... + + assert span.sampled is True + assert span.trace_id == trace_id + assert span._parent_span_id == parent_span_id + assert span._sample_rand == float(sample_rand) + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (segment,) = spans + + assert segment["is_segment"] is True + assert segment["parent_span_id"] == parent_span_id + assert segment["trace_id"] == trace_id + + +def test_continue_trace_unsampled(sentry_init, capture_envelopes): + sentry_init( + # parent sampling decision takes precedence over traces_sample_rate + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + trace_id = "0af7651916cd43dd8448eb211c80319c" + parent_span_id = "b7ad6b7169203331" + sample_rand = "0.999999" + sampled = "0" + + sentry_sdk.traces.continue_trace( + { + "sentry-trace": f"{trace_id}-{parent_span_id}-{sampled}", + "baggage": f"sentry-trace_id={trace_id},sentry-sample_rate=0.5,sentry-sample_rand={sample_rand}", + } + ) + + with sentry_sdk.traces.start_span(name="segment") as span: + ... + + assert span.sampled is False + assert span.name == "" + assert span.trace_id == "00000000000000000000000000000000" + assert span.span_id == "0000000000000000" + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 0 + + +def test_continue_trace_no_sample_rand(sentry_init, capture_envelopes): + sentry_init( + # parent sampling decision takes precedence over traces_sample_rate + traces_sample_rate=0.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + trace_id = "0af7651916cd43dd8448eb211c80319c" + parent_span_id = "b7ad6b7169203331" + sampled = "1" + + sentry_sdk.traces.continue_trace( + { + "sentry-trace": f"{trace_id}-{parent_span_id}-{sampled}", + "baggage": f"sentry-trace_id={trace_id},sentry-sample_rate=0.5", + } + ) + + with sentry_sdk.traces.start_span(name="segment") as span: + ... + + assert span.sampled is True + assert span.trace_id == trace_id + assert span._parent_span_id == parent_span_id + assert isinstance(span._sample_rand, float) + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (segment,) = spans + + assert segment["is_segment"] is True + assert segment["parent_span_id"] == parent_span_id + assert segment["trace_id"] == trace_id + + +def test_trace_decorator(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + @sentry_sdk.traces.trace + def traced_function(): ... + + traced_function() + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (span,) = spans + + assert ( + span["name"] + == "test_span_streaming.test_trace_decorator..traced_function" + ) + assert span["status"] == "ok" + + +def test_trace_decorator_arguments(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + @sentry_sdk.traces.trace(name="traced", attributes={"traced.attribute": 123}) + def traced_function(): ... + + traced_function() + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (span,) = spans + + assert span["name"] == "traced" + assert span["attributes"]["traced.attribute"] == 123 + assert span["status"] == "ok" + + +def test_trace_decorator_inactive(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + @sentry_sdk.traces.trace(name="outer", active=False) + def traced_function(): + with sentry_sdk.traces.start_span(name="inner"): + ... + + traced_function() + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 2 + (span1, span2) = spans + + assert span1["name"] == "inner" + assert span1["parent_span_id"] != span2["span_id"] + + assert span2["name"] == "outer" + + +@minimum_python_38 +def test_trace_decorator_async(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + @sentry_sdk.traces.trace + async def traced_function(): ... + + asyncio.run(traced_function()) + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (span,) = spans + + assert ( + span["name"] + == "test_span_streaming.test_trace_decorator_async..traced_function" + ) + assert span["status"] == "ok" + + +@minimum_python_38 +def test_trace_decorator_async_arguments(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + @sentry_sdk.traces.trace(name="traced", attributes={"traced.attribute": 123}) + async def traced_function(): ... + + asyncio.run(traced_function()) + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (span,) = spans + + assert span["name"] == "traced" + assert span["attributes"]["traced.attribute"] == 123 + assert span["status"] == "ok" + + +@minimum_python_38 +def test_trace_decorator_async_inactive(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + @sentry_sdk.traces.trace(name="outer", active=False) + async def traced_function(): + with sentry_sdk.traces.start_span(name="inner"): + ... + + asyncio.run(traced_function()) + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 2 + (span1, span2) = spans + + assert span1["name"] == "inner" + assert span1["parent_span_id"] != span2["span_id"] + + assert span2["name"] == "outer" + + +def test_set_span_status(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span(name="span") as span: + span.status = SpanStatus.ERROR + + with sentry_sdk.traces.start_span(name="span") as span: + span.status = "error" + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 2 + (span1, span2) = spans + + assert span1["status"] == "error" + assert span2["status"] == "error" + + +def test_set_span_status_on_error(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + events = capture_envelopes() + + with pytest.raises(ValueError): + with sentry_sdk.traces.start_span(name="span") as span: + raise ValueError("oh no!") + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (span,) = spans + + assert span["status"] == "error" + + +def test_transport_format(sentry_init, capture_envelopes): + sentry_init( + server_name="test-server", + release="1.0.0", + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + envelopes = capture_envelopes() + + with sentry_sdk.traces.start_span(name="test"): + ... + + sentry_sdk.get_client().flush() + + assert len(envelopes) == 1 + assert len(envelopes[0].items) == 1 + item = envelopes[0].items[0] + + assert item.type == "span" + assert item.headers == { + "type": "span", + "item_count": 1, + "content_type": "application/vnd.sentry.items.span.v2+json", + } + assert item.payload.json == { + "items": [ + { + "trace_id": mock.ANY, + "span_id": mock.ANY, + "name": "test", + "status": "ok", + "is_segment": True, + "start_timestamp": mock.ANY, + "end_timestamp": mock.ANY, + "attributes": { + "sentry.span.source": {"value": "custom", "type": "string"}, + # "thread.id": {"value": mock.ANY, "type": "string"}, + # "thread.name": {"value": "MainThread", "type": "string"}, + "sentry.segment.id": {"value": mock.ANY, "type": "string"}, + "sentry.segment.name": {"value": "test", "type": "string"}, + "sentry.sdk.name": {"value": "sentry.python", "type": "string"}, + "sentry.sdk.version": {"value": mock.ANY, "type": "string"}, + "server.address": {"value": "test-server", "type": "string"}, + "sentry.environment": {"value": "production", "type": "string"}, + "sentry.release": {"value": "1.0.0", "type": "string"}, + }, + } + ] + } From 09b88f0b2c3adf30e340dbad82c8f1d7a6051e2e Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 15:12:06 +0100 Subject: [PATCH 50/83] dont redefine slots --- sentry_sdk/traces.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index c35d270f13..fbbd9b457b 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -415,10 +415,7 @@ def timestamp(self) -> "Optional[datetime]": class NoOpStreamedSpan(StreamedSpan): - __slots__ = ( - "_scope", - "_previous_span_on_scope", - ) + __slots__ = () def __init__( self, From 2ac24d3f7a8acc78f4d6f10dfe7863cb88d43b74 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 15:14:16 +0100 Subject: [PATCH 51/83] redundant slots --- sentry_sdk/traces.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index f88b86801e..e2959faa83 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -441,11 +441,7 @@ def timestamp(self) -> "Optional[datetime]": class NoOpStreamedSpan(StreamedSpan): - __slots__ = ( - "_scope", - "_previous_span_on_scope", - "_unsampled_reason", - ) + __slots__ = ("_unsampled_reason",) def __init__( self, From 12d7ff7a2604d9830a6b0ccc9bb017a0d8c6b486 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 15:19:13 +0100 Subject: [PATCH 52/83] . --- sentry_sdk/traces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index da10a09c1b..f56a03b193 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -518,7 +518,7 @@ def remove_attribute(self, key: str) -> None: pass def _is_segment(self) -> bool: - return True if self._scope is not None else False + return self._scope is not None @property def status(self) -> "str": From 8e3de7660d8b675539f4c362570ce7f471085d20 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 15:25:01 +0100 Subject: [PATCH 53/83] fix comp --- sentry_sdk/traces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index f56a03b193..fda692e3d9 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -440,7 +440,7 @@ def timestamp(self) -> "Optional[datetime]": return self._timestamp def _is_segment(self) -> bool: - return self._segment == self + return self._segment is self class NoOpStreamedSpan(StreamedSpan): From 24480926389e6f54ad5acaeb45401e89b5b27b18 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 15:37:49 +0100 Subject: [PATCH 54/83] unused imports --- tests/tracing/test_span_streaming.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 423014be93..2adaddbdd7 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -1,5 +1,4 @@ import asyncio -import re import sys from typing import Any from unittest import mock @@ -7,7 +6,7 @@ import pytest import sentry_sdk -from sentry_sdk.traces import SegmentSource, SpanStatus, StreamedSpan, NoOpStreamedSpan +from sentry_sdk.traces import SpanStatus minimum_python_38 = pytest.mark.skipif( sys.version_info < (3, 8), reason="Asyncio tests need Python >= 3.8" From 73e33ea5675cdd23f428159788aba26391164bbb Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 10 Mar 2026 12:50:30 +0100 Subject: [PATCH 55/83] . --- sentry_sdk/traces.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 48cca8a8e8..3368856b05 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -264,16 +264,6 @@ def __init__( self._start_timestamp = datetime.now(timezone.utc) self._timestamp: "Optional[datetime]" = None - try: - # profiling depends on this value and requires that - # it is measured in nanoseconds - self._start_timestamp_monotonic_ns = nanosecond_time() - except AttributeError: - pass - - self._start_timestamp = datetime.now(timezone.utc) - self._timestamp: "Optional[datetime]" = None - try: # profiling depends on this value and requires that # it is measured in nanoseconds From bd9e0a38ee35d6ac3a614d0aa66e976249c46389 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 10 Mar 2026 13:02:27 +0100 Subject: [PATCH 56/83] add finished to noop span --- sentry_sdk/traces.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 3368856b05..9c217c1f05 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -447,7 +447,10 @@ def timestamp(self) -> "Optional[datetime]": class NoOpStreamedSpan(StreamedSpan): - __slots__ = ("_unsampled_reason",) + __slots__ = ( + "_finished", + "_unsampled_reason", + ) def __init__( self, @@ -479,6 +482,9 @@ def _start(self) -> None: self._previous_span_on_scope = old_span def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: + if self._finished: + return + client = sentry_sdk.get_client() if client.is_active() and client.transport: logger.debug("Discarding span because sampled = False") @@ -488,13 +494,13 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None quantity=1, ) - if self._scope is None or not hasattr(self, "_previous_span_on_scope"): - return + if self._scope: + with capture_internal_exceptions(): + old_span = self._previous_span_on_scope + del self._previous_span_on_scope + self._scope.span = old_span - with capture_internal_exceptions(): - old_span = self._previous_span_on_scope - del self._previous_span_on_scope - self._scope.span = old_span + self._finished = True def end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: self._end() From 918609cf744f5512dd74fcde842b71d93b475c33 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 10 Mar 2026 13:03:08 +0100 Subject: [PATCH 57/83] . --- sentry_sdk/traces.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 9c217c1f05..a1e0cc63cd 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -460,6 +460,8 @@ def __init__( self._scope = scope # type: ignore[assignment] self._unsampled_reason = unsampled_reason + self._finished = False + self._start() def __repr__(self) -> str: From e850994d963bcc6a680c8fbcf0c5e15b376a8d09 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 10 Mar 2026 14:32:36 +0100 Subject: [PATCH 58/83] improvements --- sentry_sdk/traces.py | 21 ++++++++++--------- sentry_sdk/tracing_utils.py | 40 +++++++++++++++++-------------------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index a1e0cc63cd..5959f3d3f0 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -487,16 +487,19 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None if self._finished: return - client = sentry_sdk.get_client() - if client.is_active() and client.transport: - logger.debug("Discarding span because sampled = False") - client.transport.record_lost_event( - reason=self._unsampled_reason or "sample_rate", - data_category="span", - quantity=1, - ) + if self._unsampled_reason is not None: + client = sentry_sdk.get_client() + if client.is_active() and client.transport: + logger.debug( + f"Discarding span because sampled=False (reason: {self._unsampled_reason})" + ) + client.transport.record_lost_event( + reason=self._unsampled_reason, + data_category="span", + quantity=1, + ) - if self._scope: + if self._scope and hasattr(self, "_previous_span_on_scope"): with capture_internal_exceptions(): old_span = self._previous_span_on_scope del self._previous_span_on_scope diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index f688fc7b1d..0b1a8f7c65 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1409,21 +1409,21 @@ def _make_sampling_decision( if sample_rand is None: sample_rand = _generate_sample_rand(propagation_context.trace_id) - sampling_context = { - "name": name, - "trace_id": propagation_context.trace_id, - "parent_span_id": propagation_context.parent_span_id, - "parent_sampled": propagation_context.parent_sampled, - "attributes": attributes or {}, - } - # If there's a traces_sampler, use that; otherwise use traces_sample_rate traces_sampler_defined = callable(client.options.get("traces_sampler")) if traces_sampler_defined: + sampling_context = { + "name": name, + "trace_id": propagation_context.trace_id, + "parent_span_id": propagation_context.parent_span_id, + "parent_sampled": propagation_context.parent_sampled, + "attributes": dict(attributes) if attributes else {}, + } + sample_rate = client.options["traces_sampler"](sampling_context) else: - if sampling_context["parent_sampled"] is not None: - sample_rate = sampling_context["parent_sampled"] + if propagation_context.parent_sampled is not None: + sample_rate = propagation_context.parent_sampled else: sample_rate = client.options["traces_sample_rate"] @@ -1434,13 +1434,6 @@ def _make_sampling_decision( return False, None, None, None sample_rate = float(sample_rate) - - # Adjust sample rate if we're under backpressure - if client.monitor: - sample_rate /= 2**client.monitor.downsample_factor - - outcome: "Optional[str]" = None - if not sample_rate: if traces_sampler_defined: reason = "traces_sampler returned 0 or False" @@ -1448,12 +1441,15 @@ def _make_sampling_decision( reason = "traces_sample_rate is set to 0" logger.debug(f"[Tracing] Discarding {name} because {reason}") - if client.monitor and client.monitor.downsample_factor > 0: - outcome = "backpressure" - else: - outcome = "sample_rate" + return False, 0.0, None, "sample_rate" + + # Adjust sample rate if we're under backpressure + if client.monitor: + sample_rate /= 2**client.monitor.downsample_factor - return False, 0.0, None, outcome + if not sample_rate: + logger.debug(f"[Tracing] Discarding {name} because backpressure") + return False, 0.0, None, "backpressure" sampled = sample_rand < sample_rate From f816c0a31ed4eb4a8a33df8df9f47f38d58f406b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 10 Mar 2026 14:40:37 +0100 Subject: [PATCH 59/83] . --- 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 0b1a8f7c65..90b4d84389 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1431,7 +1431,7 @@ def _make_sampling_decision( # traces_sampler is user-provided, it could return anything. if not is_valid_sample_rate(sample_rate, source="Tracing"): logger.warning(f"[Tracing] Discarding {name} because of invalid sample rate.") - return False, None, None, None + return False, None, None, "sample_rate" sample_rate = float(sample_rate) if not sample_rate: From fd78adffa88ccde61abf119125573d657760f65b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 10 Mar 2026 15:28:24 +0100 Subject: [PATCH 60/83] ref: Add support for custom sampling context to span first --- sentry_sdk/scope.py | 7 ++++ sentry_sdk/tracing_utils.py | 11 ++++++ tests/tracing/test_span_streaming.py | 56 ++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 454a82db85..5dc8adaeef 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -696,6 +696,13 @@ def get_active_propagation_context(self) -> "PropagationContext": isolation_scope._propagation_context = PropagationContext() return isolation_scope._propagation_context + def set_custom_sampling_context( + self, custom_sampling_context: "dict[str, Any]" + ) -> None: + self.get_active_propagation_context()._set_custom_sampling_context( + custom_sampling_context + ) + def clear(self) -> None: """Clears the entire scope.""" self._level: "Optional[LogLevelStr]" = None diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 90b4d84389..2fc1ea65af 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -417,6 +417,7 @@ class PropagationContext: "parent_span_id", "parent_sampled", "baggage", + "custom_sampling_context", ) def __init__( @@ -450,6 +451,8 @@ def __init__( if baggage is None and dynamic_sampling_context is not None: self.baggage = Baggage(dynamic_sampling_context) + self.custom_sampling_context: "Optional[dict[str, Any]]" = None + @classmethod def from_incoming_data( cls, incoming_data: "Dict[str, Any]" @@ -537,6 +540,11 @@ def update(self, other_dict: "Dict[str, Any]") -> None: except AttributeError: pass + def _set_custom_sampling_context( + self, custom_sampling_context: "dict[str, Any]" + ) -> None: + self.custom_sampling_context = custom_sampling_context + def __repr__(self) -> str: return "".format( self._trace_id, @@ -1420,6 +1428,9 @@ def _make_sampling_decision( "attributes": dict(attributes) if attributes else {}, } + if propagation_context.custom_sampling_context: + sampling_context.update(propagation_context.custom_sampling_context) + sample_rate = client.options["traces_sampler"](sampling_context) else: if propagation_context.parent_sampled is not None: diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 2adaddbdd7..962d6f4cb7 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -233,6 +233,62 @@ def traces_sampler(sampling_context): assert len(spans) == 1 +def test_custom_sampling_context(sentry_init): + class MyClass: ... + + my_class = MyClass() + + def traces_sampler(sampling_context): + assert "class" in sampling_context + assert "string" in sampling_context + assert sampling_context["class"] == my_class + assert sampling_context["string"] == "my string" + return 1.0 + + sentry_init( + traces_sampler=traces_sampler, + _experiments={"trace_lifecycle": "stream"}, + ) + + sentry_sdk.get_current_scope().set_custom_sampling_context( + { + "class": my_class, + "string": "my string", + } + ) + + with sentry_sdk.traces.start_span(name="span"): + ... + + +def test_new_custom_sampling_context(sentry_init): + def traces_sampler(sampling_context): + if sampling_context["attributes"]["first"] is True: + assert sampling_context["custom_value"] == 1 + else: + assert sampling_context["custom_value"] == 2 + return 1.0 + + sentry_init( + traces_sampler=traces_sampler, + _experiments={"trace_lifecycle": "stream"}, + ) + + sentry_sdk.traces.new_trace() + + sentry_sdk.get_current_scope().set_custom_sampling_context({"custom_value": 1}) + + with sentry_sdk.traces.start_span(name="span", attributes={"first": True}): + ... + + sentry_sdk.traces.new_trace() + + sentry_sdk.get_current_scope().set_custom_sampling_context({"custom_value": 2}) + + with sentry_sdk.traces.start_span(name="span", attributes={"first": False}): + ... + + def test_span_attributes(sentry_init, capture_envelopes): sentry_init( traces_sample_rate=1.0, From d1618c4e2ca0c10391e06bf840d1ad112f3a46d3 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 10:04:03 +0100 Subject: [PATCH 61/83] test name --- 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 962d6f4cb7..99e270a345 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -261,7 +261,7 @@ def traces_sampler(sampling_context): ... -def test_new_custom_sampling_context(sentry_init): +def test_custom_sampling_context_update_to_context_value_persists(sentry_init): def traces_sampler(sampling_context): if sampling_context["attributes"]["first"] is True: assert sampling_context["custom_value"] == 1 From c64025159bf8c0b17acdd44170f7d5f1d9e6a8b5 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 10:27:10 +0100 Subject: [PATCH 62/83] put sampling context into subdict --- sentry_sdk/tracing_utils.py | 12 ++++++----- tests/tracing/test_span_streaming.py | 32 ++++++++++++++-------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 2fc1ea65af..c7948cf510 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1421,11 +1421,13 @@ def _make_sampling_decision( traces_sampler_defined = callable(client.options.get("traces_sampler")) if traces_sampler_defined: sampling_context = { - "name": name, - "trace_id": propagation_context.trace_id, - "parent_span_id": propagation_context.parent_span_id, - "parent_sampled": propagation_context.parent_sampled, - "attributes": dict(attributes) if attributes else {}, + "span": { + "name": name, + "trace_id": propagation_context.trace_id, + "parent_span_id": propagation_context.parent_span_id, + "parent_sampled": propagation_context.parent_sampled, + "attributes": dict(attributes) if attributes else {}, + }, } if propagation_context.custom_sampling_context: diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 99e270a345..bbf0aa4527 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -120,7 +120,7 @@ def test_span_sampled_when_created(sentry_init, capture_envelopes): # at start_span() time def traces_sampler(sampling_context): - assert "delayed_attribute" not in sampling_context["attributes"] + assert "delayed_attribute" not in sampling_context["span"]["attributes"] return 1.0 sentry_init( @@ -169,9 +169,9 @@ def test_start_span_attributes(sentry_init, capture_envelopes): def test_start_span_attributes_in_traces_sampler(sentry_init, capture_envelopes): def traces_sampler(sampling_context): - assert "attributes" in sampling_context - assert "my_attribute" in sampling_context["attributes"] - assert sampling_context["attributes"]["my_attribute"] == "my_value" + assert "attributes" in sampling_context["span"] + assert "my_attribute" in sampling_context["span"]["attributes"] + assert sampling_context["span"]["attributes"]["my_attribute"] == "my_value" return 1.0 sentry_init( @@ -202,16 +202,16 @@ def test_sampling_context(sentry_init, capture_envelopes): def traces_sampler(sampling_context): nonlocal received_trace_id - assert "trace_id" in sampling_context - received_trace_id = sampling_context["trace_id"] + assert "trace_id" in sampling_context["span"] + received_trace_id = sampling_context["span"]["trace_id"] - assert "parent_span_id" in sampling_context - assert sampling_context["parent_span_id"] is None + assert "parent_span_id" in sampling_context["span"] + assert sampling_context["span"]["parent_span_id"] is None - assert "parent_sampled" in sampling_context - assert sampling_context["parent_sampled"] is None + assert "parent_sampled" in sampling_context["span"] + assert sampling_context["span"]["parent_sampled"] is None - assert "attributes" in sampling_context + assert "attributes" in sampling_context["span"] return 1.0 @@ -263,7 +263,7 @@ def traces_sampler(sampling_context): def test_custom_sampling_context_update_to_context_value_persists(sentry_init): def traces_sampler(sampling_context): - if sampling_context["attributes"]["first"] is True: + if sampling_context["span"]["attributes"]["first"] is True: assert sampling_context["custom_value"] == 1 else: assert sampling_context["custom_value"] == 2 @@ -361,10 +361,10 @@ class Class: def test_traces_sampler_drops_span(sentry_init, capture_envelopes): def traces_sampler(sampling_context): - assert "attributes" in sampling_context - assert "drop" in sampling_context["attributes"] + assert "attributes" in sampling_context["span"] + assert "drop" in sampling_context["span"]["attributes"] - if sampling_context["attributes"]["drop"] is True: + if sampling_context["span"]["attributes"]["drop"] is True: return 0.0 return 1.0 @@ -398,7 +398,7 @@ def test_traces_sampler_called_once_per_segment(sentry_init): def traces_sampler(sampling_context): nonlocal traces_sampler_called, span_name_in_traces_sampler traces_sampler_called += 1 - span_name_in_traces_sampler = sampling_context["name"] + span_name_in_traces_sampler = sampling_context["span"]["name"] return 1.0 sentry_init( From 493706e787631974885773576c6da751115455b0 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 11:08:38 +0100 Subject: [PATCH 63/83] ref: Add span filtering to span first --- sentry_sdk/_types.py | 10 +- sentry_sdk/consts.py | 2 + sentry_sdk/scope.py | 12 + sentry_sdk/tracing_utils.py | 47 ++++ tests/tracing/test_span_streaming.py | 367 ++++++++++++++++++++++++++- 5 files changed, 436 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index dad2e8c517..902dbd3af4 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, TypeVar, Union +from typing import TYPE_CHECKING, Pattern, TypeVar, Union # Re-exported for compat, since code out there in the wild might use this variable. @@ -361,3 +361,11 @@ class SDKInfo(TypedDict): class TextPart(TypedDict): type: Literal["text"] content: str + + IgnoreSpansName = Union[str, Pattern[str]] + IgnoreSpansContext = TypedDict( + "IgnoreSpansContext", + {"name": IgnoreSpansName, "attributes": Attributes}, + total=False, + ) + IgnoreSpansConfig = list[Union[IgnoreSpansName, IgnoreSpansContext]] diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index e1cc00e156..51111b55fc 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -35,6 +35,7 @@ class CompressionAlgo(Enum): Any, Callable, Dict, + IgnoreSpansConfig, List, Optional, Sequence, @@ -83,6 +84,7 @@ class CompressionAlgo(Enum): "enable_metrics": Optional[bool], "before_send_metric": Optional[Callable[[Metric, Hint], Optional[Metric]]], "trace_lifecycle": Optional[Literal["static", "stream"]], + "ignore_spans": Optional[IgnoreSpansConfig], }, total=False, ) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 5dc8adaeef..a3477cb200 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -30,6 +30,7 @@ Baggage, has_tracing_enabled, has_span_streaming_enabled, + is_ignored_span, _make_sampling_decision, normalize_incoming_data, PropagationContext, @@ -1207,6 +1208,12 @@ def start_streamed_span( if parent_span is None: propagation_context = self.get_active_propagation_context() + if is_ignored_span(name, attributes): + return NoOpStreamedSpan( + scope=self, + unsampled_reason="ignored", + ) + sampled, sample_rate, sample_rand, outcome = _make_sampling_decision( name, attributes, @@ -1238,6 +1245,11 @@ def start_streamed_span( # This is a child span; take propagation context from the parent span with new_scope(): + if is_ignored_span(name, attributes): + return NoOpStreamedSpan( + unsampled_reason="ignored", + ) + if isinstance(parent_span, NoOpStreamedSpan): return NoOpStreamedSpan(unsampled_reason=parent_span._unsampled_reason) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index c7948cf510..8faaa4a6ff 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -8,6 +8,7 @@ from collections.abc import Mapping, MutableMapping from datetime import timedelta from random import Random +from typing import Pattern from urllib.parse import quote, unquote import uuid @@ -1478,6 +1479,52 @@ def _make_sampling_decision( return sampled, sample_rate, sample_rand, outcome +def is_ignored_span(name: str, attributes: "Optional[Attributes]") -> bool: + """Determine if a span fits one of the rules in ignore_spans.""" + client = sentry_sdk.get_client() + ignore_spans = (client.options.get("_experiments") or {}).get("ignore_spans") + + if not ignore_spans: + return False + + def _matches(rule: "Any", value: "Any") -> bool: + if isinstance(rule, Pattern): + if isinstance(value, str): + return bool(rule.match(value)) + else: + return False + + return rule == value + + for rule in ignore_spans: + if isinstance(rule, (str, Pattern)): + if _matches(rule, name): + return True + + elif isinstance(rule, dict) and rule: + name_matches = True + attributes_match = True + + if "name" in rule: + name_matches = _matches(rule["name"], name) + + if "attributes" in rule: + if not attributes: + attributes_match = False + else: + for attribute, value in rule["attributes"].items(): + if attribute not in attributes or not _matches( + value, attributes[attribute] + ): + attributes_match = False + break + + if name_matches and attributes_match: + return True + + return False + + # Circular imports from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index bbf0aa4527..33e3c23757 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -1,4 +1,5 @@ import asyncio +import re import sys from typing import Any from unittest import mock @@ -6,7 +7,7 @@ import pytest import sentry_sdk -from sentry_sdk.traces import SpanStatus +from sentry_sdk.traces import NoOpStreamedSpan, SpanStatus, StreamedSpan minimum_python_38 = pytest.mark.skipif( sys.version_info < (3, 8), reason="Asyncio tests need Python >= 3.8" @@ -877,6 +878,370 @@ def test_set_span_status_on_error(sentry_init, capture_envelopes): assert span["status"] == "error" +@pytest.mark.parametrize( + ("ignore_spans", "name", "attributes", "ignored"), + [ + # no regexes + ([], "/health", {}, False), + ([{}], "/health", {}, False), + (["/health"], "/health", {}, True), + (["/health"], "/health", {"custom": "custom"}, True), + ([{"name": "/health"}], "/health", {}, True), + ([{"name": "/health"}], "/health", {"custom": "custom"}, True), + ([{"attributes": {"custom": "custom"}}], "/health", {"custom": "custom"}, True), + ([{"attributes": {"custom": "custom"}}], "/health", {}, False), + ( + [{"name": "/nothealth", "attributes": {"custom": "custom"}}], + "/health", + {"custom": "custom"}, + False, + ), + ( + [{"name": "/health", "attributes": {"custom": "notcustom"}}], + "/health", + {"custom": "custom"}, + False, + ), + ( + [{"name": "/health", "attributes": {"custom": "custom"}}], + "/health", + {"custom": "custom"}, + True, + ), + # test cases with regexes + ([re.compile("/hea.*")], "/health", {}, True), + ([re.compile("/hea.*")], "/health", {"custom": "custom"}, True), + ([{"name": re.compile("/hea.*")}], "/health", {}, True), + ([{"name": re.compile("/hea.*")}], "/health", {"custom": "custom"}, True), + ( + [{"attributes": {"custom": re.compile("c.*")}}], + "/health", + {"custom": "custom"}, + True, + ), + ([{"attributes": {"custom": re.compile("c.*")}}], "/health", {}, False), + ( + [ + { + "name": re.compile("/nothea.*"), + "attributes": {"custom": re.compile("c.*")}, + } + ], + "/health", + {"custom": "custom"}, + False, + ), + ( + [ + { + "name": re.compile("/hea.*"), + "attributes": {"custom": re.compile("notc.*")}, + } + ], + "/health", + {"custom": "custom"}, + False, + ), + ( + [ + { + "name": re.compile("/hea.*"), + "attributes": {"custom": re.compile("c.*")}, + } + ], + "/health", + {"custom": "custom"}, + True, + ), + ( + [{"attributes": {"listattr": re.compile(r"\[.*\]")}}], + "/a", + {"listattr": [1, 2, 3]}, + False, + ), + ], +) +def test_ignore_spans( + sentry_init, capture_envelopes, ignore_spans, name, attributes, ignored +): + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ignore_spans, + }, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span(name=name, attributes=attributes) as span: + if ignored: + assert span.sampled is False + assert isinstance(span, NoOpStreamedSpan) + else: + assert span.sampled is True + assert isinstance(span, StreamedSpan) + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + if ignored: + assert len(spans) == 0 + else: + assert len(spans) == 1 + (span,) = spans + assert span["name"] == name + + +def test_ignore_spans_basic( + sentry_init, capture_envelopes, capture_record_lost_event_calls +): + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + events = capture_envelopes() + lost_event_calls = capture_record_lost_event_calls() + + with sentry_sdk.traces.start_span(name="ignored") as ignored_span: + assert ignored_span.sampled is False + + with sentry_sdk.traces.start_span(name="not ignored") as span: + assert span.sampled is True + + sentry_sdk.get_client().flush() + + spans = envelopes_to_spans(events) + + assert len(spans) == 1 + (span,) = spans + assert span["name"] == "not ignored" + assert span["parent_span_id"] is None + + assert len(lost_event_calls) == 1 + assert lost_event_calls[0] == ("ignored", "span", None, 1) + + +def test_ignore_spans_ignored_segment_drops_whole_tree( + sentry_init, capture_envelopes, capture_record_lost_event_calls +): + # Ignored segments should drop the whole span tree. + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + events = capture_envelopes() + lost_event_calls = capture_record_lost_event_calls() + + with sentry_sdk.traces.start_span(name="ignored") as ignored_span: + assert ignored_span.sampled is False + assert isinstance(ignored_span, NoOpStreamedSpan) + + with sentry_sdk.traces.start_span(name="not ignored") as span1: + assert span1.sampled is False + assert isinstance(span1, NoOpStreamedSpan) + + with sentry_sdk.traces.start_span(name="not ignored") as span2: + assert span2.sampled is False + assert isinstance(span2, NoOpStreamedSpan) + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 0 + + assert len(lost_event_calls) == 3 + for lost_event_call in lost_event_calls: + assert lost_event_call == ("ignored", "span", None, 1) + + +def test_ignore_spans_ignored_segment_drops_whole_tree_explicit_parent_span( + sentry_init, capture_envelopes, capture_record_lost_event_calls +): + # Ignored segments should drop the whole span tree. + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + events = capture_envelopes() + lost_event_calls = capture_record_lost_event_calls() + + ignored_span = sentry_sdk.traces.start_span(name="ignored") + assert isinstance(ignored_span, NoOpStreamedSpan) + assert ignored_span.sampled is False + + span1 = sentry_sdk.traces.start_span(name="not ignored 1", parent_span=ignored_span) + assert isinstance(span1, NoOpStreamedSpan) + assert span1.sampled is False + + span2 = sentry_sdk.traces.start_span(name="not ignored 2", parent_span=ignored_span) + assert isinstance(span2, NoOpStreamedSpan) + assert span2.sampled is False + + span1.end() + span2.end() + ignored_span.end() + + sentry_sdk.get_client().flush() + + spans = envelopes_to_spans(events) + + assert len(spans) == 0 + + assert len(lost_event_calls) == 3 + for lost_event_call in lost_event_calls: + assert lost_event_call == ("ignored", "span", None, 1) + + +def test_ignore_spans_set_ignored_child_span_as_parent( + sentry_init, capture_envelopes, capture_record_lost_event_calls +): + # Ignored non-segment spans should NOT drop the whole subtree under them. + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + events = capture_envelopes() + lost_event_calls = capture_record_lost_event_calls() + + with sentry_sdk.traces.start_span(name="segment") as segment: + assert segment.sampled is True + + with sentry_sdk.traces.start_span(name="ignored") as ignored_span1: + assert ignored_span1.sampled is False + + with sentry_sdk.traces.start_span(name="ignored") as ignored_span2: + assert ignored_span2.sampled is False + + with sentry_sdk.traces.start_span(name="child") as span: + assert span.sampled is True + assert span._parent_span_id == segment.span_id + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 2 + (child, segment) = spans + assert segment["name"] == "segment" + assert child["name"] == "child" + assert child["parent_span_id"] == segment["span_id"] # reparented to segment + + assert len(lost_event_calls) == 2 + for lost_event_call in lost_event_calls: + assert lost_event_call == ("ignored", "span", None, 1) + + +def test_ignore_spans_set_ignored_child_span_as_parent_explicit_parent_span( + sentry_init, capture_envelopes, capture_record_lost_event_calls +): + # Ignored non-segment spans should NOT drop the whole subtree under them. + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + events = capture_envelopes() + lost_event_calls = capture_record_lost_event_calls() + + segment = sentry_sdk.traces.start_span(name="segment") + assert not isinstance(segment, NoOpStreamedSpan) + assert segment.sampled is True + assert segment._parent_span_id is None + + ignored_span1 = sentry_sdk.traces.start_span(name="ignored", parent_span=segment) + assert isinstance(ignored_span1, NoOpStreamedSpan) + assert ignored_span1.sampled is False + + ignored_span2 = sentry_sdk.traces.start_span( + name="ignored", parent_span=ignored_span1 + ) + assert isinstance(ignored_span2, NoOpStreamedSpan) + assert ignored_span2.sampled is False + + span = sentry_sdk.traces.start_span(name="child", parent_span=ignored_span2) + assert not isinstance(span, NoOpStreamedSpan) + assert span.sampled is True + assert span._parent_span_id == segment.span_id + span.end() + + ignored_span2.end() + ignored_span1.end() + segment.end() + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 2 + (child, segment) = spans + assert segment["name"] == "segment" + assert child["name"] == "child" + assert child["parent_span_id"] == segment["span_id"] # reparented to segment + + assert len(lost_event_calls) == 2 + for lost_event_call in lost_event_calls: + assert lost_event_call == ("ignored", "span", None, 1) + + +def test_ignore_spans_reparenting(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + events = capture_envelopes() + + with sentry_sdk.traces.start_span(name="segment") as span1: + assert span1.sampled is True + assert span1._parent_span_id is None + + with sentry_sdk.traces.start_span(name="ignored") as span2: + assert span2.sampled is False + + with sentry_sdk.traces.start_span(name="child 1") as span3: + assert span3.sampled is True + assert span3._parent_span_id == span1.span_id + + with sentry_sdk.traces.start_span(name="ignored") as span4: + assert span4.sampled is False + + with sentry_sdk.traces.start_span(name="child 2") as span5: + assert span5.sampled is True + assert span5._parent_span_id == span3.span_id + + sentry_sdk.get_client().flush() + spans = envelopes_to_spans(events) + + assert len(spans) == 3 + (span5, span3, span1) = spans + assert span1["name"] == "segment" + assert span3["name"] == "child 1" + assert span5["name"] == "child 2" + assert span3["parent_span_id"] == span1["span_id"] + assert span5["parent_span_id"] == span3["span_id"] + + def test_transport_format(sentry_init, capture_envelopes): sentry_init( server_name="test-server", From 8f04a7479a296789803f9be6838265dc01bcde2d Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 11:20:04 +0100 Subject: [PATCH 64/83] simplify --- sentry_sdk/tracing_utils.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 8faaa4a6ff..cbc93c8667 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1509,15 +1509,12 @@ def _matches(rule: "Any", value: "Any") -> bool: name_matches = _matches(rule["name"], name) if "attributes" in rule: - if not attributes: - attributes_match = False - else: - for attribute, value in rule["attributes"].items(): - if attribute not in attributes or not _matches( - value, attributes[attribute] - ): - attributes_match = False - break + for attribute, value in rule["attributes"].items(): + if attribute not in attributes or not _matches( + value, attributes[attribute] + ): + attributes_match = False + break if name_matches and attributes_match: return True From a8345834979518d56ed3ce3f2b30ef82ee934c7a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 11:26:43 +0100 Subject: [PATCH 65/83] mypy --- sentry_sdk/consts.py | 2 +- sentry_sdk/tracing_utils.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 51111b55fc..eb077ef62f 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -35,7 +35,6 @@ class CompressionAlgo(Enum): Any, Callable, Dict, - IgnoreSpansConfig, List, Optional, Sequence, @@ -53,6 +52,7 @@ class CompressionAlgo(Enum): Event, EventProcessor, Hint, + IgnoreSpansConfig, Log, MeasurementUnit, Metric, diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index cbc93c8667..4b77c54a46 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1505,6 +1505,8 @@ def _matches(rule: "Any", value: "Any") -> bool: name_matches = True attributes_match = True + attributes = attributes or {} + if "name" in rule: name_matches = _matches(rule["name"], name) From 7a5c168574ab958f1d9adb8c4fdab09e3b309b37 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 11:33:16 +0100 Subject: [PATCH 66/83] ref: Add thread ID, name to attributes --- sentry_sdk/traces.py | 13 +++++++++++++ tests/tracing/test_span_streaming.py | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index deb979ffd0..adf77a4ca8 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -14,10 +14,12 @@ from typing import TYPE_CHECKING import sentry_sdk +from sentry_sdk.consts import SPANDATA from sentry_sdk.tracing_utils import Baggage from sentry_sdk.utils import ( capture_internal_exceptions, format_attribute, + get_current_thread_meta, logger, nanosecond_time, should_be_treated_as_error, @@ -276,6 +278,8 @@ def __init__( self._status = SpanStatus.OK.value self.set_attribute("sentry.span.source", SegmentSource.CUSTOM.value) + self._update_active_thread() + self._start() def __repr__(self) -> str: @@ -450,6 +454,15 @@ def timestamp(self) -> "Optional[datetime]": def _is_segment(self) -> bool: return self._segment is self + def _update_active_thread(self) -> None: + thread_id, thread_name = get_current_thread_meta() + + if thread_id is not None: + self.set_attribute(SPANDATA.THREAD_ID, str(thread_id)) + + if thread_name is not None: + self.set_attribute(SPANDATA.THREAD_NAME, thread_name) + class NoOpStreamedSpan(StreamedSpan): __slots__ = ( diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 33e3c23757..cc941237f1 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -1279,8 +1279,8 @@ def test_transport_format(sentry_init, capture_envelopes): "end_timestamp": mock.ANY, "attributes": { "sentry.span.source": {"value": "custom", "type": "string"}, - # "thread.id": {"value": mock.ANY, "type": "string"}, - # "thread.name": {"value": "MainThread", "type": "string"}, + "thread.id": {"value": mock.ANY, "type": "string"}, + "thread.name": {"value": "MainThread", "type": "string"}, "sentry.segment.id": {"value": mock.ANY, "type": "string"}, "sentry.segment.name": {"value": "test", "type": "string"}, "sentry.sdk.name": {"value": "sentry.python", "type": "string"}, From bf62edca90cd9e70aa1a292ada03f2952da4d4a0 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 11:43:25 +0100 Subject: [PATCH 67/83] . --- 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 4b77c54a46..314fdca30b 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1501,7 +1501,7 @@ def _matches(rule: "Any", value: "Any") -> bool: if _matches(rule, name): return True - elif isinstance(rule, dict) and rule: + elif isinstance(rule, dict) and ("name" in rule or "attributes" in rule): name_matches = True attributes_match = True From 0eca88b6172a930a9df5b5bc3b082eb9dd80bdca Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 11:52:19 +0100 Subject: [PATCH 68/83] change to fullmatch --- 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 314fdca30b..824386274f 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1490,7 +1490,7 @@ def is_ignored_span(name: str, attributes: "Optional[Attributes]") -> bool: def _matches(rule: "Any", value: "Any") -> bool: if isinstance(rule, Pattern): if isinstance(value, str): - return bool(rule.match(value)) + return bool(rule.fullmatch(value)) else: return False From 84cbbd76852b85f43dab7a88590256c2b2932096 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 13:20:56 +0100 Subject: [PATCH 69/83] ref: Add missing data category --- sentry_sdk/envelope.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry_sdk/envelope.py b/sentry_sdk/envelope.py index 307fb26fd6..012b706938 100644 --- a/sentry_sdk/envelope.py +++ b/sentry_sdk/envelope.py @@ -255,6 +255,8 @@ def data_category(self) -> "EventDataCategory": return "attachment" elif ty == "transaction": return "transaction" + elif ty == "span": + return "span" elif ty == "event": return "error" elif ty == "log": From fea3b9b12a060e4a5c22047bd4a40d018a6f811a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 13:30:52 +0100 Subject: [PATCH 70/83] ref: Span first trace propagation --- sentry_sdk/_span_batcher.py | 4 +-- sentry_sdk/traces.py | 67 ++++++++++++++++++++++++++++++++++++- sentry_sdk/tracing_utils.py | 51 ++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index df3598b282..15acaaaaa6 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -116,9 +116,7 @@ def _flush(self) -> None: envelopes = [] for trace_id, spans in self._span_buffer.items(): if spans: - # TODO[span-first] - # dsc = spans[0].dynamic_sampling_context() - dsc = None + dsc = spans[0]._dynamic_sampling_context() # Max per envelope is 1000, so if we happen to have more than # 1000 spans in one bucket, we'll need to separate them. diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index adf77a4ca8..3d50b30642 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -26,13 +26,17 @@ ) if TYPE_CHECKING: - from typing import Any, Callable, Optional, ParamSpec, TypeVar, Union + from typing import Any, Callable, Iterator, Optional, ParamSpec, TypeVar, Union from sentry_sdk._types import Attributes, AttributeValue P = ParamSpec("P") R = TypeVar("R") +BAGGAGE_HEADER_NAME = "baggage" +SENTRY_TRACE_HEADER_NAME = "sentry-trace" + + class SpanStatus(str, Enum): OK = "ok" ERROR = "error" @@ -463,6 +467,67 @@ def _update_active_thread(self) -> None: if thread_name is not None: self.set_attribute(SPANDATA.THREAD_NAME, thread_name) + def _dynamic_sampling_context(self) -> "dict[str, str]": + return self._segment._get_baggage().dynamic_sampling_context() + + def _to_traceparent(self) -> str: + if self.sampled is True: + sampled = "1" + elif self.sampled is False: + sampled = "0" + else: + sampled = None + + traceparent = "%s-%s" % (self.trace_id, self.span_id) + if sampled is not None: + traceparent += "-%s" % (sampled,) + + return traceparent + + def _to_baggage(self) -> "Optional[Baggage]": + if self._segment: + return self._segment._get_baggage() + return None + + def _get_baggage(self) -> "Baggage": + """ + Return the :py:class:`~sentry_sdk.tracing_utils.Baggage` associated with + the segment. + + The first time a new baggage with Sentry items is made, it will be frozen. + """ + if not self._baggage or self._baggage.mutable: + self._baggage = Baggage.populate_from_segment(self) + + return self._baggage + + def _iter_headers(self) -> "Iterator[tuple[str, str]]": + if not self._segment: + return + + yield SENTRY_TRACE_HEADER_NAME, self._to_traceparent() + + baggage = self._segment._get_baggage().serialize() + if baggage: + yield BAGGAGE_HEADER_NAME, baggage + + def _get_trace_context(self) -> "dict[str, Any]": + # Even if spans themselves are not event-based anymore, we need this + # to populate trace context on events + context: "dict[str, Any]" = { + "trace_id": self.trace_id, + "span_id": self.span_id, + "parent_span_id": self._parent_span_id, + "dynamic_sampling_context": self._dynamic_sampling_context(), + } + + if "sentry.op" in self._attributes: + context["op"] = self._attributes["sentry.op"] + if "sentry.origin" in self._attributes: + context["origin"] = self._attributes["sentry.origin"] + + return context + class NoOpStreamedSpan(StreamedSpan): __slots__ = ( diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index d8b67ea9ec..f130c9917c 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -44,6 +44,7 @@ from types import FrameType from sentry_sdk._types import Attributes + from sentry_sdk.traces import StreamedSpan SENTRY_TRACE_REGEX = re.compile( @@ -762,6 +763,55 @@ def populate_from_transaction( return Baggage(sentry_items, mutable=False) + @classmethod + def populate_from_segment(cls, segment: "StreamedSpan") -> "Baggage": + """ + Populate fresh baggage entry with sentry_items and make it immutable + if this is the head SDK which originates traces. + """ + client = sentry_sdk.get_client() + sentry_items: "Dict[str, str]" = {} + + if not client.is_active(): + return Baggage(sentry_items) + + options = client.options or {} + + sentry_items["trace_id"] = segment.trace_id + sentry_items["sample_rand"] = f"{segment._sample_rand:.6f}" # noqa: E231 + + if options.get("environment"): + sentry_items["environment"] = options["environment"] + + if options.get("release"): + sentry_items["release"] = options["release"] + + if client.parsed_dsn: + sentry_items["public_key"] = client.parsed_dsn.public_key + if client.parsed_dsn.org_id: + sentry_items["org_id"] = client.parsed_dsn.org_id + + if ( + segment.get_attributes().get("sentry.span.source") + not in LOW_QUALITY_SEGMENT_SOURCES + ) and segment._name: + sentry_items["transaction"] = segment._name + + if segment._sample_rate is not None: + sentry_items["sample_rate"] = str(segment._sample_rate) + + if segment.sampled is not None: + sentry_items["sampled"] = "true" if segment.sampled else "false" + + # There's an existing baggage but it was mutable, which is why we are + # creating this new baggage. + # However, if by chance the user put some sentry items in there, give + # them precedence. + if segment._baggage and segment._baggage.sentry_items: + sentry_items.update(segment._baggage.sentry_items) + + return Baggage(sentry_items, mutable=False) + def freeze(self) -> None: self.mutable = False @@ -1531,6 +1581,7 @@ def _matches(rule: "Any", value: "Any") -> bool: SENTRY_TRACE_HEADER_NAME, ) from sentry_sdk.traces import ( + LOW_QUALITY_SEGMENT_SOURCES, start_span as start_streaming_span, ) From 5f2679721d0922a089efeffba69b43d2bb53d18e Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 14:04:12 +0100 Subject: [PATCH 71/83] compat, making mypy happy --- sentry_sdk/api.py | 15 +++++- sentry_sdk/feature_flags.py | 3 +- sentry_sdk/scope.py | 68 ++++++++++++++++++++++------ sentry_sdk/traces.py | 4 +- sentry_sdk/tracing.py | 15 ++++++ sentry_sdk/tracing_utils.py | 36 +++++++++++++-- tests/tracing/test_span_streaming.py | 25 ++++++++++ 7 files changed, 144 insertions(+), 22 deletions(-) diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index bea22d8be7..264d6393a9 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -37,6 +37,7 @@ LogLevelStr, SamplingContext, ) + from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing import Span, TransactionKwargs T = TypeVar("T") @@ -409,7 +410,9 @@ def set_measurement(name: str, value: float, unit: "MeasurementUnit" = "") -> No transaction.set_measurement(name, value, unit) -def get_current_span(scope: "Optional[Scope]" = None) -> "Optional[Span]": +def get_current_span( + scope: "Optional[Scope]" = None, +) -> "Optional[Union[Span, StreamedSpan]]": """ Returns the currently active span if there is one running, otherwise `None` """ @@ -525,6 +528,16 @@ def update_current_span( if current_span is None: return + if isinstance(current_span, StreamedSpan): + warnings.warn( + "The `update_current_span` API isn't available in streaming mode. " + "Retrieve the current span with get_current_span() and use its API " + "directly.", + DeprecationWarning, + stacklevel=2, + ) + return + if op is not None: current_span.op = op diff --git a/sentry_sdk/feature_flags.py b/sentry_sdk/feature_flags.py index c9f3f303f9..de0cefbcad 100644 --- a/sentry_sdk/feature_flags.py +++ b/sentry_sdk/feature_flags.py @@ -1,6 +1,7 @@ import copy import sentry_sdk from sentry_sdk._lru_cache import LRUCache +from sentry_sdk.tracing import Span from threading import Lock from typing import TYPE_CHECKING, Any @@ -61,5 +62,5 @@ def add_feature_flag(flag: str, result: bool) -> None: flags.set(flag, result) span = sentry_sdk.get_current_span() - if span: + if span and isinstance(span, Span): span.set_flag(f"flag.evaluation.{flag}", result) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index a3477cb200..3e16f3b23b 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -584,7 +584,7 @@ def get_traceparent(self, *args: "Any", **kwargs: "Any") -> "Optional[str]": # If we have an active span, return traceparent from there if has_tracing_enabled(client.options) and self.span is not None: - return self.span.to_traceparent() + return self.span._to_traceparent() # else return traceparent from the propagation context return self.get_active_propagation_context().to_traceparent() @@ -598,7 +598,7 @@ def get_baggage(self, *args: "Any", **kwargs: "Any") -> "Optional[Baggage]": # If we have an active span, return baggage from there if has_tracing_enabled(client.options) and self.span is not None: - return self.span.to_baggage() + return self.span._to_baggage() # else return baggage from the propagation context return self.get_active_propagation_context().get_baggage() @@ -608,7 +608,7 @@ def get_trace_context(self) -> "Dict[str, Any]": Returns the Sentry "trace" context from the Propagation Context. """ if has_tracing_enabled(self.get_client().options) and self._span is not None: - return self._span.get_trace_context() + return self._span._get_trace_context() # if we are tracing externally (otel), those values take precedence external_propagation_context = get_external_propagation_context() @@ -673,7 +673,7 @@ def iter_trace_propagation_headers( span = span or self.span if has_tracing_enabled(client.options) and span is not None: - for header in span.iter_headers(): + for header in span._iter_headers(): yield header elif has_external_propagation_context(): # when we have an external_propagation_context (otlp) @@ -720,7 +720,7 @@ def clear(self) -> None: self.clear_breadcrumbs() self._should_capture: bool = True - self._span: "Optional[Span]" = None + self._span: "Optional[Union[Span, StreamedSpan]]" = None self._session: "Optional[Session]" = None self._force_auto_session_tracking: "Optional[bool]" = None @@ -774,6 +774,14 @@ def transaction(self) -> "Any": if self._span is None: return None + if isinstance(self._span, StreamedSpan): + warnings.warn( + "Scope.transaction is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return None + # there is an orphan span on the scope if self._span.containing_transaction is None: return None @@ -803,17 +811,39 @@ def transaction(self, value: "Any") -> None: "Assigning to scope.transaction directly is deprecated: use scope.set_transaction_name() instead." ) self._transaction = value - if self._span and self._span.containing_transaction: - self._span.containing_transaction.name = value + if self._span: + if isinstance(self._span, StreamedSpan): + warnings.warn( + "Scope.transaction is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return None + + if self._span.containing_transaction: + self._span.containing_transaction.name = value def set_transaction_name(self, name: str, source: "Optional[str]" = None) -> None: """Set the transaction name and optionally the transaction source.""" self._transaction = name + if self._span: + if isinstance(self._span, NoOpStreamedSpan): + return + + elif isinstance(self._span, StreamedSpan): + self._span._segment.name = name + if source: + self._span._segment.set_attribute( + "sentry.span.source", getattr(source, "value", source) + ) + + elif self._span.containing_transaction: + self._span.containing_transaction.name = name + if source: + self._span.containing_transaction.source = source - if self._span and self._span.containing_transaction: - self._span.containing_transaction.name = name - if source: - self._span.containing_transaction.source = source + if source: + self._transaction_info["source"] = source if source: self._transaction_info["source"] = source @@ -836,12 +866,12 @@ def set_user(self, value: "Optional[Dict[str, Any]]") -> None: session.update(user=value) @property - def span(self) -> "Optional[Span]": + def span(self) -> "Optional[Union[Span, StreamedSpan]]": """Get/set current tracing span or transaction.""" return self._span @span.setter - def span(self, span: "Optional[Span]") -> None: + def span(self, span: "Optional[Union[Span, StreamedSpan]]") -> None: self._span = span # XXX: this differs from the implementation in JS, there Scope.setSpan # does not set Scope._transactionName. @@ -1150,6 +1180,15 @@ def start_span( be removed in the next major version. Going forward, it should only be used by the SDK itself. """ + client = sentry_sdk.get_client() + if has_span_streaming_enabled(client.options): + warnings.warn( + "Scope.start_span is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return NoOpSpan() + if kwargs.get("description") is not None: warnings.warn( "The `description` parameter is deprecated. Please use `name` instead.", @@ -1169,6 +1208,9 @@ def start_span( # get current span or transaction span = self.span or self.get_isolation_scope().span + if isinstance(span, StreamedSpan): + # make mypy happy + return NoOpSpan() if span is None: # New spans get the `trace_id` from the scope diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 3d50b30642..e10f2ca2a7 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -332,7 +332,7 @@ def finish(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> No def _start(self) -> None: if self._active: old_span = self._scope.span - self._scope.span = self # type: ignore + self._scope.span = self self._previous_span_on_scope = old_span def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: @@ -563,7 +563,7 @@ def _start(self) -> None: return old_span = self._scope.span - self._scope.span = self # type: ignore + self._scope.span = self self._previous_span_on_scope = old_span def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index a778da7361..91da23b19e 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -771,6 +771,12 @@ def update_active_thread(self) -> None: thread_id, thread_name = get_current_thread_meta() self.set_thread(thread_id, thread_name) + # Private aliases matching StreamedSpan's private API + _to_traceparent = to_traceparent + _to_baggage = to_baggage + _iter_headers = iter_headers + _get_trace_context = get_trace_context + class Transaction(Span): """The Transaction is the root element that holds all the spans @@ -1243,6 +1249,8 @@ def _set_initial_sampling_decision( ) ) + _get_baggage = get_baggage + class NoOpSpan(Span): def __repr__(self) -> str: @@ -1324,6 +1332,13 @@ def _set_initial_sampling_decision( ) -> None: pass + # Private aliases matching StreamedSpan's private API + _to_traceparent = to_traceparent + _to_baggage = to_baggage + _get_baggage = get_baggage + _iter_headers = iter_headers + _get_trace_context = get_trace_context + if TYPE_CHECKING: diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index f130c9917c..87097fe077 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -935,6 +935,14 @@ async def async_wrapper(*args: "Any", **kwargs: "Any") -> "Any": ) return await f(*args, **kwargs) + if isinstance(current_span, StreamedSpan): + warnings.warn( + "Use the @sentry_sdk.traces.trace decorator in span streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return await f(*args, **kwargs) + span_op = op or _get_span_op(template) function_name = name or qualname_from_function(f) or "" span_name = _get_span_name(template, function_name, kwargs) @@ -972,6 +980,14 @@ def sync_wrapper(*args: "Any", **kwargs: "Any") -> "Any": ) return f(*args, **kwargs) + if isinstance(current_span, StreamedSpan): + warnings.warn( + "Use the @sentry_sdk.traces.trace decorator in span streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return f(*args, **kwargs) + span_op = op or _get_span_op(template) function_name = name or qualname_from_function(f) or "" span_name = _get_span_name(template, function_name, kwargs) @@ -1072,7 +1088,9 @@ def sync_wrapper(*args: "Any", **kwargs: "Any") -> "Any": return span_decorator -def get_current_span(scope: "Optional[sentry_sdk.Scope]" = None) -> "Optional[Span]": +def get_current_span( + scope: "Optional[sentry_sdk.Scope]" = None, +) -> "Optional[Union[Span, StreamedSpan]]": """ Returns the currently active span if there is one running, otherwise `None` """ @@ -1081,16 +1099,24 @@ def get_current_span(scope: "Optional[sentry_sdk.Scope]" = None) -> "Optional[Sp return current_span -def set_span_errored(span: "Optional[Span]" = None) -> None: +def set_span_errored(span: "Optional[Union[Span, StreamedSpan]]" = None) -> None: """ Set the status of the current or given span to INTERNAL_ERROR. Also sets the status of the transaction (root span) to INTERNAL_ERROR. """ + from sentry_sdk.traces import StreamedSpan, SpanStatus + span = span or get_current_span() + if span is not None: - span.set_status(SPANSTATUS.INTERNAL_ERROR) - if span.containing_transaction is not None: - span.containing_transaction.set_status(SPANSTATUS.INTERNAL_ERROR) + if isinstance(span, Span): + span.set_status(SPANSTATUS.INTERNAL_ERROR) + if span.containing_transaction is not None: + span.containing_transaction.set_status(SPANSTATUS.INTERNAL_ERROR) + elif isinstance(span, StreamedSpan): + span.status = SpanStatus.ERROR + if span._segment is not None: + span._segment.status = SpanStatus.ERROR def _generate_sample_rand( diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index eb9446c1ee..9b4dc61898 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -678,6 +678,31 @@ def test_continue_trace_no_sample_rand(sentry_init, capture_envelopes): assert segment["trace_id"] == trace_id +def test_outgoing_traceparent_and_baggage(sentry_init, capture_envelopes): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + sentry_sdk.traces.new_trace() + + with sentry_sdk.traces.start_span(name="span") as span: + assert span.sampled is True + + trace_id = span.trace_id + span_id = span.span_id + + traceparent = sentry_sdk.get_traceparent() + assert traceparent == f"{trace_id}-{span_id}-1" + + baggage = sentry_sdk.get_baggage() + baggage_items = dict(tuple(item.split("=")) for item in baggage.split(",")) + assert "sentry-trace_id" in baggage_items + assert baggage_items["sentry-trace_id"] == trace_id + assert "sentry-sampled" in baggage_items + assert baggage_items["sentry-sampled"] == "true" + + def test_trace_decorator(sentry_init, capture_envelopes): sentry_init( traces_sample_rate=1.0, From cc772a072cbfc69435266fec4f7b0034a3e5666d Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 14:37:09 +0100 Subject: [PATCH 72/83] . --- sentry_sdk/api.py | 1 + sentry_sdk/tracing_utils.py | 2 +- tests/tracing/test_span_streaming.py | 38 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index 264d6393a9..a9936230ad 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -6,6 +6,7 @@ from sentry_sdk._init_implementation import init from sentry_sdk.consts import INSTRUMENTER from sentry_sdk.scope import Scope, _ScopeManager, new_scope, isolation_scope +from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing import NoOpSpan, Transaction, trace from sentry_sdk.crons import monitor diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 87097fe077..d174a01d4b 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -44,7 +44,6 @@ from types import FrameType from sentry_sdk._types import Attributes - from sentry_sdk.traces import StreamedSpan SENTRY_TRACE_REGEX = re.compile( @@ -1608,6 +1607,7 @@ def _matches(rule: "Any", value: "Any") -> bool: ) from sentry_sdk.traces import ( LOW_QUALITY_SEGMENT_SOURCES, + StreamedSpan, start_span as start_streaming_span, ) diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 9b4dc61898..7c98a902d7 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -703,6 +703,44 @@ def test_outgoing_traceparent_and_baggage(sentry_init, capture_envelopes): assert baggage_items["sentry-sampled"] == "true" +def test_outgoing_traceparent_and_baggage_when_noop_span_is_active( + sentry_init, capture_envelopes +): + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream", + "ignore_spans": ["ignored"], + }, + ) + + sentry_sdk.traces.new_trace() + + propagation_context = ( + sentry_sdk.get_current_scope().get_active_propagation_context() + ) + propagation_trace_id = propagation_context.trace_id + propagation_span_id = propagation_context.span_id + + with sentry_sdk.traces.start_span(name="ignored") as span: + assert span.sampled is False + + noop_trace_id = span.trace_id + noop_span_id = span.span_id + + traceparent = sentry_sdk.get_traceparent() + assert traceparent != f"{noop_trace_id}-{noop_span_id}-0" + assert traceparent == f"{propagation_trace_id}-{propagation_span_id}-0" + + baggage = sentry_sdk.get_baggage() + baggage_items = dict(tuple(item.split("=")) for item in baggage.split(",")) + assert "sentry-trace_id" in baggage_items + assert baggage_items["sentry-trace_id"] != noop_trace_id + assert baggage_items["sentry-trace_id"] == propagation_trace_id + assert "sentry-sampled" in baggage_items + assert baggage_items["sentry-sampled"] == "false" + + def test_trace_decorator(sentry_init, capture_envelopes): sentry_init( traces_sample_rate=1.0, From 52cd46db7f81777768b4fd8ea1a21894b9501758 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 14:54:11 +0100 Subject: [PATCH 73/83] . --- sentry_sdk/scope.py | 18 +++++++++++++++--- tests/tracing/test_span_streaming.py | 6 ++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 3e16f3b23b..6a79d2eaa4 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -583,7 +583,11 @@ def get_traceparent(self, *args: "Any", **kwargs: "Any") -> "Optional[str]": client = self.get_client() # If we have an active span, return traceparent from there - if has_tracing_enabled(client.options) and self.span is not None: + if ( + has_tracing_enabled(client.options) + and self.span is not None + and not isinstance(self.span, NoOpStreamedSpan) + ): return self.span._to_traceparent() # else return traceparent from the propagation context @@ -597,7 +601,11 @@ def get_baggage(self, *args: "Any", **kwargs: "Any") -> "Optional[Baggage]": client = self.get_client() # If we have an active span, return baggage from there - if has_tracing_enabled(client.options) and self.span is not None: + if ( + has_tracing_enabled(client.options) + and self.span is not None + and not isinstance(self.span, NoOpStreamedSpan) + ): return self.span._to_baggage() # else return baggage from the propagation context @@ -607,7 +615,11 @@ def get_trace_context(self) -> "Dict[str, Any]": """ Returns the Sentry "trace" context from the Propagation Context. """ - if has_tracing_enabled(self.get_client().options) and self._span is not None: + if ( + has_tracing_enabled(self.get_client().options) + and self._span is not None + and not isinstance(self.span, NoOpStreamedSpan) + ): return self._span._get_trace_context() # if we are tracing externally (otel), those values take precedence diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 7c98a902d7..68b7ff7e6f 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -729,16 +729,14 @@ def test_outgoing_traceparent_and_baggage_when_noop_span_is_active( noop_span_id = span.span_id traceparent = sentry_sdk.get_traceparent() - assert traceparent != f"{noop_trace_id}-{noop_span_id}-0" - assert traceparent == f"{propagation_trace_id}-{propagation_span_id}-0" + assert traceparent != f"{noop_trace_id}-{noop_span_id}" + assert traceparent == f"{propagation_trace_id}-{propagation_span_id}" baggage = sentry_sdk.get_baggage() baggage_items = dict(tuple(item.split("=")) for item in baggage.split(",")) assert "sentry-trace_id" in baggage_items assert baggage_items["sentry-trace_id"] != noop_trace_id assert baggage_items["sentry-trace_id"] == propagation_trace_id - assert "sentry-sampled" in baggage_items - assert baggage_items["sentry-sampled"] == "false" def test_trace_decorator(sentry_init, capture_envelopes): From a656540fa0fbf18dc675b904c8b055106575cae4 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 11 Mar 2026 14:57:18 +0100 Subject: [PATCH 74/83] . --- sentry_sdk/scope.py | 3 --- sentry_sdk/tracing.py | 2 ++ sentry_sdk/tracing_utils.py | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 6a79d2eaa4..e68cbdfdac 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -857,9 +857,6 @@ def set_transaction_name(self, name: str, source: "Optional[str]" = None) -> Non if source: self._transaction_info["source"] = source - if source: - self._transaction_info["source"] = source - @_attr_setter def user(self, value: "Optional[Dict[str, Any]]") -> None: """When set a specific user is bound to the scope. Deprecated in favor of set_user.""" diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 91da23b19e..eac44b852e 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1249,7 +1249,9 @@ def _set_initial_sampling_decision( ) ) + # Private aliases matching StreamedSpan's private API _get_baggage = get_baggage + _get_trace_context = get_trace_context class NoOpSpan(Span): diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index d174a01d4b..e23c5886e2 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1604,6 +1604,7 @@ def _matches(rule: "Any", value: "Any") -> bool: BAGGAGE_HEADER_NAME, LOW_QUALITY_TRANSACTION_SOURCES, SENTRY_TRACE_HEADER_NAME, + Span, ) from sentry_sdk.traces import ( LOW_QUALITY_SEGMENT_SOURCES, From e6f207d00c12977d98c5b6453e7480d5a9a9f908 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 12 Mar 2026 13:22:12 +0100 Subject: [PATCH 75/83] mypy --- sentry_sdk/ai/utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index 5acc501172..560945e9e0 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -14,6 +14,7 @@ import sentry_sdk from sentry_sdk.utils import logger +from sentry_sdk.tracing_utils import has_span_streaming_enabled MAX_GEN_AI_MESSAGE_BYTES = 20_000 # 20KB # Maximum characters when only a single message is left after bytes truncation @@ -525,7 +526,14 @@ def normalize_message_roles(messages: "list[dict[str, Any]]") -> "list[dict[str, def get_start_span_function() -> "Callable[..., Any]": + if has_span_streaming_enabled(sentry_sdk.get_client().options): + return sentry_sdk.traces.start_span + current_span = sentry_sdk.get_current_span() + if not isinstance(current_span, Span): + # mypy + return sentry_sdk.traces.start_span + transaction_exists = ( current_span is not None and current_span.containing_transaction is not None ) From 4f754923d741cbeac40a297bbc9931379437fab7 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 12 Mar 2026 13:20:57 +0100 Subject: [PATCH 76/83] ref(graphene): Simplify span creation (#5648) - don't re-implement parent span/scope management - actually set span on scope --- sentry_sdk/integrations/graphene.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/graphene.py b/sentry_sdk/integrations/graphene.py index 5a61ca5c78..c2df9e7907 100644 --- a/sentry_sdk/integrations/graphene.py +++ b/sentry_sdk/integrations/graphene.py @@ -141,17 +141,15 @@ def graphql_span( }, ) - scope = sentry_sdk.get_current_scope() - if scope.span: - _graphql_span = scope.span.start_child(op=op, name=operation_name) - else: - _graphql_span = sentry_sdk.start_span(op=op, name=operation_name) + _graphql_span = sentry_sdk.start_span(op=op, name=operation_name) _graphql_span.set_data("graphql.document", source) _graphql_span.set_data("graphql.operation.name", operation_name) _graphql_span.set_data("graphql.operation.type", operation_type) + _graphql_span.__enter__() + try: yield finally: - _graphql_span.finish() + _graphql_span.__exit__(None, None, None) From 945891167d85391a6bf1e4e6c97679a94189832f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 12 Mar 2026 13:29:48 +0100 Subject: [PATCH 77/83] mypy --- sentry_sdk/integrations/celery/__init__.py | 5 ++++- sentry_sdk/integrations/openai_agents/utils.py | 14 +++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/celery/__init__.py b/sentry_sdk/integrations/celery/__init__.py index fe832f159e..6d5dc9d450 100644 --- a/sentry_sdk/integrations/celery/__init__.py +++ b/sentry_sdk/integrations/celery/__init__.py @@ -100,7 +100,10 @@ def _set_status(status: str) -> None: with capture_internal_exceptions(): scope = sentry_sdk.get_current_scope() if scope.span is not None: - scope.span.set_status(status) + if isinstance(scope.span, Span): + scope.span.set_status(status) + else: + scope.span.status = "ok" if status == "ok" else "error" def _capture_exception(task: "Any", exc_info: "ExcInfo") -> None: diff --git a/sentry_sdk/integrations/openai_agents/utils.py b/sentry_sdk/integrations/openai_agents/utils.py index 15119a393c..e496ed7fb5 100644 --- a/sentry_sdk/integrations/openai_agents/utils.py +++ b/sentry_sdk/integrations/openai_agents/utils.py @@ -11,6 +11,7 @@ from sentry_sdk.consts import SPANDATA, SPANSTATUS, OP from sentry_sdk.integrations import DidNotEnable from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing import Span from sentry_sdk.tracing_utils import set_span_errored from sentry_sdk.utils import event_from_exception, safe_serialize from sentry_sdk.ai._openai_completions_api import _transform_system_instructions @@ -22,10 +23,10 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any + from typing import Any, Union from agents import Usage, TResponseInputItem - from sentry_sdk.tracing import Span + from sentry_sdk.traces import StreamedSpan from sentry_sdk._types import TextPart try: @@ -46,8 +47,15 @@ def _capture_exception(exc: "Any") -> None: sentry_sdk.capture_event(event, hint=hint) -def _record_exception_on_span(span: "Span", error: Exception) -> "Any": +def _record_exception_on_span( + span: "Union[Span, StreamedSpan]", error: Exception +) -> "Any": set_span_errored(span) + + if not isinstance(span, Span): + # TODO[span-first]: make this work with streamedspans + return + span.set_data("span.status", "error") # Optionally capture the error details if we have them From 59b18de26222b6692db9f06dd00a7ea40b9213b7 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 12 Mar 2026 13:48:35 +0100 Subject: [PATCH 78/83] . --- sentry_sdk/ai/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index 560945e9e0..0091ffdbf5 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -14,6 +14,7 @@ import sentry_sdk from sentry_sdk.utils import logger +from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing_utils import has_span_streaming_enabled MAX_GEN_AI_MESSAGE_BYTES = 20_000 # 20KB @@ -530,7 +531,7 @@ def get_start_span_function() -> "Callable[..., Any]": return sentry_sdk.traces.start_span current_span = sentry_sdk.get_current_span() - if not isinstance(current_span, Span): + if isinstance(current_span, StreamedSpan): # mypy return sentry_sdk.traces.start_span From 6663470443b157b1287fe26e523eb1f93d97f900 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 12 Mar 2026 13:49:59 +0100 Subject: [PATCH 79/83] . --- sentry_sdk/integrations/celery/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/celery/__init__.py b/sentry_sdk/integrations/celery/__init__.py index 6d5dc9d450..b8b1691979 100644 --- a/sentry_sdk/integrations/celery/__init__.py +++ b/sentry_sdk/integrations/celery/__init__.py @@ -14,7 +14,7 @@ ) from sentry_sdk.integrations.celery.utils import _now_seconds_since_epoch from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, TransactionSource +from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, Span, TransactionSource from sentry_sdk.tracing_utils import Baggage from sentry_sdk.utils import ( capture_internal_exceptions, @@ -34,7 +34,6 @@ from typing import Union from sentry_sdk._types import EventProcessor, Event, Hint, ExcInfo - from sentry_sdk.tracing import Span F = TypeVar("F", bound=Callable[..., Any]) From 0466a914907178fee3fdf1f6bd3aed8fdaf37117 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 13 Mar 2026 10:06:10 +0100 Subject: [PATCH 80/83] . --- sentry_sdk/tracing_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index a435d8d65c..9efb01000c 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -8,7 +8,6 @@ from collections.abc import Mapping, MutableMapping from datetime import timedelta from random import Random -from typing import Pattern from urllib.parse import quote, unquote import uuid From a5f0069e18ac8866b4aa8b2c646758cbc0a17579 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 13 Mar 2026 10:08:01 +0100 Subject: [PATCH 81/83] extra noopguard --- sentry_sdk/scope.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index ddf6422ecb..07e1bf4ee1 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -682,7 +682,11 @@ def iter_trace_propagation_headers( span = kwargs.pop("span", None) span = span or self.span - if has_tracing_enabled(client.options) and span is not None: + if ( + has_tracing_enabled(client.options) + and span is not None + and not isinstance(span, NoOpStreamedSpan) + ): for header in span._iter_headers(): yield header elif has_external_propagation_context(): From 1757fdb54702a18ce08a5e9b55775c356875b625 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 13 Mar 2026 10:08:36 +0100 Subject: [PATCH 82/83] . --- sentry_sdk/scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 07e1bf4ee1..e92c0bf7fc 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -616,7 +616,7 @@ def get_trace_context(self) -> "Dict[str, Any]": if ( has_tracing_enabled(self.get_client().options) and self._span is not None - and not isinstance(self.span, NoOpStreamedSpan) + and not isinstance(self._span, NoOpStreamedSpan) ): return self._span._get_trace_context() From 7ada1f3028b41b84113dd8c001dd5df46cb1d842 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 13 Mar 2026 06:42:21 -0400 Subject: [PATCH 83/83] feat(anthropic): Add `gen_ai.system` attribute to spans (#5661) Add the `gen_ai.system` span attribute (set to `"anthropic"`) to the Anthropic integration. Other AI integrations (OpenAI, Langchain, Google GenAI, LiteLLM, Pydantic AI) already set this attribute, but it was missing from the Anthropic integration. The attribute is set in `_set_input_data()` which is called for every span (streaming/non-streaming, sync/async). Refs PY-2135 Closes https://github.com/getsentry/sentry-python/issues/5657 --- sentry_sdk/integrations/anthropic.py | 1 + tests/integrations/anthropic/test_anthropic.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/sentry_sdk/integrations/anthropic.py b/sentry_sdk/integrations/anthropic.py index 0aa812cab3..37ace1a64a 100644 --- a/sentry_sdk/integrations/anthropic.py +++ b/sentry_sdk/integrations/anthropic.py @@ -247,6 +247,7 @@ def _set_input_data( """ Set input data for the span based on the provided keyword arguments for the anthropic message creation. """ + span.set_data(SPANDATA.GEN_AI_SYSTEM, "anthropic") span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") system_instructions: "Union[str, Iterable[TextBlockParam]]" = kwargs.get("system") # type: ignore messages = kwargs.get("messages") diff --git a/tests/integrations/anthropic/test_anthropic.py b/tests/integrations/anthropic/test_anthropic.py index ea48f5d4db..1c9ddfa3c7 100644 --- a/tests/integrations/anthropic/test_anthropic.py +++ b/tests/integrations/anthropic/test_anthropic.py @@ -117,6 +117,7 @@ def test_nonstreaming_create_message( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -187,6 +188,7 @@ async def test_nonstreaming_create_message_async( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -288,6 +290,7 @@ def test_streaming_create_message( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -393,6 +396,7 @@ async def test_streaming_create_message_async( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -525,6 +529,7 @@ def test_streaming_create_message_with_input_json_delta( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -666,6 +671,7 @@ async def test_streaming_create_message_with_input_json_delta_async( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -730,6 +736,7 @@ def test_span_status_error(sentry_init, capture_events): assert transaction["spans"][0]["status"] == "internal_error" assert transaction["spans"][0]["tags"]["status"] == "internal_error" assert transaction["contexts"]["trace"]["status"] == "internal_error" + assert transaction["spans"][0]["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert transaction["spans"][0]["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" @@ -755,6 +762,7 @@ async def test_span_status_error_async(sentry_init, capture_events): assert transaction["spans"][0]["status"] == "internal_error" assert transaction["spans"][0]["tags"]["status"] == "internal_error" assert transaction["contexts"]["trace"]["status"] == "internal_error" + assert transaction["spans"][0]["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert transaction["spans"][0]["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" @@ -803,6 +811,7 @@ def test_span_origin(sentry_init, capture_events): assert event["contexts"]["trace"]["origin"] == "manual" assert event["spans"][0]["origin"] == "auto.ai.anthropic" + assert event["spans"][0]["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert event["spans"][0]["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" @@ -831,6 +840,7 @@ async def test_span_origin_async(sentry_init, capture_events): assert event["contexts"]["trace"]["origin"] == "manual" assert event["spans"][0]["origin"] == "auto.ai.anthropic" + assert event["spans"][0]["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert event["spans"][0]["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" @@ -951,6 +961,7 @@ def mock_messages_create(*args, **kwargs): # Verify that the span was created correctly assert span["op"] == "gen_ai.chat" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert SPANDATA.GEN_AI_REQUEST_MESSAGES in span["data"] @@ -996,6 +1007,7 @@ def test_anthropic_message_truncation(sentry_init, capture_events): assert len(chat_spans) > 0 chat_span = chat_spans[0] + assert chat_span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert chat_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert SPANDATA.GEN_AI_REQUEST_MESSAGES in chat_span["data"] @@ -1047,6 +1059,7 @@ async def test_anthropic_message_truncation_async(sentry_init, capture_events): assert len(chat_spans) > 0 chat_span = chat_spans[0] + assert chat_span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert chat_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert SPANDATA.GEN_AI_REQUEST_MESSAGES in chat_span["data"] @@ -1115,6 +1128,7 @@ def test_nonstreaming_create_message_with_system_prompt( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -1199,6 +1213,7 @@ async def test_nonstreaming_create_message_with_system_prompt_async( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -1315,6 +1330,7 @@ def test_streaming_create_message_with_system_prompt( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -1435,6 +1451,7 @@ async def test_streaming_create_message_with_system_prompt_async( assert span["op"] == OP.GEN_AI_CHAT assert span["description"] == "chat model" + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "model" @@ -1501,6 +1518,7 @@ def test_system_prompt_with_complex_structure(sentry_init, capture_events): assert len(event["spans"]) == 1 (span,) = event["spans"] + assert span["data"][SPANDATA.GEN_AI_SYSTEM] == "anthropic" assert span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS in span["data"]