Skip to content
10 changes: 9 additions & 1 deletion sentry_sdk/_span_batcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,15 @@ def add(self, span: "StreamedSpan") -> None:
@staticmethod
def _to_transport_format(item: "StreamedSpan") -> "Any":
# TODO[span-first]
res: "dict[str, Any]" = {}
res: "dict[str, Any]" = {
"name": item.name,
}

if item._attributes:
res["attributes"] = {
k: serialize_attribute(v) for (k, v) in item._attributes.items()
}

return res

def _flush(self) -> None:
Expand Down
11 changes: 10 additions & 1 deletion sentry_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,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
Expand Down Expand Up @@ -227,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

Copy link

Choose a reason for hiding this comment

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

Missing span_batcher in uWSGI thread support check

Low Severity

span_batcher spawns a background flusher thread via the inherited _ensure_thread method, but the condition checking whether to call check_uwsgi_thread_support() only includes self.log_batcher, not self.span_batcher. When running under uWSGI with threading disabled and only span streaming enabled (no logs, profiling, or monitor), users won't receive the warning about thread support issues, and spans will be silently dropped when thread creation fails.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will fix in follow up PR

def capture_session(self, *args: "Any", **kwargs: "Any") -> None:
return None

Expand Down Expand Up @@ -920,7 +924,7 @@ def capture_event(

def _capture_telemetry(
self,
telemetry: "Optional[Union[Log, Metric]]",
telemetry: "Optional[Union[Log, Metric, StreamedSpan]]",
ty: str,
scope: "Scope",
) -> None:
Expand All @@ -947,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
Expand All @@ -957,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",
Expand Down
51 changes: 38 additions & 13 deletions sentry_sdk/scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@
from sentry_sdk.tracing_utils import (
Baggage,
has_tracing_enabled,
has_span_streaming_enabled,
normalize_incoming_data,
PropagationContext,
)
from sentry_sdk.traces import StreamedSpan
from sentry_sdk.tracing import (
BAGGAGE_HEADER_NAME,
SENTRY_TRACE_HEADER_NAME,
Expand Down Expand Up @@ -1278,6 +1280,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,
Expand Down Expand Up @@ -1522,16 +1535,25 @@ def _apply_flags_to_event(
)

def _apply_scope_attributes_to_telemetry(
self, telemetry: "Union[Log, Metric]"
self, telemetry: "Union[Log, Metric, StreamedSpan]"
) -> None:
# TODO: turn Logs, Metrics into actual classes
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Want to do this eventually, but it's a breaking change so it has to wait.

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, StreamedSpan]"
) -> 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
Expand Down Expand Up @@ -1651,16 +1673,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):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The stuff for metrics and logs in this if should be moved elsewhere, but out of scope of this PR.

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)
Expand Down
30 changes: 29 additions & 1 deletion sentry_sdk/traces.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
import uuid
from typing import TYPE_CHECKING

from sentry_sdk.utils import format_attribute

if TYPE_CHECKING:
from typing import Optional
from sentry_sdk._types import Attributes, AttributeValue


class StreamedSpan:
Expand All @@ -22,15 +25,40 @@ class StreamedSpan:
span implementation lives in tracing.Span.
"""

__slots__ = ("_trace_id",)
__slots__ = (
"name",
"_attributes",
"_trace_id",
)

def __init__(
self,
*,
name: str,
attributes: "Optional[Attributes]" = None,
trace_id: "Optional[str]" = None,
):
self.name: str = name
self._attributes: "Attributes" = attributes or {}
Copy link

Choose a reason for hiding this comment

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

Initial attributes in StreamedSpan not formatted

Medium Severity

StreamedSpan.__init__ assigns attributes directly without calling format_attribute on each value, unlike the existing patterns in logger.py and metrics.py. This creates inconsistent behavior between using __init__(attributes=...) versus set_attribute()/set_attributes(), and violates the design principle stated in format_attribute's docstring: "We do this as soon as a user-provided attribute is set, to prevent spans, logs, metrics and similar from having live references to various objects." Unformatted attributes could retain live object references that mutate before serialization.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is actually correct, will fix


self._trace_id = trace_id

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 remove_attribute(self, key: str) -> None:
try:
del self._attributes[key]
except KeyError:
pass

@property
def trace_id(self) -> str:
if not self._trace_id:
Expand Down
Loading