Skip to content

Commit 14a70da

Browse files
feat: Ensure PropagationContext has sample_rand
1 parent 2ebaa7c commit 14a70da

File tree

2 files changed

+129
-1
lines changed

2 files changed

+129
-1
lines changed

sentry_sdk/tracing_utils.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from collections.abc import Mapping
77
from datetime import timedelta
88
from functools import wraps
9+
from random import Random
910
from urllib.parse import quote, unquote
1011
import uuid
1112

@@ -397,6 +398,8 @@ def __init__(
397398
self.dynamic_sampling_context = dynamic_sampling_context
398399
"""Data that is used for dynamic sampling decisions."""
399400

401+
self._fill_sample_rand()
402+
400403
@classmethod
401404
def from_incoming_data(cls, incoming_data):
402405
# type: (Dict[str, Any]) -> Optional[PropagationContext]
@@ -418,6 +421,9 @@ def from_incoming_data(cls, incoming_data):
418421
propagation_context = PropagationContext()
419422
propagation_context.update(sentrytrace_data)
420423

424+
if propagation_context is not None:
425+
propagation_context._fill_sample_rand()
426+
421427
return propagation_context
422428

423429
@property
@@ -426,6 +432,7 @@ def trace_id(self):
426432
"""The trace id of the Sentry trace."""
427433
if not self._trace_id:
428434
self._trace_id = uuid.uuid4().hex
435+
self._fill_sample_rand()
429436

430437
return self._trace_id
431438

@@ -469,6 +476,45 @@ def __repr__(self):
469476
self.dynamic_sampling_context,
470477
)
471478

479+
def _fill_sample_rand(self):
480+
"""
481+
If the sample_rand is missing from the Dynamic Sampling Context (or invalid),
482+
we generate it here.
483+
484+
We only generate a sample_rand if the trace_id is set.
485+
486+
If we have a parent_sampled value and a sample_rate in the DSC, we compute
487+
a sample_rand value randomly in the range [0, sample_rate) if parent_sampled is True,
488+
or in the range [sample_rate, 1) if parent_sampled is False. If either parent_sampled
489+
or sample_rate is missing, we generate a random value in the range [0, 1).
490+
491+
The sample_rand is deterministically generated from the trace_id.
492+
"""
493+
if self._trace_id is None:
494+
# We only want to generate a sample_rand if the _trace_id is set.
495+
return
496+
497+
# Ensure that the dynamic_sampling_context is a dict
498+
self.dynamic_sampling_context = self.dynamic_sampling_context or {}
499+
500+
sample_rand = _try_float(self.dynamic_sampling_context.get("sample_rand"))
501+
if sample_rand is not None and 0 <= sample_rand < 1:
502+
# sample_rand is present and valid, so don't overwrite it
503+
return
504+
505+
# Get a random value in [0, 1)
506+
random_value = Random(self.trace_id).random()
507+
508+
# Get the sample rate and compute the transformation that will map the random value
509+
# to the desired range: [0, 1), [0, sample_rate), or [sample_rate, 1).
510+
sample_rate = _try_float(self.dynamic_sampling_context.get("sample_rate"))
511+
factor, offset = _sample_rand_transormation(self.parent_sampled, sample_rate)
512+
513+
# Transform the random value to the desired range
514+
self.dynamic_sampling_context["sample_rand"] = str(
515+
random_value * factor + offset
516+
)
517+
472518

473519
class Baggage:
474520
"""
@@ -744,6 +790,35 @@ def get_current_span(scope=None):
744790
return current_span
745791

746792

793+
def _try_float(value):
794+
# type: (object) -> Optional[float]
795+
"""Small utility to convert a value to a float, if possible."""
796+
try:
797+
return float(value)
798+
except (ValueError, TypeError):
799+
return None
800+
801+
802+
def _sample_rand_transormation(parent_sampled, sample_rate):
803+
# type: (Optional[bool], Optional[float]) -> tuple[float, float]
804+
"""
805+
Compute the factor and offset to scale and translate a random number in [0, 1) to
806+
a range consistent with the parent_sampled and sample_rate values.
807+
808+
The return value is a tuple (factor, offset) such that, given random_value in [0, 1),
809+
and new_value = random_value * factor + offset:
810+
- new_value will be unchanged if either parent_sampled or sample_rate is None
811+
- if parent_sampled and sample_rate are both set, new_value will be in [0, sample_rate)
812+
if parent_sampled is True, or in [sample_rate, 1) if parent_sampled is False
813+
"""
814+
if parent_sampled is None or sample_rate is None:
815+
return 1.0, 0.0
816+
elif parent_sampled is True:
817+
return sample_rate, 0.0
818+
else: # parent_sampled is False
819+
return 1.0 - sample_rate, sample_rate
820+
821+
747822
# Circular imports
748823
from sentry_sdk.tracing import (
749824
BAGGAGE_HEADER_NAME,

tests/test_propagationcontext.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
1+
import pytest
2+
13
from sentry_sdk.tracing_utils import PropagationContext
24

35

46
def test_empty_context():
57
ctx = PropagationContext()
68

9+
# DSC is None before calling ctx.trace_id
10+
assert ctx.dynamic_sampling_context is None
11+
712
assert ctx.trace_id is not None
813
assert len(ctx.trace_id) == 32
914

15+
# ctx.trace_id lazily computes the trace_id and therefor also the sample_rand
16+
assert ctx.dynamic_sampling_context is not None
17+
sample_rand = float(ctx.dynamic_sampling_context["sample_rand"])
18+
assert 0 <= sample_rand < 1
19+
1020
assert ctx.span_id is not None
1121
assert len(ctx.span_id) == 16
1222

1323
assert ctx.parent_span_id is None
1424
assert ctx.parent_sampled is None
15-
assert ctx.dynamic_sampling_context is None
1625

1726

1827
def test_context_with_values():
@@ -32,6 +41,8 @@ def test_context_with_values():
3241
assert ctx.parent_sampled
3342
assert ctx.dynamic_sampling_context == {
3443
"foo": "bar",
44+
# sample_rand deterministically generated from trace_id
45+
"sample_rand": "0.20286121767364262",
3546
}
3647

3748

@@ -81,3 +92,45 @@ def test_update():
8192
assert ctx.dynamic_sampling_context is None
8293

8394
assert not hasattr(ctx, "foo")
95+
96+
97+
def test_existing_sample_rand_kept():
98+
ctx = PropagationContext(
99+
trace_id="00000000000000000000000000000000",
100+
dynamic_sampling_context={"sample_rand": "0.5"},
101+
)
102+
103+
# If sample_rand was regenerated, the value would be 0.8766381713144122 based on the trace_id
104+
assert ctx.dynamic_sampling_context["sample_rand"] == "0.5"
105+
106+
107+
@pytest.mark.parametrize(
108+
("parent_sampled", "sample_rate", "expected_sample_rand"),
109+
(
110+
(None, None, "0.8766381713144122"),
111+
(None, "0.5", "0.8766381713144122"),
112+
(False, None, "0.8766381713144122"),
113+
(True, None, "0.8766381713144122"),
114+
(False, "0.0", "0.8766381713144122"),
115+
(False, "0.01", "0.8778717896012681"),
116+
(True, "0.01", "0.008766381713144122"),
117+
(False, "0.1", "0.888974354182971"),
118+
(True, "0.1", "0.08766381713144122"),
119+
(False, "0.5", "0.9383190856572061"),
120+
(True, "0.5", "0.4383190856572061"),
121+
(True, "1.0", "0.8766381713144122"),
122+
),
123+
)
124+
def test_sample_rand_filled(parent_sampled, sample_rate, expected_sample_rand):
125+
"""When continuing a trace, we want to fill in the sample_rand value if it's missing."""
126+
dsc = {}
127+
if sample_rate is not None:
128+
dsc["sample_rate"] = sample_rate
129+
130+
ctx = PropagationContext(
131+
trace_id="00000000000000000000000000000000",
132+
parent_sampled=parent_sampled,
133+
dynamic_sampling_context=dsc,
134+
)
135+
136+
assert ctx.dynamic_sampling_context["sample_rand"] == expected_sample_rand

0 commit comments

Comments
 (0)