From bab4359d5239d18f4dd96e3fc558a4068af4d6da Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 30 Jan 2026 12:32:12 +0100 Subject: [PATCH 1/8] feat(span-streaming): Add experimental trace_lifecycle switch --- sentry_sdk/consts.py | 1 + sentry_sdk/tracing_utils.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index f794754b05..f294414edf 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -82,6 +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[Literal["static", "stream"]], }, total=False, ) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 742582423b..c1d6c44535 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 37c990e2f42438677823ddb4e4183fa658ab2ac9 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 30 Jan 2026 12:50:02 +0100 Subject: [PATCH 2/8] feat(span-streaming): Add span batcher --- sentry_sdk/_span_batcher.py | 134 +++++++ sentry_sdk/client.py | 28 +- sentry_sdk/traces.py | 705 ++++++++++++++++++++++++++++++++++++ 3 files changed, 866 insertions(+), 1 deletion(-) create mode 100644 sentry_sdk/_span_batcher.py create mode 100644 sentry_sdk/traces.py diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py new file mode 100644 index 0000000000..07c84c2d0b --- /dev/null +++ b/sentry_sdk/_span_batcher.py @@ -0,0 +1,134 @@ +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 format_timestamp, serialize_attribute, safe_repr + +if TYPE_CHECKING: + from typing import Any, Callable, Optional + from sentry_sdk.traces import SpanStatus, StreamedSpan + from sentry_sdk._types import SerializedAttributeValue + + +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 + + 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["StreamedSpan"]] = 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: "StreamedSpan") -> 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: "StreamedSpan") -> "Any": + res: "dict[str, Any]" = { + "trace_id": item.trace_id, + "span_id": item.span_id, + "name": item.get_name(), + "status": item.status, + "is_segment": item.is_segment(), + "start_timestamp": item.start_timestamp.timestamp(), # TODO[span-first] + } + + if item.timestamp: + res["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) -> None: + with self._lock: + if len(self._span_buffer) == 0: + return None + + envelopes = [] + for trace_id, spans in self._span_buffer.items(): + if spans: + dsc = spans[0].dynamic_sampling_context() + + 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 + ] + } + ), + ) + ) + + envelopes.append(envelope) + + self._span_buffer.clear() + + for envelope in envelopes: + self._capture_func(envelope) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index fb14d8e36a..d81415ad15 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.traces 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": @@ -224,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 @@ -399,6 +406,13 @@ 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 +923,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: @@ -934,6 +951,8 @@ def _capture_telemetry( batcher = self.log_batcher elif ty == "metric": batcher = self.metrics_batcher # type: ignore + elif ty == "span": + batcher = self.span_batcher # type: ignore if batcher is not None: batcher.add(telemetry) # type: ignore @@ -944,6 +963,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", @@ -993,6 +1015,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() @@ -1018,6 +1042,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/traces.py b/sentry_sdk/traces.py new file mode 100644 index 0000000000..ec5951ccff --- /dev/null +++ b/sentry_sdk/traces.py @@ -0,0 +1,705 @@ +""" +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 +import warnings +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import TYPE_CHECKING, Pattern + +import sentry_sdk +from sentry_sdk.consts import OP, 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, Callable, Iterator, Optional, ParamSpec, TypeVar, Union + from sentry_sdk._types import Attributes, AttributeValue, SamplingContext + from sentry_sdk.profiler.continuous_profiler import ContinuousProfile + + P = ParamSpec("P") + R = TypeVar("R") + + +FLAGS_CAPACITY = 10 + +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 +# 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 +- tags +- 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 +- check where we're auto filtering out spans in integrations (health checks etc?) + +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": + """ + 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. + """ + # This is set both on the isolation and the current scope for compatibility + # reasons. Conceptually, it belongs on the isolation scope, and it also + # used to be set there in non-span-first mode. But in span first mode, we + # start spans on the current scope, regardless of type, like JS does, so we + # need to set the propagation context there. + sentry_sdk.get_isolation_scope().generate_propagation_context( + incoming, + ) + return sentry_sdk.get_current_scope().generate_propagation_context( + incoming, + ) + + +def new_trace() -> None: + """ + Resets the propagation context, forcing a new trace. + + This function sets the propagation context on the scope. Any span started + in the updated scope will start its own trace. + + new_trace() doesn't start any spans on its own. + """ + sentry_sdk.get_current_scope().set_new_propagation_context() + + +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", + "_continuous_profile", + "_baggage", + "sample_rate", + "_sample_rand", + "_finished", + "_unsampled_reason", + "_last_valid_parent_id", + ) + + def __init__( + self, + *, + name: str, + scope: "sentry_sdk.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, + sampled: "Optional[bool]" = None, + unsampled_reason: "Optional[str]" = None, + last_valid_parent_id: "Optional[str]" = 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: str = SpanStatus.OK.value + self.set_source(SegmentSource.CUSTOM) + # XXX[span-first] ^ populate this correctly + + self._sampled: "Optional[bool]" = sampled + self._unsampled_reason: "Optional[str]" = unsampled_reason + self.sample_rate: "Optional[float]" = None + self._last_valid_parent_id: "Optional[str]" = last_valid_parent_id + + # 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._continuous_profile: "Optional[ContinuousProfile]" = None + + self._update_active_thread() + self._set_profile_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(): + sampling_context = { + "name": self.name, + "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, + } + custom_sampling_context = ( + scope.get_active_propagation_context()._custom_sampling_context + ) + if custom_sampling_context: + sampling_context.update(custom_sampling_context) + + # Use traces_sample_rate, traces_sampler, and/or inheritance to make a + # sampling decision + self._set_sampling_decision(sampling_context=sampling_context) + + scope._update_sample_rate_from_segment(self) + scope._start_profile_on_segment(self) + + return self + + def __exit__( + self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" + ) -> None: + if self.is_segment(): + 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 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. + """ + return self.__enter__() + + def finish(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__(None, None, None) + + def _end( + self, + scope: "Optional[sentry_sdk.Scope]" = None, + ) -> None: + client = sentry_sdk.get_client() + if not client.is_active(): + return + + self._set_segment_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" + elif self._unsampled_reason: + reason = self._unsampled_reason + 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 remove_attribute(self, key: str) -> None: + try: + del self.attributes[key] + except KeyError: + pass + + def set_attributes(self, attributes: "Attributes") -> None: + for key, value in attributes.items(): + self.set_attribute(key, value) + + def set_status(self, status: "Union[SpanStatus, str]") -> None: + if isinstance(status, Enum): + status = status.value + + 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 set_op(self, op: str) -> None: + self.set_attribute("sentry.op", op) + + def set_origin(self, origin: str) -> None: + self.set_attribute("sentry.origin", origin) + + def set_source(self, source: "Union[str, SegmentSource]") -> None: + if isinstance(source, Enum): + source = source.value + + self.set_attribute("sentry.span.source", source) + + 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.segment.sampled + + return self._sampled + + 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 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) + + 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_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: + 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 a segment's sampling decision.""" + client = sentry_sdk.get_client() + + if not has_tracing_enabled(client.options): + self._sampled = False + return + + if not self.is_segment(): + return + + if self._sampled is not None: + 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_segment_attributes(self) -> None: + if not self.is_segment(): + 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 From e720dec727148ff98e8ea4f12a801471a0fd7597 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 30 Jan 2026 12:52:42 +0100 Subject: [PATCH 3/8] . --- sentry_sdk/traces.py | 688 +------------------------------------------ 1 file changed, 1 insertion(+), 687 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index ec5951ccff..884ba90579 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -5,192 +5,6 @@ sentry_sdk.init(_experiments={"trace_lifecycle": "stream"}). """ -import uuid -import warnings -from datetime import datetime, timedelta, timezone -from enum import Enum -from typing import TYPE_CHECKING, Pattern - -import sentry_sdk -from sentry_sdk.consts import OP, 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, Callable, Iterator, Optional, ParamSpec, TypeVar, Union - from sentry_sdk._types import Attributes, AttributeValue, SamplingContext - from sentry_sdk.profiler.continuous_profiler import ContinuousProfile - - P = ParamSpec("P") - R = TypeVar("R") - - -FLAGS_CAPACITY = 10 - -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 -# 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 -- tags -- 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 -- check where we're auto filtering out spans in integrations (health checks etc?) - -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": - """ - 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. - """ - # This is set both on the isolation and the current scope for compatibility - # reasons. Conceptually, it belongs on the isolation scope, and it also - # used to be set there in non-span-first mode. But in span first mode, we - # start spans on the current scope, regardless of type, like JS does, so we - # need to set the propagation context there. - sentry_sdk.get_isolation_scope().generate_propagation_context( - incoming, - ) - return sentry_sdk.get_current_scope().generate_propagation_context( - incoming, - ) - - -def new_trace() -> None: - """ - Resets the propagation context, forcing a new trace. - - This function sets the propagation context on the scope. Any span started - in the updated scope will start its own trace. - - new_trace() doesn't start any spans on its own. - """ - sentry_sdk.get_current_scope().set_new_propagation_context() - class StreamedSpan: """ @@ -202,504 +16,4 @@ class StreamedSpan: 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", - "_continuous_profile", - "_baggage", - "sample_rate", - "_sample_rand", - "_finished", - "_unsampled_reason", - "_last_valid_parent_id", - ) - - def __init__( - self, - *, - name: str, - scope: "sentry_sdk.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, - sampled: "Optional[bool]" = None, - unsampled_reason: "Optional[str]" = None, - last_valid_parent_id: "Optional[str]" = 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: str = SpanStatus.OK.value - self.set_source(SegmentSource.CUSTOM) - # XXX[span-first] ^ populate this correctly - - self._sampled: "Optional[bool]" = sampled - self._unsampled_reason: "Optional[str]" = unsampled_reason - self.sample_rate: "Optional[float]" = None - self._last_valid_parent_id: "Optional[str]" = last_valid_parent_id - - # 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._continuous_profile: "Optional[ContinuousProfile]" = None - - self._update_active_thread() - self._set_profile_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(): - sampling_context = { - "name": self.name, - "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, - } - custom_sampling_context = ( - scope.get_active_propagation_context()._custom_sampling_context - ) - if custom_sampling_context: - sampling_context.update(custom_sampling_context) - - # Use traces_sample_rate, traces_sampler, and/or inheritance to make a - # sampling decision - self._set_sampling_decision(sampling_context=sampling_context) - - scope._update_sample_rate_from_segment(self) - scope._start_profile_on_segment(self) - - return self - - def __exit__( - self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" - ) -> None: - if self.is_segment(): - 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 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. - """ - return self.__enter__() - - def finish(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__(None, None, None) - - def _end( - self, - scope: "Optional[sentry_sdk.Scope]" = None, - ) -> None: - client = sentry_sdk.get_client() - if not client.is_active(): - return - - self._set_segment_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" - elif self._unsampled_reason: - reason = self._unsampled_reason - 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 remove_attribute(self, key: str) -> None: - try: - del self.attributes[key] - except KeyError: - pass - - def set_attributes(self, attributes: "Attributes") -> None: - for key, value in attributes.items(): - self.set_attribute(key, value) - - def set_status(self, status: "Union[SpanStatus, str]") -> None: - if isinstance(status, Enum): - status = status.value - - 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 set_op(self, op: str) -> None: - self.set_attribute("sentry.op", op) - - def set_origin(self, origin: str) -> None: - self.set_attribute("sentry.origin", origin) - - def set_source(self, source: "Union[str, SegmentSource]") -> None: - if isinstance(source, Enum): - source = source.value - - self.set_attribute("sentry.span.source", source) - - 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.segment.sampled - - return self._sampled - - 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 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) - - 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_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: - 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 a segment's sampling decision.""" - client = sentry_sdk.get_client() - - if not has_tracing_enabled(client.options): - self._sampled = False - return - - if not self.is_segment(): - return - - if self._sampled is not None: - 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_segment_attributes(self) -> None: - if not self.is_segment(): - 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 + pass From 6738f303534a650fc4e6a1afc5b51a724b986bb6 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 30 Jan 2026 12:58:14 +0100 Subject: [PATCH 4/8] remove pipeline stuff for now --- sentry_sdk/client.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index d81415ad15..c0681fa0a6 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -69,7 +69,6 @@ from sentry_sdk.scope import Scope from sentry_sdk.session import Session from sentry_sdk.spotlight import SpotlightClient - 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 @@ -228,9 +227,6 @@ 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 @@ -924,7 +920,7 @@ def capture_event( def _capture_telemetry( self, - telemetry: "Optional[Union[Log, Metric, StreamedSpan]]", + telemetry: "Optional[Union[Log, Metric]]", ty: str, scope: "Scope", ) -> None: @@ -951,8 +947,6 @@ def _capture_telemetry( batcher = self.log_batcher elif ty == "metric": batcher = self.metrics_batcher # type: ignore - elif ty == "span": - batcher = self.span_batcher # type: ignore if batcher is not None: batcher.add(telemetry) # type: ignore @@ -963,9 +957,6 @@ 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", From 3fd25de47ab8197bb591f853a78f050cf0f834e9 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 30 Jan 2026 12:59:09 +0100 Subject: [PATCH 5/8] . --- sentry_sdk/_span_batcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index 07c84c2d0b..f6ef5a5639 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -75,7 +75,7 @@ def _to_transport_format(item: "StreamedSpan") -> "Any": "name": item.get_name(), "status": item.status, "is_segment": item.is_segment(), - "start_timestamp": item.start_timestamp.timestamp(), # TODO[span-first] + "start_timestamp": item.start_timestamp.timestamp(), } if item.timestamp: From 8c52a9caea7ba274578da3b3d5126218d2faae09 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 30 Jan 2026 13:01:38 +0100 Subject: [PATCH 6/8] strip down unimplemented stuff --- sentry_sdk/_span_batcher.py | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index f6ef5a5639..5275090c4d 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -69,26 +69,8 @@ def add(self, span: "StreamedSpan") -> None: @staticmethod def _to_transport_format(item: "StreamedSpan") -> "Any": - res: "dict[str, Any]" = { - "trace_id": item.trace_id, - "span_id": item.span_id, - "name": item.get_name(), - "status": item.status, - "is_segment": item.is_segment(), - "start_timestamp": item.start_timestamp.timestamp(), - } - - if item.timestamp: - res["end_timestamp"] = item.timestamp.timestamp() - - if item.parent_span_id: - res["parent_span_id"] = item.parent_span_id - - if item.attributes: - res["attributes"] = { - k: serialize_attribute(v) for (k, v) in item.attributes.items() - } - + # TODO[span-first] + res: "dict[str, Any]" = {} return res def _flush(self) -> None: @@ -99,7 +81,9 @@ def _flush(self) -> None: envelopes = [] for trace_id, spans in self._span_buffer.items(): if spans: - dsc = spans[0].dynamic_sampling_context() + # TODO[span-first] + # dsc = spans[0].dynamic_sampling_context() + dsc = None envelope = Envelope( headers={ From 65e667c99e2999558bac92283f7868383917c91d Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 30 Jan 2026 13:03:58 +0100 Subject: [PATCH 7/8] . --- sentry_sdk/_span_batcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index 5275090c4d..6f3f11c2f3 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.traces import SpanStatus, StreamedSpan + from sentry_sdk.traces import StreamedSpan from sentry_sdk._types import SerializedAttributeValue From ad4767539371c7cfcdc2fd7d1c796002bc10ae41 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 30 Jan 2026 13:08:27 +0100 Subject: [PATCH 8/8] add trace_id --- sentry_sdk/traces.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 884ba90579..d2f6549e83 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -5,6 +5,12 @@ sentry_sdk.init(_experiments={"trace_lifecycle": "stream"}). """ +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Optional + class StreamedSpan: """ @@ -16,4 +22,18 @@ class StreamedSpan: span implementation lives in tracing.Span. """ - pass + __slots__ = ("_trace_id",) + + def __init__( + self, + *, + trace_id: "Optional[str]" = None, + ): + self._trace_id = trace_id + + @property + def trace_id(self) -> str: + if not self._trace_id: + self._trace_id = uuid.uuid4().hex + + return self._trace_id