Skip to content
4 changes: 4 additions & 0 deletions sentry_sdk/_span_batcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +97 to +98
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no scenario where a span that's already in the span batcher wouldn't have an end _timestamp, but mypy doesn't know that. 🤡


if item._parent_span_id:
res["parent_span_id"] = item._parent_span_id

Expand Down
161 changes: 156 additions & 5 deletions sentry_sdk/traces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -189,8 +197,12 @@ class StreamedSpan:
"_parent_span_id",
"_segment",
"_parent_sampled",
"_start_timestamp",
"_start_timestamp_monotonic_ns",
"_timestamp",
"_status",
"_scope",
"_previous_span_on_scope",
"_baggage",
)

Expand Down Expand Up @@ -223,11 +235,23 @@ def __init__(
self._parent_sampled = parent_sampled
self._baggage = baggage

self._start_timestamp = datetime.now(timezone.utc)
self._timestamp: "Optional[datetime]" = None

try:
# profiling depends on this value and requires that
# it is measured in nanoseconds
self._start_timestamp_monotonic_ns = nanosecond_time()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anything related to _start_timestamp_monotonic_ns is just copy-pasted from the Span implementation

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__}("
Expand All @@ -244,7 +268,79 @@ 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]]"
"""
self._end(end_timestamp)

def finish(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just here for compat reasons, to make the transition from the old API easier (very slightly).

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:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_active basically controls whether a span will be set on the scope, so if it's False, we don't need to keep track of the old parent span to restore it later, because we're never replacing it.

old_span = self._scope.span
self._scope.span = self
self._previous_span_on_scope = old_span

def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None:
if self._timestamp is not None:
# 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

# 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 end_timestamp is not None:
if isinstance(end_timestamp, (float, int)):
try:
end_timestamp = datetime.fromtimestamp(end_timestamp, timezone.utc)
except Exception:
pass

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:
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)

client = sentry_sdk.get_client()
if not client.is_active():
return

# Finally, queue the span for sending to Sentry
self._scope._capture_span(self)

def get_attributes(self) -> "Attributes":
return self._attributes
Expand Down Expand Up @@ -309,10 +405,28 @@ 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})>"
Expand All @@ -323,7 +437,36 @@ 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

old_span = self._scope.span
self._scope.span = self
self._previous_span_on_scope = old_span
Comment on lines +443 to +448
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike the main span class, no-op spans don't have an active flag. The user cannot control whether a no-op span will be set on the scope; the SDK does this internally, and it uses the _scope attribute to remember the decision. In the case of normal spans, the _scope attribute plays a different role: they all need a scope, regardless of whether they'll actually be set on it or not, because they will be captured using that scope in either case.

Coming back to no-op spans, the decision whether to set them on the scope or not is tightly coupled to how we want ignore_spans to work (coming in a future PR). Basically, if a segment/root span would be ignored, all of its children should be as well. That's easiest to accomplish if we actually set the root no-op span on the scope. On the other hand, if a non-root span is ignored, it's not set on scope, so that any children spans effectively use the last not ignored span that's set on the scope as parent.


def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> 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 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 {}
Expand Down Expand Up @@ -369,6 +512,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,
Expand Down
Loading