Skip to content
Merged
Show file tree
Hide file tree
Changes from 76 commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
946decc
ref: Remove flag storage from StreamedSpan
sentrivana Mar 5, 2026
f3ee55c
ref: Tweak StreamedSpan interface
sentrivana Mar 5, 2026
47ed910
Add missing logger
sentrivana Mar 5, 2026
5023c76
fixes
sentrivana Mar 5, 2026
6445447
ref: Add active to StreamedSpan
sentrivana Mar 5, 2026
47e6211
Add property
sentrivana Mar 5, 2026
1e7b694
ref: Add no-op streaming span class
sentrivana Mar 5, 2026
80bfe5a
Remove redundant stuff
sentrivana Mar 5, 2026
1f0ffc1
Merge branch 'master' into ivana/span-first-4-add-noop-span
sentrivana Mar 5, 2026
d773428
ref: Add experimental streaming API
sentrivana Mar 5, 2026
647fa79
reformat
sentrivana Mar 5, 2026
49bdbe6
Add a __repr__
sentrivana Mar 5, 2026
cdd8bd6
Merge branch 'master' into ivana/span-first-5-add-start-span-api
sentrivana Mar 5, 2026
54f81af
ref: Add new_trace, continue_trace to span first
sentrivana Mar 5, 2026
941863e
ref: Add streaming trace decorator
sentrivana Mar 5, 2026
4b14e8d
Remove redundant code
sentrivana Mar 5, 2026
474f8e6
simplify
sentrivana Mar 5, 2026
9996e29
Merge branch 'ivana/span-first-5-add-start-span-api' into ivana/span-…
sentrivana Mar 5, 2026
e20d4fd
Merge branch 'ivana/span-first-6-add-continue-and-new-trace' into iva…
sentrivana Mar 5, 2026
f2738ff
reorder imports
sentrivana Mar 5, 2026
7874a54
ref: Per-bucket limits, fix envelope chunking
sentrivana Mar 5, 2026
63a9396
.
sentrivana Mar 5, 2026
c974d3e
add dummy __enter__, __exit__
sentrivana Mar 5, 2026
5d8c238
Merge branch 'ivana/span-first-7-add-trace-decorator' into ivana/span…
sentrivana Mar 5, 2026
831adae
type hint
sentrivana Mar 5, 2026
656ef2e
Merge branch 'ivana/span-first-7-add-trace-decorator' into ivana/span…
sentrivana Mar 5, 2026
1dcf176
remove unused import
sentrivana Mar 5, 2026
0a7eae8
ref: Allow to start and finish StreamedSpans
sentrivana Mar 5, 2026
6888c56
Add end, finish to noop spans
sentrivana Mar 6, 2026
09e5cce
fixes
sentrivana Mar 6, 2026
ae2fd52
.
sentrivana Mar 6, 2026
f223574
Correctly detect user-set parent_span=None
sentrivana Mar 6, 2026
05a4157
Merge branch 'master' into ivana/span-first-5-add-start-span-api
sentrivana Mar 6, 2026
9e8e60e
mypy
sentrivana Mar 6, 2026
777a246
Merge branch 'ivana/span-first-5-add-start-span-api' into ivana/span-…
sentrivana Mar 6, 2026
9b1e2f3
Merge branch 'ivana/span-first-6-add-continue-and-new-trace' into iva…
sentrivana Mar 6, 2026
e589c53
Merge branch 'ivana/span-first-7-add-trace-decorator' into ivana/span…
sentrivana Mar 6, 2026
1487ea8
Merge branch 'ivana/span-first-8-bucket-based-limits-in-batcher' into…
sentrivana Mar 6, 2026
1006e7b
remove unused imports
sentrivana Mar 6, 2026
6c16dbf
Merge branch 'ivana/span-first-7-add-trace-decorator' into ivana/span…
sentrivana Mar 6, 2026
cb37a07
Merge branch 'ivana/span-first-8-bucket-based-limits-in-batcher' into…
sentrivana Mar 6, 2026
ad6e7cc
move where finished is set
sentrivana Mar 6, 2026
ba29f0c
remove finished
sentrivana Mar 6, 2026
d6a42b2
end_timestamp improvements
sentrivana Mar 6, 2026
5e20ad3
.
sentrivana Mar 6, 2026
c70fae4
fix
sentrivana Mar 6, 2026
b995770
simplify
sentrivana Mar 6, 2026
0235053
Merge branch 'master' into ivana/span-first-9-start-end
sentrivana Mar 9, 2026
60217e1
ref: Add warnings to span streaming APIs
sentrivana Mar 9, 2026
b673a09
Merge branch 'master' into ivana/span-first-9-start-end
sentrivana Mar 9, 2026
d6fa965
.
sentrivana Mar 9, 2026
3602f86
.
sentrivana Mar 9, 2026
9f59eb0
fix
sentrivana Mar 9, 2026
bd8e1c9
Merge branch 'ivana/span-first-9-start-end' into ivana/span-first-10-…
sentrivana Mar 9, 2026
9b3df81
Merge branch 'master' into ivana/span-first-10-random-improvements
sentrivana Mar 9, 2026
8614d52
Merge branch 'master' into ivana/span-first-9-start-end
sentrivana Mar 9, 2026
cdee8bc
Merge branch 'ivana/span-first-9-start-end' into ivana/span-first-10-…
sentrivana Mar 9, 2026
72f0968
move
sentrivana Mar 9, 2026
dab1970
add a guard
sentrivana Mar 9, 2026
7daa720
.
sentrivana Mar 9, 2026
b59f3cd
move warnings
sentrivana Mar 9, 2026
2f0dc01
.
sentrivana Mar 9, 2026
dc81637
Merge branch 'ivana/span-first-9-start-end' into ivana/span-first-10-…
sentrivana Mar 9, 2026
bc9f765
Merge branch 'master' into ivana/span-first-9-start-end
sentrivana Mar 9, 2026
45372c1
Merge branch 'ivana/span-first-9-start-end' into ivana/span-first-10-…
sentrivana Mar 9, 2026
c5fcb3e
ref: Add sampling to span first
sentrivana Mar 9, 2026
51342fb
add sample_rate, sample_rate to spans
sentrivana Mar 9, 2026
336b643
order
sentrivana Mar 9, 2026
aba1b50
make private
sentrivana Mar 9, 2026
09b88f0
dont redefine slots
sentrivana Mar 9, 2026
2ac24d3
redundant slots
sentrivana Mar 9, 2026
a9b33a9
Merge branch 'ivana/span-first-9-start-end' into ivana/span-first-10-…
sentrivana Mar 9, 2026
4ba4351
Merge branch 'ivana/span-first-10-random-improvements' into ivana/spa…
sentrivana Mar 9, 2026
5d8c5f3
Merge branch 'master' into ivana/span-first-11-sampling
sentrivana Mar 10, 2026
73e33ea
.
sentrivana Mar 10, 2026
bd9e0a3
add finished to noop span
sentrivana Mar 10, 2026
918609c
.
sentrivana Mar 10, 2026
e850994
improvements
sentrivana Mar 10, 2026
f816c0a
.
sentrivana Mar 10, 2026
62deebf
Add [Tracing] prefix
sentrivana Mar 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion sentry_sdk/scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
Baggage,
has_tracing_enabled,
has_span_streaming_enabled,
_make_sampling_decision,
normalize_incoming_data,
PropagationContext,
)
Expand Down Expand Up @@ -1199,6 +1200,21 @@
if parent_span is None:
propagation_context = self.get_active_propagation_context()

sampled, sample_rate, sample_rand, outcome = _make_sampling_decision(
name,
attributes,
self,
)

if sample_rate is not None:
self._update_sample_rate(sample_rate)

if sampled is False:
return NoOpStreamedSpan(
scope=self,
unsampled_reason=outcome,
)
Comment thread
sentrivana marked this conversation as resolved.
Comment thread
sentrivana marked this conversation as resolved.

return StreamedSpan(
name=name,
attributes=attributes,
Expand All @@ -1209,12 +1225,14 @@
parent_span_id=propagation_context.parent_span_id,
parent_sampled=propagation_context.parent_sampled,
baggage=propagation_context.baggage,
sample_rand=sample_rand,
sample_rate=sample_rate,
)

# This is a child span; take propagation context from the parent span
with new_scope():
if isinstance(parent_span, NoOpStreamedSpan):
return NoOpStreamedSpan()
return NoOpStreamedSpan(unsampled_reason=parent_span._unsampled_reason)

Check failure on line 1235 in sentry_sdk/scope.py

View workflow job for this annotation

GitHub Actions / warden: find-bugs

NoOpStreamedSpan created without scope will raise AttributeError on end()

When a child span is created from a NoOpStreamedSpan parent (line 1235), the new NoOpStreamedSpan is instantiated without the `scope` parameter. While this causes `_start()` to return early (since `self._scope is None`), the `_finished` attribute is never initialized in `NoOpStreamedSpan.__init__()`. When `_end()` is later called (e.g., via the context manager `__exit__` or explicit `end()` call), it accesses `self._finished` before any assignment, causing an `AttributeError: 'NoOpStreamedSpan' object has no attribute '_finished'`.

return StreamedSpan(
name=name,
Expand All @@ -1227,6 +1245,15 @@
parent_sampled=parent_span.sampled,
)

def _update_sample_rate(self, sample_rate: float) -> None:
# If we had to adjust the sample rate when setting the sampling decision
# for a span, 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(sample_rate)

Check warning on line 1255 in sentry_sdk/scope.py

View workflow job for this annotation

GitHub Actions / warden: code-review

Baggage mutability check missing before modification

The `_update_sample_rate` method modifies `baggage.sentry_items["sample_rate"]` without checking if the baggage is mutable. According to the Baggage class documentation, callers must check `baggage.mutable` is `True` before mutation. When a trace is continued from an incoming header containing sentry items, the baggage is immutable (`mutable=False`), so this modification violates the baggage contract and could lead to unexpected behavior in trace propagation.
Comment thread
github-actions[bot] marked this conversation as resolved.

def continue_trace(
self,
environ_or_headers: "Dict[str, Any]",
Expand Down
36 changes: 28 additions & 8 deletions sentry_sdk/traces.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@
"_scope",
"_previous_span_on_scope",
"_baggage",
"_sample_rand",
"_sample_rate",
)

def __init__(
Expand All @@ -238,6 +240,8 @@
parent_span_id: "Optional[str]" = None,
parent_sampled: "Optional[bool]" = None,
baggage: "Optional[Baggage]" = None,
sample_rate: "Optional[float]" = None,
sample_rand: "Optional[float]" = None,
):
self._name: str = name
self._active: bool = active
Expand All @@ -254,6 +258,8 @@
self._parent_span_id = parent_span_id
self._parent_sampled = parent_sampled
self._baggage = baggage
self._sample_rand = sample_rand
self._sample_rate = sample_rate

self._start_timestamp = datetime.now(timezone.utc)
self._timestamp: "Optional[datetime]" = None
Expand Down Expand Up @@ -441,13 +447,18 @@


class NoOpStreamedSpan(StreamedSpan):
__slots__ = ()
__slots__ = (
"_finished",
"_unsampled_reason",
)

def __init__(
self,
unsampled_reason: "Optional[str]" = None,
scope: "Optional[sentry_sdk.Scope]" = None,
) -> None:
self._scope = scope # type: ignore[assignment]
self._unsampled_reason = unsampled_reason

self._start()

Expand All @@ -471,16 +482,25 @@
self._previous_span_on_scope = old_span

def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None:
if self._scope is None:
if self._finished:

Check failure on line 485 in sentry_sdk/traces.py

View workflow job for this annotation

GitHub Actions / warden: code-review

Uninitialized `_finished` attribute causes AttributeError on first call to `_end()`

The `_finished` attribute is declared in `__slots__` but never initialized in `__init__`. When `_end()` is called, the check `if self._finished:` on line 485 will raise an `AttributeError` because the attribute doesn't exist yet. This affects every `NoOpStreamedSpan` when it finishes, breaking span cleanup.
Comment thread
sentrivana marked this conversation as resolved.
return

Check failure on line 486 in sentry_sdk/traces.py

View workflow job for this annotation

GitHub Actions / warden: find-bugs

[VMY-PE3] NoOpStreamedSpan created without scope will raise AttributeError on end() (additional location)

When a child span is created from a NoOpStreamedSpan parent (line 1235), the new NoOpStreamedSpan is instantiated without the `scope` parameter. While this causes `_start()` to return early (since `self._scope is None`), the `_finished` attribute is never initialized in `NoOpStreamedSpan.__init__()`. When `_end()` is later called (e.g., via the context manager `__exit__` or explicit `end()` call), it accesses `self._finished` before any assignment, causing an `AttributeError: 'NoOpStreamedSpan' object has no attribute '_finished'`.

if not hasattr(self, "_previous_span_on_scope"):
return
client = sentry_sdk.get_client()
if client.is_active() and client.transport:
logger.debug("Discarding span because sampled = False")
client.transport.record_lost_event(
reason=self._unsampled_reason or "sample_rate",
data_category="span",
quantity=1,
)

if self._scope:
with capture_internal_exceptions():
old_span = self._previous_span_on_scope
del self._previous_span_on_scope
self._scope.span = old_span

with capture_internal_exceptions():
old_span = self._previous_span_on_scope
del self._previous_span_on_scope
self._scope.span = old_span
self._finished = True
Comment thread
github-actions[bot] marked this conversation as resolved.

def end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None:
self._end()
Expand Down
90 changes: 90 additions & 0 deletions sentry_sdk/tracing_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
to_string,
try_convert,
is_sentry_url,
is_valid_sample_rate,
_is_external_source,
_is_in_project_root,
_module_in_list,
Expand All @@ -41,6 +42,8 @@

from types import FrameType

from sentry_sdk._types import Attributes


SENTRY_TRACE_REGEX = re.compile(
"^[ \t]*" # whitespace
Expand Down Expand Up @@ -1379,6 +1382,93 @@ def add_sentry_baggage_to_headers(
)


def _make_sampling_decision(
name: str,
attributes: "Optional[Attributes]",
scope: "sentry_sdk.Scope",
) -> "tuple[bool, Optional[float], Optional[float], Optional[str]]":
"""
Decide whether a span should be sampled.

Returns a tuple with:
1. the sampling decision
2. the effective sample rate
3. the sample rand
4. the reason for not sampling the span, if unsampled
"""
client = sentry_sdk.get_client()

if not has_tracing_enabled(client.options):
return False, None, None, None
Comment thread
sentrivana marked this conversation as resolved.

propagation_context = scope.get_active_propagation_context()

sample_rand = None
if propagation_context.baggage is not None:
sample_rand = propagation_context.baggage._sample_rand()
if sample_rand is None:
sample_rand = _generate_sample_rand(propagation_context.trace_id)

sampling_context = {
"name": name,
"trace_id": propagation_context.trace_id,
"parent_span_id": propagation_context.parent_span_id,
"parent_sampled": propagation_context.parent_sampled,
"attributes": attributes or {},
}

# If there's a traces_sampler, use that; otherwise use traces_sample_rate
traces_sampler_defined = callable(client.options.get("traces_sampler"))
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"]

# Validate whether the sample_rate we got is actually valid. Since
# traces_sampler is user-provided, it could return anything.
if not is_valid_sample_rate(sample_rate, source="Tracing"):
logger.warning(f"[Tracing] Discarding {name} because of invalid sample rate.")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a bigger refactor that would happen outside of this PR (if we do decide to do this), but I wonder if we should consider leveraging the extra property within the logger in order to both hold dynamic values like name, but also to include a structured enum reason discard_reason=TraceDiscardReasons.INVALID_SAMPLE_RATE.

If a user is trying to determine if/why spans were dropped, this would help with that effort.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think more info in log messages is always nice, so we can def do something like this in the future.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Made PY-2133 to track this, might be a future maintenance day exploration for me 👀

return False, None, None, None

sample_rate = float(sample_rate)

# Adjust sample rate if we're under backpressure
if client.monitor:
sample_rate /= 2**client.monitor.downsample_factor

outcome: "Optional[str]" = None

if not 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 {name} because {reason}")
if client.monitor and client.monitor.downsample_factor > 0:
outcome = "backpressure"
else:
outcome = "sample_rate"

return False, 0.0, None, outcome
Comment thread
sentrivana marked this conversation as resolved.
Outdated

sampled = sample_rand < sample_rate

if sampled:
logger.debug(f"[Tracing] Starting {name}")
outcome = None
else:
logger.debug(
f"[Tracing] Discarding {name} because it's not included in the random sample (sampling rate = {sample_rate})"
)
outcome = "sample_rate"

return sampled, sample_rate, sample_rand, outcome


# Circular imports
from sentry_sdk.tracing import (
BAGGAGE_HEADER_NAME,
Expand Down
Loading