From 946decc9ff9b9f057612f39a297b4df95a5ab0dd Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 12:08:24 +0100 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 4b14e8d534e686b5a18f931655128453f0ecc14b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 14:52:21 +0100 Subject: [PATCH 12/14] 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 f2235745e492a8429ed4f88cb53cdb2054aef73e Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 13:50:28 +0100 Subject: [PATCH 13/14] 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 14/14] 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": """