From 0a7eae805e2466ee3314e8ef7f81f0ae6c96dfce Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Mar 2026 16:38:52 +0100 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] . --- 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 ad6e7cc09ccc059ee933f3b97d758d5d8ac9394c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Mar 2026 14:11:31 +0100 Subject: [PATCH 05/10] 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 06/10] 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 07/10] 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 08/10] . --- 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 09/10] 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 10/10] 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