From 757ee12698ee149120ea67af2a48c2e9e2b177b6 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 15 Jan 2026 13:44:50 +0100 Subject: [PATCH 01/38] feat: Span streaming & new span API --- sentry_sdk/_span_batcher.py | 136 +++++++++++++++ sentry_sdk/_types.py | 22 +++ sentry_sdk/client.py | 24 ++- sentry_sdk/consts.py | 5 + sentry_sdk/envelope.py | 2 + sentry_sdk/scope.py | 38 +++++ sentry_sdk/trace.py | 322 ++++++++++++++++++++++++++++++++++++ sentry_sdk/tracing_utils.py | 7 + 8 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 sentry_sdk/_span_batcher.py create mode 100644 sentry_sdk/trace.py diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py new file mode 100644 index 0000000000..35ed09148e --- /dev/null +++ b/sentry_sdk/_span_batcher.py @@ -0,0 +1,136 @@ +import threading +from collections import defaultdict +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sentry_sdk._batcher import Batcher +from sentry_sdk.consts import SPANSTATUS +from sentry_sdk.envelope import Envelope, Item, PayloadRef +from sentry_sdk.utils import serialize_attribute, safe_repr + +if TYPE_CHECKING: + from typing import Any, Callable, Optional + from sentry_sdk.trace import Span + + +class SpanBatcher(Batcher["Span"]): + # TODO[span-first]: size-based flushes + MAX_BEFORE_FLUSH = 1000 + MAX_BEFORE_DROP = 5000 + FLUSH_WAIT_TIME = 5.0 + + TYPE = "span" + CONTENT_TYPE = "application/vnd.sentry.items.span.v2+json" + + def __init__( + self, + capture_func: "Callable[[Envelope], None]", + record_lost_func: "Callable[..., None]", + ) -> None: + # Spans from different traces cannot be emitted in the same envelope + # since the envelope contains a shared trace header. That's why we bucket + # by trace_id, so that we can then send the buckets each in its own + # envelope. + # trace_id -> span buffer + self._span_buffer: dict[str, list["Span"]] = defaultdict(list) + self._capture_func = capture_func + self._record_lost_func = record_lost_func + self._running = True + self._lock = threading.Lock() + + self._flush_event: "threading.Event" = threading.Event() + + 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: Span) -> None: + if not self._ensure_thread() or self._flusher is None: + return None + + with self._lock: + size = self.get_size() + if size >= self.MAX_BEFORE_DROP: + self._record_lost_func( + reason="queue_overflow", + data_category="span", + quantity=1, + ) + return None + + self._span_buffer[span.trace_id].append(span) + if size + 1 >= self.MAX_BEFORE_FLUSH: + self._flush_event.set() + + @staticmethod + def _to_transport_format(item: "Span") -> "Any": + is_segment = item.containing_transaction == item + + res = { + "trace_id": item.trace_id, + "span_id": item.span_id, + "name": item.name if is_segment else item.description, + "status": SPANSTATUS.OK + if item.status == SPANSTATUS.OK + else SPANSTATUS.INTERNAL_ERROR, + "is_segment": is_segment, + "start_timestamp": item.start_timestamp.timestamp(), # TODO[span-first] + "end_timestamp": item.timestamp.timestamp(), + } + + 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() + } + + return res + + def _flush(self): + # type: (...) -> Optional[Envelope] + from sentry_sdk.utils import format_timestamp + + with self._lock: + if len(self._span_buffer) == 0: + return None + + for trace_id, spans in self._span_buffer.items(): + if spans: + trace_context = spans[0].get_trace_context() + dsc = trace_context.get("dynamic_sampling_context") + # XXX[span-first]: empty dsc? + + 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", + headers={ + "item_count": len(spans), + }, + payload=PayloadRef( + json={ + "items": [ + self._to_transport_format(span) + for span in spans + ] + } + ), + ) + ) + + self._span_buffer.clear() + + self._capture_func(envelope) + return envelope diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 0ae3e653a7..5f17aaab11 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -1,5 +1,7 @@ from typing import TYPE_CHECKING, TypeVar, Union +from sentry_sdk.consts import SPANSTATUS + # Re-exported for compat, since code out there in the wild might use this variable. MYPY = TYPE_CHECKING @@ -274,6 +276,26 @@ class SDKInfo(TypedDict): MetricProcessor = Callable[[Metric, Hint], Optional[Metric]] + SpanV2Status = Literal[SPANSTATUS.OK, SPANSTATUS.ERROR] + # This is the V2 span format + # https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/ + SpanV2 = TypedDict( + "SpanV2", + { + "trace_id": str, + "span_id": str, + "parent_span_id": Optional[str], + "name": str, + "status": SpanV2Status, + "is_segment": bool, + "start_timestamp": float, + "end_timestamp": float, + "attributes": Attributes, + }, + ) + + TraceLifecycleMode = Literal["static", "stream"] + # TODO: Make a proper type definition for this (PRs welcome!) Breadcrumb = Dict[str, Any] diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index fb14d8e36a..0896027db2 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -11,6 +11,7 @@ import sentry_sdk from sentry_sdk._compat import PY37, check_uwsgi_thread_support from sentry_sdk._metrics_batcher import MetricsBatcher +from sentry_sdk._span_batcher import SpanBatcher from sentry_sdk.utils import ( AnnotatedValue, ContextVar, @@ -31,6 +32,7 @@ ) from sentry_sdk.serializer import serialize from sentry_sdk.tracing import trace +from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.transport import BaseHttpTransport, make_transport from sentry_sdk.consts import ( SPANDATA, @@ -67,6 +69,7 @@ from sentry_sdk.scope import Scope from sentry_sdk.session import Session from sentry_sdk.spotlight import SpotlightClient + from sentry_sdk.trace import StreamedSpan from sentry_sdk.transport import Transport, Item from sentry_sdk._log_batcher import LogBatcher from sentry_sdk._metrics_batcher import MetricsBatcher @@ -188,6 +191,7 @@ def __init__(self, options: "Optional[Dict[str, Any]]" = None) -> None: self.monitor: "Optional[Monitor]" = None self.log_batcher: "Optional[LogBatcher]" = None self.metrics_batcher: "Optional[MetricsBatcher]" = None + self.span_batcher: "Optional[SpanBatcher]" = None self.integrations: "dict[str, Integration]" = {} def __getstate__(self, *args: "Any", **kwargs: "Any") -> "Any": @@ -399,6 +403,12 @@ def _record_lost_event( record_lost_func=_record_lost_event, ) + self.span_batcher = None + if has_span_streaming_enabled(self.options): + self.span_batcher = SpanBatcher( + capture_func=_capture_envelope, record_lost_func=_record_lost_event + ) + max_request_body_size = ("always", "never", "small", "medium") if self.options["max_request_body_size"] not in max_request_body_size: raise ValueError( @@ -909,7 +919,10 @@ def capture_event( return return_value def _capture_telemetry( - self, telemetry: "Optional[Union[Log, Metric]]", ty: str, scope: "Scope" + self, + telemetry: "Optional[Union[Log, Metric, StreamedSpan]]", + ty: str, + scope: "Scope", ) -> None: # Capture attributes-based telemetry (logs, metrics, spansV2) if telemetry is None: @@ -921,7 +934,7 @@ def _capture_telemetry( if ty == "log": before_send = get_before_send_log(self.options) elif ty == "metric": - before_send = get_before_send_metric(self.options) # type: ignore + before_send = get_before_send_metric(self.options) if before_send is not None: telemetry = before_send(telemetry, {}) # type: ignore @@ -933,7 +946,9 @@ def _capture_telemetry( if ty == "log": batcher = self.log_batcher elif ty == "metric": - batcher = self.metrics_batcher # type: ignore + batcher = self.metrics_batcher + elif ty == "span": + batcher = self.span_batcher if batcher is not None: batcher.add(telemetry) # type: ignore @@ -944,6 +959,9 @@ def _capture_log(self, log: "Optional[Log]", scope: "Scope") -> None: def _capture_metric(self, metric: "Optional[Metric]", scope: "Scope") -> None: self._capture_telemetry(metric, "metric", scope) + def _capture_span(self, span: "Optional[StreamedSpan]", scope: "Scope") -> None: + self._capture_telemetry(span, "span", scope) + def capture_session( self, session: "Session", diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 59d3997c9a..cd1e8243e5 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -2,6 +2,8 @@ from enum import Enum from typing import TYPE_CHECKING +from sentry_sdk._types import TraceLifecycleMode + # up top to prevent circular import due to integration import # This is more or less an arbitrary large-ish value for now, so that we allow # pretty long strings (like LLM prompts), but still have *some* upper limit @@ -82,6 +84,7 @@ class CompressionAlgo(Enum): "before_send_log": Optional[Callable[[Log, Hint], Optional[Log]]], "enable_metrics": Optional[bool], "before_send_metric": Optional[Callable[[Metric, Hint], Optional[Metric]]], + "trace_lifecycle": Optional[TraceLifecycleMode], }, total=False, ) @@ -877,6 +880,8 @@ class SPANSTATUS: UNIMPLEMENTED = "unimplemented" UNKNOWN_ERROR = "unknown_error" + ERROR = "error" # span-first specific + class OP: ANTHROPIC_MESSAGES_CREATE = "ai.messages.create.anthropic" diff --git a/sentry_sdk/envelope.py b/sentry_sdk/envelope.py index 307fb26fd6..5e52c6196f 100644 --- a/sentry_sdk/envelope.py +++ b/sentry_sdk/envelope.py @@ -253,6 +253,8 @@ def data_category(self) -> "EventDataCategory": return "session" elif ty == "attachment": return "attachment" + elif ty == "span": + return "span" elif ty == "transaction": return "transaction" elif ty == "event": diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 6df26690c8..eee951b7f9 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -32,6 +32,7 @@ normalize_incoming_data, PropagationContext, ) +from sentry_sdk.trace import StreamedSpan from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, @@ -1147,6 +1148,40 @@ def start_span( return span + def start_streamed_span( + self, + name: str, + attributes: "Optional[Attributes]" = None, + parent_span: "Optional[StreamedSpan]" = None, + ) -> "StreamedSpan": + # TODO: rename to start_span once we drop the old API + with new_scope(): + if parent_span is None: + # get current span or transaction + parent_span = self.span or self.get_isolation_scope().span + + if parent_span is None: + # New spans get the `trace_id` from the scope + propagation_context = self.get_active_propagation_context() + span = StreamedSpan( + name=name, + attributes=attributes, + trace_id=propagation_context.trace_id, + scope=self, + ) + else: + # Children take propagation context from the parent span + span = StreamedSpan( + name=name, + attributes=attributes, + trace_id=parent_span.trace_id, + parent_span_id=parent_span.span_id, + segment=parent_span.segment, + scope=self, + ) + + return span + def continue_trace( self, environ_or_headers: "Dict[str, Any]", @@ -1180,6 +1215,9 @@ def continue_trace( **optional_kwargs, ) + def set_propagation_context(self, environ_or_headers: "dict[str, Any]") -> None: + self.generate_propagation_context(environ_or_headers) + def capture_event( self, event: "Event", diff --git a/sentry_sdk/trace.py b/sentry_sdk/trace.py new file mode 100644 index 0000000000..90bfe69205 --- /dev/null +++ b/sentry_sdk/trace.py @@ -0,0 +1,322 @@ +import uuid +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk.consts import SPANDATA, SPANSTATUS +from sentry_sdk.profiler.continuous_profiler import get_profiler_id +from sentry_sdk.tracing import Span +from sentry_sdk.tracing_utils import has_span_streaming_enabled, has_tracing_enabled +from sentry_sdk.utils import ( + capture_internal_exceptions, + format_attribute, + get_current_thread_meta, + logger, + nanosecond_time, + should_be_treated_as_error, +) + +if TYPE_CHECKING: + from typing import Any, Optional, Union + from sentry_sdk._types import Attributes, AttributeValue + from sentry_sdk.scope import Scope + + +FLAGS_CAPACITY = 10 + +""" +TODO[span-first] / notes +- redis, http, subprocess breadcrumbs (maybe_create_breadcrumbs_from_span) work + on op, change or ignore? +- @trace +- tags +- initial status: OK? or unset? +- dropped spans are not migrated +- recheck transaction.finish <-> Streamedspan.end +- profile not part of the event, how to send? + +Notes: +- removed ability to provide a start_timestamp +- moved _flags_capacity to a const +""" + + +def start_span( + name: str, + attributes: "Optional[Attributes]" = None, + parent_span: "Optional[Span]" = None, +) -> Span: + return sentry_sdk.get_current_scope().start_streamed_span() + + +BAGGAGE_HEADER_NAME = "baggage" +SENTRY_TRACE_HEADER_NAME = "sentry-trace" + + +# Segment source, see +# https://getsentry.github.io/sentry-conventions/generated/attributes/sentry.html#sentryspansource +class SegmentSource(str, Enum): + COMPONENT = "component" + CUSTOM = "custom" + ROUTE = "route" + TASK = "task" + URL = "url" + VIEW = "view" + + def __str__(self) -> str: + return self.value + + +# These are typically high cardinality and the server hates them +LOW_QUALITY_SEGMENT_SOURCES = [ + SegmentSource.URL, +] + +SOURCE_FOR_STYLE = { + "endpoint": SegmentSource.COMPONENT, + "function_name": SegmentSource.COMPONENT, + "handler_name": SegmentSource.COMPONENT, + "method_and_path_pattern": SegmentSource.ROUTE, + "path": SegmentSource.URL, + "route_name": SegmentSource.COMPONENT, + "route_pattern": SegmentSource.ROUTE, + "uri_template": SegmentSource.ROUTE, + "url": SegmentSource.ROUTE, +} + + +class StreamedSpan: + """ + A span holds timing information of a block of code. + + 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. + """ + + __slots__ = ( + "_name", + "_attributes", + "_span_id", + "_trace_id", + "_parent_span_id", + "_segment", + "_sampled", + "_start_timestamp", + "_timestamp", + "_status", + "_start_timestamp_monotonic_ns", + "_scope", + "_flags", + "_context_manager_state", + "_profile", + "_continuous_profile", + ) + + def __init__( + self, + name: str, + trace_id: str, + attributes: Optional[Attributes] = None, + parent_span_id: Optional[str] = None, + segment: Optional[Span] = None, + scope: Optional[Scope] = None, + ) -> None: + self._name: str = name + self._attributes: "Attributes" = attributes + + self._trace_id = trace_id + self._parent_span_id = parent_span_id + self._segment = segment or self + + self._start_timestamp = datetime.now(timezone.utc) + + 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._timestamp: "Optional[datetime]" = None + self._span_id: "Optional[str]" = None + self._status: SPANSTATUS = SPANSTATUS.OK + self._sampled: "Optional[bool]" = None + self._scope: "Optional[Scope]" = scope # TODO[span-first] when are we starting a span with a specific scope? is this needed? + self._flags: dict[str, bool] = {} + + self._update_active_thread() + self._set_profiler_id(get_profiler_id()) + + 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}, " + f"parent_span_id={self._parent_span_id}, " + f"sampled={self._sampled})>" + ) + + def __enter__(self) -> "Span": + scope = self._scope or sentry_sdk.get_current_scope() + old_span = scope.span + scope.span = self + self._context_manager_state = (scope, old_span) + + if self.is_segment() and self._profile is not None: + self._profile.__enter__() + + return self + + def __exit__( + self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" + ) -> None: + if self.is_segment(): + if self._profile is not None: + self._profile.__exit__(ty, value, tb) + + if self._continuous_profile is not None: + self._continuous_profile.stop() + + if value is not None and should_be_treated_as_error(ty, value): + self.set_status(SPANSTATUS.INTERNAL_ERROR) + + with capture_internal_exceptions(): + scope, old_span = self._context_manager_state + del self._context_manager_state + self.end(scope=scope) + scope.span = old_span + + def end( + self, + end_timestamp: "Optional[Union[float, datetime]]" = None, + scope: "Optional[sentry_sdk.Scope]" = None, + ) -> "Optional[str]": + """ + Set the end timestamp of the span. + + :param end_timestamp: Optional timestamp that should + be used as timestamp instead of the current time. + :param scope: The scope to use for this transaction. + If not provided, the current scope will be used. + """ + client = sentry_sdk.get_client() + if not client.is_active(): + return None + + scope: "Optional[sentry_sdk.Scope]" = ( + scope or self._scope or sentry_sdk.get_current_scope() + ) + + # Explicit check against False needed because self.sampled might be None + if self._sampled is False: + logger.debug("Discarding span because sampled = False") + + # This is not entirely accurate because discards here are not + # exclusively based on sample rate but also traces sampler, but + # we handle this the same here. + if client.transport and has_tracing_enabled(client.options): + if client.monitor and client.monitor.downsample_factor > 0: + reason = "backpressure" + else: + reason = "sample_rate" + + client.transport.record_lost_event(reason, data_category="span") + + return None + + if self._sampled is None: + logger.warning("Discarding transaction without sampling decision.") + + if self.timestamp is not None: + # This span is already finished, ignore. + return None + + try: + if end_timestamp: + if isinstance(end_timestamp, float): + end_timestamp = datetime.fromtimestamp(end_timestamp, timezone.utc) + self.timestamp = end_timestamp + else: + 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) + + if self.segment.sampled: + client._capture_span(self) + return + + def get_attributes(self) -> Attributes: + return self._attributes + + def set_attribute(self, key: str, value: AttributeValue) -> None: + self._attributes[key] = format_attribute(value) + + def set_attributes(self, attributes: Attributes) -> None: + for key, value in attributes.items(): + self.set_attribute(key, value) + + def set_status(self, status: SPANSTATUS) -> None: + self._status = status + + def get_name(self) -> str: + return self._name + + def set_name(self, name: str) -> None: + self._name = name + + @property + def segment(self) -> "StreamedSpan": + return self._segment + + def is_segment(self) -> bool: + return self.segment == self + + @property + def sampled(self) -> "Optional[bool]": + return self._sampled + + @property + def span_id(self) -> str: + if not self._span_id: + self._span_id = uuid.uuid4().hex[16:] + + return self._span_id + + @property + def trace_id(self) -> str: + if not self._trace_id: + self._trace_id = uuid.uuid4().hex + + return self._trace_id + + def _update_active_thread(self) -> None: + thread_id, thread_name = get_current_thread_meta() + self._set_thread(thread_id, thread_name) + + def _set_thread( + self, thread_id: "Optional[int]", thread_name: "Optional[str]" + ) -> None: + if thread_id is not None: + self.set_attribute(SPANDATA.THREAD_ID, str(thread_id)) + + if thread_name is not None: + self.set_attribute(SPANDATA.THREAD_NAME, thread_name) + + def _set_profiler_id(self, profiler_id: "Optional[str]") -> None: + if profiler_id is not None: + self.set_attribute(SPANDATA.PROFILER_ID, profiler_id) + + def _set_http_status(self, http_status: int) -> None: + self.set_attribute(SPANDATA.HTTP_STATUS_CODE, http_status) + + if http_status >= 400: + self.set_status(SPANSTATUS.ERROR) + else: + self.set_status(SPANSTATUS.OK) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index f45b849499..5b6e22be36 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -106,6 +106,13 @@ def has_tracing_enabled(options: "Optional[Dict[str, Any]]") -> bool: ) +def has_span_streaming_enabled(options: "Optional[dict[str, Any]]") -> bool: + if options is None: + return False + + return (options.get("_experiments") or {}).get("trace_lifecycle") == "stream" + + @contextlib.contextmanager def record_sql_queries( cursor: "Any", From 705790a29a03b9771a944d2643c090c37a9a0551 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 15 Jan 2026 13:47:43 +0100 Subject: [PATCH 02/38] More refactor, fixing some types --- sentry_sdk/_span_batcher.py | 26 +++++++++++--------------- sentry_sdk/_types.py | 2 -- sentry_sdk/consts.py | 4 +--- sentry_sdk/trace.py | 37 ++++++++++++++++++++++--------------- 4 files changed, 34 insertions(+), 35 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index 35ed09148e..0be0dbe8cb 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from typing import Any, Callable, Optional - from sentry_sdk.trace import Span + from sentry_sdk.trace import SpanStatus, StreamedSpan class SpanBatcher(Batcher["Span"]): @@ -32,7 +32,7 @@ def __init__( # by trace_id, so that we can then send the buckets each in its own # envelope. # trace_id -> span buffer - self._span_buffer: dict[str, list["Span"]] = defaultdict(list) + self._span_buffer: dict[str, list["StreamedSpan"]] = defaultdict(list) self._capture_func = capture_func self._record_lost_func = record_lost_func self._running = True @@ -47,7 +47,7 @@ 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: Span) -> None: + def add(self, span: "StreamedSpan") -> None: if not self._ensure_thread() or self._flusher is None: return None @@ -66,23 +66,19 @@ def add(self, span: Span) -> None: self._flush_event.set() @staticmethod - def _to_transport_format(item: "Span") -> "Any": - is_segment = item.containing_transaction == item - + def _to_transport_format(item: "StreamedSpan") -> "Any": res = { "trace_id": item.trace_id, "span_id": item.span_id, - "name": item.name if is_segment else item.description, - "status": SPANSTATUS.OK - if item.status == SPANSTATUS.OK - else SPANSTATUS.INTERNAL_ERROR, - "is_segment": is_segment, - "start_timestamp": item.start_timestamp.timestamp(), # TODO[span-first] - "end_timestamp": item.timestamp.timestamp(), + "name": item.get_name(), + "status": item._status, + "is_segment": item.is_segment(), + "start_timestamp": item._start_timestamp.timestamp(), # TODO[span-first] + "end_timestamp": item._timestamp.timestamp(), } - if item.parent_span_id: - res["parent_span_id"] = item.parent_span_id + if item._parent_span_id: + res["parent_span_id"] = item._parent_span_id if item._attributes: res["attributes"] = { diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 5f17aaab11..204227aa00 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -294,8 +294,6 @@ class SDKInfo(TypedDict): }, ) - TraceLifecycleMode = Literal["static", "stream"] - # TODO: Make a proper type definition for this (PRs welcome!) Breadcrumb = Dict[str, Any] diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index cd1e8243e5..d682e8dc9f 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -2,8 +2,6 @@ from enum import Enum from typing import TYPE_CHECKING -from sentry_sdk._types import TraceLifecycleMode - # up top to prevent circular import due to integration import # This is more or less an arbitrary large-ish value for now, so that we allow # pretty long strings (like LLM prompts), but still have *some* upper limit @@ -84,7 +82,7 @@ class CompressionAlgo(Enum): "before_send_log": Optional[Callable[[Log, Hint], Optional[Log]]], "enable_metrics": Optional[bool], "before_send_metric": Optional[Callable[[Metric, Hint], Optional[Metric]]], - "trace_lifecycle": Optional[TraceLifecycleMode], + "trace_lifecycle": Optional[Literal["static", "stream"]], }, total=False, ) diff --git a/sentry_sdk/trace.py b/sentry_sdk/trace.py index 90bfe69205..59e699f557 100644 --- a/sentry_sdk/trace.py +++ b/sentry_sdk/trace.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING import sentry_sdk -from sentry_sdk.consts import SPANDATA, SPANSTATUS +from sentry_sdk.consts import SPANDATA from sentry_sdk.profiler.continuous_profiler import get_profiler_id from sentry_sdk.tracing import Span from sentry_sdk.tracing_utils import has_span_streaming_enabled, has_tracing_enabled @@ -35,6 +35,8 @@ - dropped spans are not migrated - recheck transaction.finish <-> Streamedspan.end - profile not part of the event, how to send? +- maybe: use getters/setter OR properties but not both +- add size-based flushing to buffer(s) Notes: - removed ability to provide a start_timestamp @@ -50,8 +52,12 @@ def start_span( return sentry_sdk.get_current_scope().start_streamed_span() -BAGGAGE_HEADER_NAME = "baggage" -SENTRY_TRACE_HEADER_NAME = "sentry-trace" +class SpanStatus(str, Enum): + OK = "ok" + ERROR = "error" + + def __str__(self) -> str: + return self.value # Segment source, see @@ -73,6 +79,7 @@ def __str__(self) -> str: SegmentSource.URL, ] + SOURCE_FOR_STYLE = { "endpoint": SegmentSource.COMPONENT, "function_name": SegmentSource.COMPONENT, @@ -119,10 +126,10 @@ def __init__( self, name: str, trace_id: str, - attributes: Optional[Attributes] = None, - parent_span_id: Optional[str] = None, - segment: Optional[Span] = None, - scope: Optional[Scope] = None, + attributes: "Optional[Attributes]" = None, + parent_span_id: "Optional[str]" = None, + segment: "Optional[Span]" = None, + scope: "Optional[Scope]" = None, ) -> None: self._name: str = name self._attributes: "Attributes" = attributes @@ -142,7 +149,7 @@ def __init__( self._timestamp: "Optional[datetime]" = None self._span_id: "Optional[str]" = None - self._status: SPANSTATUS = SPANSTATUS.OK + self._status: SpanStatus = SpanStatus.OK self._sampled: "Optional[bool]" = None self._scope: "Optional[Scope]" = scope # TODO[span-first] when are we starting a span with a specific scope? is this needed? self._flags: dict[str, bool] = {} @@ -182,7 +189,7 @@ def __exit__( self._continuous_profile.stop() if value is not None and should_be_treated_as_error(ty, value): - self.set_status(SPANSTATUS.INTERNAL_ERROR) + self.set_status(SpanStatus.ERROR) with capture_internal_exceptions(): scope, old_span = self._context_manager_state @@ -252,17 +259,17 @@ def end( client._capture_span(self) return - def get_attributes(self) -> Attributes: + def get_attributes(self) -> "Attributes": return self._attributes - def set_attribute(self, key: str, value: AttributeValue) -> None: + def set_attribute(self, key: str, value: "AttributeValue") -> None: self._attributes[key] = format_attribute(value) - def set_attributes(self, attributes: Attributes) -> None: + def set_attributes(self, attributes: "Attributes") -> None: for key, value in attributes.items(): self.set_attribute(key, value) - def set_status(self, status: SPANSTATUS) -> None: + def set_status(self, status: SpanStatus) -> None: self._status = status def get_name(self) -> str: @@ -317,6 +324,6 @@ def _set_http_status(self, http_status: int) -> None: self.set_attribute(SPANDATA.HTTP_STATUS_CODE, http_status) if http_status >= 400: - self.set_status(SPANSTATUS.ERROR) + self.set_status(SpanStatus.ERROR) else: - self.set_status(SPANSTATUS.OK) + self.set_status(SpanStatus.OK) From b3f4f988ce55951096e148c4373f4efccc22ec41 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 15 Jan 2026 14:32:42 +0100 Subject: [PATCH 03/38] Cant use sentry_sdk.trace as that already exists --- sentry_sdk/trace.py => sentry_sdk._tracing.py | 12 +- sentry_sdk/_tracing.py | 353 ++++++++++++++++++ sentry_sdk/scope.py | 2 +- sentry_sdk/tracing.py | 4 + sentry_sdk/tracing_utils.py | 48 +++ 5 files changed, 417 insertions(+), 2 deletions(-) rename sentry_sdk/trace.py => sentry_sdk._tracing.py (97%) create mode 100644 sentry_sdk/_tracing.py diff --git a/sentry_sdk/trace.py b/sentry_sdk._tracing.py similarity index 97% rename from sentry_sdk/trace.py rename to sentry_sdk._tracing.py index 59e699f557..8dfd4de1b9 100644 --- a/sentry_sdk/trace.py +++ b/sentry_sdk._tracing.py @@ -7,7 +7,11 @@ from sentry_sdk.consts import SPANDATA from sentry_sdk.profiler.continuous_profiler import get_profiler_id from sentry_sdk.tracing import Span -from sentry_sdk.tracing_utils import has_span_streaming_enabled, has_tracing_enabled +from sentry_sdk.tracing_utils import ( + Baggage, + has_span_streaming_enabled, + has_tracing_enabled, +) from sentry_sdk.utils import ( capture_internal_exceptions, format_attribute, @@ -120,6 +124,7 @@ class StreamedSpan: "_context_manager_state", "_profile", "_continuous_profile", + "_baggage", ) def __init__( @@ -303,6 +308,11 @@ def trace_id(self) -> str: return self._trace_id + @property + def dynamic_sampling_context(self) -> str: + # TODO + return self.segment.get_baggage().dynamic_sampling_context() + def _update_active_thread(self) -> None: thread_id, thread_name = get_current_thread_meta() self._set_thread(thread_id, thread_name) diff --git a/sentry_sdk/_tracing.py b/sentry_sdk/_tracing.py new file mode 100644 index 0000000000..8ba0ea9e12 --- /dev/null +++ b/sentry_sdk/_tracing.py @@ -0,0 +1,353 @@ +import uuid +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk.consts import SPANDATA +from sentry_sdk.profiler.continuous_profiler import get_profiler_id +from sentry_sdk.tracing_utils import ( + Baggage, + has_span_streaming_enabled, + has_tracing_enabled, +) +from sentry_sdk.utils import ( + capture_internal_exceptions, + format_attribute, + get_current_thread_meta, + logger, + nanosecond_time, + should_be_treated_as_error, +) + +if TYPE_CHECKING: + from typing import Any, Optional, Union + from sentry_sdk._types import Attributes, AttributeValue + from sentry_sdk.scope import Scope + + +FLAGS_CAPACITY = 10 + +""" +TODO[span-first] / notes +- redis, http, subprocess breadcrumbs (maybe_create_breadcrumbs_from_span) work + on op, change or ignore? +- @trace +- tags +- initial status: OK? or unset? +- dropped spans are not migrated +- recheck transaction.finish <-> Streamedspan.end +- profile not part of the event, how to send? +- maybe: use getters/setter OR properties but not both +- add size-based flushing to buffer(s) +- migrate transaction sample_rand logic + +Notes: +- removed ability to provide a start_timestamp +- moved _flags_capacity to a const +""" + + +def start_span( + name: str, + attributes: "Optional[Attributes]" = None, + parent_span: "Optional[StreamedSpan]" = None, +) -> "StreamedSpan": + return sentry_sdk.get_current_scope().start_streamed_span() + + +class SpanStatus(str, Enum): + OK = "ok" + ERROR = "error" + + def __str__(self) -> str: + return self.value + + +# Segment source, see +# https://getsentry.github.io/sentry-conventions/generated/attributes/sentry.html#sentryspansource +class SegmentSource(str, Enum): + COMPONENT = "component" + CUSTOM = "custom" + ROUTE = "route" + TASK = "task" + URL = "url" + VIEW = "view" + + def __str__(self) -> str: + return self.value + + +# These are typically high cardinality and the server hates them +LOW_QUALITY_SEGMENT_SOURCES = [ + SegmentSource.URL, +] + + +SOURCE_FOR_STYLE = { + "endpoint": SegmentSource.COMPONENT, + "function_name": SegmentSource.COMPONENT, + "handler_name": SegmentSource.COMPONENT, + "method_and_path_pattern": SegmentSource.ROUTE, + "path": SegmentSource.URL, + "route_name": SegmentSource.COMPONENT, + "route_pattern": SegmentSource.ROUTE, + "uri_template": SegmentSource.ROUTE, + "url": SegmentSource.ROUTE, +} + + +class StreamedSpan: + """ + A span holds timing information of a block of code. + + 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. + """ + + __slots__ = ( + "_name", + "_attributes", + "_span_id", + "_trace_id", + "_parent_span_id", + "_segment", + "_sampled", + "_start_timestamp", + "_timestamp", + "_status", + "_start_timestamp_monotonic_ns", + "_scope", + "_flags", + "_context_manager_state", + "_profile", + "_continuous_profile", + "_baggage", + "_sample_rate", + "_sample_rand", + ) + + def __init__( + self, + name: str, + trace_id: str, + attributes: "Optional[Attributes]" = None, + parent_span_id: "Optional[str]" = None, + segment: "Optional[StreamedSpan]" = None, + scope: "Optional[Scope]" = None, + ) -> None: + self._name: str = name + self._attributes: "Attributes" = attributes + + self._trace_id = trace_id + self._parent_span_id = parent_span_id + self._segment = segment or self + + self._start_timestamp = datetime.now(timezone.utc) + + 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._timestamp: "Optional[datetime]" = None + self._span_id: "Optional[str]" = None + self._status: SpanStatus = SpanStatus.OK + self._sampled: "Optional[bool]" = None + self._scope: "Optional[Scope]" = scope # TODO[span-first] when are we starting a span with a specific scope? is this needed? + self._flags: dict[str, bool] = {} + + self._update_active_thread() + self._set_profiler_id(get_profiler_id()) + + 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}, " + f"parent_span_id={self._parent_span_id}, " + f"sampled={self._sampled})>" + ) + + def __enter__(self) -> "StreamedSpan": + scope = self._scope or sentry_sdk.get_current_scope() + old_span = scope.span + scope.span = self + self._context_manager_state = (scope, old_span) + + if self.is_segment() and self._profile is not None: + self._profile.__enter__() + + return self + + def __exit__( + self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" + ) -> None: + if self.is_segment(): + if self._profile is not None: + self._profile.__exit__(ty, value, tb) + + if self._continuous_profile is not None: + self._continuous_profile.stop() + + if value is not None and should_be_treated_as_error(ty, value): + self.set_status(SpanStatus.ERROR) + + with capture_internal_exceptions(): + scope, old_span = self._context_manager_state + del self._context_manager_state + self.end(scope=scope) + scope.span = old_span + + def end( + self, + end_timestamp: "Optional[Union[float, datetime]]" = None, + scope: "Optional[sentry_sdk.Scope]" = None, + ) -> "Optional[str]": + """ + Set the end timestamp of the span. + + :param end_timestamp: Optional timestamp that should + be used as timestamp instead of the current time. + :param scope: The scope to use for this transaction. + If not provided, the current scope will be used. + """ + client = sentry_sdk.get_client() + if not client.is_active(): + return None + + scope: "Optional[sentry_sdk.Scope]" = ( + scope or self._scope or sentry_sdk.get_current_scope() + ) + + # Explicit check against False needed because self.sampled might be None + if self._sampled is False: + logger.debug("Discarding span because sampled = False") + + # This is not entirely accurate because discards here are not + # exclusively based on sample rate but also traces sampler, but + # we handle this the same here. + if client.transport and has_tracing_enabled(client.options): + if client.monitor and client.monitor.downsample_factor > 0: + reason = "backpressure" + else: + reason = "sample_rate" + + client.transport.record_lost_event(reason, data_category="span") + + return None + + if self._sampled is None: + logger.warning("Discarding transaction without sampling decision.") + + if self.timestamp is not None: + # This span is already finished, ignore. + return None + + try: + if end_timestamp: + if isinstance(end_timestamp, float): + end_timestamp = datetime.fromtimestamp(end_timestamp, timezone.utc) + self.timestamp = end_timestamp + else: + 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) + + if self.segment.sampled: + client._capture_span(self) + return + + def get_attributes(self) -> "Attributes": + return self._attributes + + def set_attribute(self, key: str, value: "AttributeValue") -> None: + self._attributes[key] = format_attribute(value) + + def set_attributes(self, attributes: "Attributes") -> None: + for key, value in attributes.items(): + self.set_attribute(key, value) + + def set_status(self, status: SpanStatus) -> None: + self._status = status + + def get_name(self) -> str: + return self._name + + def set_name(self, name: str) -> None: + self._name = name + + @property + def segment(self) -> "StreamedSpan": + return self._segment + + def is_segment(self) -> bool: + return self.segment == self + + @property + def sampled(self) -> "Optional[bool]": + return self._sampled + + @property + def span_id(self) -> str: + if not self._span_id: + self._span_id = uuid.uuid4().hex[16:] + + return self._span_id + + @property + def trace_id(self) -> str: + if not self._trace_id: + self._trace_id = uuid.uuid4().hex + + return self._trace_id + + @property + def dynamic_sampling_context(self) -> str: + # TODO + return self.segment.get_baggage().dynamic_sampling_context() + + def _update_active_thread(self) -> None: + thread_id, thread_name = get_current_thread_meta() + self._set_thread(thread_id, thread_name) + + def _set_thread( + self, thread_id: "Optional[int]", thread_name: "Optional[str]" + ) -> None: + if thread_id is not None: + self.set_attribute(SPANDATA.THREAD_ID, str(thread_id)) + + if thread_name is not None: + self.set_attribute(SPANDATA.THREAD_NAME, thread_name) + + def _set_profiler_id(self, profiler_id: "Optional[str]") -> None: + if profiler_id is not None: + self.set_attribute(SPANDATA.PROFILER_ID, profiler_id) + + def _set_http_status(self, http_status: int) -> None: + self.set_attribute(SPANDATA.HTTP_STATUS_CODE, http_status) + + if http_status >= 400: + self.set_status(SpanStatus.ERROR) + else: + self.set_status(SpanStatus.OK) + + def _get_baggage(self) -> "Baggage": + """ + Return the :py:class:`~sentry_sdk.tracing_utils.Baggage` associated with + the segment. + + The first time a new baggage with Sentry items is made, it will be frozen. + """ + if not self._baggage or self._baggage.mutable: + self._baggage = Baggage.populate_from_segment(self) + + return self._baggage diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index eee951b7f9..97140c6227 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -32,7 +32,7 @@ normalize_incoming_data, PropagationContext, ) -from sentry_sdk.trace import StreamedSpan +from sentry_sdk._tracing import StreamedSpan from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index c4b38e4528..9160ae7b20 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -126,6 +126,10 @@ class TransactionKwargs(SpanKwargs, total=False): }, ) +# TODO: Once the old Span class is gone, move _tracing.py to tracing.py. This is +# here for now so that you can do sentry_sdk.tracing.start_span for the new API. +from sentry_sdk._tracing import start_span + BAGGAGE_HEADER_NAME = "baggage" SENTRY_TRACE_HEADER_NAME = "sentry-trace" diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 5b6e22be36..a1e8fbc4fe 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -749,6 +749,54 @@ def populate_from_transaction( return Baggage(sentry_items, mutable=False) + @classmethod + def populate_from_segment( + cls, segment: "sentry_sdk.trace.StreamedSpan" + ) -> "Baggage": + """ + Populate fresh baggage entry with sentry_items and make it immutable + if this is the head SDK which originates traces. + """ + client = sentry_sdk.get_client() + sentry_items: "Dict[str, str]" = {} + + if not client.is_active(): + return Baggage(sentry_items) + + options = client.options or {} + + sentry_items["trace_id"] = segment.trace_id + sentry_items["sample_rand"] = f"{segment._sample_rand:.6f}" # noqa: E231 + + if options.get("environment"): + sentry_items["environment"] = options["environment"] + + if options.get("release"): + sentry_items["release"] = options["release"] + + if client.parsed_dsn: + sentry_items["public_key"] = client.parsed_dsn.public_key + if client.parsed_dsn.org_id: + sentry_items["org_id"] = client.parsed_dsn.org_id + + if segment.source not in LOW_QUALITY_TRANSACTION_SOURCES: + sentry_items["transaction"] = segment.name + + if segment._sample_rate is not None: + sentry_items["sample_rate"] = str(segment._sample_rate) + + if segment._sampled is not None: + sentry_items["sampled"] = "true" if segment._sampled else "false" + + # There's an existing baggage but it was mutable, which is why we are + # creating this new baggage. + # However, if by chance the user put some sentry items in there, give + # them precedence. + if segment._baggage and segment._baggage.sentry_items: + sentry_items.update(segment._baggage.sentry_items) + + return Baggage(sentry_items, mutable=False) + def freeze(self) -> None: self.mutable = False From 97cf2916a420fec586c1554791db9f3ca0c49d7b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 15 Jan 2026 14:38:53 +0100 Subject: [PATCH 04/38] bubu --- sentry_sdk._tracing.py | 339 ----------------------------------------- 1 file changed, 339 deletions(-) delete mode 100644 sentry_sdk._tracing.py diff --git a/sentry_sdk._tracing.py b/sentry_sdk._tracing.py deleted file mode 100644 index 8dfd4de1b9..0000000000 --- a/sentry_sdk._tracing.py +++ /dev/null @@ -1,339 +0,0 @@ -import uuid -from datetime import datetime, timedelta, timezone -from enum import Enum -from typing import TYPE_CHECKING - -import sentry_sdk -from sentry_sdk.consts import SPANDATA -from sentry_sdk.profiler.continuous_profiler import get_profiler_id -from sentry_sdk.tracing import Span -from sentry_sdk.tracing_utils import ( - Baggage, - has_span_streaming_enabled, - has_tracing_enabled, -) -from sentry_sdk.utils import ( - capture_internal_exceptions, - format_attribute, - get_current_thread_meta, - logger, - nanosecond_time, - should_be_treated_as_error, -) - -if TYPE_CHECKING: - from typing import Any, Optional, Union - from sentry_sdk._types import Attributes, AttributeValue - from sentry_sdk.scope import Scope - - -FLAGS_CAPACITY = 10 - -""" -TODO[span-first] / notes -- redis, http, subprocess breadcrumbs (maybe_create_breadcrumbs_from_span) work - on op, change or ignore? -- @trace -- tags -- initial status: OK? or unset? -- dropped spans are not migrated -- recheck transaction.finish <-> Streamedspan.end -- profile not part of the event, how to send? -- maybe: use getters/setter OR properties but not both -- add size-based flushing to buffer(s) - -Notes: -- removed ability to provide a start_timestamp -- moved _flags_capacity to a const -""" - - -def start_span( - name: str, - attributes: "Optional[Attributes]" = None, - parent_span: "Optional[Span]" = None, -) -> Span: - return sentry_sdk.get_current_scope().start_streamed_span() - - -class SpanStatus(str, Enum): - OK = "ok" - ERROR = "error" - - def __str__(self) -> str: - return self.value - - -# Segment source, see -# https://getsentry.github.io/sentry-conventions/generated/attributes/sentry.html#sentryspansource -class SegmentSource(str, Enum): - COMPONENT = "component" - CUSTOM = "custom" - ROUTE = "route" - TASK = "task" - URL = "url" - VIEW = "view" - - def __str__(self) -> str: - return self.value - - -# These are typically high cardinality and the server hates them -LOW_QUALITY_SEGMENT_SOURCES = [ - SegmentSource.URL, -] - - -SOURCE_FOR_STYLE = { - "endpoint": SegmentSource.COMPONENT, - "function_name": SegmentSource.COMPONENT, - "handler_name": SegmentSource.COMPONENT, - "method_and_path_pattern": SegmentSource.ROUTE, - "path": SegmentSource.URL, - "route_name": SegmentSource.COMPONENT, - "route_pattern": SegmentSource.ROUTE, - "uri_template": SegmentSource.ROUTE, - "url": SegmentSource.ROUTE, -} - - -class StreamedSpan: - """ - A span holds timing information of a block of code. - - 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. - """ - - __slots__ = ( - "_name", - "_attributes", - "_span_id", - "_trace_id", - "_parent_span_id", - "_segment", - "_sampled", - "_start_timestamp", - "_timestamp", - "_status", - "_start_timestamp_monotonic_ns", - "_scope", - "_flags", - "_context_manager_state", - "_profile", - "_continuous_profile", - "_baggage", - ) - - def __init__( - self, - name: str, - trace_id: str, - attributes: "Optional[Attributes]" = None, - parent_span_id: "Optional[str]" = None, - segment: "Optional[Span]" = None, - scope: "Optional[Scope]" = None, - ) -> None: - self._name: str = name - self._attributes: "Attributes" = attributes - - self._trace_id = trace_id - self._parent_span_id = parent_span_id - self._segment = segment or self - - self._start_timestamp = datetime.now(timezone.utc) - - 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._timestamp: "Optional[datetime]" = None - self._span_id: "Optional[str]" = None - self._status: SpanStatus = SpanStatus.OK - self._sampled: "Optional[bool]" = None - self._scope: "Optional[Scope]" = scope # TODO[span-first] when are we starting a span with a specific scope? is this needed? - self._flags: dict[str, bool] = {} - - self._update_active_thread() - self._set_profiler_id(get_profiler_id()) - - 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}, " - f"parent_span_id={self._parent_span_id}, " - f"sampled={self._sampled})>" - ) - - def __enter__(self) -> "Span": - scope = self._scope or sentry_sdk.get_current_scope() - old_span = scope.span - scope.span = self - self._context_manager_state = (scope, old_span) - - if self.is_segment() and self._profile is not None: - self._profile.__enter__() - - return self - - def __exit__( - self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" - ) -> None: - if self.is_segment(): - if self._profile is not None: - self._profile.__exit__(ty, value, tb) - - if self._continuous_profile is not None: - self._continuous_profile.stop() - - if value is not None and should_be_treated_as_error(ty, value): - self.set_status(SpanStatus.ERROR) - - with capture_internal_exceptions(): - scope, old_span = self._context_manager_state - del self._context_manager_state - self.end(scope=scope) - scope.span = old_span - - def end( - self, - end_timestamp: "Optional[Union[float, datetime]]" = None, - scope: "Optional[sentry_sdk.Scope]" = None, - ) -> "Optional[str]": - """ - Set the end timestamp of the span. - - :param end_timestamp: Optional timestamp that should - be used as timestamp instead of the current time. - :param scope: The scope to use for this transaction. - If not provided, the current scope will be used. - """ - client = sentry_sdk.get_client() - if not client.is_active(): - return None - - scope: "Optional[sentry_sdk.Scope]" = ( - scope or self._scope or sentry_sdk.get_current_scope() - ) - - # Explicit check against False needed because self.sampled might be None - if self._sampled is False: - logger.debug("Discarding span because sampled = False") - - # This is not entirely accurate because discards here are not - # exclusively based on sample rate but also traces sampler, but - # we handle this the same here. - if client.transport and has_tracing_enabled(client.options): - if client.monitor and client.monitor.downsample_factor > 0: - reason = "backpressure" - else: - reason = "sample_rate" - - client.transport.record_lost_event(reason, data_category="span") - - return None - - if self._sampled is None: - logger.warning("Discarding transaction without sampling decision.") - - if self.timestamp is not None: - # This span is already finished, ignore. - return None - - try: - if end_timestamp: - if isinstance(end_timestamp, float): - end_timestamp = datetime.fromtimestamp(end_timestamp, timezone.utc) - self.timestamp = end_timestamp - else: - 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) - - if self.segment.sampled: - client._capture_span(self) - return - - def get_attributes(self) -> "Attributes": - return self._attributes - - def set_attribute(self, key: str, value: "AttributeValue") -> None: - self._attributes[key] = format_attribute(value) - - def set_attributes(self, attributes: "Attributes") -> None: - for key, value in attributes.items(): - self.set_attribute(key, value) - - def set_status(self, status: SpanStatus) -> None: - self._status = status - - def get_name(self) -> str: - return self._name - - def set_name(self, name: str) -> None: - self._name = name - - @property - def segment(self) -> "StreamedSpan": - return self._segment - - def is_segment(self) -> bool: - return self.segment == self - - @property - def sampled(self) -> "Optional[bool]": - return self._sampled - - @property - def span_id(self) -> str: - if not self._span_id: - self._span_id = uuid.uuid4().hex[16:] - - return self._span_id - - @property - def trace_id(self) -> str: - if not self._trace_id: - self._trace_id = uuid.uuid4().hex - - return self._trace_id - - @property - def dynamic_sampling_context(self) -> str: - # TODO - return self.segment.get_baggage().dynamic_sampling_context() - - def _update_active_thread(self) -> None: - thread_id, thread_name = get_current_thread_meta() - self._set_thread(thread_id, thread_name) - - def _set_thread( - self, thread_id: "Optional[int]", thread_name: "Optional[str]" - ) -> None: - if thread_id is not None: - self.set_attribute(SPANDATA.THREAD_ID, str(thread_id)) - - if thread_name is not None: - self.set_attribute(SPANDATA.THREAD_NAME, thread_name) - - def _set_profiler_id(self, profiler_id: "Optional[str]") -> None: - if profiler_id is not None: - self.set_attribute(SPANDATA.PROFILER_ID, profiler_id) - - def _set_http_status(self, http_status: int) -> None: - self.set_attribute(SPANDATA.HTTP_STATUS_CODE, http_status) - - if http_status >= 400: - self.set_status(SpanStatus.ERROR) - else: - self.set_status(SpanStatus.OK) From 0d2097bf8d5f9416222fc2d349f7081874aa9fad Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 15 Jan 2026 14:53:05 +0100 Subject: [PATCH 05/38] dsc, sampling --- sentry_sdk/_span_batcher.py | 8 ++-- sentry_sdk/_tracing.py | 84 +++++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index 0be0dbe8cb..ecf3518116 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from typing import Any, Callable, Optional - from sentry_sdk.trace import SpanStatus, StreamedSpan + from sentry_sdk._tracing import SpanStatus, StreamedSpan class SpanBatcher(Batcher["Span"]): @@ -87,8 +87,7 @@ def _to_transport_format(item: "StreamedSpan") -> "Any": return res - def _flush(self): - # type: (...) -> Optional[Envelope] + def _flush(self) -> Optional[Envelope]: from sentry_sdk.utils import format_timestamp with self._lock: @@ -97,8 +96,7 @@ def _flush(self): for trace_id, spans in self._span_buffer.items(): if spans: - trace_context = spans[0].get_trace_context() - dsc = trace_context.get("dynamic_sampling_context") + dsc = spans[0].dynamic_sampling_context() # XXX[span-first]: empty dsc? envelope = Envelope( diff --git a/sentry_sdk/_tracing.py b/sentry_sdk/_tracing.py index 8ba0ea9e12..cfcc652f8f 100644 --- a/sentry_sdk/_tracing.py +++ b/sentry_sdk/_tracing.py @@ -15,6 +15,7 @@ capture_internal_exceptions, format_attribute, get_current_thread_meta, + is_valid_sample_rate, logger, nanosecond_time, should_be_treated_as_error, @@ -22,7 +23,7 @@ if TYPE_CHECKING: from typing import Any, Optional, Union - from sentry_sdk._types import Attributes, AttributeValue + from sentry_sdk._types import Attributes, AttributeValue, SamplingContext from sentry_sdk.scope import Scope @@ -312,8 +313,7 @@ def trace_id(self) -> str: @property def dynamic_sampling_context(self) -> str: - # TODO - return self.segment.get_baggage().dynamic_sampling_context() + return self.segment._get_baggage().dynamic_sampling_context() def _update_active_thread(self) -> None: thread_id, thread_name = get_current_thread_meta() @@ -351,3 +351,81 @@ def _get_baggage(self) -> "Baggage": self._baggage = Baggage.populate_from_segment(self) return self._baggage + + def _set_initial_sampling_decision( + self, sampling_context: "SamplingContext" + ) -> None: + """ + Sets the segment's sampling decision, according to the following + precedence rules: + + 1. If `traces_sampler` is defined, its decision will be used. It can + choose to keep or ignore any parent sampling decision, or use the + sampling context data to make its own decision or to choose a sample + rate for the transaction. + + 2. If `traces_sampler` is not defined, but there's a parent sampling + decision, the parent sampling decision will be used. + + 3. If `traces_sampler` is not defined and there's no parent sampling + decision, `traces_sample_rate` will be used. + """ + client = sentry_sdk.get_client() + + # nothing to do if tracing is disabled + if not has_tracing_enabled(client.options): + self.sampled = False + return + + if not self.is_segment(): + return + + traces_sampler_defined = callable(client.options.get("traces_sampler")) + + # We would have bailed already if neither `traces_sampler` nor + # `traces_sample_rate` were defined, so one of these should work; prefer + # the hook if so + if traces_sampler_defined: + sample_rate = client.options["traces_sampler"](sampling_context) + else: + if sampling_context["parent_sampled"] is not None: + sample_rate = sampling_context["parent_sampled"] + else: + sample_rate = client.options["traces_sample_rate"] + + # Since this is coming from the user (or from a function provided by the + # user), who knows what we might get. (The only valid values are + # booleans or numbers between 0 and 1.) + if not is_valid_sample_rate(sample_rate, source="Tracing"): + logger.warning( + f"[Tracing] Discarding {self._name} because of invalid sample rate." + ) + self.sampled = False + return + + self.sample_rate = float(sample_rate) + + if client.monitor: + self.sample_rate /= 2**client.monitor.downsample_factor + + # if the function returned 0 (or false), or if `traces_sample_rate` is + # 0, it's a sign the transaction should be dropped + if not self.sample_rate: + if traces_sampler_defined: + reason = "traces_sampler returned 0 or False" + else: + reason = "traces_sample_rate is set to 0" + + logger.debug(f"[Tracing] Discarding {self._name} because {reason}") + self.sampled = False + return + + # Now we roll the dice. + self.sampled = self._sample_rand < self.sample_rate + + if self.sampled: + logger.debug(f"[Tracing] Starting {self.name}") + else: + logger.debug( + f"[Tracing] Discarding {self.name} because it's not included in the random sample (sampling rate = {self.sample_rate})" + ) From b01caabd77f88f107fbc7cf94f3534857f4f4749 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 16 Jan 2026 14:45:38 +0100 Subject: [PATCH 06/38] . --- sentry_sdk/_span_batcher.py | 20 ++--- sentry_sdk/_tracing.py | 160 +++++++++++++++++++++--------------- sentry_sdk/client.py | 11 ++- sentry_sdk/scope.py | 121 +++++++++++++++++++-------- sentry_sdk/tracing_utils.py | 10 +-- 5 files changed, 202 insertions(+), 120 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index ecf3518116..a5f3ab7cc0 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -6,7 +6,7 @@ from sentry_sdk._batcher import Batcher from sentry_sdk.consts import SPANSTATUS from sentry_sdk.envelope import Envelope, Item, PayloadRef -from sentry_sdk.utils import serialize_attribute, safe_repr +from sentry_sdk.utils import format_timestamp, serialize_attribute, safe_repr if TYPE_CHECKING: from typing import Any, Callable, Optional @@ -71,24 +71,24 @@ def _to_transport_format(item: "StreamedSpan") -> "Any": "trace_id": item.trace_id, "span_id": item.span_id, "name": item.get_name(), - "status": item._status, + "status": item.status.value, "is_segment": item.is_segment(), - "start_timestamp": item._start_timestamp.timestamp(), # TODO[span-first] - "end_timestamp": item._timestamp.timestamp(), + "start_timestamp": item.start_timestamp.timestamp(), # TODO[span-first] + "end_timestamp": item.timestamp.timestamp(), } - if item._parent_span_id: - res["parent_span_id"] = item._parent_span_id + if item.parent_span_id: + res["parent_span_id"] = item.parent_span_id - if item._attributes: + if item.attributes: res["attributes"] = { - k: serialize_attribute(v) for (k, v) in item._attributes.items() + k: serialize_attribute(v) for (k, v) in item.attributes.items() } return res - def _flush(self) -> Optional[Envelope]: - from sentry_sdk.utils import format_timestamp + def _flush(self) -> "Optional[Envelope]": + print("batcher.flush") with self._lock: if len(self._span_buffer) == 0: diff --git a/sentry_sdk/_tracing.py b/sentry_sdk/_tracing.py index cfcc652f8f..8f3d82397e 100644 --- a/sentry_sdk/_tracing.py +++ b/sentry_sdk/_tracing.py @@ -8,6 +8,7 @@ from sentry_sdk.profiler.continuous_profiler import get_profiler_id from sentry_sdk.tracing_utils import ( Baggage, + _generate_sample_rand, has_span_streaming_enabled, has_tracing_enabled, ) @@ -38,10 +39,15 @@ - initial status: OK? or unset? - dropped spans are not migrated - recheck transaction.finish <-> Streamedspan.end -- profile not part of the event, how to send? +- profiling: drop transaction based +- profiling: actually send profiles - maybe: use getters/setter OR properties but not both - add size-based flushing to buffer(s) - migrate transaction sample_rand logic +- remove deprecated profiler impl +- {custom_}sampling_context? -> if this is going to die, we need to revive the + potel pr that went through the integrations and got rid of custom_sampling_context + in favor of attributes Notes: - removed ability to provide a start_timestamp @@ -54,7 +60,9 @@ def start_span( attributes: "Optional[Attributes]" = None, parent_span: "Optional[StreamedSpan]" = None, ) -> "StreamedSpan": - return sentry_sdk.get_current_scope().start_streamed_span() + return sentry_sdk.get_current_scope().start_streamed_span( + name, attributes, parent_span + ) class SpanStatus(str, Enum): @@ -109,16 +117,17 @@ class StreamedSpan: """ __slots__ = ( - "_name", - "_attributes", + "name", + "attributes", "_span_id", "_trace_id", - "_parent_span_id", - "_segment", + "parent_span_id", + "segment", "_sampled", - "_start_timestamp", - "_timestamp", - "_status", + "parent_sampled", + "start_timestamp", + "timestamp", + "status", "_start_timestamp_monotonic_ns", "_scope", "_flags", @@ -126,27 +135,39 @@ class StreamedSpan: "_profile", "_continuous_profile", "_baggage", - "_sample_rate", + "sample_rate", "_sample_rand", + "source", ) def __init__( self, + *, name: str, - trace_id: str, + scope: "Scope", attributes: "Optional[Attributes]" = None, + # TODO[span-first]: would be good to actually take this propagation + # context stuff directly from the PropagationContext, but for that + # we'd actually need to refactor PropagationContext to stay in sync + # with what's going on (e.g. update the current span_id) and not just + # update when a trace is continued + trace_id: "Optional[str]" = None, parent_span_id: "Optional[str]" = None, + parent_sampled: "Optional[bool]" = None, + baggage: "Optional[Baggage]" = None, segment: "Optional[StreamedSpan]" = None, - scope: "Optional[Scope]" = None, ) -> None: - self._name: str = name - self._attributes: "Attributes" = attributes + self._scope = scope + + self.name: str = name + self.attributes: "Attributes" = attributes self._trace_id = trace_id - self._parent_span_id = parent_span_id - self._segment = segment or self + self.parent_span_id = parent_span_id + self.parent_sampled = parent_sampled + self.segment = segment or self - self._start_timestamp = datetime.now(timezone.utc) + self.start_timestamp = datetime.now(timezone.utc) try: # profiling depends on this value and requires that @@ -155,12 +176,30 @@ def __init__( except AttributeError: pass - self._timestamp: "Optional[datetime]" = None + self.timestamp: "Optional[datetime]" = None self._span_id: "Optional[str]" = None - self._status: SpanStatus = SpanStatus.OK + + self.status: SpanStatus = SpanStatus.OK + self.source: "Optional[SegmentSource]" = SegmentSource.CUSTOM + # XXX[span-first] ^ populate this correctly + self._sampled: "Optional[bool]" = None - self._scope: "Optional[Scope]" = scope # TODO[span-first] when are we starting a span with a specific scope? is this needed? + self.sample_rate: "Optional[float]" = None + self._sample_rand: "Optional[float]" = None + + # XXX[span-first]: just do this for segments? + self._baggage = baggage + baggage_sample_rand = ( + None if self._baggage is None else self._baggage._sample_rand() + ) + if baggage_sample_rand is not None: + self._sample_rand = baggage_sample_rand + else: + self._sample_rand = _generate_sample_rand(self.trace_id) + self._flags: dict[str, bool] = {} + self._profile = None + self._continuous_profile = None self._update_active_thread() self._set_profiler_id(get_profiler_id()) @@ -168,11 +207,11 @@ def __init__( 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}, " - f"parent_span_id={self._parent_span_id}, " - f"sampled={self._sampled})>" + 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"sampled={self.sampled})>" ) def __enter__(self) -> "StreamedSpan": @@ -215,8 +254,7 @@ def end( :param end_timestamp: Optional timestamp that should be used as timestamp instead of the current time. - :param scope: The scope to use for this transaction. - If not provided, the current scope will be used. + :param scope: The scope to use. """ client = sentry_sdk.get_client() if not client.is_active(): @@ -227,7 +265,7 @@ def end( ) # Explicit check against False needed because self.sampled might be None - if self._sampled is False: + if self.sampled is False: logger.debug("Discarding span because sampled = False") # This is not entirely accurate because discards here are not @@ -243,7 +281,7 @@ def end( return None - if self._sampled is None: + if self.sampled is None: logger.warning("Discarding transaction without sampling decision.") if self.timestamp is not None: @@ -257,46 +295,39 @@ def end( self.timestamp = end_timestamp else: elapsed = nanosecond_time() - self._start_timestamp_monotonic_ns - self.timestamp = self._start_timestamp + timedelta( + self.timestamp = self.start_timestamp + timedelta( microseconds=elapsed / 1000 ) except AttributeError: self.timestamp = datetime.now(timezone.utc) - if self.segment.sampled: - client._capture_span(self) + if self.segment.sampled: # XXX this should just use its own sampled + sentry_sdk.get_current_scope()._capture_span(self) + return def get_attributes(self) -> "Attributes": - return self._attributes + return self.attributes def set_attribute(self, key: str, value: "AttributeValue") -> None: - self._attributes[key] = format_attribute(value) + self.attributes[key] = format_attribute(value) def set_attributes(self, attributes: "Attributes") -> None: for key, value in attributes.items(): self.set_attribute(key, value) def set_status(self, status: SpanStatus) -> None: - self._status = status + self.status = status def get_name(self) -> str: - return self._name + return self.name def set_name(self, name: str) -> None: - self._name = name - - @property - def segment(self) -> "StreamedSpan": - return self._segment + self.name = name def is_segment(self) -> bool: return self.segment == self - @property - def sampled(self) -> "Optional[bool]": - return self._sampled - @property def span_id(self) -> str: if not self._span_id: @@ -312,6 +343,15 @@ def trace_id(self) -> str: return self._trace_id @property + def sampled(self) -> "Optional[bool]": + if self._sampled is not None: + return self._sampled + + if not self.is_segment(): + self._sampled = self.parent_sampled + + return self._sampled + def dynamic_sampling_context(self) -> str: return self.segment._get_baggage().dynamic_sampling_context() @@ -352,29 +392,15 @@ def _get_baggage(self) -> "Baggage": return self._baggage - def _set_initial_sampling_decision( - self, sampling_context: "SamplingContext" - ) -> None: + def _set_sampling_decision(self, sampling_context: "SamplingContext") -> None: """ - Sets the segment's sampling decision, according to the following - precedence rules: - - 1. If `traces_sampler` is defined, its decision will be used. It can - choose to keep or ignore any parent sampling decision, or use the - sampling context data to make its own decision or to choose a sample - rate for the transaction. - - 2. If `traces_sampler` is not defined, but there's a parent sampling - decision, the parent sampling decision will be used. - - 3. If `traces_sampler` is not defined and there's no parent sampling - decision, `traces_sample_rate` will be used. + Set the segment's sampling decision, inherited by all child spans. """ client = sentry_sdk.get_client() # nothing to do if tracing is disabled if not has_tracing_enabled(client.options): - self.sampled = False + self._sampled = False return if not self.is_segment(): @@ -398,9 +424,9 @@ def _set_initial_sampling_decision( # booleans or numbers between 0 and 1.) if not is_valid_sample_rate(sample_rate, source="Tracing"): logger.warning( - f"[Tracing] Discarding {self._name} because of invalid sample rate." + f"[Tracing] Discarding {self.name} because of invalid sample rate." ) - self.sampled = False + self._sampled = False return self.sample_rate = float(sample_rate) @@ -416,12 +442,12 @@ def _set_initial_sampling_decision( else: reason = "traces_sample_rate is set to 0" - logger.debug(f"[Tracing] Discarding {self._name} because {reason}") - self.sampled = False + logger.debug(f"[Tracing] Discarding {self.name} because {reason}") + self._sampled = False return # Now we roll the dice. - self.sampled = self._sample_rand < self.sample_rate + self._sampled = self._sample_rand < self.sample_rate if self.sampled: logger.debug(f"[Tracing] Starting {self.name}") diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 0896027db2..cdd05a806b 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -228,6 +228,9 @@ def _capture_log(self, log: "Log", scope: "Scope") -> None: def _capture_metric(self, metric: "Metric", scope: "Scope") -> None: pass + def _capture_span(self, span: "StreamedSpan", scope: "Scope") -> None: + pass + def capture_session(self, *args: "Any", **kwargs: "Any") -> None: return None @@ -406,7 +409,8 @@ def _record_lost_event( self.span_batcher = None if has_span_streaming_enabled(self.options): self.span_batcher = SpanBatcher( - capture_func=_capture_envelope, record_lost_func=_record_lost_event + capture_func=_capture_envelope, + record_lost_func=_record_lost_event, ) max_request_body_size = ("always", "never", "small", "medium") @@ -935,6 +939,7 @@ def _capture_telemetry( before_send = get_before_send_log(self.options) elif ty == "metric": before_send = get_before_send_metric(self.options) + # no before_send for spans if before_send is not None: telemetry = before_send(telemetry, {}) # type: ignore @@ -1011,6 +1016,8 @@ def close( self.log_batcher.kill() if self.metrics_batcher is not None: self.metrics_batcher.kill() + if self.span_batcher is not None: + self.span_batcher.kill() if self.monitor: self.monitor.kill() self.transport.kill() @@ -1036,6 +1043,8 @@ def flush( self.log_batcher.flush() if self.metrics_batcher is not None: self.metrics_batcher.flush() + if self.span_batcher is not None: + self.span_batcher.flush() self.transport.flush(timeout=timeout, callback=callback) def __enter__(self) -> "_Client": diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 97140c6227..f3f49a723b 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -29,6 +29,7 @@ from sentry_sdk.tracing_utils import ( Baggage, has_tracing_enabled, + has_span_streaming_enabled, normalize_incoming_data, PropagationContext, ) @@ -1155,30 +1156,55 @@ def start_streamed_span( parent_span: "Optional[StreamedSpan]" = None, ) -> "StreamedSpan": # TODO: rename to start_span once we drop the old API - with new_scope(): - if parent_span is None: - # get current span or transaction - parent_span = self.span or self.get_isolation_scope().span + if parent_span is None: + # Get currently active span + parent_span = self.span or self.get_isolation_scope().span - if parent_span is None: - # New spans get the `trace_id` from the scope - propagation_context = self.get_active_propagation_context() - span = StreamedSpan( - name=name, - attributes=attributes, - trace_id=propagation_context.trace_id, - scope=self, - ) - else: - # Children take propagation context from the parent span - span = StreamedSpan( - name=name, - attributes=attributes, - trace_id=parent_span.trace_id, - parent_span_id=parent_span.span_id, - segment=parent_span.segment, - scope=self, - ) + # If no specific parent_span provided and there is no currently + # active span, this is a segment + if parent_span is None: + propagation_context = self.get_active_propagation_context() + span = StreamedSpan( + name=name, + attributes=attributes, + 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, + ) + + try_autostart_continuous_profiler() + + # XXX[span-first]: no sampling context? + sampling_context = { + "transaction_context": { + "trace_id": span.trace_id, + "span_id": span.span_id, + "parent_span_id": span.parent_span_id, + }, + "parent_sampled": span.parent_sampled, + "attributes": span.attributes, + } + # Use traces_sample_rate, traces_sampler, and/or inheritance to make a + # sampling decision + span._set_sampling_decision(sampling_context=sampling_context) + + return span + + # This is a child span; take propagation context from the parent span + with new_scope(): + span = StreamedSpan( + name=name, + attributes=attributes, + scope=self, + trace_id=parent_span.trace_id, + parent_span_id=parent_span.span_id, + parent_sampled=parent_span.sampled, + segment=parent_span.segment, + # XXX[span-first]: baggage? + ) return span @@ -1291,6 +1317,17 @@ def _capture_metric(self, metric: "Optional[Metric]") -> None: client._capture_metric(metric, scope=merged_scope) + def _capture_span(self, span: "Optional[StreamedSpan]") -> None: + if span is None: + return + + client = self.get_client() + if not has_span_streaming_enabled(client.options): + return + + merged_scope = self._merge_scopes() + client._capture_span(span, scope=merged_scope) + def capture_message( self, message: str, @@ -1535,16 +1572,25 @@ def _apply_flags_to_event( ) def _apply_scope_attributes_to_telemetry( - self, telemetry: "Union[Log, Metric]" + self, telemetry: "Union[Log, Metric, Span]" ) -> None: + # TODO: turn Logs, Metrics into actual classes + if isinstance(telemetry, dict): + attributes = telemetry["attributes"] + else: + attributes = telemetry.attributes + for attribute, value in self._attributes.items(): - if attribute not in telemetry["attributes"]: - telemetry["attributes"][attribute] = value + if attribute not in attributes: + attributes[attribute] = value def _apply_user_attributes_to_telemetry( - self, telemetry: "Union[Log, Metric]" + self, telemetry: "Union[Log, Metric, Span]" ) -> None: - attributes = telemetry["attributes"] + if isinstance(telemetry, dict): + attributes = telemetry["attributes"] + else: + attributes = telemetry.attributes if not should_send_default_pii() or self._user is None: return @@ -1664,16 +1710,19 @@ def apply_to_event( return event @_disable_capture - def apply_to_telemetry(self, telemetry: "Union[Log, Metric]") -> None: + def apply_to_telemetry(self, telemetry: "Union[Log, Metric, StreamedSpan]") -> None: # Attributes-based events and telemetry go through here (logs, metrics, # spansV2) - trace_context = self.get_trace_context() - trace_id = trace_context.get("trace_id") - if telemetry.get("trace_id") is None: - telemetry["trace_id"] = trace_id or "00000000-0000-0000-0000-000000000000" - span_id = trace_context.get("span_id") - if telemetry.get("span_id") is None and span_id: - telemetry["span_id"] = span_id + if not isinstance(telemetry, StreamedSpan): + trace_context = self.get_trace_context() + trace_id = trace_context.get("trace_id") + if telemetry.get("trace_id") is None: + telemetry["trace_id"] = ( + trace_id or "00000000-0000-0000-0000-000000000000" + ) + span_id = trace_context.get("span_id") + if telemetry.get("span_id") is None and span_id: + telemetry["span_id"] = span_id self._apply_scope_attributes_to_telemetry(telemetry) self._apply_user_attributes_to_telemetry(telemetry) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index ffe3cf496b..4c046bc518 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -452,10 +452,8 @@ def from_incoming_data( ) -> "PropagationContext": propagation_context = PropagationContext() normalized_data = normalize_incoming_data(incoming_data) - sentry_trace_header = normalized_data.get(SENTRY_TRACE_HEADER_NAME) sentrytrace_data = extract_sentrytrace_data(sentry_trace_header) - # nothing to propagate if no sentry-trace if sentrytrace_data is None: return propagation_context @@ -782,11 +780,11 @@ def populate_from_segment( if segment.source not in LOW_QUALITY_TRANSACTION_SOURCES: sentry_items["transaction"] = segment.name - if segment._sample_rate is not None: - sentry_items["sample_rate"] = str(segment._sample_rate) + if segment.sample_rate is not None: + sentry_items["sample_rate"] = str(segment.sample_rate) - if segment._sampled is not None: - sentry_items["sampled"] = "true" if segment._sampled else "false" + if segment.sampled is not None: + sentry_items["sampled"] = "true" if segment.sampled else "false" # There's an existing baggage but it was mutable, which is why we are # creating this new baggage. From e946df6ad974959e2d2efa677c0ed51867983eb4 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 16 Jan 2026 14:49:43 +0100 Subject: [PATCH 07/38] . --- sentry_sdk/_span_batcher.py | 2 -- sentry_sdk/_types.py | 18 ------------------ sentry_sdk/tracing_utils.py | 2 ++ 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index a5f3ab7cc0..ff8f13517b 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -88,8 +88,6 @@ def _to_transport_format(item: "StreamedSpan") -> "Any": return res def _flush(self) -> "Optional[Envelope]": - print("batcher.flush") - with self._lock: if len(self._span_buffer) == 0: return None diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 10ee225aa3..0e761026e3 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -284,24 +284,6 @@ class SDKInfo(TypedDict): MetricProcessor = Callable[[Metric, Hint], Optional[Metric]] - SpanV2Status = Literal[SPANSTATUS.OK, SPANSTATUS.ERROR] - # This is the V2 span format - # https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/ - SpanV2 = TypedDict( - "SpanV2", - { - "trace_id": str, - "span_id": str, - "parent_span_id": Optional[str], - "name": str, - "status": SpanV2Status, - "is_segment": bool, - "start_timestamp": float, - "end_timestamp": float, - "attributes": Attributes, - }, - ) - # TODO: Make a proper type definition for this (PRs welcome!) Breadcrumb = Dict[str, Any] diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 4c046bc518..9e1bf3a043 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -452,8 +452,10 @@ def from_incoming_data( ) -> "PropagationContext": propagation_context = PropagationContext() normalized_data = normalize_incoming_data(incoming_data) + sentry_trace_header = normalized_data.get(SENTRY_TRACE_HEADER_NAME) sentrytrace_data = extract_sentrytrace_data(sentry_trace_header) + # nothing to propagate if no sentry-trace if sentrytrace_data is None: return propagation_context From c47a0d0f86660eed1b88255454019d3485bf5772 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 16 Jan 2026 15:14:54 +0100 Subject: [PATCH 08/38] fix? some types --- sentry_sdk/_span_batcher.py | 10 +++++++--- sentry_sdk/_tracing.py | 2 +- sentry_sdk/_types.py | 2 -- sentry_sdk/client.py | 6 +++--- sentry_sdk/scope.py | 10 +++++----- sentry_sdk/tracing_utils.py | 13 ++++++++----- 6 files changed, 24 insertions(+), 19 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index ff8f13517b..b7c9ef9e5c 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -11,9 +11,10 @@ if TYPE_CHECKING: from typing import Any, Callable, Optional from sentry_sdk._tracing import SpanStatus, StreamedSpan + from sentry_sdk._types import SerializedAttributeValue -class SpanBatcher(Batcher["Span"]): +class SpanBatcher(Batcher["StreamedSpan"]): # TODO[span-first]: size-based flushes MAX_BEFORE_FLUSH = 1000 MAX_BEFORE_DROP = 5000 @@ -67,16 +68,19 @@ def add(self, span: "StreamedSpan") -> None: @staticmethod def _to_transport_format(item: "StreamedSpan") -> "Any": - res = { + res: "dict[str, Any]" = { "trace_id": item.trace_id, "span_id": item.span_id, "name": item.get_name(), "status": item.status.value, "is_segment": item.is_segment(), "start_timestamp": item.start_timestamp.timestamp(), # TODO[span-first] - "end_timestamp": item.timestamp.timestamp(), } + if item.timestamp: + # this is here to make mypy happy + res["end_timestamp"] = item.timestamp.timestamp() + if item.parent_span_id: res["parent_span_id"] = item.parent_span_id diff --git a/sentry_sdk/_tracing.py b/sentry_sdk/_tracing.py index 8f3d82397e..8055a1ae48 100644 --- a/sentry_sdk/_tracing.py +++ b/sentry_sdk/_tracing.py @@ -160,7 +160,7 @@ def __init__( self._scope = scope self.name: str = name - self.attributes: "Attributes" = attributes + self.attributes: "Attributes" = attributes or {} self._trace_id = trace_id self.parent_span_id = parent_span_id diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 0e761026e3..7043bbc2ee 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -1,7 +1,5 @@ from typing import TYPE_CHECKING, TypeVar, Union -from sentry_sdk.consts import SPANSTATUS - # Re-exported for compat, since code out there in the wild might use this variable. MYPY = TYPE_CHECKING diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index cdd05a806b..129e89bd9d 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -938,7 +938,7 @@ def _capture_telemetry( if ty == "log": before_send = get_before_send_log(self.options) elif ty == "metric": - before_send = get_before_send_metric(self.options) + before_send = get_before_send_metric(self.options) # type: ignore # no before_send for spans if before_send is not None: @@ -951,9 +951,9 @@ def _capture_telemetry( if ty == "log": batcher = self.log_batcher elif ty == "metric": - batcher = self.metrics_batcher + batcher = self.metrics_batcher # type: ignore elif ty == "span": - batcher = self.span_batcher + batcher = self.span_batcher # type: ignore if batcher is not None: batcher.add(telemetry) # type: ignore diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index f3f49a723b..2b7de1e3f4 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -708,7 +708,7 @@ def clear(self) -> None: self.clear_breadcrumbs() self._should_capture: bool = True - self._span: "Optional[Span]" = None + self._span: "Optional[Union[Span, StreamedSpan]]" = None self._session: "Optional[Session]" = None self._force_auto_session_tracking: "Optional[bool]" = None @@ -822,12 +822,12 @@ def set_user(self, value: "Optional[Dict[str, Any]]") -> None: session.update(user=value) @property - def span(self) -> "Optional[Span]": + def span(self) -> "Optional[Union[Span, StreamedSpan]]": """Get/set current tracing span or transaction.""" return self._span @span.setter - def span(self, span: "Optional[Span]") -> None: + def span(self, span: "Optional[Union[Span, StreamedSpan]]") -> None: self._span = span # XXX: this differs from the implementation in JS, there Scope.setSpan # does not set Scope._transactionName. @@ -1572,7 +1572,7 @@ def _apply_flags_to_event( ) def _apply_scope_attributes_to_telemetry( - self, telemetry: "Union[Log, Metric, Span]" + self, telemetry: "Union[Log, Metric, StreamedSpan]" ) -> None: # TODO: turn Logs, Metrics into actual classes if isinstance(telemetry, dict): @@ -1585,7 +1585,7 @@ def _apply_scope_attributes_to_telemetry( attributes[attribute] = value def _apply_user_attributes_to_telemetry( - self, telemetry: "Union[Log, Metric, Span]" + self, telemetry: "Union[Log, Metric, StreamedSpan]" ) -> None: if isinstance(telemetry, dict): attributes = telemetry["attributes"] diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 9e1bf3a043..47456b8cb0 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -750,9 +750,7 @@ def populate_from_transaction( return Baggage(sentry_items, mutable=False) @classmethod - def populate_from_segment( - cls, segment: "sentry_sdk.trace.StreamedSpan" - ) -> "Baggage": + def populate_from_segment(cls, segment: "StreamedSpan") -> "Baggage": """ Populate fresh baggage entry with sentry_items and make it immutable if this is the head SDK which originates traces. @@ -779,7 +777,7 @@ def populate_from_segment( if client.parsed_dsn.org_id: sentry_items["org_id"] = client.parsed_dsn.org_id - if segment.source not in LOW_QUALITY_TRANSACTION_SOURCES: + if segment.source not in LOW_QUALITY_SEGMENT_SOURCES: sentry_items["transaction"] = segment.name if segment.sample_rate is not None: @@ -990,7 +988,9 @@ def sync_wrapper(*args: "Any", **kwargs: "Any") -> "Any": return span_decorator -def get_current_span(scope: "Optional[sentry_sdk.Scope]" = None) -> "Optional[Span]": +def get_current_span( + scope: "Optional[sentry_sdk.Scope]" = None, +) -> "Optional[Union[Span, StreamedSpan]]": """ Returns the currently active span if there is one running, otherwise `None` """ @@ -1366,5 +1366,8 @@ def add_sentry_baggage_to_headers( SENTRY_TRACE_HEADER_NAME, ) +from sentry_sdk._tracing import LOW_QUALITY_SEGMENT_SOURCES + if TYPE_CHECKING: from sentry_sdk.tracing import Span + from sentry_sdk._tracing import StreamedSpan From 131f61c42ddfd2366b25ad3daf63f56a09e0d4ea Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 16 Jan 2026 15:56:57 +0100 Subject: [PATCH 09/38] safeguards, some type fixes --- sentry_sdk/_tracing.py | 25 +++++++----- sentry_sdk/api.py | 15 ++++++- sentry_sdk/client.py | 2 +- sentry_sdk/scope.py | 80 ++++++++++++++++++++++++++++++++++--- sentry_sdk/tracing.py | 63 +++++++++++++++++++++++++++++ sentry_sdk/tracing_utils.py | 69 +++++++++++++++++++++++++++++++- 6 files changed, 235 insertions(+), 19 deletions(-) diff --git a/sentry_sdk/_tracing.py b/sentry_sdk/_tracing.py index 8055a1ae48..96cac67447 100644 --- a/sentry_sdk/_tracing.py +++ b/sentry_sdk/_tracing.py @@ -48,6 +48,8 @@ - {custom_}sampling_context? -> if this is going to die, we need to revive the potel pr that went through the integrations and got rid of custom_sampling_context in favor of attributes +- noop spans +- add a switcher to top level API that figures out which @trace to enable Notes: - removed ability to provide a start_timestamp @@ -106,6 +108,10 @@ def __str__(self) -> str: } +class NoOpStreamedSpan: + pass + + class StreamedSpan: """ A span holds timing information of a block of code. @@ -185,7 +191,6 @@ def __init__( self._sampled: "Optional[bool]" = None self.sample_rate: "Optional[float]" = None - self._sample_rand: "Optional[float]" = None # XXX[span-first]: just do this for segments? self._baggage = baggage @@ -248,9 +253,9 @@ def end( self, end_timestamp: "Optional[Union[float, datetime]]" = None, scope: "Optional[sentry_sdk.Scope]" = None, - ) -> "Optional[str]": + ) -> None: """ - Set the end timestamp of the span. + Set the end timestamp of the span and queue it for sending. :param end_timestamp: Optional timestamp that should be used as timestamp instead of the current time. @@ -258,7 +263,7 @@ def end( """ client = sentry_sdk.get_client() if not client.is_active(): - return None + return scope: "Optional[sentry_sdk.Scope]" = ( scope or self._scope or sentry_sdk.get_current_scope() @@ -279,14 +284,14 @@ def end( client.transport.record_lost_event(reason, data_category="span") - return None + return if self.sampled is None: logger.warning("Discarding transaction without sampling decision.") if self.timestamp is not None: # This span is already finished, ignore. - return None + return try: if end_timestamp: @@ -304,8 +309,6 @@ def end( if self.segment.sampled: # XXX this should just use its own sampled sentry_sdk.get_current_scope()._capture_span(self) - return - def get_attributes(self) -> "Attributes": return self.attributes @@ -325,6 +328,10 @@ 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 is_segment(self) -> bool: return self.segment == self @@ -352,7 +359,7 @@ def sampled(self) -> "Optional[bool]": return self._sampled - def dynamic_sampling_context(self) -> str: + def dynamic_sampling_context(self) -> dict[str, str]: return self.segment._get_baggage().dynamic_sampling_context() def _update_active_thread(self) -> None: diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index c4e2229938..2c0603ec96 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -7,6 +7,7 @@ from sentry_sdk.consts import INSTRUMENTER from sentry_sdk.scope import Scope, _ScopeManager, new_scope, isolation_scope from sentry_sdk.tracing import NoOpSpan, Transaction, trace +from sentry_sdk._tracing import StreamedSpan from sentry_sdk.crons import monitor from typing import TYPE_CHECKING @@ -385,7 +386,9 @@ def set_measurement(name: str, value: float, unit: "MeasurementUnit" = "") -> No transaction.set_measurement(name, value, unit) -def get_current_span(scope: "Optional[Scope]" = None) -> "Optional[Span]": +def get_current_span( + scope: "Optional[Scope]" = None, +) -> "Optional[Union[Span, StreamedSpan]]": """ Returns the currently active span if there is one running, otherwise `None` """ @@ -501,6 +504,16 @@ def update_current_span( if current_span is None: return + if isinstance(current_span, StreamedSpan): + warnings.warn( + "The `update_current_span` API isn't available in streaming mode. " + "Retrieve the current span with get_current_span() and use its API " + "directly.", + DeprecationWarning, + stacklevel=2, + ) + return + if op is not None: current_span.op = op diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 129e89bd9d..ff47000bb8 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -69,7 +69,7 @@ from sentry_sdk.scope import Scope from sentry_sdk.session import Session from sentry_sdk.spotlight import SpotlightClient - from sentry_sdk.trace import StreamedSpan + from sentry_sdk._tracing import StreamedSpan from sentry_sdk.transport import Transport, Item from sentry_sdk._log_batcher import LogBatcher from sentry_sdk._metrics_batcher import MetricsBatcher diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 2b7de1e3f4..99a4edd23c 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -579,6 +579,13 @@ def get_traceparent(self, *args: "Any", **kwargs: "Any") -> "Optional[str]": # If we have an active span, return traceparent from there if has_tracing_enabled(client.options) and self.span is not None: + if isinstance(self.span, StreamedSpan): + warnings.warn( + "Scope.get_traceparent is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return None return self.span.to_traceparent() # else return traceparent from the propagation context @@ -593,6 +600,13 @@ def get_baggage(self, *args: "Any", **kwargs: "Any") -> "Optional[Baggage]": # If we have an active span, return baggage from there if has_tracing_enabled(client.options) and self.span is not None: + if isinstance(self.span, StreamedSpan): + warnings.warn( + "Scope.get_baggage is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return None return self.span.to_baggage() # else return baggage from the propagation context @@ -603,6 +617,14 @@ def get_trace_context(self) -> "Dict[str, Any]": Returns the Sentry "trace" context from the Propagation Context. """ if has_tracing_enabled(self.get_client().options) and self._span is not None: + if isinstance(self._span, StreamedSpan): + warnings.warn( + "Scope.get_trace_context is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return {} + return self._span.get_trace_context() # if we are tracing externally (otel), those values take precedence @@ -667,6 +689,15 @@ def iter_trace_propagation_headers( span = kwargs.pop("span", None) span = span or self.span + if isinstance(span, StreamedSpan): + warnings.warn( + "Scope.iter_trace_propagation_headers is not available in " + "streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return None + if has_tracing_enabled(client.options) and span is not None: for header in span.iter_headers(): yield header @@ -760,6 +791,14 @@ def transaction(self) -> "Any": if self._span is None: return None + if isinstance(self._span, StreamedSpan): + warnings.warn( + "Scope.transaction is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return None + # there is an orphan span on the scope if self._span.containing_transaction is None: return None @@ -789,17 +828,34 @@ def transaction(self, value: "Any") -> None: "Assigning to scope.transaction directly is deprecated: use scope.set_transaction_name() instead." ) self._transaction = value - if self._span and self._span.containing_transaction: - self._span.containing_transaction.name = value + if self._span: + if isinstance(self._span, StreamedSpan): + warnings.warn( + "Scope.transaction is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return None + + if self._span.containing_transaction: + self._span.containing_transaction.name = value def set_transaction_name(self, name: str, source: "Optional[str]" = None) -> None: """Set the transaction name and optionally the transaction source.""" self._transaction = name + if self._span: + if isinstance(self._span, StreamedSpan): + warnings.warn( + "Scope.set_transaction_name is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return None - if self._span and self._span.containing_transaction: - self._span.containing_transaction.name = name - if source: - self._span.containing_transaction.source = source + if self._span.containing_transaction: + self._span.containing_transaction.name = name + if source: + self._span.containing_transaction.source = source if source: self._transaction_info["source"] = source @@ -1116,6 +1172,15 @@ def start_span( be removed in the next major version. Going forward, it should only be used by the SDK itself. """ + client = sentry_sdk.get_client() + if has_span_streaming_enabled(client.options): + warnings.warn( + "Scope.start_span is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return NoOpSpan() + if kwargs.get("description") is not None: warnings.warn( "The `description` parameter is deprecated. Please use `name` instead.", @@ -1135,6 +1200,9 @@ def start_span( # get current span or transaction span = self.span or self.get_isolation_scope().span + if isinstance(span, StreamedSpan): + # make mypy happy + return NoOpSpan() if span is None: # New spans get the `trace_id` from the scope diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 9160ae7b20..060da623b6 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1427,6 +1427,69 @@ def calculate_interest_rate(amount, rate, years): return decorator +def streamind_trace( + func: "Optional[Callable[P, R]]" = None, + *, + name: "Optional[str]" = None, + attributes: "Optional[dict[str, Any]]" = None, +) -> "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 + + :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, + ) + + if func: + return decorator(func) + else: + return decorator + + # Circular imports from sentry_sdk.tracing_utils import ( diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 47456b8cb0..68f95f4c3b 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -4,11 +4,12 @@ import os import re import sys +import uuid +import warnings from collections.abc import Mapping, MutableMapping from datetime import timedelta from random import Random from urllib.parse import quote, unquote -import uuid import sentry_sdk from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS, SPANTEMPLATE @@ -988,6 +989,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, +) -> "Any": + """ + Create a span decorator that can wrap both sync and async functions. + + :param name: The name of the span. + :type name: str or None + :param attributes: Additional attributes to set on the span. + :type attributes: dict or None + """ + 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): + 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): + 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[Union[Span, StreamedSpan]]": @@ -1005,6 +1058,15 @@ def set_span_errored(span: "Optional[Span]" = None) -> None: Also sets the status of the transaction (root span) to INTERNAL_ERROR. """ span = span or get_current_span() + + if not isinstance(span, Span): + warnings.warn( + "set_span_errored is not available in streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return + if span is not None: span.set_status(SPANSTATUS.INTERNAL_ERROR) if span.containing_transaction is not None: @@ -1366,7 +1428,10 @@ def add_sentry_baggage_to_headers( SENTRY_TRACE_HEADER_NAME, ) -from sentry_sdk._tracing import LOW_QUALITY_SEGMENT_SOURCES +from sentry_sdk._tracing import ( + LOW_QUALITY_SEGMENT_SOURCES, + start_span as start_streaming_span, +) if TYPE_CHECKING: from sentry_sdk.tracing import Span From 40878e303f5bdb2073fe4675251b5bc78b7abef1 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 16 Jan 2026 15:59:40 +0100 Subject: [PATCH 10/38] . --- sentry_sdk/consts.py | 2 -- sentry_sdk/tracing.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index d682e8dc9f..c095467c8e 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -878,8 +878,6 @@ class SPANSTATUS: UNIMPLEMENTED = "unimplemented" UNKNOWN_ERROR = "unknown_error" - ERROR = "error" # span-first specific - class OP: ANTHROPIC_MESSAGES_CREATE = "ai.messages.create.anthropic" diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 060da623b6..17c84fdd3d 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1427,7 +1427,7 @@ def calculate_interest_rate(amount, rate, years): return decorator -def streamind_trace( +def streaming_trace( func: "Optional[Callable[P, R]]" = None, *, name: "Optional[str]" = None, From 3f985c4de5d5fffce5612e7f87709367746efad6 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 20 Jan 2026 13:17:30 +0100 Subject: [PATCH 11/38] move to traces.py --- sentry_sdk/_span_batcher.py | 2 +- sentry_sdk/_tracing.py | 464 ------------------------------------ sentry_sdk/api.py | 2 +- sentry_sdk/client.py | 2 +- sentry_sdk/scope.py | 2 +- sentry_sdk/tracing.py | 4 - sentry_sdk/tracing_utils.py | 4 +- 7 files changed, 6 insertions(+), 474 deletions(-) delete mode 100644 sentry_sdk/_tracing.py diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index b7c9ef9e5c..d980438016 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from typing import Any, Callable, Optional - from sentry_sdk._tracing import SpanStatus, StreamedSpan + from sentry_sdk.traces import SpanStatus, StreamedSpan from sentry_sdk._types import SerializedAttributeValue diff --git a/sentry_sdk/_tracing.py b/sentry_sdk/_tracing.py deleted file mode 100644 index 96cac67447..0000000000 --- a/sentry_sdk/_tracing.py +++ /dev/null @@ -1,464 +0,0 @@ -import uuid -from datetime import datetime, timedelta, timezone -from enum import Enum -from typing import TYPE_CHECKING - -import sentry_sdk -from sentry_sdk.consts import SPANDATA -from sentry_sdk.profiler.continuous_profiler import get_profiler_id -from sentry_sdk.tracing_utils import ( - Baggage, - _generate_sample_rand, - has_span_streaming_enabled, - has_tracing_enabled, -) -from sentry_sdk.utils import ( - capture_internal_exceptions, - format_attribute, - get_current_thread_meta, - is_valid_sample_rate, - logger, - nanosecond_time, - should_be_treated_as_error, -) - -if TYPE_CHECKING: - from typing import Any, Optional, Union - from sentry_sdk._types import Attributes, AttributeValue, SamplingContext - from sentry_sdk.scope import Scope - - -FLAGS_CAPACITY = 10 - -""" -TODO[span-first] / notes -- redis, http, subprocess breadcrumbs (maybe_create_breadcrumbs_from_span) work - on op, change or ignore? -- @trace -- tags -- initial status: OK? or unset? -- dropped spans are not migrated -- recheck transaction.finish <-> Streamedspan.end -- profiling: drop transaction based -- profiling: actually send profiles -- maybe: use getters/setter OR properties but not both -- add size-based flushing to buffer(s) -- migrate transaction sample_rand logic -- remove deprecated profiler impl -- {custom_}sampling_context? -> if this is going to die, we need to revive the - potel pr that went through the integrations and got rid of custom_sampling_context - in favor of attributes -- noop spans -- add a switcher to top level API that figures out which @trace to enable - -Notes: -- removed ability to provide a start_timestamp -- moved _flags_capacity to a const -""" - - -def start_span( - name: str, - attributes: "Optional[Attributes]" = None, - parent_span: "Optional[StreamedSpan]" = None, -) -> "StreamedSpan": - return sentry_sdk.get_current_scope().start_streamed_span( - name, attributes, parent_span - ) - - -class SpanStatus(str, Enum): - OK = "ok" - ERROR = "error" - - def __str__(self) -> str: - return self.value - - -# Segment source, see -# https://getsentry.github.io/sentry-conventions/generated/attributes/sentry.html#sentryspansource -class SegmentSource(str, Enum): - COMPONENT = "component" - CUSTOM = "custom" - ROUTE = "route" - TASK = "task" - URL = "url" - VIEW = "view" - - def __str__(self) -> str: - return self.value - - -# These are typically high cardinality and the server hates them -LOW_QUALITY_SEGMENT_SOURCES = [ - SegmentSource.URL, -] - - -SOURCE_FOR_STYLE = { - "endpoint": SegmentSource.COMPONENT, - "function_name": SegmentSource.COMPONENT, - "handler_name": SegmentSource.COMPONENT, - "method_and_path_pattern": SegmentSource.ROUTE, - "path": SegmentSource.URL, - "route_name": SegmentSource.COMPONENT, - "route_pattern": SegmentSource.ROUTE, - "uri_template": SegmentSource.ROUTE, - "url": SegmentSource.ROUTE, -} - - -class NoOpStreamedSpan: - pass - - -class StreamedSpan: - """ - A span holds timing information of a block of code. - - 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. - """ - - __slots__ = ( - "name", - "attributes", - "_span_id", - "_trace_id", - "parent_span_id", - "segment", - "_sampled", - "parent_sampled", - "start_timestamp", - "timestamp", - "status", - "_start_timestamp_monotonic_ns", - "_scope", - "_flags", - "_context_manager_state", - "_profile", - "_continuous_profile", - "_baggage", - "sample_rate", - "_sample_rand", - "source", - ) - - def __init__( - self, - *, - name: str, - scope: "Scope", - attributes: "Optional[Attributes]" = None, - # TODO[span-first]: would be good to actually take this propagation - # context stuff directly from the PropagationContext, but for that - # we'd actually need to refactor PropagationContext to stay in sync - # with what's going on (e.g. update the current span_id) and not just - # update when a trace is continued - trace_id: "Optional[str]" = None, - parent_span_id: "Optional[str]" = None, - parent_sampled: "Optional[bool]" = None, - baggage: "Optional[Baggage]" = None, - segment: "Optional[StreamedSpan]" = None, - ) -> None: - self._scope = scope - - self.name: str = name - self.attributes: "Attributes" = attributes or {} - - self._trace_id = trace_id - self.parent_span_id = parent_span_id - self.parent_sampled = parent_sampled - self.segment = segment or self - - self.start_timestamp = datetime.now(timezone.utc) - - 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.timestamp: "Optional[datetime]" = None - self._span_id: "Optional[str]" = None - - self.status: SpanStatus = SpanStatus.OK - self.source: "Optional[SegmentSource]" = SegmentSource.CUSTOM - # XXX[span-first] ^ populate this correctly - - self._sampled: "Optional[bool]" = None - self.sample_rate: "Optional[float]" = None - - # XXX[span-first]: just do this for segments? - self._baggage = baggage - baggage_sample_rand = ( - None if self._baggage is None else self._baggage._sample_rand() - ) - if baggage_sample_rand is not None: - self._sample_rand = baggage_sample_rand - else: - self._sample_rand = _generate_sample_rand(self.trace_id) - - self._flags: dict[str, bool] = {} - self._profile = None - self._continuous_profile = None - - self._update_active_thread() - self._set_profiler_id(get_profiler_id()) - - 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}, " - f"parent_span_id={self.parent_span_id}, " - f"sampled={self.sampled})>" - ) - - def __enter__(self) -> "StreamedSpan": - scope = self._scope or sentry_sdk.get_current_scope() - old_span = scope.span - scope.span = self - self._context_manager_state = (scope, old_span) - - if self.is_segment() and self._profile is not None: - self._profile.__enter__() - - return self - - def __exit__( - self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" - ) -> None: - if self.is_segment(): - if self._profile is not None: - self._profile.__exit__(ty, value, tb) - - if self._continuous_profile is not None: - self._continuous_profile.stop() - - if value is not None and should_be_treated_as_error(ty, value): - self.set_status(SpanStatus.ERROR) - - with capture_internal_exceptions(): - scope, old_span = self._context_manager_state - del self._context_manager_state - self.end(scope=scope) - scope.span = old_span - - def end( - self, - end_timestamp: "Optional[Union[float, datetime]]" = None, - scope: "Optional[sentry_sdk.Scope]" = None, - ) -> None: - """ - Set the end timestamp of the span and queue it for sending. - - :param end_timestamp: Optional timestamp that should - be used as timestamp instead of the current time. - :param scope: The scope to use. - """ - client = sentry_sdk.get_client() - if not client.is_active(): - return - - scope: "Optional[sentry_sdk.Scope]" = ( - scope or self._scope or sentry_sdk.get_current_scope() - ) - - # Explicit check against False needed because self.sampled might be None - if self.sampled is False: - logger.debug("Discarding span because sampled = False") - - # This is not entirely accurate because discards here are not - # exclusively based on sample rate but also traces sampler, but - # we handle this the same here. - if client.transport and has_tracing_enabled(client.options): - if client.monitor and client.monitor.downsample_factor > 0: - reason = "backpressure" - else: - reason = "sample_rate" - - client.transport.record_lost_event(reason, data_category="span") - - return - - if self.sampled is None: - logger.warning("Discarding transaction without sampling decision.") - - if self.timestamp is not None: - # This span is already finished, ignore. - return - - try: - if end_timestamp: - if isinstance(end_timestamp, float): - end_timestamp = datetime.fromtimestamp(end_timestamp, timezone.utc) - self.timestamp = end_timestamp - else: - 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) - - if self.segment.sampled: # XXX this should just use its own sampled - sentry_sdk.get_current_scope()._capture_span(self) - - def get_attributes(self) -> "Attributes": - return self.attributes - - def set_attribute(self, key: str, value: "AttributeValue") -> None: - self.attributes[key] = format_attribute(value) - - def set_attributes(self, attributes: "Attributes") -> None: - for key, value in attributes.items(): - self.set_attribute(key, value) - - def set_status(self, status: SpanStatus) -> None: - self.status = status - - def get_name(self) -> str: - return self.name - - 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 is_segment(self) -> bool: - return self.segment == self - - @property - def span_id(self) -> str: - if not self._span_id: - self._span_id = uuid.uuid4().hex[16:] - - return self._span_id - - @property - def trace_id(self) -> str: - if not self._trace_id: - self._trace_id = uuid.uuid4().hex - - return self._trace_id - - @property - def sampled(self) -> "Optional[bool]": - if self._sampled is not None: - return self._sampled - - if not self.is_segment(): - self._sampled = self.parent_sampled - - return self._sampled - - def dynamic_sampling_context(self) -> dict[str, str]: - return self.segment._get_baggage().dynamic_sampling_context() - - def _update_active_thread(self) -> None: - thread_id, thread_name = get_current_thread_meta() - self._set_thread(thread_id, thread_name) - - def _set_thread( - self, thread_id: "Optional[int]", thread_name: "Optional[str]" - ) -> None: - if thread_id is not None: - self.set_attribute(SPANDATA.THREAD_ID, str(thread_id)) - - if thread_name is not None: - self.set_attribute(SPANDATA.THREAD_NAME, thread_name) - - def _set_profiler_id(self, profiler_id: "Optional[str]") -> None: - if profiler_id is not None: - self.set_attribute(SPANDATA.PROFILER_ID, profiler_id) - - def _set_http_status(self, http_status: int) -> None: - self.set_attribute(SPANDATA.HTTP_STATUS_CODE, http_status) - - if http_status >= 400: - self.set_status(SpanStatus.ERROR) - else: - self.set_status(SpanStatus.OK) - - def _get_baggage(self) -> "Baggage": - """ - Return the :py:class:`~sentry_sdk.tracing_utils.Baggage` associated with - the segment. - - The first time a new baggage with Sentry items is made, it will be frozen. - """ - if not self._baggage or self._baggage.mutable: - self._baggage = Baggage.populate_from_segment(self) - - return self._baggage - - def _set_sampling_decision(self, sampling_context: "SamplingContext") -> None: - """ - Set the segment's sampling decision, inherited by all child spans. - """ - client = sentry_sdk.get_client() - - # nothing to do if tracing is disabled - if not has_tracing_enabled(client.options): - self._sampled = False - return - - if not self.is_segment(): - return - - traces_sampler_defined = callable(client.options.get("traces_sampler")) - - # We would have bailed already if neither `traces_sampler` nor - # `traces_sample_rate` were defined, so one of these should work; prefer - # the hook if so - if traces_sampler_defined: - sample_rate = client.options["traces_sampler"](sampling_context) - else: - if sampling_context["parent_sampled"] is not None: - sample_rate = sampling_context["parent_sampled"] - else: - sample_rate = client.options["traces_sample_rate"] - - # Since this is coming from the user (or from a function provided by the - # user), who knows what we might get. (The only valid values are - # booleans or numbers between 0 and 1.) - if not is_valid_sample_rate(sample_rate, source="Tracing"): - logger.warning( - f"[Tracing] Discarding {self.name} because of invalid sample rate." - ) - self._sampled = False - return - - self.sample_rate = float(sample_rate) - - if client.monitor: - self.sample_rate /= 2**client.monitor.downsample_factor - - # if the function returned 0 (or false), or if `traces_sample_rate` is - # 0, it's a sign the transaction should be dropped - if not self.sample_rate: - if traces_sampler_defined: - reason = "traces_sampler returned 0 or False" - else: - reason = "traces_sample_rate is set to 0" - - logger.debug(f"[Tracing] Discarding {self.name} because {reason}") - self._sampled = False - return - - # Now we roll the dice. - self._sampled = self._sample_rand < self.sample_rate - - if self.sampled: - logger.debug(f"[Tracing] Starting {self.name}") - else: - logger.debug( - f"[Tracing] Discarding {self.name} because it's not included in the random sample (sampling rate = {self.sample_rate})" - ) diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index 2c0603ec96..a1db79763f 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -7,7 +7,7 @@ from sentry_sdk.consts import INSTRUMENTER from sentry_sdk.scope import Scope, _ScopeManager, new_scope, isolation_scope from sentry_sdk.tracing import NoOpSpan, Transaction, trace -from sentry_sdk._tracing import StreamedSpan +from sentry_sdk.traces import StreamedSpan from sentry_sdk.crons import monitor from typing import TYPE_CHECKING diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index ff47000bb8..06de3f7c0a 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -69,7 +69,7 @@ from sentry_sdk.scope import Scope from sentry_sdk.session import Session from sentry_sdk.spotlight import SpotlightClient - from sentry_sdk._tracing import StreamedSpan + from sentry_sdk.traces import StreamedSpan from sentry_sdk.transport import Transport, Item from sentry_sdk._log_batcher import LogBatcher from sentry_sdk._metrics_batcher import MetricsBatcher diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 99a4edd23c..a361be375f 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -33,7 +33,7 @@ normalize_incoming_data, PropagationContext, ) -from sentry_sdk._tracing import StreamedSpan +from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing import ( BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 17c84fdd3d..dcdc81eba9 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -126,10 +126,6 @@ class TransactionKwargs(SpanKwargs, total=False): }, ) -# TODO: Once the old Span class is gone, move _tracing.py to tracing.py. This is -# here for now so that you can do sentry_sdk.tracing.start_span for the new API. -from sentry_sdk._tracing import start_span - BAGGAGE_HEADER_NAME = "baggage" SENTRY_TRACE_HEADER_NAME = "sentry-trace" diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 68f95f4c3b..e31c362723 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1428,11 +1428,11 @@ def add_sentry_baggage_to_headers( SENTRY_TRACE_HEADER_NAME, ) -from sentry_sdk._tracing import ( +from sentry_sdk.traces import ( LOW_QUALITY_SEGMENT_SOURCES, start_span as start_streaming_span, ) if TYPE_CHECKING: from sentry_sdk.tracing import Span - from sentry_sdk._tracing import StreamedSpan + from sentry_sdk.traces import StreamedSpan From d9874dbc0c9b20023f2a19bd23ebe8129152aabe Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 20 Jan 2026 13:35:36 +0100 Subject: [PATCH 12/38] fix multiple envelopes being sent from the batcher --- sentry_sdk/_span_batcher.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index d980438016..b3dcfbc9ec 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -91,13 +91,15 @@ def _to_transport_format(item: "StreamedSpan") -> "Any": return res - def _flush(self) -> "Optional[Envelope]": + def _flush(self) -> None: with self._lock: if len(self._span_buffer) == 0: return None for trace_id, spans in self._span_buffer.items(): if spans: + for span in spans: + print(span.name) dsc = spans[0].dynamic_sampling_context() # XXX[span-first]: empty dsc? @@ -126,7 +128,6 @@ def _flush(self) -> "Optional[Envelope]": ) ) - self._span_buffer.clear() + self._capture_func(envelope) - self._capture_func(envelope) - return envelope + self._span_buffer.clear() From a173352066ea0162c0a86388ffd3f84365524fff Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 20 Jan 2026 13:38:11 +0100 Subject: [PATCH 13/38] send outside of lock --- 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 b3dcfbc9ec..c9259f9b40 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -96,6 +96,7 @@ def _flush(self) -> None: if len(self._span_buffer) == 0: return None + envelopes = [] for trace_id, spans in self._span_buffer.items(): if spans: for span in spans: @@ -128,6 +129,9 @@ def _flush(self) -> None: ) ) - self._capture_func(envelope) + envelopes.append(envelope) self._span_buffer.clear() + + for envelope in envelopes: + self._capture_func(envelope) From fcb8ae5fdc0be7daa94499828ee0fe47b513d134 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 20 Jan 2026 15:07:47 +0100 Subject: [PATCH 14/38] . --- sentry_sdk/_span_batcher.py | 1 - sentry_sdk/traces.py | 489 ++++++++++++++++++++++++++++++++++++ 2 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 sentry_sdk/traces.py diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index c9259f9b40..7c4f34e5d8 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -102,7 +102,6 @@ def _flush(self) -> None: for span in spans: print(span.name) dsc = spans[0].dynamic_sampling_context() - # XXX[span-first]: empty dsc? envelope = Envelope( headers={ diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py new file mode 100644 index 0000000000..9d92cbb457 --- /dev/null +++ b/sentry_sdk/traces.py @@ -0,0 +1,489 @@ +import uuid +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk.consts import SPANDATA +from sentry_sdk.profiler.continuous_profiler import get_profiler_id +from sentry_sdk.tracing_utils import ( + Baggage, + _generate_sample_rand, + has_span_streaming_enabled, + has_tracing_enabled, +) +from sentry_sdk.utils import ( + capture_internal_exceptions, + format_attribute, + get_current_thread_meta, + is_valid_sample_rate, + logger, + nanosecond_time, + should_be_treated_as_error, +) + +if TYPE_CHECKING: + from typing import Any, Optional, Union + from sentry_sdk._types import Attributes, AttributeValue, SamplingContext + from sentry_sdk.scope import Scope + + +FLAGS_CAPACITY = 10 + +""" +TODO[span-first] / notes +- redis, http, subprocess breadcrumbs (maybe_create_breadcrumbs_from_span) work + on op, change or ignore? +- tags +- initial status: OK? or unset? -> OK +- dropped spans are not migrated +- recheck transaction.finish <-> Streamedspan.end +- profiling: drop transaction based +- profiling: actually send profiles +- maybe: use getters/setter OR properties but not both +- add size-based flushing to buffer(s) +- migrate transaction sample_rand logic +- remove deprecated profiler impl +- custom_sampling_context? + - store on scope/propagation context instead? + - function to set on propagation context +- noop spans +- iso +- check where we're auto filtering out spans in integrations (health checks etc?) +- two top-level start_spans: they'll share the same trace now, before: two start_transactions would each set their own scope + +Notes: +- removed ability to provide a start_timestamp +- moved _flags_capacity to a const +""" + + +def start_span( + name: str, + attributes: "Optional[Attributes]" = None, + parent_span: "Optional[StreamedSpan]" = None, +) -> "StreamedSpan": + return sentry_sdk.get_current_scope().start_streamed_span( + name, attributes, parent_span + ) + + +class SpanStatus(str, Enum): + OK = "ok" + ERROR = "error" + + def __str__(self) -> str: + return self.value + + +# Segment source, see +# https://getsentry.github.io/sentry-conventions/generated/attributes/sentry.html#sentryspansource +class SegmentSource(str, Enum): + COMPONENT = "component" + CUSTOM = "custom" + ROUTE = "route" + TASK = "task" + URL = "url" + VIEW = "view" + + def __str__(self) -> str: + return self.value + + +# These are typically high cardinality and the server hates them +LOW_QUALITY_SEGMENT_SOURCES = [ + SegmentSource.URL, +] + + +SOURCE_FOR_STYLE = { + "endpoint": SegmentSource.COMPONENT, + "function_name": SegmentSource.COMPONENT, + "handler_name": SegmentSource.COMPONENT, + "method_and_path_pattern": SegmentSource.ROUTE, + "path": SegmentSource.URL, + "route_name": SegmentSource.COMPONENT, + "route_pattern": SegmentSource.ROUTE, + "uri_template": SegmentSource.ROUTE, + "url": SegmentSource.ROUTE, +} + + +class NoOpStreamedSpan: + pass + + +class StreamedSpan: + """ + A span holds timing information of a block of code. + + 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. + """ + + __slots__ = ( + "name", + "attributes", + "_span_id", + "_trace_id", + "parent_span_id", + "segment", + "_sampled", + "parent_sampled", + "start_timestamp", + "timestamp", + "status", + "_start_timestamp_monotonic_ns", + "_scope", + "_flags", + "_context_manager_state", + "_profile", + "_continuous_profile", + "_baggage", + "sample_rate", + "_sample_rand", + "source", + "_finished", + ) + + def __init__( + self, + *, + name: str, + scope: "Scope", + attributes: "Optional[Attributes]" = None, + # TODO[span-first]: would be good to actually take this propagation + # context stuff directly from the PropagationContext, but for that + # we'd actually need to refactor PropagationContext to stay in sync + # with what's going on (e.g. update the current span_id) and not just + # update when a trace is continued + trace_id: "Optional[str]" = None, + parent_span_id: "Optional[str]" = None, + parent_sampled: "Optional[bool]" = None, + baggage: "Optional[Baggage]" = None, + segment: "Optional[StreamedSpan]" = None, + ) -> None: + self._scope = scope + + self.name: str = name + self.attributes: "Attributes" = attributes or {} + + self._trace_id = trace_id + self.parent_span_id = parent_span_id + self.parent_sampled = parent_sampled + self.segment = segment or self + + self.start_timestamp = datetime.now(timezone.utc) + + 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.timestamp: "Optional[datetime]" = None + self._finished: bool = False + self._span_id: "Optional[str]" = None + + self.status: SpanStatus = SpanStatus.OK + self.source: "Optional[SegmentSource]" = SegmentSource.CUSTOM + # XXX[span-first] ^ populate this correctly + + self._sampled: "Optional[bool]" = None + self.sample_rate: "Optional[float]" = None + + # XXX[span-first]: just do this for segments? + self._baggage = baggage + baggage_sample_rand = ( + None if self._baggage is None else self._baggage._sample_rand() + ) + if baggage_sample_rand is not None: + self._sample_rand = baggage_sample_rand + else: + self._sample_rand = _generate_sample_rand(self.trace_id) + + self._flags: dict[str, bool] = {} + self._profile = None + self._continuous_profile = None + + self._update_active_thread() + self._set_profiler_id(get_profiler_id()) + + 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}, " + f"parent_span_id={self.parent_span_id}, " + f"sampled={self.sampled})>" + ) + + def __enter__(self) -> "StreamedSpan": + scope = self._scope or sentry_sdk.get_current_scope() + old_span = scope.span + scope.span = self + self._context_manager_state = (scope, old_span) + + if self.is_segment() and self._profile is not None: + self._profile.__enter__() + + return self + + def __exit__( + self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" + ) -> None: + if self.is_segment(): + if self._profile is not None: + self._profile.__exit__(ty, value, tb) + + if self._continuous_profile is not None: + self._continuous_profile.stop() + + if value is not None and should_be_treated_as_error(ty, value): + self.set_status(SpanStatus.ERROR) + + with capture_internal_exceptions(): + scope, old_span = self._context_manager_state + del self._context_manager_state + self._end(scope=scope) + scope.span = old_span + + 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: + if isinstance(end_timestamp, float): + end_timestamp = datetime.fromtimestamp(end_timestamp, timezone.utc) + self.timestamp = end_timestamp + except AttributeError: + pass + + self.__exit__(ty=None, value=None, tb=None) + + def _end( + self, + scope: "Optional[sentry_sdk.Scope]" = None, + ) -> None: + client = sentry_sdk.get_client() + if not client.is_active(): + return + + self._set_grouping_attributes() + + scope: "Optional[sentry_sdk.Scope]" = ( + scope or self._scope or sentry_sdk.get_current_scope() + ) + + # Explicit check against False needed because self.sampled might be None + if self.sampled is False: + logger.debug("Discarding span because sampled = False") + + # This is not entirely accurate because discards here are not + # exclusively based on sample rate but also traces sampler, but + # we handle this the same here. + if client.transport and has_tracing_enabled(client.options): + if client.monitor and client.monitor.downsample_factor > 0: + reason = "backpressure" + else: + reason = "sample_rate" + + client.transport.record_lost_event(reason, data_category="span") + + return + + if self.sampled is None: + logger.warning("Discarding transaction without sampling decision.") + + if self._finished is True: + # This span is already finished, ignore. + return + + 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) + + if self.segment.sampled: # XXX this should just use its own sampled + sentry_sdk.get_current_scope()._capture_span(self) + + self._finished = True + + def get_attributes(self) -> "Attributes": + return self.attributes + + def set_attribute(self, key: str, value: "AttributeValue") -> None: + self.attributes[key] = format_attribute(value) + + def set_attributes(self, attributes: "Attributes") -> None: + for key, value in attributes.items(): + self.set_attribute(key, value) + + def set_status(self, status: SpanStatus) -> None: + self.status = status + + def get_name(self) -> str: + return self.name + + 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 is_segment(self) -> bool: + return self.segment == self + + @property + def span_id(self) -> str: + if not self._span_id: + self._span_id = uuid.uuid4().hex[16:] + + return self._span_id + + @property + def trace_id(self) -> str: + if not self._trace_id: + self._trace_id = uuid.uuid4().hex + + return self._trace_id + + @property + def sampled(self) -> "Optional[bool]": + if self._sampled is not None: + return self._sampled + + if not self.is_segment(): + self._sampled = self.parent_sampled + + return self._sampled + + def dynamic_sampling_context(self) -> dict[str, str]: + return self.segment._get_baggage().dynamic_sampling_context() + + def _update_active_thread(self) -> None: + thread_id, thread_name = get_current_thread_meta() + self._set_thread(thread_id, thread_name) + + def _set_thread( + self, thread_id: "Optional[int]", thread_name: "Optional[str]" + ) -> None: + if thread_id is not None: + self.set_attribute(SPANDATA.THREAD_ID, str(thread_id)) + + if thread_name is not None: + self.set_attribute(SPANDATA.THREAD_NAME, thread_name) + + def _set_profiler_id(self, profiler_id: "Optional[str]") -> None: + if profiler_id is not None: + self.set_attribute(SPANDATA.PROFILER_ID, profiler_id) + + def _set_http_status(self, http_status: int) -> None: + self.set_attribute(SPANDATA.HTTP_STATUS_CODE, http_status) + + if http_status >= 400: + self.set_status(SpanStatus.ERROR) + else: + self.set_status(SpanStatus.OK) + + def _get_baggage(self) -> "Baggage": + """ + Return the :py:class:`~sentry_sdk.tracing_utils.Baggage` associated with + the segment. + + The first time a new baggage with Sentry items is made, it will be frozen. + """ + if not self._baggage or self._baggage.mutable: + self._baggage = Baggage.populate_from_segment(self) + + return self._baggage + + def _set_sampling_decision(self, sampling_context: "SamplingContext") -> None: + """ + Set the segment's sampling decision, inherited by all child spans. + """ + client = sentry_sdk.get_client() + + # nothing to do if tracing is disabled + if not has_tracing_enabled(client.options): + self._sampled = False + return + + if not self.is_segment(): + return + + traces_sampler_defined = callable(client.options.get("traces_sampler")) + + # We would have bailed already if neither `traces_sampler` nor + # `traces_sample_rate` were defined, so one of these should work; prefer + # the hook if so + if traces_sampler_defined: + sample_rate = client.options["traces_sampler"](sampling_context) + else: + if sampling_context["parent_sampled"] is not None: + sample_rate = sampling_context["parent_sampled"] + else: + sample_rate = client.options["traces_sample_rate"] + + # Since this is coming from the user (or from a function provided by the + # user), who knows what we might get. (The only valid values are + # booleans or numbers between 0 and 1.) + if not is_valid_sample_rate(sample_rate, source="Tracing"): + logger.warning( + f"[Tracing] Discarding {self.name} because of invalid sample rate." + ) + self._sampled = False + return + + self.sample_rate = float(sample_rate) + + if client.monitor: + self.sample_rate /= 2**client.monitor.downsample_factor + + # if the function returned 0 (or false), or if `traces_sample_rate` is + # 0, it's a sign the transaction should be dropped + if not self.sample_rate: + if traces_sampler_defined: + reason = "traces_sampler returned 0 or False" + else: + reason = "traces_sample_rate is set to 0" + + logger.debug(f"[Tracing] Discarding {self.name} because {reason}") + self._sampled = False + return + + # Now we roll the dice. + self._sampled = self._sample_rand < self.sample_rate + + if self.sampled: + logger.debug(f"[Tracing] Starting {self.name}") + else: + logger.debug( + f"[Tracing] Discarding {self.name} because it's not included in the random sample (sampling rate = {self.sample_rate})" + ) + + def _set_grouping_attributes(self): + if self.is_segment(): + self.set_attribute("sentry.span.source", self.source.value) + else: + self.set_attribute("sentry.segment.id", self.segment.span_id) + + self.set_attribute("sentry.segment.name", self.segment.name) + + +def continue_trace(incoming: dict[str, "Any"]) -> None: + # XXX[span-first]: conceptually, this should be set on the iso scope + sentry_sdk.get_current_scope().set_propagation_context(incoming) From bc2fd7925a59da323d399664ed5611501e656f56 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 20 Jan 2026 15:09:53 +0100 Subject: [PATCH 15/38] old py --- 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 9d92cbb457..32b40f7910 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -371,7 +371,7 @@ def sampled(self) -> "Optional[bool]": return self._sampled - def dynamic_sampling_context(self) -> dict[str, str]: + def dynamic_sampling_context(self) -> "dict[str, str]": return self.segment._get_baggage().dynamic_sampling_context() def _update_active_thread(self) -> None: From 73c6b73830d29b3ca26f3381b8054cc7e480d15a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 20 Jan 2026 15:18:11 +0100 Subject: [PATCH 16/38] more old py --- 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 32b40f7910..9b44ea2ee5 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -484,6 +484,6 @@ def _set_grouping_attributes(self): self.set_attribute("sentry.segment.name", self.segment.name) -def continue_trace(incoming: dict[str, "Any"]) -> None: +def continue_trace(incoming: "dict[str, Any]") -> None: # XXX[span-first]: conceptually, this should be set on the iso scope sentry_sdk.get_current_scope().set_propagation_context(incoming) From d40367d9ee55f536c04d9fe8c7050c9f6fbfdd2e Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 10:52:17 +0100 Subject: [PATCH 17/38] trace propagation --- sentry_sdk/scope.py | 15 +------ sentry_sdk/traces.py | 105 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 100 insertions(+), 20 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index a361be375f..6147244e28 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -579,13 +579,6 @@ def get_traceparent(self, *args: "Any", **kwargs: "Any") -> "Optional[str]": # If we have an active span, return traceparent from there if has_tracing_enabled(client.options) and self.span is not None: - if isinstance(self.span, StreamedSpan): - warnings.warn( - "Scope.get_traceparent is not available in streaming mode.", - DeprecationWarning, - stacklevel=2, - ) - return None return self.span.to_traceparent() # else return traceparent from the propagation context @@ -600,13 +593,6 @@ def get_baggage(self, *args: "Any", **kwargs: "Any") -> "Optional[Baggage]": # If we have an active span, return baggage from there if has_tracing_enabled(client.options) and self.span is not None: - if isinstance(self.span, StreamedSpan): - warnings.warn( - "Scope.get_baggage is not available in streaming mode.", - DeprecationWarning, - stacklevel=2, - ) - return None return self.span.to_baggage() # else return baggage from the propagation context @@ -1226,6 +1212,7 @@ def start_streamed_span( # TODO: rename to start_span once we drop the old API if parent_span is None: # Get currently active span + # TODO[span-first]: should this be current scope? parent_span = self.span or self.get_isolation_scope().span # If no specific parent_span provided and there is no currently diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 9b44ea2ee5..7e444fd8dd 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -50,7 +50,6 @@ - noop spans - iso - check where we're auto filtering out spans in integrations (health checks etc?) -- two top-level start_spans: they'll share the same trace now, before: two start_transactions would each set their own scope Notes: - removed ability to provide a start_timestamp @@ -63,11 +62,82 @@ def start_span( attributes: "Optional[Attributes]" = None, parent_span: "Optional[StreamedSpan]" = None, ) -> "StreamedSpan": + """ + Start a span. + + The span's parent, unless provided explicitly via the `parent_span` argument, + will be the currently active span, if any. + + `start_span()` can either be used as context manager or you can use the span + object it returns and explicitly start and end it via the `span.start()` and + `span.end()` interface. 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") + span.start() + # do something + span.end() + ``` + + To continue a trace from another service, call + sentry_sdk.traces.continue_trace() prior to creating the top-level span. + + :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. + When provided via the `start_span()` function, these will also be + accessible in the traces sampler. + :type attributes: "Optional[Attributes]" + :param parent_span: A span instance that the new span should be parented to. + If not provided, the parent will be set to the currently active span, + if any. + :type parent_span: "Optional[StreamedSpan]" + :return: A span. + :rtype: StreamedSpan + """ return sentry_sdk.get_current_scope().start_streamed_span( name, attributes, parent_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. + """ + 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. + """ + sentry_sdk.get_current_scope().set_new_propagation_context() + + class SpanStatus(str, Enum): OK = "ok" ERROR = "error" @@ -252,6 +322,15 @@ def __exit__( self._end(scope=scope) scope.span = old_span + def start(self): + """ + Start this span. + + Only usable if the span was not started via the `with start_span():` + context manager, since that starts it automatically. + """ + self.__enter__() + def end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: """ Finish this span and queue it for sending. @@ -374,6 +453,25 @@ def sampled(self) -> "Optional[bool]": def dynamic_sampling_context(self) -> "dict[str, str]": return self.segment._get_baggage().dynamic_sampling_context() + def to_traceparent(self) -> str: + if self.sampled is True: + sampled = "1" + elif self.sampled is False: + sampled = "0" + else: + sampled = None + + traceparent = "%s-%s" % (self.trace_id, self.span_id) + if sampled is not None: + traceparent += "-%s" % (sampled,) + + return traceparent + + def to_baggage(self) -> "Optional[Baggage]": + if self.segment: + return self.segment._get_baggage() + return None + def _update_active_thread(self) -> None: thread_id, thread_name = get_current_thread_meta() self._set_thread(thread_id, thread_name) @@ -482,8 +580,3 @@ def _set_grouping_attributes(self): self.set_attribute("sentry.segment.id", self.segment.span_id) self.set_attribute("sentry.segment.name", self.segment.name) - - -def continue_trace(incoming: "dict[str, Any]") -> None: - # XXX[span-first]: conceptually, this should be set on the iso scope - sentry_sdk.get_current_scope().set_propagation_context(incoming) From 89d608452c7edeee66675f4a268c5eb30d2c0039 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 11:00:47 +0100 Subject: [PATCH 18/38] fix tracing utils --- sentry_sdk/scope.py | 1 + sentry_sdk/tracing_utils.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 6147244e28..164969fd70 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1219,6 +1219,7 @@ def start_streamed_span( # active span, this is a segment if parent_span is None: propagation_context = self.get_active_propagation_context() + span = StreamedSpan( name=name, attributes=attributes, diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index e31c362723..95350cefc8 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1426,6 +1426,7 @@ def add_sentry_baggage_to_headers( BAGGAGE_HEADER_NAME, LOW_QUALITY_TRANSACTION_SOURCES, SENTRY_TRACE_HEADER_NAME, + Span, ) from sentry_sdk.traces import ( @@ -1434,5 +1435,4 @@ def add_sentry_baggage_to_headers( ) if TYPE_CHECKING: - from sentry_sdk.tracing import Span from sentry_sdk.traces import StreamedSpan From 0e8ab89eb94415cc4120884bf0dcbf07ac413ee8 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 11:20:16 +0100 Subject: [PATCH 19/38] move stuff around --- sentry_sdk/traces.py | 150 ++++++++++++++++++++++++++++++------------ sentry_sdk/tracing.py | 63 ------------------ 2 files changed, 108 insertions(+), 105 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 7e444fd8dd..feed9123f8 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -23,13 +23,57 @@ ) 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, SamplingContext from sentry_sdk.scope import Scope + P = ParamSpec("P") + R = TypeVar("R") + FLAGS_CAPACITY = 10 + +class SpanStatus(str, Enum): + OK = "ok" + ERROR = "error" + + def __str__(self) -> str: + return self.value + + +# Segment source, see +# https://getsentry.github.io/sentry-conventions/generated/attributes/sentry.html#sentryspansource +class SegmentSource(str, Enum): + COMPONENT = "component" + CUSTOM = "custom" + ROUTE = "route" + TASK = "task" + URL = "url" + VIEW = "view" + + def __str__(self) -> str: + return self.value + + +# These are typically high cardinality and the server hates them +LOW_QUALITY_SEGMENT_SOURCES = [ + SegmentSource.URL, +] + + +SOURCE_FOR_STYLE = { + "endpoint": SegmentSource.COMPONENT, + "function_name": SegmentSource.COMPONENT, + "handler_name": SegmentSource.COMPONENT, + "method_and_path_pattern": SegmentSource.ROUTE, + "path": SegmentSource.URL, + "route_name": SegmentSource.COMPONENT, + "route_pattern": SegmentSource.ROUTE, + "uri_template": SegmentSource.ROUTE, + "url": SegmentSource.ROUTE, +} + """ TODO[span-first] / notes - redis, http, subprocess breadcrumbs (maybe_create_breadcrumbs_from_span) work @@ -138,47 +182,6 @@ def new_trace() -> None: sentry_sdk.get_current_scope().set_new_propagation_context() -class SpanStatus(str, Enum): - OK = "ok" - ERROR = "error" - - def __str__(self) -> str: - return self.value - - -# Segment source, see -# https://getsentry.github.io/sentry-conventions/generated/attributes/sentry.html#sentryspansource -class SegmentSource(str, Enum): - COMPONENT = "component" - CUSTOM = "custom" - ROUTE = "route" - TASK = "task" - URL = "url" - VIEW = "view" - - def __str__(self) -> str: - return self.value - - -# These are typically high cardinality and the server hates them -LOW_QUALITY_SEGMENT_SOURCES = [ - SegmentSource.URL, -] - - -SOURCE_FOR_STYLE = { - "endpoint": SegmentSource.COMPONENT, - "function_name": SegmentSource.COMPONENT, - "handler_name": SegmentSource.COMPONENT, - "method_and_path_pattern": SegmentSource.ROUTE, - "path": SegmentSource.URL, - "route_name": SegmentSource.COMPONENT, - "route_pattern": SegmentSource.ROUTE, - "uri_template": SegmentSource.ROUTE, - "url": SegmentSource.ROUTE, -} - - class NoOpStreamedSpan: pass @@ -580,3 +583,66 @@ def _set_grouping_attributes(self): self.set_attribute("sentry.segment.id", self.segment.span_id) self.set_attribute("sentry.segment.name", self.segment.name) + + +def trace( + func: "Optional[Callable[P, R]]" = None, + *, + name: "Optional[str]" = None, + attributes: "Optional[dict[str, Any]]" = None, +) -> "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 + + :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, + ) + + if func: + return decorator(func) + else: + return decorator diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index dcdc81eba9..c4b38e4528 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -1423,69 +1423,6 @@ def calculate_interest_rate(amount, rate, years): return decorator -def streaming_trace( - func: "Optional[Callable[P, R]]" = None, - *, - name: "Optional[str]" = None, - attributes: "Optional[dict[str, Any]]" = None, -) -> "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 - - :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, - ) - - if func: - return decorator(func) - else: - return decorator - - # Circular imports from sentry_sdk.tracing_utils import ( From 4b5c205b3e90764775de84438d5ca74e191bc78d Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 11:51:12 +0100 Subject: [PATCH 20/38] profiler, source, op --- sentry_sdk/scope.py | 27 +++++++++++++++++++++++++++ sentry_sdk/traces.py | 21 ++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 164969fd70..88e810132c 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1247,6 +1247,33 @@ def start_streamed_span( # sampling decision span._set_sampling_decision(sampling_context=sampling_context) + # update the sample rate in the dsc + if span.sample_rate is not None: + propagation_context = self.get_active_propagation_context() + baggage = propagation_context.baggage + + if baggage is not None: + baggage.sentry_items["sample_rate"] = str(span.sample_rate) + + if span._baggage: + span._baggage.sentry_items["sample_rate"] = str(span.sample_rate) + + if span.sampled: + profile = Profile(span.sampled, span._start_timestamp_monotonic_ns) + profile._set_initial_sampling_decision( + sampling_context=sampling_context + ) + + span._profile = profile + + span._continuous_profile = try_profile_lifecycle_trace_start() + + # Typically, the profiler is set when the segment is created. But when + # using the auto lifecycle, the profiler isn't running when the first + # segment is started. So make sure we update the profiler id on it. + if span._continuous_profile is not None: + span._set_profiler_id(get_profiler_id()) + return span # This is a child span; take propagation context from the parent span diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index feed9123f8..0f253ec956 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -1,10 +1,17 @@ +""" +The API in this file is only meant to be used in span streaming mode. + +You can enable span streaming mode via +sentry_sdk.init(_experiments={"trace_lifecycle": "stream"}). +""" + import uuid from datetime import datetime, timedelta, timezone from enum import Enum from typing import TYPE_CHECKING import sentry_sdk -from sentry_sdk.consts import SPANDATA +from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.profiler.continuous_profiler import get_profiler_id from sentry_sdk.tracing_utils import ( Baggage, @@ -426,6 +433,18 @@ def set_flag(self, flag: str, result: bool) -> None: if len(self._flags) < FLAGS_CAPACITY: self._flags[flag] = result + def set_op(self, op: "Union[OP, str]") -> None: + if isinstance(op, Enum): + op = op.value + + self.set_attribute("sentry.op", op) + + def set_source(self, source: "Union[SegmentSource, str]") -> None: + if isinstance(source, Enum): + source = source.value + + self.set_attribute("sentry.span.source", source) + def is_segment(self) -> bool: return self.segment == self From 546c2f0f0f0f186eb5467568ea9e3dab6adadaab Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 12:38:42 +0100 Subject: [PATCH 21/38] prepare for profiler changes --- sentry_sdk/scope.py | 2 +- sentry_sdk/traces.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 88e810132c..fb8a8ce451 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1272,7 +1272,7 @@ def start_streamed_span( # using the auto lifecycle, the profiler isn't running when the first # segment is started. So make sure we update the profiler id on it. if span._continuous_profile is not None: - span._set_profiler_id(get_profiler_id()) + span._set_profile_id(get_profiler_id()) return span diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 0f253ec956..bc1848509f 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -290,7 +290,7 @@ def __init__( self._continuous_profile = None self._update_active_thread() - self._set_profiler_id(get_profiler_id()) + self._set_profile_id(get_profiler_id()) def __repr__(self) -> str: return ( @@ -507,9 +507,9 @@ def _set_thread( if thread_name is not None: self.set_attribute(SPANDATA.THREAD_NAME, thread_name) - def _set_profiler_id(self, profiler_id: "Optional[str]") -> None: + def _set_profile_id(self, profiler_id: "Optional[str]") -> None: if profiler_id is not None: - self.set_attribute(SPANDATA.PROFILER_ID, profiler_id) + self.set_attribute("sentry.profiler_id", profiler_id) def _set_http_status(self, http_status: int) -> None: self.set_attribute(SPANDATA.HTTP_STATUS_CODE, http_status) From 9b2b592e492af252658c0e3344c4e5d090c5f456 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 13:36:26 +0100 Subject: [PATCH 22/38] source, segment attrs/props --- sentry_sdk/scope.py | 1 - sentry_sdk/traces.py | 11 ++++------- sentry_sdk/transport.py | 1 - 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index fb8a8ce451..4ebeda47af 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1233,7 +1233,6 @@ def start_streamed_span( try_autostart_continuous_profiler() - # XXX[span-first]: no sampling context? sampling_context = { "transaction_context": { "trace_id": span.trace_id, diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index bc1848509f..2763376e07 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -224,7 +224,6 @@ class StreamedSpan: "_baggage", "sample_rate", "_sample_rand", - "source", "_finished", ) @@ -269,7 +268,7 @@ def __init__( self._span_id: "Optional[str]" = None self.status: SpanStatus = SpanStatus.OK - self.source: "Optional[SegmentSource]" = SegmentSource.CUSTOM + self.set_source(SegmentSource.CUSTOM) # XXX[span-first] ^ populate this correctly self._sampled: "Optional[bool]" = None @@ -366,7 +365,7 @@ def _end( if not client.is_active(): return - self._set_grouping_attributes() + self._set_segment_attributes() scope: "Optional[sentry_sdk.Scope]" = ( scope or self._scope or sentry_sdk.get_current_scope() @@ -595,10 +594,8 @@ def _set_sampling_decision(self, sampling_context: "SamplingContext") -> None: f"[Tracing] Discarding {self.name} because it's not included in the random sample (sampling rate = {self.sample_rate})" ) - def _set_grouping_attributes(self): - if self.is_segment(): - self.set_attribute("sentry.span.source", self.source.value) - else: + def _set_segment_attributes(self): + if not self.is_segment(): self.set_attribute("sentry.segment.id", self.segment.span_id) self.set_attribute("sentry.segment.name", self.segment.name) diff --git a/sentry_sdk/transport.py b/sentry_sdk/transport.py index cee4fa882b..48778839b1 100644 --- a/sentry_sdk/transport.py +++ b/sentry_sdk/transport.py @@ -445,7 +445,6 @@ def _send_envelope(self: "Self", envelope: "Envelope") -> None: envelope.items.append(client_report_item) content_encoding, body = self._serialize_envelope(envelope) - assert self.parsed_dsn is not None logger.debug( "Sending envelope [%s] project:%s host:%s", From 0e198f247ec5242fb7d355eae5fff094b06f78d7 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 13:46:34 +0100 Subject: [PATCH 23/38] add todo --- sentry_sdk/_span_batcher.py | 1 + sentry_sdk/tracing_utils.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index 7c4f34e5d8..68ac5565d6 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -16,6 +16,7 @@ class SpanBatcher(Batcher["StreamedSpan"]): # TODO[span-first]: size-based flushes + # TODO[span-first]: adjust flush/drop defaults MAX_BEFORE_FLUSH = 1000 MAX_BEFORE_DROP = 5000 FLUSH_WAIT_TIME = 5.0 diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 95350cefc8..2cb595c371 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -778,7 +778,7 @@ def populate_from_segment(cls, segment: "StreamedSpan") -> "Baggage": if client.parsed_dsn.org_id: sentry_items["org_id"] = client.parsed_dsn.org_id - if segment.source not in LOW_QUALITY_SEGMENT_SOURCES: + if segment.get_attributes().get("source") not in LOW_QUALITY_SEGMENT_SOURCES: sentry_items["transaction"] = segment.name if segment.sample_rate is not None: From dca0870e05f6d0e00336e80abd642e8afe860d0f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 14:34:28 +0100 Subject: [PATCH 24/38] profiler fixes, asgi first pass, sampling on start --- sentry_sdk/integrations/asgi.py | 83 +++++++++++++++++++++++---------- sentry_sdk/scope.py | 65 +++++++++----------------- sentry_sdk/traces.py | 32 +++++++++++-- 3 files changed, 110 insertions(+), 70 deletions(-) diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 6983af89ed..0e41c16e99 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -36,6 +36,7 @@ _get_installed_modules, ) from sentry_sdk.tracing import Transaction +from sentry_sdk.tracing_utils import has_span_streaming_enabled from typing import TYPE_CHECKING @@ -185,6 +186,9 @@ async def _run_app( self._capture_lifespan_exception(exc) raise exc from None + client = sentry_sdk.get_client() + span_streaming = has_span_streaming_enabled(client.options) + _asgi_middleware_applied.set(True) try: with sentry_sdk.isolation_scope() as sentry_scope: @@ -204,48 +208,77 @@ async def _run_app( ) method = scope.get("method", "").upper() - transaction = None - if ty in ("http", "websocket"): - if ty == "websocket" or method in self.http_methods_to_capture: - transaction = continue_trace( - _get_headers(scope), - op="{}.server".format(ty), + span = None + + if span_streaming: + if ty in ("http", "websocket"): + if ( + ty == "websocket" + or method in self.http_methods_to_capture + ): + sentry_sdk.traces.continue_trace(_get_headers(scope)) + span = sentry_sdk.traces.start_span( + name=transaction_name + ) + span.set_op(f"{ty}.server") + else: + span = sentry_sdk.traces.start_span( + name=transaction_name, + ) + span.set_op(OP.HTTP_SERVER) + + if span is not None: + span.set_source(transaction_source) + span.set_origin(self.span_origin) + + span_context = span if span is not None else nullcontext() + + else: + if ty in ("http", "websocket"): + if ( + ty == "websocket" + or method in self.http_methods_to_capture + ): + span = continue_trace( + _get_headers(scope), + op="{}.server".format(ty), + name=transaction_name, + source=transaction_source, + origin=self.span_origin, + ) + else: + span = Transaction( + op=OP.HTTP_SERVER, name=transaction_name, source=transaction_source, origin=self.span_origin, ) - else: - transaction = Transaction( - op=OP.HTTP_SERVER, - name=transaction_name, - source=transaction_source, - origin=self.span_origin, - ) - if transaction: - transaction.set_tag("asgi.type", ty) + if span: + span.set_tag("asgi.type", ty) - transaction_context = ( - sentry_sdk.start_transaction( - transaction, - custom_sampling_context={"asgi_scope": scope}, + span_context = ( + sentry_sdk.start_transaction( + span, + custom_sampling_context={"asgi_scope": scope}, + ) + if span is not None + else nullcontext() ) - if transaction is not None - else nullcontext() - ) - with transaction_context: + + with span_context: try: async def _sentry_wrapped_send( event: "Dict[str, Any]", ) -> "Any": - if transaction is not None: + if span is not None: is_http_response = ( event.get("type") == "http.response.start" and "status" in event ) if is_http_response: - transaction.set_http_status(event["status"]) + span.set_http_status(event["status"]) return await send(event) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 4ebeda47af..eeb0f3f26d 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1231,48 +1231,6 @@ def start_streamed_span( baggage=propagation_context.baggage, ) - try_autostart_continuous_profiler() - - sampling_context = { - "transaction_context": { - "trace_id": span.trace_id, - "span_id": span.span_id, - "parent_span_id": span.parent_span_id, - }, - "parent_sampled": span.parent_sampled, - "attributes": span.attributes, - } - # Use traces_sample_rate, traces_sampler, and/or inheritance to make a - # sampling decision - span._set_sampling_decision(sampling_context=sampling_context) - - # update the sample rate in the dsc - if span.sample_rate is not None: - propagation_context = self.get_active_propagation_context() - baggage = propagation_context.baggage - - if baggage is not None: - baggage.sentry_items["sample_rate"] = str(span.sample_rate) - - if span._baggage: - span._baggage.sentry_items["sample_rate"] = str(span.sample_rate) - - if span.sampled: - profile = Profile(span.sampled, span._start_timestamp_monotonic_ns) - profile._set_initial_sampling_decision( - sampling_context=sampling_context - ) - - span._profile = profile - - span._continuous_profile = try_profile_lifecycle_trace_start() - - # Typically, the profiler is set when the segment is created. But when - # using the auto lifecycle, the profiler isn't running when the first - # segment is started. So make sure we update the profiler id on it. - if span._continuous_profile is not None: - span._set_profile_id(get_profiler_id()) - return span # This is a child span; take propagation context from the parent span @@ -1290,6 +1248,29 @@ def start_streamed_span( return span + def start_profile_on_segment(self, span: "StreamedSpan") -> None: + try_autostart_continuous_profiler() + + if not span.sampled: + return + + span._continuous_profile = try_profile_lifecycle_trace_start() + + # Typically, the profiler is set when the segment is created. But when + # using the auto lifecycle, the profiler isn't running when the first + # segment is started. So make sure we update the profiler id on it. + if span._continuous_profile is not None: + span._set_profile_id(get_profiler_id()) + + def update_sample_rate_from_segment(self, span: "StreamedSpan") -> None: + # If we had to adjust the sample rate when setting the sampling decision + # for the spans, it needs to be updated in the propagation context too + propagation_context = self.get_active_propagation_context() + baggage = propagation_context.baggage + + if baggage is not None: + baggage.sentry_items["sample_rate"] = str(span.sample_rate) + def continue_trace( self, environ_or_headers: "Dict[str, Any]", diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 2763376e07..2ef1ea4d2b 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -172,6 +172,14 @@ def continue_trace(incoming: "dict[str, Any]") -> None: continue_trace() doesn't start any spans on its own. """ + # 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 segments on the current span, 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, ) @@ -307,8 +315,26 @@ def __enter__(self) -> "StreamedSpan": scope.span = self self._context_manager_state = (scope, old_span) - if self.is_segment() and self._profile is not None: - self._profile.__enter__() + if self.is_segment(): + sampling_context = { + "transaction_context": { + "trace_id": self.trace_id, + "span_id": self.span_id, + "parent_span_id": self.parent_span_id, + }, + "parent_sampled": self.parent_sampled, + "attributes": self.attributes, + } + # Use traces_sample_rate, traces_sampler, and/or inheritance to make a + # sampling decision + self._set_sampling_decision(sampling_context=sampling_context) + + # update the sample rate in the dsc + if self.sample_rate is not None: + if self._baggage: + self._baggage.sentry_items["sample_rate"] = str(self.sample_rate) + + scope.start_profile_on_segment(self) return self @@ -510,7 +536,7 @@ def _set_profile_id(self, profiler_id: "Optional[str]") -> None: if profiler_id is not None: self.set_attribute("sentry.profiler_id", profiler_id) - def _set_http_status(self, http_status: int) -> None: + def set_http_status(self, http_status: int) -> None: self.set_attribute(SPANDATA.HTTP_STATUS_CODE, http_status) if http_status >= 400: From 68cf5d299189335163606070c1fb0d5dbf05b311 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 14:37:26 +0100 Subject: [PATCH 25/38] . --- sentry_sdk/scope.py | 4 ++-- sentry_sdk/traces.py | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index eeb0f3f26d..2c98a67331 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1248,7 +1248,7 @@ def start_streamed_span( return span - def start_profile_on_segment(self, span: "StreamedSpan") -> None: + def _start_profile_on_segment(self, span: "StreamedSpan") -> None: try_autostart_continuous_profiler() if not span.sampled: @@ -1262,7 +1262,7 @@ def start_profile_on_segment(self, span: "StreamedSpan") -> None: if span._continuous_profile is not None: span._set_profile_id(get_profiler_id()) - def update_sample_rate_from_segment(self, span: "StreamedSpan") -> None: + def _update_sample_rate_from_segment(self, span: "StreamedSpan") -> None: # If we had to adjust the sample rate when setting the sampling decision # for the spans, it needs to be updated in the propagation context too propagation_context = self.get_active_propagation_context() diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 2ef1ea4d2b..16c5f7ad67 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -329,12 +329,8 @@ def __enter__(self) -> "StreamedSpan": # sampling decision self._set_sampling_decision(sampling_context=sampling_context) - # update the sample rate in the dsc - if self.sample_rate is not None: - if self._baggage: - self._baggage.sentry_items["sample_rate"] = str(self.sample_rate) - - scope.start_profile_on_segment(self) + scope._update_sample_rate_from_segment(self) + scope._start_profile_on_segment(self) return self From dedfaf04b4dd6f17db2c2d1e23b259f4bc3e7d7d Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 14:59:26 +0100 Subject: [PATCH 26/38] starlette --- sentry_sdk/integrations/starlette.py | 79 +++++++++++++++++++++------- sentry_sdk/scope.py | 18 ++----- 2 files changed, 63 insertions(+), 34 deletions(-) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 0b797ebcde..b1b2480b49 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -24,6 +24,7 @@ SOURCE_FOR_STYLE, TransactionSource, ) +from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( AnnotatedValue, capture_internal_exceptions, @@ -147,10 +148,13 @@ async def _create_span_call( send: "Callable[[Dict[str, Any]], Awaitable[None]]", **kwargs: "Any", ) -> None: - integration = sentry_sdk.get_client().get_integration(StarletteIntegration) + client = sentry_sdk.get_client() + integration = client.get_integration(StarletteIntegration) if integration is None: return await old_call(app, scope, receive, send, **kwargs) + span_streaming = has_span_streaming_enabled(client.options) + # Update transaction name with middleware name name, source = _get_transaction_from_middleware(app, scope, integration) @@ -165,21 +169,45 @@ async def _create_span_call( middleware_name = app.__class__.__name__ - with sentry_sdk.start_span( - op=OP.MIDDLEWARE_STARLETTE, - name=middleware_name, - origin=StarletteIntegration.origin, - ) as middleware_span: - middleware_span.set_tag("starlette.middleware_name", middleware_name) + if span_streaming: + span_ctx = sentry_sdk.traces.start_span(name=middleware_name) + else: + span_ctx = sentry_sdk.start_span( + op=OP.MIDDLEWARE_STARLETTE, + name=middleware_name, + origin=StarletteIntegration.origin, + ) + + with span_ctx as middleware_span: + if span_streaming: + middleware_span.set_op(OP.MIDDLEWARE_STARLETTE) + middleware_span.set_origin(StarletteIntegration.origin) + middleware_span.set_attribute( + "starlette.middleware_name", middleware_name + ) + else: + middleware_span.set_tag("starlette.middleware_name", middleware_name) # Creating spans for the "receive" callback async def _sentry_receive(*args: "Any", **kwargs: "Any") -> "Any": - with sentry_sdk.start_span( - op=OP.MIDDLEWARE_STARLETTE_RECEIVE, - name=getattr(receive, "__qualname__", str(receive)), - origin=StarletteIntegration.origin, - ) as span: - span.set_tag("starlette.middleware_name", middleware_name) + if span_streaming: + span_ctx = sentry_sdk.tracing.start_span( + name=getattr(receive, "__qualname__", str(receive)), + ) + else: + span_ctx = sentry_sdk.start_span( + op=OP.MIDDLEWARE_STARLETTE_RECEIVE, + name=getattr(receive, "__qualname__", str(receive)), + origin=StarletteIntegration.origin, + ) + + with span_ctx as span: + if span_streaming: + span.set_origin(StarletteIntegration.origin) + span.set_op(OP.MIDDLEWARE_STARLETTE_RECEIVE) + span.set_attribute("starlette.middleware_name", middleware_name) + else: + span.set_tag("starlette.middleware_name", middleware_name) return await receive(*args, **kwargs) receive_name = getattr(receive, "__name__", str(receive)) @@ -188,12 +216,25 @@ async def _sentry_receive(*args: "Any", **kwargs: "Any") -> "Any": # Creating spans for the "send" callback async def _sentry_send(*args: "Any", **kwargs: "Any") -> "Any": - with sentry_sdk.start_span( - op=OP.MIDDLEWARE_STARLETTE_SEND, - name=getattr(send, "__qualname__", str(send)), - origin=StarletteIntegration.origin, - ) as span: - span.set_tag("starlette.middleware_name", middleware_name) + if span_streaming: + span_ctx = sentry_sdk.tracing.start_span( + name=getattr(send, "__qualname__", str(send)), + ) + else: + span_ctx = sentry_sdk.start_span( + op=OP.MIDDLEWARE_STARLETTE_SEND, + name=getattr(send, "__qualname__", str(send)), + origin=StarletteIntegration.origin, + ) + + with span_ctx as span: + if span_streaming: + span.set_op(OP.MIDDLEWARE_STARLETTE_SEND) + span.set_origin(StarletteIntegration.origin) + span.set_attribute("starlette.middleware_name", middleware_name) + else: + span.set_tag("starlette.middleware_name", middleware_name) + return await send(*args, **kwargs) send_name = getattr(send, "__name__", str(send)) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 2c98a67331..70b3ab4d89 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -675,15 +675,6 @@ def iter_trace_propagation_headers( span = kwargs.pop("span", None) span = span or self.span - if isinstance(span, StreamedSpan): - warnings.warn( - "Scope.iter_trace_propagation_headers is not available in " - "streaming mode.", - DeprecationWarning, - stacklevel=2, - ) - return None - if has_tracing_enabled(client.options) and span is not None: for header in span.iter_headers(): yield header @@ -831,12 +822,9 @@ def set_transaction_name(self, name: str, source: "Optional[str]" = None) -> Non self._transaction = name if self._span: if isinstance(self._span, StreamedSpan): - warnings.warn( - "Scope.set_transaction_name is not available in streaming mode.", - DeprecationWarning, - stacklevel=2, - ) - return None + self._span.segment.name = name + if source: + self._span.segment.set_source(source) if self._span.containing_transaction: self._span.containing_transaction.name = name From 3ca46dced9877175e4a4b0509b8297bef1eabd1b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 15:13:48 +0100 Subject: [PATCH 27/38] asgi fixes, set_origin --- sentry_sdk/integrations/asgi.py | 2 ++ sentry_sdk/scope.py | 2 +- sentry_sdk/traces.py | 3 +++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 0e41c16e99..38014ac53c 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -222,6 +222,7 @@ async def _run_app( ) span.set_op(f"{ty}.server") else: + sentry_sdk.traces.new_trace() span = sentry_sdk.traces.start_span( name=transaction_name, ) @@ -230,6 +231,7 @@ async def _run_app( if span is not None: span.set_source(transaction_source) span.set_origin(self.span_origin) + span.set_attribute("asgi.type", ty) span_context = span if span is not None else nullcontext() diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 70b3ab4d89..81429ac98c 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -826,7 +826,7 @@ def set_transaction_name(self, name: str, source: "Optional[str]" = None) -> Non if source: self._span.segment.set_source(source) - if self._span.containing_transaction: + elif self._span.containing_transaction: self._span.containing_transaction.name = name if source: self._span.containing_transaction.source = source diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 16c5f7ad67..c8b0b01017 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -460,6 +460,9 @@ def set_op(self, op: "Union[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[SegmentSource, str]") -> None: if isinstance(source, Enum): source = source.value From 667397815f8f4e1dd1232717c4bc18d39dde7b6a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 15:59:33 +0100 Subject: [PATCH 28/38] stdlib --- sentry_sdk/integrations/starlette.py | 52 +++++----- sentry_sdk/integrations/stdlib.py | 136 ++++++++++++++++++++------- sentry_sdk/traces.py | 31 +++--- sentry_sdk/utils.py | 4 + 4 files changed, 148 insertions(+), 75 deletions(-) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index b1b2480b49..abbbcfdad0 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -170,44 +170,37 @@ async def _create_span_call( middleware_name = app.__class__.__name__ if span_streaming: - span_ctx = sentry_sdk.traces.start_span(name=middleware_name) + middleware_span = sentry_sdk.traces.start_span(name=middleware_name) + middleware_span.set_op(OP.MIDDLEWARE_STARLETTE) + middleware_span.set_origin(StarletteIntegration.origin) + middleware_span.set_attribute("starlette.middleware_name", middleware_name) else: - span_ctx = sentry_sdk.start_span( + middleware_span = sentry_sdk.start_span( op=OP.MIDDLEWARE_STARLETTE, name=middleware_name, origin=StarletteIntegration.origin, ) + middleware_span.set_tag("starlette.middleware_name", middleware_name) - with span_ctx as middleware_span: - if span_streaming: - middleware_span.set_op(OP.MIDDLEWARE_STARLETTE) - middleware_span.set_origin(StarletteIntegration.origin) - middleware_span.set_attribute( - "starlette.middleware_name", middleware_name - ) - else: - middleware_span.set_tag("starlette.middleware_name", middleware_name) - + with middleware_span: # Creating spans for the "receive" callback async def _sentry_receive(*args: "Any", **kwargs: "Any") -> "Any": if span_streaming: - span_ctx = sentry_sdk.tracing.start_span( + span = sentry_sdk.traces.start_span( name=getattr(receive, "__qualname__", str(receive)), ) + span.set_origin(StarletteIntegration.origin) + span.set_op(OP.MIDDLEWARE_STARLETTE_RECEIVE) + span.set_attribute("starlette.middleware_name", middleware_name) else: - span_ctx = sentry_sdk.start_span( + span = sentry_sdk.start_span( op=OP.MIDDLEWARE_STARLETTE_RECEIVE, name=getattr(receive, "__qualname__", str(receive)), origin=StarletteIntegration.origin, ) + span.set_tag("starlette.middleware_name", middleware_name) - with span_ctx as span: - if span_streaming: - span.set_origin(StarletteIntegration.origin) - span.set_op(OP.MIDDLEWARE_STARLETTE_RECEIVE) - span.set_attribute("starlette.middleware_name", middleware_name) - else: - span.set_tag("starlette.middleware_name", middleware_name) + with span: return await receive(*args, **kwargs) receive_name = getattr(receive, "__name__", str(receive)) @@ -217,24 +210,21 @@ async def _sentry_receive(*args: "Any", **kwargs: "Any") -> "Any": # Creating spans for the "send" callback async def _sentry_send(*args: "Any", **kwargs: "Any") -> "Any": if span_streaming: - span_ctx = sentry_sdk.tracing.start_span( + span = sentry_sdk.traces.start_span( name=getattr(send, "__qualname__", str(send)), ) + span.set_op(OP.MIDDLEWARE_STARLETTE_SEND) + span.set_origin(StarletteIntegration.origin) + span.set_attribute("starlette.middleware_name", middleware_name) else: - span_ctx = sentry_sdk.start_span( + span = sentry_sdk.start_span( op=OP.MIDDLEWARE_STARLETTE_SEND, name=getattr(send, "__qualname__", str(send)), origin=StarletteIntegration.origin, ) + span.set_tag("starlette.middleware_name", middleware_name) - with span_ctx as span: - if span_streaming: - span.set_op(OP.MIDDLEWARE_STARLETTE_SEND) - span.set_origin(StarletteIntegration.origin) - span.set_attribute("starlette.middleware_name", middleware_name) - else: - span.set_tag("starlette.middleware_name", middleware_name) - + with span: return await send(*args, **kwargs) send_name = getattr(send, "__name__", str(send)) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index e3120a3b32..334a3d9513 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -8,10 +8,12 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor +from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing_utils import ( EnvironHeaders, should_propagate_trace, add_http_request_source, + has_span_streaming_enabled, ) from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, @@ -86,6 +88,8 @@ def putrequest( ): return real_putrequest(self, method, url, *args, **kwargs) + span_streaming = has_span_streaming_enabled(client.options) + real_url = url if real_url is None or not real_url.startswith(("http://", "https://")): real_url = "%s://%s%s%s" % ( @@ -99,22 +103,44 @@ def putrequest( with capture_internal_exceptions(): parsed_url = parse_url(real_url, sanitize=False) - span = sentry_sdk.start_span( - op=OP.HTTP_CLIENT, - name="%s %s" - % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), - origin="auto.http.stdlib.httplib", - ) - span.set_data(SPANDATA.HTTP_METHOD, method) - if parsed_url is not None: - span.set_data("url", parsed_url.url) - span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) - span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - - # for proxies, these point to the proxy host/port - if tunnel_host: - span.set_data(SPANDATA.NETWORK_PEER_ADDRESS, self.host) - span.set_data(SPANDATA.NETWORK_PEER_PORT, self.port) + if span_streaming: + span = sentry_sdk.traces.start_span( + name=f"{method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}" + ) + span.set_op(OP.HTTP_CLIENT) + span.set_origin("auto.http.stdlib.httplib") + + span.set_attribute(SPANDATA.HTTP_METHOD, method) + if parsed_url is not None: + span.set_attribute("url", parsed_url.url) + span.set_attribute(SPANDATA.HTTP_QUERY, parsed_url.query) + span.set_attribute(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + + # for proxies, these point to the proxy host/port + if tunnel_host: + span.set_attribute(SPANDATA.NETWORK_PEER_ADDRESS, self.host) + span.set_attribute(SPANDATA.NETWORK_PEER_PORT, self.port) + + span.start() + + else: + span = sentry_sdk.start_span( + op=OP.HTTP_CLIENT, + name="%s %s" + % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), + origin="auto.http.stdlib.httplib", + ) + + span.set_data(SPANDATA.HTTP_METHOD, method) + if parsed_url is not None: + span.set_data("url", parsed_url.url) + span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) + span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + + # for proxies, these point to the proxy host/port + if tunnel_host: + span.set_data(SPANDATA.NETWORK_PEER_ADDRESS, self.host) + span.set_data(SPANDATA.NETWORK_PEER_PORT, self.port) rv = real_putrequest(self, method, url, *args, **kwargs) @@ -146,7 +172,10 @@ def getresponse(self: "HTTPConnection", *args: "Any", **kwargs: "Any") -> "Any": rv = real_getresponse(self, *args, **kwargs) span.set_http_status(int(rv.status)) - span.set_data("reason", rv.reason) + if isinstance(span, StreamedSpan): + span.set_attribute("reason", rv.reason) + else: + span.set_data("reason", rv.reason) finally: span.finish() @@ -226,11 +255,23 @@ def sentry_patched_popen_init( env = None - with sentry_sdk.start_span( - op=OP.SUBPROCESS, - name=description, - origin="auto.subprocess.stdlib.subprocess", - ) as span: + client = sentry_sdk.get_client() + span_streaming = has_span_streaming_enabled(client.options) + + if span_streaming: + span = sentry_sdk.traces.start_span( + name=description, + ) + span.set_op(OP.SUBPROCESS) + span.set_origin("auto.subprocess.stdlib.subprocess") + else: + span = sentry_sdk.start_span( + op=OP.SUBPROCESS, + name=description, + origin="auto.subprocess.stdlib.subprocess", + ) + + with span: for k, v in sentry_sdk.get_current_scope().iter_trace_propagation_headers( span=span ): @@ -245,11 +286,18 @@ def sentry_patched_popen_init( env["SUBPROCESS_" + k.upper().replace("-", "_")] = v if cwd: - span.set_data("subprocess.cwd", cwd) + if span_streaming: + span.set_attribute("subprocess.cwd", cwd) + else: + span.set_data("subprocess.cwd", cwd) rv = old_popen_init(self, *a, **kw) - span.set_tag("subprocess.pid", self.pid) + if span_streaming: + span.set_attribute("subprocess.pid", self.pid) + else: + span.set_tag("subprocess.pid", self.pid) + return rv subprocess.Popen.__init__ = sentry_patched_popen_init # type: ignore @@ -260,11 +308,22 @@ def sentry_patched_popen_init( def sentry_patched_popen_wait( self: "subprocess.Popen[Any]", *a: "Any", **kw: "Any" ) -> "Any": - with sentry_sdk.start_span( - op=OP.SUBPROCESS_WAIT, - origin="auto.subprocess.stdlib.subprocess", - ) as span: + client = sentry_sdk.get_client() + span_streaming = has_span_streaming_enabled(client.options) + + if span_streaming: + span = sentry_sdk.traces.start_span(name="subprocess popen") + span.set_op(OP.SUBPROCESS_WAIT) + span.set_origin("auto.subprocess.stdlib.subprocess") + span.set_tag("subprocess.pid", self.pid) + else: + span = sentry_sdk.start_span( + op=OP.SUBPROCESS_WAIT, + origin="auto.subprocess.stdlib.subprocess", + ) span.set_tag("subprocess.pid", self.pid) + + with span: return old_popen_wait(self, *a, **kw) subprocess.Popen.wait = sentry_patched_popen_wait # type: ignore @@ -275,11 +334,24 @@ def sentry_patched_popen_wait( def sentry_patched_popen_communicate( self: "subprocess.Popen[Any]", *a: "Any", **kw: "Any" ) -> "Any": - with sentry_sdk.start_span( - op=OP.SUBPROCESS_COMMUNICATE, - origin="auto.subprocess.stdlib.subprocess", - ) as span: + client = sentry_sdk.get_client() + span_streaming = has_span_streaming_enabled(client.options) + + if span_streaming: + span = sentry_sdk.traces.start_span( + name="subprocess communicate", + ) + span.set_op(OP.SUBPROCESS_COMMUNICATE) + span.set_origin("auto.subprocess.stdlib.subprocess") span.set_tag("subprocess.pid", self.pid) + else: + span = sentry_sdk.start_span( + op=OP.SUBPROCESS_COMMUNICATE, + origin="auto.subprocess.stdlib.subprocess", + ) + span.set_tag("subprocess.pid", self.pid) + + with span: return old_popen_communicate(self, *a, **kw) subprocess.Popen.communicate = sentry_patched_popen_communicate # type: ignore diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index c8b0b01017..588e9c9e81 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -30,7 +30,7 @@ ) if TYPE_CHECKING: - from typing import Any, Callable, Optional, ParamSpec, TypeVar, Union + from typing import Any, Callable, Iterator, Optional, ParamSpec, TypeVar, Union from sentry_sdk._types import Attributes, AttributeValue, SamplingContext from sentry_sdk.scope import Scope @@ -40,6 +40,9 @@ FLAGS_CAPACITY = 10 +BAGGAGE_HEADER_NAME = "baggage" +SENTRY_TRACE_HEADER_NAME = "sentry-trace" + class SpanStatus(str, Enum): OK = "ok" @@ -362,7 +365,7 @@ def start(self): """ self.__enter__() - def end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: + def finish(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: """ Finish this span and queue it for sending. @@ -455,18 +458,12 @@ def set_flag(self, flag: str, result: bool) -> None: self._flags[flag] = result def set_op(self, op: "Union[OP, str]") -> None: - if isinstance(op, Enum): - op = op.value - 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[SegmentSource, str]") -> None: - if isinstance(source, Enum): - source = source.value - + def set_source(self, source: "SegmentSource") -> None: self.set_attribute("sentry.span.source", source) def is_segment(self) -> bool: @@ -497,7 +494,7 @@ def sampled(self) -> "Optional[bool]": return self._sampled def dynamic_sampling_context(self) -> "dict[str, str]": - return self.segment._get_baggage().dynamic_sampling_context() + return self.segment.get_baggage().dynamic_sampling_context() def to_traceparent(self) -> str: if self.sampled is True: @@ -515,9 +512,19 @@ def to_traceparent(self) -> str: def to_baggage(self) -> "Optional[Baggage]": if self.segment: - return self.segment._get_baggage() + return self.segment.get_baggage() return None + def iter_headers(self) -> "Iterator[tuple[str, str]]": + if not self.segment: + return + + yield SENTRY_TRACE_HEADER_NAME, self.to_traceparent() + + baggage = self.segment.get_baggage().serialize() + if baggage: + yield BAGGAGE_HEADER_NAME, baggage + def _update_active_thread(self) -> None: thread_id, thread_name = get_current_thread_meta() self._set_thread(thread_id, thread_name) @@ -543,7 +550,7 @@ def set_http_status(self, http_status: int) -> None: else: self.set_status(SpanStatus.OK) - def _get_baggage(self) -> "Baggage": + def get_baggage(self) -> "Baggage": """ Return the :py:class:`~sentry_sdk.tracing_utils.Baggage` associated with the segment. diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 2fbca486de..8c746bb75e 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -14,6 +14,7 @@ from collections import namedtuple from datetime import datetime, timezone from decimal import Decimal +from enum import Enum from functools import partial, partialmethod, wraps from numbers import Real from urllib.parse import parse_qs, unquote, urlencode, urlsplit, urlunsplit @@ -2059,6 +2060,9 @@ def format_attribute(val: "Any") -> "AttributeValue": they're serialized further into the actual format the protocol expects: https://develop.sentry.dev/sdk/telemetry/attributes/ """ + if isinstance(val, Enum): + val = val.value + if isinstance(val, (bool, int, float, str)): return val From e72d3cdc90fef15320b36a67d82a7d30a6755a46 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 17:07:29 +0100 Subject: [PATCH 29/38] httpx fix --- sentry_sdk/integrations/httpx.py | 86 ++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index 38c4f437bc..bf49dbec96 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -7,6 +7,7 @@ should_propagate_trace, add_http_request_source, add_sentry_baggage_to_headers, + has_span_streaming_enabled, ) from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, @@ -47,27 +48,46 @@ def setup_once() -> None: def _install_httpx_client() -> None: real_send = Client.send - @ensure_integration_enabled(HttpxIntegration, real_send) def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": + client = sentry_sdk.get_client() + if client.get_integration(HttpxIntegration) is None: + return real_send(self, request, **kwargs) + + span_streaming = has_span_streaming_enabled(client.options) + parsed_url = None with capture_internal_exceptions(): parsed_url = parse_url(str(request.url), sanitize=False) - with start_span( - op=OP.HTTP_CLIENT, - name="%s %s" - % ( - request.method, - parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, - ), - origin=HttpxIntegration.origin, - ) as span: + if span_streaming: + span = sentry_sdk.traces.start_span( + name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}" + ) + span.set_op(OP.HTTP_CLIENT) + span.set_origin(HttpxIntegration.origin) + + span.set_attribute(SPANDATA.HTTP_METHOD, request.method) + if parsed_url is not None: + span.set_attribute("url", parsed_url.url) + span.set_attribute(SPANDATA.HTTP_QUERY, parsed_url.query) + span.set_attribute(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + else: + span = start_span( + op=OP.HTTP_CLIENT, + name="%s %s" + % ( + request.method, + parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, + ), + origin=HttpxIntegration.origin, + ) span.set_data(SPANDATA.HTTP_METHOD, request.method) if parsed_url is not None: span.set_data("url", parsed_url.url) span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + with span: if should_propagate_trace(sentry_sdk.get_client(), str(request.url)): for ( key, @@ -87,7 +107,10 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": rv = real_send(self, request, **kwargs) span.set_http_status(rv.status_code) - span.set_data("reason", rv.reason_phrase) + if span_streaming: + span.set_attribute("reason", rv.reason_phrase) + else: + span.set_data("reason", rv.reason_phrase) with capture_internal_exceptions(): add_http_request_source(span) @@ -103,28 +126,44 @@ def _install_httpx_async_client() -> None: async def send( self: "AsyncClient", request: "Request", **kwargs: "Any" ) -> "Response": - if sentry_sdk.get_client().get_integration(HttpxIntegration) is None: + client = sentry_sdk.get_client() + if client.get_integration(HttpxIntegration) is None: return await real_send(self, request, **kwargs) + span_streaming = has_span_streaming_enabled(client.options) + parsed_url = None with capture_internal_exceptions(): parsed_url = parse_url(str(request.url), sanitize=False) - with start_span( - op=OP.HTTP_CLIENT, - name="%s %s" - % ( - request.method, - parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, - ), - origin=HttpxIntegration.origin, - ) as span: + if span_streaming: + span = sentry_sdk.traces.start_span( + name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}" + ) + span.set_op(OP.HTTP_CLIENT) + span.set_origin(HttpxIntegration.origin) + span.set_attribute(SPANDATA.HTTP_METHOD, request.method) + if parsed_url is not None: + span.set_attribute("url", parsed_url.url) + span.set_attribute(SPANDATA.HTTP_QUERY, parsed_url.query) + span.set_attribute(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + else: + span = start_span( + op=OP.HTTP_CLIENT, + name="%s %s" + % ( + request.method, + parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, + ), + origin=HttpxIntegration.origin, + ) span.set_data(SPANDATA.HTTP_METHOD, request.method) if parsed_url is not None: span.set_data("url", parsed_url.url) span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + with span: if should_propagate_trace(sentry_sdk.get_client(), str(request.url)): for ( key, @@ -146,7 +185,10 @@ async def send( rv = await real_send(self, request, **kwargs) span.set_http_status(rv.status_code) - span.set_data("reason", rv.reason_phrase) + if span_streaming: + span.set_data("reason", rv.reason_phrase) + else: + span.set_attribute("reason", rv.reason_phrase) with capture_internal_exceptions(): add_http_request_source(span) From 016c341af39090445702bff5a735a029bb91eaad Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 17:15:06 +0100 Subject: [PATCH 30/38] sqlalchemy --- sentry_sdk/integrations/sqlalchemy.py | 24 ++++++++++++++++++------ sentry_sdk/tracing_utils.py | 27 ++++++++++++++++++++------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/sentry_sdk/integrations/sqlalchemy.py b/sentry_sdk/integrations/sqlalchemy.py index 7d3ed95373..055cda46fd 100644 --- a/sentry_sdk/integrations/sqlalchemy.py +++ b/sentry_sdk/integrations/sqlalchemy.py @@ -1,5 +1,6 @@ from sentry_sdk.consts import SPANSTATUS, SPANDATA from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable +from sentry_sdk.traces import StreamedSpan, SpanStatus from sentry_sdk.tracing_utils import add_query_source, record_sql_queries from sentry_sdk.utils import ( capture_internal_exceptions, @@ -20,6 +21,7 @@ from typing import Any from typing import ContextManager from typing import Optional + from typing import Union from sentry_sdk.tracing import Span @@ -96,7 +98,10 @@ def _handle_error(context: "Any", *args: "Any") -> None: span: "Optional[Span]" = getattr(execution_context, "_sentry_sql_span", None) if span is not None: - span.set_status(SPANSTATUS.INTERNAL_ERROR) + if isinstance(span, StreamedSpan): + span.set_status(SpanStatus.ERROR) + else: + span.set_status(SPANSTATUS.INTERNAL_ERROR) # _after_cursor_execute does not get called for crashing SQL stmts. Judging # from SQLAlchemy codebase it does seem like any error coming into this @@ -132,22 +137,29 @@ def _get_db_system(name: str) -> "Optional[str]": return None -def _set_db_data(span: "Span", conn: "Any") -> None: +def _set_db_data(span: "Union[Span, StreamedSpan]", conn: "Any") -> None: + span_streaming = isinstance(span, StreamedSpan) + + if span_streaming: + set_on_span = span.set_attribute + else: + set_on_span = span.set_data + db_system = _get_db_system(conn.engine.name) if db_system is not None: - span.set_data(SPANDATA.DB_SYSTEM, db_system) + set_on_span(SPANDATA.DB_SYSTEM, db_system) if conn.engine.url is None: return db_name = conn.engine.url.database if db_name is not None: - span.set_data(SPANDATA.DB_NAME, db_name) + set_on_span(SPANDATA.DB_NAME, db_name) server_address = conn.engine.url.host if server_address is not None: - span.set_data(SPANDATA.SERVER_ADDRESS, server_address) + set_on_span(SPANDATA.SERVER_ADDRESS, server_address) server_port = conn.engine.url.port if server_port is not None: - span.set_data(SPANDATA.SERVER_PORT, server_port) + set_on_span(SPANDATA.SERVER_PORT, server_port) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 2cb595c371..97d5099311 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -125,7 +125,8 @@ def record_sql_queries( span_origin: str = "manual", ) -> "Generator[sentry_sdk.tracing.Span, None, None]": # TODO: Bring back capturing of params by default - if sentry_sdk.get_client().options["_experiments"].get("record_sql_params", False): + client = sentry_sdk.get_client() + if client.options["_experiments"].get("record_sql_params", False): if not params_list or params_list == [None]: params_list = None @@ -135,6 +136,8 @@ def record_sql_queries( params_list = None paramstyle = None + span_streaming = has_span_streaming_enabled(client.options) + query = _format_sql(cursor, query) data = {} @@ -150,13 +153,23 @@ def record_sql_queries( with capture_internal_exceptions(): sentry_sdk.add_breadcrumb(message=query, category="query", data=data) - with sentry_sdk.start_span( - op=OP.DB, - name=query, - origin=span_origin, - ) as span: + if span_streaming: + span = sentry_sdk.traces.start_span(name=query) + span.set_op(OP.DB) + span.set_origin(span_origin) + else: + span = sentry_sdk.start_span( + op=OP.DB, + name=query, + origin=span_origin, + ) + + with span: for k, v in data.items(): - span.set_data(k, v) + if span_streaming: + span.set_attribute(k, v) + else: + span.set_data(k, v) yield span From 802c7e9a90390b920ca1619174b634e9f9aa9973 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 17:23:33 +0100 Subject: [PATCH 31/38] fix --- sentry_sdk/integrations/httpx.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index bf49dbec96..9a62a0764d 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -186,9 +186,9 @@ async def send( span.set_http_status(rv.status_code) if span_streaming: - span.set_data("reason", rv.reason_phrase) - else: span.set_attribute("reason", rv.reason_phrase) + else: + span.set_data("reason", rv.reason_phrase) with capture_internal_exceptions(): add_http_request_source(span) From 7c352616edb45cec32198e52b4d1b9450f508545 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 17:44:03 +0100 Subject: [PATCH 32/38] ctx mng things --- sentry_sdk/integrations/httpx.py | 35 +++++++++++++++++--------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index 9a62a0764d..cea12c9b6d 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -137,18 +137,11 @@ async def send( parsed_url = parse_url(str(request.url), sanitize=False) if span_streaming: - span = sentry_sdk.traces.start_span( + span_ctx = sentry_sdk.traces.start_span( name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}" ) - span.set_op(OP.HTTP_CLIENT) - span.set_origin(HttpxIntegration.origin) - span.set_attribute(SPANDATA.HTTP_METHOD, request.method) - if parsed_url is not None: - span.set_attribute("url", parsed_url.url) - span.set_attribute(SPANDATA.HTTP_QUERY, parsed_url.query) - span.set_attribute(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) else: - span = start_span( + span_ctx = start_span( op=OP.HTTP_CLIENT, name="%s %s" % ( @@ -157,14 +150,24 @@ async def send( ), origin=HttpxIntegration.origin, ) - span.set_data(SPANDATA.HTTP_METHOD, request.method) - if parsed_url is not None: - span.set_data("url", parsed_url.url) - span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) - span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - with span: - if should_propagate_trace(sentry_sdk.get_client(), str(request.url)): + with span_ctx as span: + if span_streaming: + span.set_op(OP.HTTP_CLIENT) + span.set_origin(HttpxIntegration.origin) + span.set_attribute(SPANDATA.HTTP_METHOD, request.method) + if parsed_url is not None: + span.set_attribute("url", parsed_url.url) + span.set_attribute(SPANDATA.HTTP_QUERY, parsed_url.query) + span.set_attribute(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + else: + span.set_data(SPANDATA.HTTP_METHOD, request.method) + if parsed_url is not None: + span.set_data("url", parsed_url.url) + span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) + span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + + if should_propagate_trace(client, str(request.url)): for ( key, value, From 3f2747ca078a6913ae5d622a8f0c885225ee359e Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 17:56:13 +0100 Subject: [PATCH 33/38] fix --- sentry_sdk/integrations/httpx.py | 34 +++++++++++++++++--------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index cea12c9b6d..00be62b31c 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -60,19 +60,11 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": parsed_url = parse_url(str(request.url), sanitize=False) if span_streaming: - span = sentry_sdk.traces.start_span( + span_ctx = sentry_sdk.traces.start_span( name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}" ) - span.set_op(OP.HTTP_CLIENT) - span.set_origin(HttpxIntegration.origin) - - span.set_attribute(SPANDATA.HTTP_METHOD, request.method) - if parsed_url is not None: - span.set_attribute("url", parsed_url.url) - span.set_attribute(SPANDATA.HTTP_QUERY, parsed_url.query) - span.set_attribute(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) else: - span = start_span( + span_ctx = start_span( op=OP.HTTP_CLIENT, name="%s %s" % ( @@ -81,13 +73,23 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": ), origin=HttpxIntegration.origin, ) - span.set_data(SPANDATA.HTTP_METHOD, request.method) - if parsed_url is not None: - span.set_data("url", parsed_url.url) - span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) - span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - with span: + with span_ctx as span: + if span_streaming: + span.set_op(OP.HTTP_CLIENT) + span.set_origin(HttpxIntegration.origin) + + span.set_attribute(SPANDATA.HTTP_METHOD, request.method) + if parsed_url is not None: + span.set_attribute("url", parsed_url.url) + span.set_attribute(SPANDATA.HTTP_QUERY, parsed_url.query) + span.set_attribute(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + else: + span.set_data(SPANDATA.HTTP_METHOD, request.method) + if parsed_url is not None: + span.set_data("url", parsed_url.url) + span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) + span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) if should_propagate_trace(sentry_sdk.get_client(), str(request.url)): for ( key, From be9094b3b729c1073ce2c3861923c9d9ddab40ab Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 18:35:57 +0100 Subject: [PATCH 34/38] mypy --- sentry_sdk/integrations/asgi.py | 25 ++++++++++------- sentry_sdk/integrations/httpx.py | 16 +++++++---- sentry_sdk/integrations/starlette.py | 5 ++++ sentry_sdk/integrations/stdlib.py | 14 +++++++--- sentry_sdk/traces.py | 18 +++++++----- sentry_sdk/tracing_utils.py | 42 ++++++++++++++++++++++------ sentry_sdk/utils.py | 3 -- 7 files changed, 84 insertions(+), 39 deletions(-) diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 38014ac53c..92f1e58ff6 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -25,7 +25,9 @@ from sentry_sdk.tracing import ( SOURCE_FOR_STYLE, TransactionSource, + Span, ) +from sentry_sdk.traces import StreamedSpan from sentry_sdk.utils import ( ContextVar, event_from_exception, @@ -43,10 +45,13 @@ if TYPE_CHECKING: from typing import Any from typing import Dict + from typing import ContextManager from typing import Optional from typing import Tuple + from typing import Union from sentry_sdk._types import Event, Hint + from sentry_sdk.tracing import NoOpSpan _asgi_middleware_applied = ContextVar("sentry_asgi_middleware_applied") @@ -209,7 +214,7 @@ async def _run_app( method = scope.get("method", "").upper() span = None - + span_ctx: "ContextManager[Union[Span, StreamedSpan, None]]" if span_streaming: if ty in ("http", "websocket"): if ( @@ -233,7 +238,7 @@ async def _run_app( span.set_origin(self.span_origin) span.set_attribute("asgi.type", ty) - span_context = span if span is not None else nullcontext() + span_ctx = span or nullcontext() else: if ty in ("http", "websocket"): @@ -241,7 +246,7 @@ async def _run_app( ty == "websocket" or method in self.http_methods_to_capture ): - span = continue_trace( + transaction = continue_trace( _get_headers(scope), op="{}.server".format(ty), name=transaction_name, @@ -249,26 +254,26 @@ async def _run_app( origin=self.span_origin, ) else: - span = Transaction( + transaction = Transaction( op=OP.HTTP_SERVER, name=transaction_name, source=transaction_source, origin=self.span_origin, ) - if span: - span.set_tag("asgi.type", ty) + if transaction: + transaction.set_tag("asgi.type", ty) - span_context = ( + span_ctx = ( sentry_sdk.start_transaction( - span, + transaction, custom_sampling_context={"asgi_scope": scope}, ) - if span is not None + if transaction is not None else nullcontext() ) - with span_context: + with span_ctx: try: async def _sentry_wrapped_send( diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index 00be62b31c..c7f67d96c3 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -2,7 +2,8 @@ from sentry_sdk import start_span from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration, DidNotEnable -from sentry_sdk.tracing import BAGGAGE_HEADER_NAME +from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, Span +from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing_utils import ( should_propagate_trace, add_http_request_source, @@ -20,7 +21,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any + from typing import Any, Optional, Union try: @@ -59,6 +60,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": with capture_internal_exceptions(): parsed_url = parse_url(str(request.url), sanitize=False) + span_ctx: "Optional[Union[Span, StreamedSpan]]" = None if span_streaming: span_ctx = sentry_sdk.traces.start_span( name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}" @@ -75,7 +77,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": ) with span_ctx as span: - if span_streaming: + if isinstance(span, StreamedSpan): span.set_op(OP.HTTP_CLIENT) span.set_origin(HttpxIntegration.origin) @@ -90,6 +92,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": span.set_data("url", parsed_url.url) span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + if should_propagate_trace(sentry_sdk.get_client(), str(request.url)): for ( key, @@ -109,7 +112,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": rv = real_send(self, request, **kwargs) span.set_http_status(rv.status_code) - if span_streaming: + if isinstance(span, StreamedSpan): span.set_attribute("reason", rv.reason_phrase) else: span.set_data("reason", rv.reason_phrase) @@ -138,6 +141,7 @@ async def send( with capture_internal_exceptions(): parsed_url = parse_url(str(request.url), sanitize=False) + span_ctx: "Optional[Union[Span, StreamedSpan]]" = None if span_streaming: span_ctx = sentry_sdk.traces.start_span( name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}" @@ -154,7 +158,7 @@ async def send( ) with span_ctx as span: - if span_streaming: + if isinstance(span, StreamedSpan): span.set_op(OP.HTTP_CLIENT) span.set_origin(HttpxIntegration.origin) span.set_attribute(SPANDATA.HTTP_METHOD, request.method) @@ -190,7 +194,7 @@ async def send( rv = await real_send(self, request, **kwargs) span.set_http_status(rv.status_code) - if span_streaming: + if isinstance(span, StreamedSpan): span.set_attribute("reason", rv.reason_phrase) else: span.set_data("reason", rv.reason_phrase) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index abbbcfdad0..b90a8e4f0d 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -40,6 +40,8 @@ from typing import Any, Awaitable, Callable, Container, Dict, Optional, Tuple, Union from sentry_sdk._types import Event, HttpStatusCodeRange + from sentry_sdk.tracing import Span + from sentry_sdk.traces import StreamedSpan try: import starlette # type: ignore @@ -169,6 +171,7 @@ async def _create_span_call( middleware_name = app.__class__.__name__ + middleware_span: "Optional[Union[Span, StreamedSpan]]" = None if span_streaming: middleware_span = sentry_sdk.traces.start_span(name=middleware_name) middleware_span.set_op(OP.MIDDLEWARE_STARLETTE) @@ -185,6 +188,7 @@ async def _create_span_call( with middleware_span: # Creating spans for the "receive" callback async def _sentry_receive(*args: "Any", **kwargs: "Any") -> "Any": + span: "Optional[Union[Span, StreamedSpan]]" = None if span_streaming: span = sentry_sdk.traces.start_span( name=getattr(receive, "__qualname__", str(receive)), @@ -209,6 +213,7 @@ async def _sentry_receive(*args: "Any", **kwargs: "Any") -> "Any": # Creating spans for the "send" callback async def _sentry_send(*args: "Any", **kwargs: "Any") -> "Any": + span: "Optional[Union[Span, StreamedSpan]]" = None if span_streaming: span = sentry_sdk.traces.start_span( name=getattr(send, "__qualname__", str(send)), diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 334a3d9513..8b42b93b1e 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -8,6 +8,7 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor +from sentry_sdk.tracing import Span from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing_utils import ( EnvironHeaders, @@ -33,6 +34,7 @@ from typing import Dict from typing import Optional from typing import List + from typing import Union from sentry_sdk._types import Event, Hint @@ -103,6 +105,7 @@ def putrequest( with capture_internal_exceptions(): parsed_url = parse_url(real_url, sanitize=False) + span: "Optional[Union[Span, StreamedSpan]]" = None if span_streaming: span = sentry_sdk.traces.start_span( name=f"{method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}" @@ -258,6 +261,7 @@ def sentry_patched_popen_init( client = sentry_sdk.get_client() span_streaming = has_span_streaming_enabled(client.options) + span: "Optional[Union[Span, StreamedSpan]]" = None if span_streaming: span = sentry_sdk.traces.start_span( name=description, @@ -286,14 +290,14 @@ def sentry_patched_popen_init( env["SUBPROCESS_" + k.upper().replace("-", "_")] = v if cwd: - if span_streaming: + if isinstance(span, StreamedSpan): span.set_attribute("subprocess.cwd", cwd) else: span.set_data("subprocess.cwd", cwd) rv = old_popen_init(self, *a, **kw) - if span_streaming: + if isinstance(span, StreamedSpan): span.set_attribute("subprocess.pid", self.pid) else: span.set_tag("subprocess.pid", self.pid) @@ -311,11 +315,12 @@ def sentry_patched_popen_wait( client = sentry_sdk.get_client() span_streaming = has_span_streaming_enabled(client.options) + span: "Optional[Union[Span, StreamedSpan]]" = None if span_streaming: span = sentry_sdk.traces.start_span(name="subprocess popen") span.set_op(OP.SUBPROCESS_WAIT) span.set_origin("auto.subprocess.stdlib.subprocess") - span.set_tag("subprocess.pid", self.pid) + span.set_attribute("subprocess.pid", self.pid) else: span = sentry_sdk.start_span( op=OP.SUBPROCESS_WAIT, @@ -337,13 +342,14 @@ def sentry_patched_popen_communicate( client = sentry_sdk.get_client() span_streaming = has_span_streaming_enabled(client.options) + span: "Optional[Union[Span, StreamedSpan]]" = None if span_streaming: span = sentry_sdk.traces.start_span( name="subprocess communicate", ) span.set_op(OP.SUBPROCESS_COMMUNICATE) span.set_origin("auto.subprocess.stdlib.subprocess") - span.set_tag("subprocess.pid", self.pid) + span.set_attribute("subprocess.pid", self.pid) else: span = sentry_sdk.start_span( op=OP.SUBPROCESS_COMMUNICATE, diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 588e9c9e81..d57a2e6ebf 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -32,6 +32,7 @@ if TYPE_CHECKING: from typing import Any, Callable, Iterator, Optional, ParamSpec, TypeVar, Union from sentry_sdk._types import Attributes, AttributeValue, SamplingContext + from sentry_sdk.profiler.continuous_profiler import ContinuousProfile from sentry_sdk.scope import Scope P = ParamSpec("P") @@ -297,7 +298,7 @@ def __init__( self._flags: dict[str, bool] = {} self._profile = None - self._continuous_profile = None + self._continuous_profile: "Optional[ContinuousProfile]" = None self._update_active_thread() self._set_profile_id(get_profiler_id()) @@ -356,14 +357,14 @@ def __exit__( self._end(scope=scope) scope.span = old_span - def start(self): + def start(self) -> "StreamedSpan": """ Start this span. Only usable if the span was not started via the `with start_span():` context manager, since that starts it automatically. """ - self.__enter__() + return self.__enter__() def finish(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: """ @@ -380,7 +381,7 @@ def finish(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> No except AttributeError: pass - self.__exit__(ty=None, value=None, tb=None) + self.__exit__(None, None, None) def _end( self, @@ -457,13 +458,16 @@ def set_flag(self, flag: str, result: bool) -> None: if len(self._flags) < FLAGS_CAPACITY: self._flags[flag] = result - def set_op(self, op: "Union[OP, str]") -> None: + 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: "SegmentSource") -> None: + def set_source(self, source: "Union[str, SegmentSource]") -> None: + if isinstance(source, Enum): + source = source.value + self.set_attribute("sentry.span.source", source) def is_segment(self) -> bool: @@ -626,7 +630,7 @@ def _set_sampling_decision(self, sampling_context: "SamplingContext") -> None: f"[Tracing] Discarding {self.name} because it's not included in the random sample (sampling rate = {self.sample_rate})" ) - def _set_segment_attributes(self): + def _set_segment_attributes(self) -> None: if not self.is_segment(): self.set_attribute("sentry.segment.id", self.segment.span_id) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 97d5099311..7d1c40daff 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -123,7 +123,7 @@ def record_sql_queries( executemany: bool, record_cursor_repr: bool = False, span_origin: str = "manual", -) -> "Generator[sentry_sdk.tracing.Span, None, None]": +) -> "Generator[Union[sentry_sdk.tracing.Span, sentry_sdk.traces.StreamedSpan], None, None]": # TODO: Bring back capturing of params by default client = sentry_sdk.get_client() if client.options["_experiments"].get("record_sql_params", False): @@ -153,8 +153,9 @@ def record_sql_queries( with capture_internal_exceptions(): sentry_sdk.add_breadcrumb(message=query, category="query", data=data) + span: "Optional[Union[Span, StreamedSpan]]" = None if span_streaming: - span = sentry_sdk.traces.start_span(name=query) + span = sentry_sdk.traces.start_span(name=query or "query") span.set_op(OP.DB) span.set_origin(span_origin) else: @@ -166,7 +167,7 @@ def record_sql_queries( with span: for k, v in data.items(): - if span_streaming: + if isinstance(span, StreamedSpan): span.set_attribute(k, v) else: span.set_data(k, v) @@ -233,7 +234,7 @@ def _should_be_included( def add_source( - span: "sentry_sdk.tracing.Span", + span: "Union[sentry_sdk.tracing.Span, sentry_sdk.traces.StreamedSpan]", project_root: "Optional[str]", in_app_include: "Optional[list[str]]", in_app_exclude: "Optional[list[str]]", @@ -271,20 +272,25 @@ def add_source( frame = None # Set the data + if isinstance(span, StreamedSpan): + set_on_span = span.set_attribute + else: + set_on_span = span.set_data + if frame is not None: try: lineno = frame.f_lineno except Exception: lineno = None if lineno is not None: - span.set_data(SPANDATA.CODE_LINENO, frame.f_lineno) + set_on_span(SPANDATA.CODE_LINENO, frame.f_lineno) try: namespace = frame.f_globals.get("__name__") except Exception: namespace = None if namespace is not None: - span.set_data(SPANDATA.CODE_NAMESPACE, namespace) + set_on_span(SPANDATA.CODE_NAMESPACE, namespace) filepath = _get_frame_module_abs_path(frame) if filepath is not None: @@ -294,7 +300,7 @@ def add_source( in_app_path = filepath.replace(project_root, "").lstrip(os.sep) else: in_app_path = filepath - span.set_data(SPANDATA.CODE_FILEPATH, in_app_path) + set_on_span(SPANDATA.CODE_FILEPATH, in_app_path) try: code_function = frame.f_code.co_name @@ -302,7 +308,7 @@ def add_source( code_function = None if code_function is not None: - span.set_data(SPANDATA.CODE_FUNCTION, frame.f_code.co_name) + set_on_span(SPANDATA.CODE_FUNCTION, frame.f_code.co_name) def add_query_source(span: "sentry_sdk.tracing.Span") -> None: @@ -335,7 +341,9 @@ def add_query_source(span: "sentry_sdk.tracing.Span") -> None: ) -def add_http_request_source(span: "sentry_sdk.tracing.Span") -> None: +def add_http_request_source( + span: "Union[sentry_sdk.tracing.Span, sentry_sdk.traces.StreamedSpan]", +) -> None: """ Adds OTel compatible source code information to a span for an outgoing HTTP request """ @@ -932,6 +940,14 @@ async def async_wrapper(*args: "Any", **kwargs: "Any") -> "Any": ) return await f(*args, **kwargs) + if isinstance(current_span, StreamedSpan): + warnings.warn( + "Use the @sentry_sdk.traces.trace decorator in span streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return await f(*args, **kwargs) + span_op = op or _get_span_op(template) function_name = name or qualname_from_function(f) or "" span_name = _get_span_name(template, function_name, kwargs) @@ -969,6 +985,14 @@ def sync_wrapper(*args: "Any", **kwargs: "Any") -> "Any": ) return f(*args, **kwargs) + if isinstance(current_span, StreamedSpan): + warnings.warn( + "Use the @sentry_sdk.traces.trace decorator in span streaming mode.", + DeprecationWarning, + stacklevel=2, + ) + return f(*args, **kwargs) + span_op = op or _get_span_op(template) function_name = name or qualname_from_function(f) or "" span_name = _get_span_name(template, function_name, kwargs) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 8c746bb75e..2a689d3df1 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2060,9 +2060,6 @@ def format_attribute(val: "Any") -> "AttributeValue": they're serialized further into the actual format the protocol expects: https://develop.sentry.dev/sdk/telemetry/attributes/ """ - if isinstance(val, Enum): - val = val.value - if isinstance(val, (bool, int, float, str)): return val From 33deedd07c40f1d6e4abd07b07a0fcb4bd290c31 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 20:28:49 +0100 Subject: [PATCH 35/38] more mypy --- sentry_sdk/integrations/sqlalchemy.py | 4 +--- sentry_sdk/tracing_utils.py | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/sqlalchemy.py b/sentry_sdk/integrations/sqlalchemy.py index 055cda46fd..754b452318 100644 --- a/sentry_sdk/integrations/sqlalchemy.py +++ b/sentry_sdk/integrations/sqlalchemy.py @@ -138,9 +138,7 @@ def _get_db_system(name: str) -> "Optional[str]": def _set_db_data(span: "Union[Span, StreamedSpan]", conn: "Any") -> None: - span_streaming = isinstance(span, StreamedSpan) - - if span_streaming: + if isinstance(span, StreamedSpan): set_on_span = span.set_attribute else: set_on_span = span.set_data diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 7d1c40daff..4381e5613c 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -300,7 +300,8 @@ def add_source( in_app_path = filepath.replace(project_root, "").lstrip(os.sep) else: in_app_path = filepath - set_on_span(SPANDATA.CODE_FILEPATH, in_app_path) + if in_app_path: + set_on_span(SPANDATA.CODE_FILEPATH, in_app_path) try: code_function = frame.f_code.co_name From 6374a2ac155bc9ba85a4c77408838036bbde9020 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 20:39:23 +0100 Subject: [PATCH 36/38] migrate huggingface_hub --- sentry_sdk/ai/monitoring.py | 22 +++++++---- sentry_sdk/ai/utils.py | 15 ++++++-- sentry_sdk/integrations/huggingface_hub.py | 44 ++++++++++++++-------- sentry_sdk/tracing_utils.py | 27 +++++++------ 4 files changed, 67 insertions(+), 41 deletions(-) diff --git a/sentry_sdk/ai/monitoring.py b/sentry_sdk/ai/monitoring.py index 581e967bd4..6498a25c2f 100644 --- a/sentry_sdk/ai/monitoring.py +++ b/sentry_sdk/ai/monitoring.py @@ -6,6 +6,7 @@ import sentry_sdk.utils from sentry_sdk import start_span from sentry_sdk.tracing import Span +from sentry_sdk.traces import StreamedSpan from sentry_sdk.utils import ContextVar, reraise, capture_internal_exceptions from typing import TYPE_CHECKING @@ -97,7 +98,7 @@ async def async_wrapped(*args: "Any", **kwargs: "Any") -> "Any": def record_token_usage( - span: "Span", + span: "Union[Span, StreamedSpan]", input_tokens: "Optional[int]" = None, input_tokens_cached: "Optional[int]" = None, input_tokens_cache_write: "Optional[int]" = None, @@ -106,30 +107,35 @@ def record_token_usage( total_tokens: "Optional[int]" = None, ) -> None: # TODO: move pipeline name elsewhere + if isinstance(span, StreamedSpan): + set_on_span = span.set_attribute + else: + set_on_span = span.set_data + ai_pipeline_name = get_ai_pipeline_name() if ai_pipeline_name: - span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, ai_pipeline_name) + set_on_span(SPANDATA.GEN_AI_PIPELINE_NAME, ai_pipeline_name) if input_tokens is not None: - span.set_data(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, input_tokens) + set_on_span(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, input_tokens) if input_tokens_cached is not None: - span.set_data( + set_on_span( SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, input_tokens_cached, ) if input_tokens_cache_write is not None: - span.set_data( + set_on_span( SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE, input_tokens_cache_write, ) if output_tokens is not None: - span.set_data(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens) + set_on_span(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens) if output_tokens_reasoning is not None: - span.set_data( + set_on_span( SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, output_tokens_reasoning, ) @@ -138,4 +144,4 @@ def record_token_usage( total_tokens = input_tokens + output_tokens if total_tokens is not None: - span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, total_tokens) + set_on_span(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, total_tokens) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index a4ebe96d99..dcc0530704 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -8,12 +8,13 @@ from sentry_sdk._types import BLOB_DATA_SUBSTITUTE if TYPE_CHECKING: - from typing import Any, Callable, Dict, List, Optional, Tuple + from typing import Any, Callable, Dict, List, Optional, Tuple, Union from sentry_sdk.tracing import Span import sentry_sdk from sentry_sdk.utils import logger +from sentry_sdk.traces import StreamedSpan MAX_GEN_AI_MESSAGE_BYTES = 20_000 # 20KB # Maximum characters when only a single message is left after bytes truncation @@ -489,13 +490,19 @@ def _normalize_data(data: "Any", unpack: bool = True) -> "Any": def set_data_normalized( - span: "Span", key: str, value: "Any", unpack: bool = True + span: "Union[Span, StreamedSpan]", key: str, value: "Any", unpack: bool = True ) -> None: normalized = _normalize_data(value, unpack=unpack) + + if isinstance(span, StreamedSpan): + set_on_span = span.set_attribute + else: + set_on_span = span.set_data + if isinstance(normalized, (int, float, bool, str)): - span.set_data(key, normalized) + set_on_span(key, normalized) else: - span.set_data(key, json.dumps(normalized)) + set_on_span(key, json.dumps(normalized)) def normalize_message_role(role: str) -> str: diff --git a/sentry_sdk/integrations/huggingface_hub.py b/sentry_sdk/integrations/huggingface_hub.py index 8509cadefa..c6477f558e 100644 --- a/sentry_sdk/integrations/huggingface_hub.py +++ b/sentry_sdk/integrations/huggingface_hub.py @@ -8,7 +8,9 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.tracing_utils import set_span_errored +from sentry_sdk.traces import StreamedSpan +from sentry_sdk.tracing import Span +from sentry_sdk.tracing_utils import has_span_streaming_enabled, set_span_errored from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, @@ -18,7 +20,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Callable, Iterable + from typing import Any, Callable, Iterable, Union try: import huggingface_hub.inference._client @@ -66,7 +68,8 @@ def _capture_exception(exc: "Any") -> None: def _wrap_huggingface_task(f: "Callable[..., Any]", op: str) -> "Callable[..., Any]": @wraps(f) def new_huggingface_task(*args: "Any", **kwargs: "Any") -> "Any": - integration = sentry_sdk.get_client().get_integration(HuggingfaceHubIntegration) + sentry_client = sentry_sdk.get_client() + integration = sentry_client.get_integration(HuggingfaceHubIntegration) if integration is None: return f(*args, **kwargs) @@ -87,17 +90,30 @@ def new_huggingface_task(*args: "Any", **kwargs: "Any") -> "Any": model = client.model or kwargs.get("model") or "" operation_name = op.split(".")[-1] - span = sentry_sdk.start_span( - op=op, - name=f"{operation_name} {model}", - origin=HuggingfaceHubIntegration.origin, - ) + span_streaming = has_span_streaming_enabled(sentry_client.options) + span: "Union[StreamedSpan, Span]" + if span_streaming: + span = sentry_sdk.traces.start_span(name=f"{operation_name} {model}") + span.set_op(op) + span.set_origin(HuggingfaceHubIntegration.origin) + else: + span = sentry_sdk.start_span( + op=op, + name=f"{operation_name} {model}", + origin=HuggingfaceHubIntegration.origin, + ) + span.__enter__() - span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, operation_name) + if isinstance(span, StreamedSpan): + set_on_span = span.set_attribute + else: + set_on_span = span.set_data + + set_on_span(SPANDATA.GEN_AI_OPERATION_NAME, operation_name) if model: - span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model) + set_on_span(SPANDATA.GEN_AI_REQUEST_MODEL, model) # Input attributes if should_send_default_pii() and integration.include_prompts: @@ -120,7 +136,7 @@ def new_huggingface_task(*args: "Any", **kwargs: "Any") -> "Any": value = kwargs.get(attribute, None) if value is not None: if isinstance(value, (int, float, bool, str)): - span.set_data(span_attribute, value) + set_on_span(span_attribute, value) else: set_data_normalized(span, span_attribute, value, unpack=False) @@ -181,7 +197,7 @@ def new_huggingface_task(*args: "Any", **kwargs: "Any") -> "Any": response_text_buffer.append(choice.message.content) if response_model is not None: - span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, response_model) + set_on_span(SPANDATA.GEN_AI_RESPONSE_MODEL, response_model) if finish_reason is not None: set_data_normalized( @@ -332,9 +348,7 @@ def new_iterator() -> "Iterable[str]": yield chunk if response_model is not None: - span.set_data( - SPANDATA.GEN_AI_RESPONSE_MODEL, response_model - ) + set_on_span(SPANDATA.GEN_AI_RESPONSE_MODEL, response_model) if finish_reason is not None: set_data_normalized( diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 4381e5613c..5f55606d3a 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -1090,25 +1090,24 @@ def get_current_span( return current_span -def set_span_errored(span: "Optional[Span]" = None) -> None: +def set_span_errored(span: "Optional[Union[Span, StreamedSpan]]" = None) -> None: """ - Set the status of the current or given span to INTERNAL_ERROR. - Also sets the status of the transaction (root span) to INTERNAL_ERROR. + Set the status of the current or given span to error. + Also sets the status of the transaction (root span) to error. """ - span = span or get_current_span() + from sentry_sdk.traces import StreamedSpan, SpanStatus - if not isinstance(span, Span): - warnings.warn( - "set_span_errored is not available in streaming mode.", - DeprecationWarning, - stacklevel=2, - ) - return + span = span or get_current_span() if span is not None: - span.set_status(SPANSTATUS.INTERNAL_ERROR) - if span.containing_transaction is not None: - span.containing_transaction.set_status(SPANSTATUS.INTERNAL_ERROR) + if isinstance(span, Span): + span.set_status(SPANSTATUS.INTERNAL_ERROR) + if span.containing_transaction is not None: + span.containing_transaction.set_status(SPANSTATUS.INTERNAL_ERROR) + elif isinstance(span, StreamedSpan): + span.set_status(SpanStatus.ERROR) + if span.segment is not None: + span.segment.set_status(SpanStatus.ERROR) def _generate_sample_rand( From 481e7b693d9d6f7beb4cebc1b81b228a46802aa7 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 21:04:19 +0100 Subject: [PATCH 37/38] more mypy, more integrations --- sentry_sdk/integrations/asyncpg.py | 27 +++-- sentry_sdk/integrations/graphene.py | 23 +++-- sentry_sdk/integrations/strawberry.py | 137 +++++++++++++++++++------- sentry_sdk/tracing_utils.py | 4 +- 4 files changed, 139 insertions(+), 52 deletions(-) diff --git a/sentry_sdk/integrations/asyncpg.py b/sentry_sdk/integrations/asyncpg.py index 7f3591154a..6de8a6c9ab 100644 --- a/sentry_sdk/integrations/asyncpg.py +++ b/sentry_sdk/integrations/asyncpg.py @@ -1,10 +1,11 @@ from __future__ import annotations import contextlib -from typing import Any, TypeVar, Callable, Awaitable, Iterator +from typing import Any, TypeVar, Callable, Awaitable, Iterator, Union import sentry_sdk from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable +from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing import Span from sentry_sdk.tracing_utils import add_query_source, record_sql_queries from sentry_sdk.utils import ( @@ -96,7 +97,7 @@ def _record( params_list: "tuple[Any, ...] | None", *, executemany: bool = False, -) -> "Iterator[Span]": +) -> "Iterator[Union[StreamedSpan, Span]]": integration = sentry_sdk.get_client().get_integration(AsyncPGIntegration) if integration is not None and not integration._record_params: params_list = None @@ -146,7 +147,10 @@ def _inner(*args: "Any", **kwargs: "Any") -> "T": # noqa: N807 ) as span: _set_db_data(span, args[0]) res = f(*args, **kwargs) - span.set_data("db.cursor", res) + if isinstance(span, StreamedSpan): + span.set_attribute("db.cursor", res) # type: ignore + else: + span.set_data("db.cursor", res) return res @@ -190,21 +194,26 @@ async def _inner(*args: "Any", **kwargs: "Any") -> "T": return _inner -def _set_db_data(span: "Span", conn: "Any") -> None: - span.set_data(SPANDATA.DB_SYSTEM, "postgresql") +def _set_db_data(span: "Union[StreamedSpan, Span]", conn: "Any") -> None: + if isinstance(span, StreamedSpan): + set_on_span = span.set_attribute + else: + set_on_span = span.set_data + + set_on_span(SPANDATA.DB_SYSTEM, "postgresql") addr = conn._addr if addr: try: - span.set_data(SPANDATA.SERVER_ADDRESS, addr[0]) - span.set_data(SPANDATA.SERVER_PORT, addr[1]) + set_on_span(SPANDATA.SERVER_ADDRESS, addr[0]) + set_on_span(SPANDATA.SERVER_PORT, addr[1]) except IndexError: pass database = conn._params.database if database: - span.set_data(SPANDATA.DB_NAME, database) + set_on_span(SPANDATA.DB_NAME, database) user = conn._params.user if user: - span.set_data(SPANDATA.DB_USER, user) + set_on_span(SPANDATA.DB_USER, user) diff --git a/sentry_sdk/integrations/graphene.py b/sentry_sdk/integrations/graphene.py index 5a61ca5c78..29f972d185 100644 --- a/sentry_sdk/integrations/graphene.py +++ b/sentry_sdk/integrations/graphene.py @@ -4,6 +4,7 @@ from sentry_sdk.consts import OP from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( capture_internal_exceptions, ensure_integration_enabled, @@ -25,6 +26,8 @@ from graphql.execution import ExecutionResult from graphql.type import GraphQLSchema from sentry_sdk._types import Event + from sentry_sdk.tracing import Span + from sentry_sdk.traces import StreamedSpan class GrapheneIntegration(Integration): @@ -141,15 +144,21 @@ def graphql_span( }, ) - scope = sentry_sdk.get_current_scope() - if scope.span: - _graphql_span = scope.span.start_child(op=op, name=operation_name) + client = sentry_sdk.get_client() + span_streaming = has_span_streaming_enabled(client.options) + _graphql_span: "Union[Span, StreamedSpan]" + if span_streaming: + _graphql_span = sentry_sdk.traces.start_span(name=operation_name or "operation") + _graphql_span.set_op(op) + _graphql_span.set_attribute("graphql.document", source) + if operation_name: + _graphql_span.set_attribute("graphql.operation.name", operation_name) + _graphql_span.set_attribute("graphql.operation.type", operation_type) else: _graphql_span = sentry_sdk.start_span(op=op, name=operation_name) - - _graphql_span.set_data("graphql.document", source) - _graphql_span.set_data("graphql.operation.name", operation_name) - _graphql_span.set_data("graphql.operation.type", operation_type) + _graphql_span.set_data("graphql.document", source) + _graphql_span.set_data("graphql.operation.name", operation_name) + _graphql_span.set_data("graphql.operation.type", operation_type) try: yield diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index da3c31a967..ccd60ca71f 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -9,6 +9,7 @@ from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing import TransactionSource +from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( capture_internal_exceptions, ensure_integration_enabled, @@ -49,11 +50,13 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Callable, Generator, List, Optional + from typing import Any, Callable, Generator, List, Optional, Union from graphql import GraphQLError, GraphQLResolveInfo from strawberry.http import GraphQLHTTPResponse from strawberry.types import ExecutionContext from sentry_sdk._types import Event, EventProcessor + from sentry_sdk.traces import StreamedSpan + from sentry_sdk.tracing import Span ignore_logger("strawberry.execution") @@ -181,12 +184,27 @@ def on_operation(self) -> "Generator[None, None, None]": event_processor = _make_request_event_processor(self.execution_context) scope.add_event_processor(event_processor) - span = sentry_sdk.get_current_span() - if span: - self.graphql_span = span.start_child( - op=op, + client = sentry_sdk.get_client() + span_streaming = has_span_streaming_enabled(client.options) + + self.graphql_span: "Union[Span, StreamedSpan]" + if span_streaming: + self.graphql_span = sentry_sdk.traces.start_span( name=description, - origin=StrawberryIntegration.origin, + ) + self.graphql_span.set_op(op) + self.graphql_span.set_origin(StrawberryIntegration.origin) + + self.graphql_span.set_attribute("graphql.operation.type", operation_type) + if self._operation_name: + self.graphql_span.set_attribute( + "graphql.operation.name", self._operation_name + ) + self.graphql_span.set_attribute( + "graphql.document", self.execution_context.query + ) + self.graphql_span.set_attribute( + "graphql.resource_name", self._resource_name ) else: self.graphql_span = sentry_sdk.start_span( @@ -195,38 +213,62 @@ def on_operation(self) -> "Generator[None, None, None]": origin=StrawberryIntegration.origin, ) - self.graphql_span.set_data("graphql.operation.type", operation_type) - self.graphql_span.set_data("graphql.operation.name", self._operation_name) - self.graphql_span.set_data("graphql.document", self.execution_context.query) - self.graphql_span.set_data("graphql.resource_name", self._resource_name) + self.graphql_span.set_data("graphql.operation.type", operation_type) + self.graphql_span.set_data("graphql.operation.name", self._operation_name) + self.graphql_span.set_data("graphql.document", self.execution_context.query) + self.graphql_span.set_data("graphql.resource_name", self._resource_name) yield - transaction = self.graphql_span.containing_transaction - if transaction and self.execution_context.operation_name: - transaction.name = self.execution_context.operation_name - transaction.source = TransactionSource.COMPONENT - transaction.op = op + if self.execution_context.operation_name: + sentry_sdk.get_current_scope().set_transaction_name( + self.execution_context.operation_name, + TransactionSource.COMPONENT, + ) + if isinstance(self.graphql_span, StreamedSpan): + self.graphql_span.segment.set_op(op) + else: + if self.graphql_span.containing_transaction: + self.graphql_span.containing_transaction.op = op self.graphql_span.finish() def on_validate(self) -> "Generator[None, None, None]": - self.validation_span = self.graphql_span.start_child( - op=OP.GRAPHQL_VALIDATE, - name="validation", - origin=StrawberryIntegration.origin, - ) + self.validation_span: "Union[StreamedSpan, Span]" + if isinstance(self.graphql_span, StreamedSpan): + self.validation_span = sentry_sdk.traces.start_span( + parent_span=self.graphql_span, + name="validation", + ) + self.validation_span.set_op(OP.GRAPHQL_VALIDATE) + self.validation_span.set_origin( + StrawberryIntegration.origin, + ) + else: + self.validation_span = self.graphql_span.start_child( + op=OP.GRAPHQL_VALIDATE, + name="validation", + origin=StrawberryIntegration.origin, + ) yield self.validation_span.finish() def on_parse(self) -> "Generator[None, None, None]": - self.parsing_span = self.graphql_span.start_child( - op=OP.GRAPHQL_PARSE, - name="parsing", - origin=StrawberryIntegration.origin, - ) + self.parsing_span: "Union[StreamedSpan, Span]" + if isinstance(self.graphql_span, StreamedSpan): + self.parsing_span = sentry_sdk.traces.start_span( + name="parsing", + ) + self.parsing_span.set_op(OP.GRAPHQL_PARSE) + self.parsing_span.set_origin(StrawberryIntegration.origin) + else: + self.parsing_span = self.graphql_span.start_child( + op=OP.GRAPHQL_PARSE, + name="parsing", + origin=StrawberryIntegration.origin, + ) yield @@ -267,16 +309,27 @@ async def resolve( field_path = "{}.{}".format(info.parent_type, info.field_name) - with self.graphql_span.start_child( - op=OP.GRAPHQL_RESOLVE, - name="resolving {}".format(field_path), - origin=StrawberryIntegration.origin, - ) as span: + span: "Union[StreamedSpan, Span]" + if isinstance(self.graphql_span, StreamedSpan): + span = sentry_sdk.traces.start_span( + parent_span=self.graphql_span, name=f"resolving {field_path}" + ) + span.set_attribute("graphql.field_name", info.field_name) + span.set_attribute("graphql.parent_type", info.parent_type.name) + span.set_attribute("graphql.field_path", field_path) + span.set_attribute("graphql.path", ".".join(map(str, info.path.as_list()))) + else: + span = self.graphql_span.start_child( + op=OP.GRAPHQL_RESOLVE, + name="resolving {}".format(field_path), + origin=StrawberryIntegration.origin, + ) span.set_data("graphql.field_name", info.field_name) span.set_data("graphql.parent_type", info.parent_type.name) span.set_data("graphql.field_path", field_path) span.set_data("graphql.path", ".".join(map(str, info.path.as_list()))) + with span: return await self._resolve(_next, root, info, *args, **kwargs) @@ -294,16 +347,30 @@ def resolve( field_path = "{}.{}".format(info.parent_type, info.field_name) - with self.graphql_span.start_child( - op=OP.GRAPHQL_RESOLVE, - name="resolving {}".format(field_path), - origin=StrawberryIntegration.origin, - ) as span: + span: "Union[StreamedSpan, Span]" + if isinstance(self.graphql_span, StreamedSpan): + span = sentry_sdk.traces.start_span( + parent_span=self.graphql_span, + name="resolving {field_path}", + ) + span.set_op(OP.GRAPHQL_RESOLVE) + span.set_origin(StrawberryIntegration.origin) + span.set_attribute("graphql.field_name", info.field_name) + span.set_attribute("graphql.parent_type", info.parent_type.name) + span.set_attribute("graphql.field_path", field_path) + span.set_attribute("graphql.path", ".".join(map(str, info.path.as_list()))) + else: + span = self.graphql_span.start_child( + op=OP.GRAPHQL_RESOLVE, + name="resolving {}".format(field_path), + origin=StrawberryIntegration.origin, + ) span.set_data("graphql.field_name", info.field_name) span.set_data("graphql.parent_type", info.parent_type.name) span.set_data("graphql.field_path", field_path) span.set_data("graphql.path", ".".join(map(str, info.path.as_list()))) + with span: return _next(root, info, *args, **kwargs) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 5f55606d3a..b5d9085954 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -312,7 +312,9 @@ def add_source( set_on_span(SPANDATA.CODE_FUNCTION, frame.f_code.co_name) -def add_query_source(span: "sentry_sdk.tracing.Span") -> None: +def add_query_source( + span: "Union[sentry_sdk.tracing.Span, sentry_sdk.traces.StreamedSpan]", +) -> None: """ Adds OTel compatible source code information to a database query span """ From 7ae020a98821a6543efdd89cddb8cd8298143702 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 22 Jan 2026 21:10:22 +0100 Subject: [PATCH 38/38] more mypy --- sentry_sdk/ai/utils.py | 8 ++++++++ sentry_sdk/integrations/openai_agents/utils.py | 18 +++++++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index dcc0530704..65b8589507 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -15,6 +15,7 @@ import sentry_sdk from sentry_sdk.utils import logger from sentry_sdk.traces import StreamedSpan +from sentry_sdk.tracing_utils import has_span_streaming_enabled MAX_GEN_AI_MESSAGE_BYTES = 20_000 # 20KB # Maximum characters when only a single message is left after bytes truncation @@ -532,7 +533,14 @@ def normalize_message_roles(messages: "list[dict[str, Any]]") -> "list[dict[str, def get_start_span_function() -> "Callable[..., Any]": + client = sentry_sdk.get_client() + current_span = sentry_sdk.get_current_span() + if isinstance(current_span, StreamedSpan) or has_span_streaming_enabled( + client.options + ): + return sentry_sdk.traces.start_span + transaction_exists = ( current_span is not None and current_span.containing_transaction is not None ) diff --git a/sentry_sdk/integrations/openai_agents/utils.py b/sentry_sdk/integrations/openai_agents/utils.py index a24d0e909d..dbbdd66792 100644 --- a/sentry_sdk/integrations/openai_agents/utils.py +++ b/sentry_sdk/integrations/openai_agents/utils.py @@ -9,13 +9,14 @@ from sentry_sdk.consts import SPANDATA, SPANSTATUS, OP from sentry_sdk.integrations import DidNotEnable from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing_utils import set_span_errored from sentry_sdk.utils import event_from_exception, safe_serialize from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any + from typing import Any, Union from agents import Usage from sentry_sdk.tracing import Span @@ -38,17 +39,24 @@ def _capture_exception(exc: "Any") -> None: sentry_sdk.capture_event(event, hint=hint) -def _record_exception_on_span(span: "Span", error: Exception) -> "Any": +def _record_exception_on_span( + span: "Union[StreamedSpan, Span]", error: Exception +) -> "Any": set_span_errored(span) - span.set_data("span.status", "error") + if isinstance(span, StreamedSpan): + set_on_span = span.set_attribute + else: + set_on_span = span.set_data + + set_on_span("span.status", "error") # Optionally capture the error details if we have them if hasattr(error, "__class__"): - span.set_data("error.type", error.__class__.__name__) + set_on_span("error.type", error.__class__.__name__) if hasattr(error, "__str__"): error_message = str(error) if error_message: - span.set_data("error.message", error_message) + set_on_span("error.message", error_message) def _set_agent_data(span: "sentry_sdk.tracing.Span", agent: "agents.Agent") -> None: