From 946decc9ff9b9f057612f39a297b4df95a5ab0dd Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 12:08:24 +0100 Subject: [PATCH 01/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] . --- 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] . --- 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] . --- 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/38] 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/38] 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 d6fa965ddf6e3dec05d3d672a7db2120b4e8151c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 10:41:39 +0100 Subject: [PATCH 35/38] . --- 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 dab1970a511362560c1a7e6e4487ff19173a68f6 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 11:16:47 +0100 Subject: [PATCH 36/38] 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 2f0dc01735381357a95b5ebaa098d1af0dbf5cd0 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 11:26:25 +0100 Subject: [PATCH 37/38] . --- 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 09b88f0b2c3adf30e340dbad82c8f1d7a6051e2e Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 9 Mar 2026 15:12:06 +0100 Subject: [PATCH 38/38] 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,