Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
e53c7ec
.
sentrivana Feb 27, 2025
5f3051e
.
sentrivana Feb 28, 2025
d22d8a4
Read or generate sample_rand
sentrivana Feb 28, 2025
65935f5
comment
sentrivana Feb 28, 2025
bac69a7
fix
sentrivana Feb 28, 2025
b0e6d10
.
sentrivana Feb 28, 2025
a482084
.
sentrivana Feb 28, 2025
9b24fe5
fix type
sentrivana Feb 28, 2025
49a3507
Add sample_rand to propagator tests
sentrivana Mar 20, 2025
3d4953e
Merge branch 'potel-base' into ivana/potel/port-sample-rand
sentrivana Mar 21, 2025
1332ebc
facepalm
sentrivana Mar 21, 2025
4fe4b2e
unused import
sentrivana Mar 21, 2025
8d14de5
.
sentrivana Mar 21, 2025
50db70f
unused import
sentrivana Mar 21, 2025
67ca0e5
patch correct func
sentrivana Mar 21, 2025
248306f
.
sentrivana Mar 21, 2025
ab3d868
fix
sentrivana Mar 21, 2025
d52b361
Merge branch 'potel-base' into ivana/potel/port-sample-rand
sentrivana Mar 24, 2025
fc610e3
.
sentrivana Mar 24, 2025
541a83d
.
sentrivana Mar 24, 2025
46e6f9b
.
sentrivana Mar 24, 2025
b57ee5e
precommit
sentrivana Mar 24, 2025
8d917f5
.
sentrivana Mar 24, 2025
d2055d9
.
sentrivana Mar 24, 2025
8523fde
.
sentrivana Mar 24, 2025
948b926
.
sentrivana Mar 24, 2025
66c4b6a
.
sentrivana Mar 24, 2025
507c21f
.
sentrivana Mar 24, 2025
8010e46
make it possible to gen a sample rand
sentrivana Mar 24, 2025
172acaf
.
sentrivana Mar 24, 2025
2fe50f1
.
sentrivana Mar 25, 2025
221bed4
Merge branch 'potel-base' into ivana/potel/port-sample-rand
sentrivana Mar 25, 2025
3541762
mypy
sentrivana Mar 25, 2025
d49038b
wording
sentrivana Mar 25, 2025
5c20b12
be more defensive
sentrivana Mar 25, 2025
5db195a
Update tests/integrations/opentelemetry/test_propagator.py
sentrivana Mar 25, 2025
1b118cf
Apply suggestions from code review
sentrivana Mar 25, 2025
8754c5e
some things
sentrivana Mar 25, 2025
8830e34
add link
sentrivana Mar 25, 2025
6abb38c
simplify logic in sampler
sentrivana Mar 26, 2025
3246d38
move rounding to separate helper func
sentrivana Mar 26, 2025
6ef0ebd
Merge branch 'potel-base' into ivana/potel/port-sample-rand
sentrivana Mar 26, 2025
a0f1a35
more lint
sentrivana Mar 26, 2025
34cb097
tests
sentrivana Mar 26, 2025
be19ff4
.
sentrivana Mar 26, 2025
9711c99
fixes
sentrivana Mar 27, 2025
bd6312f
simplify this -- sample_rand will never be None here
sentrivana Mar 27, 2025
d9fe5f9
Merge branch 'potel-base' into ivana/potel/port-sample-rand
sentrivana Mar 27, 2025
9fe8606
Merge branch 'potel-base' into ivana/potel/port-sample-rand
sentrivana Mar 27, 2025
9380196
Merge branch 'potel-base' into ivana/potel/port-sample-rand
sentrivana Mar 27, 2025
c3d0156
dont round incoming sample_rand
sentrivana Mar 27, 2025
c18cdf0
misleading comments
sentrivana Mar 27, 2025
185cc15
fix type signature
sentrivana Mar 27, 2025
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
1 change: 1 addition & 0 deletions MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ Looking to upgrade from Sentry SDK 2.x to 3.x? Here's a comprehensive list of wh
- `profiles_sample_rate` and `profiler_mode` were removed from options available via `_experiments`. Use the top-level `profiles_sample_rate` and `profiler_mode` options instead.
- `Transport.capture_event` has been removed. Use `Transport.capture_envelope` instead.
- Function transports are no longer supported. Subclass the `Transport` instead.
- `start_transaction` (`start_span`) no longer takes a `baggage` argument. Use the `continue_trace()` context manager instead to propagate baggage.

### Deprecated

Expand Down
3 changes: 3 additions & 0 deletions sentry_sdk/integrations/opentelemetry/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
SENTRY_USE_CURRENT_SCOPE_KEY = create_key("sentry_use_current_scope")
SENTRY_USE_ISOLATION_SCOPE_KEY = create_key("sentry_use_isolation_scope")

# trace state keys
TRACESTATE_SAMPLED_KEY = Baggage.SENTRY_PREFIX + "sampled"
TRACESTATE_SAMPLE_RATE_KEY = Baggage.SENTRY_PREFIX + "sample_rate"
TRACESTATE_SAMPLE_RAND_KEY = Baggage.SENTRY_PREFIX + "sample_rand"

# misc
OTEL_SENTRY_CONTEXT = "otel"
SPAN_ORIGIN = "auto.otel"

Expand Down
133 changes: 99 additions & 34 deletions sentry_sdk/integrations/opentelemetry/sampler.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import random
from decimal import Decimal
from typing import cast

from opentelemetry import trace
from opentelemetry.sdk.trace.sampling import Sampler, SamplingResult, Decision
from opentelemetry.trace.span import TraceState

import sentry_sdk
from sentry_sdk.tracing_utils import has_tracing_enabled
from sentry_sdk.tracing_utils import (
_generate_sample_rand,
has_tracing_enabled,
)
from sentry_sdk.utils import is_valid_sample_rate, logger
from sentry_sdk.integrations.opentelemetry.consts import (
TRACESTATE_SAMPLED_KEY,
TRACESTATE_SAMPLE_RAND_KEY,
TRACESTATE_SAMPLE_RATE_KEY,
SentrySpanAttribute,
)
Expand Down Expand Up @@ -70,23 +74,40 @@ def get_parent_sample_rate(parent_context, trace_id):
return None


def dropped_result(parent_span_context, attributes, sample_rate=None):
# type: (SpanContext, Attributes, Optional[float]) -> SamplingResult
# these will only be added the first time in a root span sampling decision
# if sample_rate is provided, it'll be updated in trace state
trace_state = parent_span_context.trace_state
def get_parent_sample_rand(parent_context, trace_id):
# type: (Optional[SpanContext], int) -> Optional[Decimal]
if parent_context is None:
return None

if TRACESTATE_SAMPLED_KEY not in trace_state:
trace_state = trace_state.add(TRACESTATE_SAMPLED_KEY, "false")
elif trace_state.get(TRACESTATE_SAMPLED_KEY) == "deferred":
trace_state = trace_state.update(TRACESTATE_SAMPLED_KEY, "false")
is_span_context_valid = parent_context is not None and parent_context.is_valid

if sample_rate is not None:
trace_state = trace_state.update(TRACESTATE_SAMPLE_RATE_KEY, str(sample_rate))
if is_span_context_valid and parent_context.trace_id == trace_id:
parent_sample_rand = parent_context.trace_state.get(TRACESTATE_SAMPLE_RAND_KEY)
if parent_sample_rand is None:
return None

is_root_span = not (
parent_span_context.is_valid and not parent_span_context.is_remote
return Decimal(parent_sample_rand)

return None


def dropped_result(span_context, attributes, sample_rate=None, sample_rand=None):
# type: (SpanContext, Attributes, Optional[float], Optional[Decimal]) -> SamplingResult
"""
React to a span getting unsampled and return a DROP SamplingResult.

Update the trace_state with the effective sampled, sample_rate and sample_rand,
record that we dropped the event for client report purposes, and return
an OTel SamplingResult with Decision.DROP.

See for more info about OTel sampling:
https://opentelemetry-python.readthedocs.io/en/latest/sdk/trace.sampling.html
"""
trace_state = _update_trace_state(
span_context, sampled=False, sample_rate=sample_rate, sample_rand=sample_rand
)

is_root_span = not (span_context.is_valid and not span_context.is_remote)
if is_root_span:
# Tell Sentry why we dropped the transaction/root-span
client = sentry_sdk.get_client()
Expand All @@ -108,19 +129,20 @@ def dropped_result(parent_span_context, attributes, sample_rate=None):
)


def sampled_result(span_context, attributes, sample_rate):
# type: (SpanContext, Attributes, Optional[float]) -> SamplingResult
# these will only be added the first time in a root span sampling decision
# if sample_rate is provided, it'll be updated in trace state
trace_state = span_context.trace_state
def sampled_result(span_context, attributes, sample_rate=None, sample_rand=None):
# type: (SpanContext, Attributes, Optional[float], Optional[Decimal]) -> SamplingResult
"""
React to a span being sampled and return a sampled SamplingResult.

if TRACESTATE_SAMPLED_KEY not in trace_state:
trace_state = trace_state.add(TRACESTATE_SAMPLED_KEY, "true")
elif trace_state.get(TRACESTATE_SAMPLED_KEY) == "deferred":
trace_state = trace_state.update(TRACESTATE_SAMPLED_KEY, "true")
Update the trace_state with the effective sampled, sample_rate and sample_rand,
and return an OTel SamplingResult with Decision.RECORD_AND_SAMPLE.

if sample_rate is not None:
trace_state = trace_state.update(TRACESTATE_SAMPLE_RATE_KEY, str(sample_rate))
See for more info about OTel sampling:
https://opentelemetry-python.readthedocs.io/en/latest/sdk/trace.sampling.html
"""
trace_state = _update_trace_state(
span_context, sampled=True, sample_rate=sample_rate, sample_rand=sample_rand
)

return SamplingResult(
Decision.RECORD_AND_SAMPLE,
Expand All @@ -129,6 +151,27 @@ def sampled_result(span_context, attributes, sample_rate):
)


def _update_trace_state(span_context, sampled, sample_rate=None, sample_rand=None):
# type: (SpanContext, bool, Optional[float], Optional[Decimal]) -> TraceState
trace_state = span_context.trace_state

sampled = "true" if sampled else "false"
if TRACESTATE_SAMPLED_KEY not in trace_state:
trace_state = trace_state.add(TRACESTATE_SAMPLED_KEY, sampled)
elif trace_state.get(TRACESTATE_SAMPLED_KEY) == "deferred":
trace_state = trace_state.update(TRACESTATE_SAMPLED_KEY, sampled)

if sample_rate is not None:
trace_state = trace_state.update(TRACESTATE_SAMPLE_RATE_KEY, str(sample_rate))

if sample_rand is not None:
trace_state = trace_state.update(
TRACESTATE_SAMPLE_RAND_KEY, f"{sample_rand:.6f}" # noqa: E231
)

Comment on lines +167 to +171
Copy link
Member

Choose a reason for hiding this comment

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

[optional] since it seems like we are setting the sample_rand both here and above in dropped_result using the same duplicated code, perhaps we can extract this code into a separate function? If we did this, we could also potentially more other stuff we set to that new function (this might be out of scope for this PR though)

If you think this change would be too much hassle, feel free to ignore this suggestion

Copy link
Contributor Author

Choose a reason for hiding this comment

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

return trace_state


class SentrySampler(Sampler):
def should_sample(
self,
Expand Down Expand Up @@ -156,6 +199,18 @@ def should_sample(

sample_rate = None

parent_sampled = get_parent_sampled(parent_span_context, trace_id)
parent_sample_rate = get_parent_sample_rate(parent_span_context, trace_id)
parent_sample_rand = get_parent_sample_rand(parent_span_context, trace_id)

if parent_sample_rand is not None:
# We have a sample_rand on the incoming trace or we already backfilled
# it in PropagationContext
sample_rand = parent_sample_rand
else:
# We are the head SDK and we need to generate a new sample_rand
sample_rand = cast(Decimal, _generate_sample_rand(str(trace_id), (0, 1)))
Copy link
Member

Choose a reason for hiding this comment

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

This cast is quite confusing.

Could we please try to modify _generate_sample_rand's signature to return Decimal rather than Optional[Decimal]? That would eliminate the need for a cast

Suggested change
sample_rand = cast(Decimal, _generate_sample_rand(str(trace_id), (0, 1)))
sample_rand = _generate_sample_rand(str(trace_id), (0, 1))


# Explicit sampled value provided at start_span
custom_sampled = cast(
"Optional[bool]", attributes.get(SentrySpanAttribute.CUSTOM_SAMPLED)
Expand All @@ -165,11 +220,17 @@ def should_sample(
sample_rate = float(custom_sampled)
if sample_rate > 0:
return sampled_result(
parent_span_context, attributes, sample_rate=sample_rate
parent_span_context,
attributes,
sample_rate=sample_rate,
sample_rand=sample_rand,
)
else:
return dropped_result(
parent_span_context, attributes, sample_rate=sample_rate
parent_span_context,
attributes,
sample_rate=sample_rate,
sample_rand=sample_rand,
)
else:
logger.debug(
Expand All @@ -190,8 +251,6 @@ def should_sample(
sample_rate_to_propagate = sample_rate
else:
# Check if there is a parent with a sampling decision
parent_sampled = get_parent_sampled(parent_span_context, trace_id)
parent_sample_rate = get_parent_sample_rate(parent_span_context, trace_id)
if parent_sampled is not None:
sample_rate = bool(parent_sampled)
sample_rate_to_propagate = (
Expand All @@ -215,17 +274,23 @@ def should_sample(
if client.monitor.downsample_factor > 0:
sample_rate_to_propagate = sample_rate

# Roll the dice on sample rate
# Compare sample_rand to sample_rate to make the final sampling decision
sample_rate = float(cast("Union[bool, float, int]", sample_rate))
sampled = random.random() < sample_rate
sampled = sample_rand < sample_rate

if sampled:
return sampled_result(
parent_span_context, attributes, sample_rate=sample_rate_to_propagate
parent_span_context,
attributes,
sample_rate=sample_rate_to_propagate,
sample_rand=None if sample_rand == parent_sample_rand else sample_rand,
Copy link
Member

@szokeasaurusrex szokeasaurusrex Mar 27, 2025

Choose a reason for hiding this comment

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

[question] What is the purpose of comparing sample_rand to parent_sample_rand? We might want to clarify this with a code comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

sampled_result (and dropped_result as well) will use the sample_rand provided to overwrite it in the trace state. Since we don't want to overwrite incoming parent_sample_rand (since we would potentially change the precision), we only pass it to sampled_result if it's different from parent_sample_rand -- i.e., we generated it ourselves.

Copy link
Member

Choose a reason for hiding this comment

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

Ngl I am still a bit confused. Didn't we get rid of the code that would potentially change the precision?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We got rid of the rounding code but not the precision code, we still format sample_rand to 6 decimal places here https://github.com/getsentry/sentry-python/pull/4106/files#diff-59aa7195d955e153b5cdd730f888994996a72eaf5e9ea174335ce961841584a9R169

Copy link
Contributor Author

@sentrivana sentrivana Mar 27, 2025

Choose a reason for hiding this comment

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

In any case, the bigger picture here is just: if we have a halfway valid incoming sample_rand, don't tamper with it, even if it'd mean just adding a few zeroes at the end.

Copy link
Member

Choose a reason for hiding this comment

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

okay, fair enough

)
else:
return dropped_result(
parent_span_context, attributes, sample_rate=sample_rate_to_propagate
parent_span_context,
attributes,
sample_rate=sample_rate_to_propagate,
sample_rand=None if sample_rand == parent_sample_rand else sample_rand,
)

def get_description(self) -> str:
Expand Down
1 change: 0 additions & 1 deletion sentry_sdk/integrations/opentelemetry/scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
from sentry_sdk.integrations.opentelemetry.utils import trace_state_from_baggage
from sentry_sdk.scope import Scope, ScopeType
from sentry_sdk.tracing import Span
from sentry_sdk.utils import logger
from sentry_sdk._types import TYPE_CHECKING

if TYPE_CHECKING:
Expand Down
2 changes: 1 addition & 1 deletion sentry_sdk/integrations/stdlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def putrequest(self, method, url, *args, **kwargs):

client = sentry_sdk.get_client()
if client.get_integration(StdlibIntegration) is None or is_sentry_url(
client, f"{host}:{port}"
client, f"{host}:{port}" # noqa: E231
):
return real_putrequest(self, method, url, *args, **kwargs)

Expand Down
1 change: 0 additions & 1 deletion sentry_sdk/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
from sentry_sdk.utils import (
_serialize_span_attribute,
get_current_thread_meta,
logger,
should_be_treated_as_error,
)

Expand Down
84 changes: 77 additions & 7 deletions sentry_sdk/tracing_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import contextlib
import decimal
import inspect
import os
import re
Expand Down Expand Up @@ -392,6 +393,9 @@ def from_incoming_data(cls, incoming_data):
propagation_context = PropagationContext()
propagation_context.update(sentrytrace_data)

if propagation_context is not None:
propagation_context._fill_sample_rand()

return propagation_context

@property
Expand Down Expand Up @@ -433,6 +437,78 @@ def update(self, other_dict):
except AttributeError:
pass

def _fill_sample_rand(self):
# type: () -> None
"""
Ensure that there is a valid sample_rand value in the baggage.

If there is a valid sample_rand value in the baggage, we keep it.
Otherwise, we generate a sample_rand value according to the following:

- If we have a parent_sampled value and a sample_rate in the DSC, we compute
a sample_rand value randomly in the range:
- [0, sample_rate) if parent_sampled is True,
- or, in the range [sample_rate, 1) if parent_sampled is False.

- If either parent_sampled or sample_rate is missing, we generate a random
value in the range [0, 1).

The sample_rand is deterministically generated from the trace_id, if present.

This function does nothing if there is no dynamic_sampling_context.
"""
if self.dynamic_sampling_context is None or self.baggage is None:
return

sentry_baggage = self.baggage.sentry_items

sample_rand = None
if sentry_baggage.get("sample_rand"):
try:
sample_rand = Decimal(sentry_baggage["sample_rand"])
except Exception:
logger.debug(
f"Failed to convert incoming sample_rand to Decimal: {sample_rand}"
)

if sample_rand is not None and 0 <= sample_rand < 1:
# sample_rand is present and valid, so don't overwrite it
return

sample_rate = None
if sentry_baggage.get("sample_rate"):
try:
sample_rate = float(sentry_baggage["sample_rate"])
except Exception:
logger.debug(
f"Failed to convert incoming sample_rate to float: {sample_rate}"
)

lower, upper = _sample_rand_range(self.parent_sampled, sample_rate)

try:
sample_rand = _generate_sample_rand(self.trace_id, interval=(lower, upper))
except ValueError:
# ValueError is raised if the interval is invalid, i.e. lower >= upper.
# lower >= upper might happen if the incoming trace's sampled flag
# and sample_rate are inconsistent, e.g. sample_rate=0.0 but sampled=True.
# We cannot generate a sensible sample_rand value in this case.
logger.debug(
f"Could not backfill sample_rand, since parent_sampled={self.parent_sampled} "
f"and sample_rate={sample_rate}."
)
return

self.baggage.sentry_items["sample_rand"] = f"{sample_rand:.6f}" # noqa: E231

def _sample_rand(self):
# type: () -> Optional[str]
"""Convenience method to get the sample_rand value from the baggage."""
if self.baggage is None:
return None

return self.baggage.sentry_items.get("sample_rand")

def __repr__(self):
# type: (...) -> str
return "<PropagationContext _trace_id={} _span_id={} parent_span_id={} parent_sampled={} baggage={} dynamic_sampling_context={}>".format(
Expand Down Expand Up @@ -684,13 +760,11 @@ def get_current_span(scope=None):
return current_span


# XXX-potel-ivana: use this
def _generate_sample_rand(
trace_id, # type: Optional[str]
*,
interval=(0.0, 1.0), # type: tuple[float, float]
):
# type: (...) -> Any
# type: (...) -> Optional[decimal.Decimal]
"""Generate a sample_rand value from a trace ID.

The generated value will be pseudorandomly chosen from the provided
Expand All @@ -709,15 +783,11 @@ def _generate_sample_rand(
while sample_rand >= upper:
sample_rand = rng.uniform(lower, upper)

# Round down to exactly six decimal-digit precision.
# Setting the context is needed to avoid an InvalidOperation exception
# in case the user has changed the default precision.
return Decimal(sample_rand).quantize(
Decimal("0.000001"), rounding=ROUND_DOWN, context=Context(prec=6)
)


# XXX-potel-ivana: use this
def _sample_rand_range(parent_sampled, sample_rate):
# type: (Optional[bool], Optional[float]) -> tuple[float, float]
"""
Expand Down
Loading
Loading