Skip to content

Commit bd2bfcb

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

File tree

2 files changed

+128
-1
lines changed

2 files changed

+128
-1
lines changed

sentry_sdk/tracing_utils.py

Lines changed: 74 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,8 @@ def from_incoming_data(cls, incoming_data):
418421
propagation_context = PropagationContext()
419422
propagation_context.update(sentrytrace_data)
420423

424+
propagation_context._fill_sample_rand()
425+
421426
return propagation_context
422427

423428
@property
@@ -426,6 +431,7 @@ def trace_id(self):
426431
"""The trace id of the Sentry trace."""
427432
if not self._trace_id:
428433
self._trace_id = uuid.uuid4().hex
434+
self._fill_sample_rand()
429435

430436
return self._trace_id
431437

@@ -469,6 +475,45 @@ def __repr__(self):
469475
self.dynamic_sampling_context,
470476
)
471477

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

473518
class Baggage:
474519
"""
@@ -744,6 +789,35 @@ def get_current_span(scope=None):
744789
return current_span
745790

746791

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