Skip to content

Commit cbfc36a

Browse files
feat(tracing): Backfill missing sample_rand on PropagationContext
Whenever the `PropagationContext` continues an incoming trace (i.e. whenever the `trace_id` is set, rather than being randomly generated as for a new trace), check if the `sample_rand` is present and valid in the incoming DSC. If the `sample_rand` is missing, generate it deterministically based on the `trace_id` and backfill it into the DSC on the `PropagationContext`. When generating the backfilled `sample_rand`, we ensure the generated value is consistent with the incoming trace's sampling decision and sample rate, if both of these are present. Otherwise, we generate a new value in the range [0, 1). Future PRs will address propagating the `sample_rand` to transactions generated with `continue_trace` (allowing the `sample_rand` to be propagated on outgoing traces), and will also allow `sample_rand` to be used for making sampling decisions. Ref #3998
1 parent b2fc801 commit cbfc36a

File tree

3 files changed

+133
-3
lines changed

3 files changed

+133
-3
lines changed

sentry_sdk/tracing_utils.py

Lines changed: 76 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,13 +421,17 @@ 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
424430
def trace_id(self):
425431
# type: () -> str
426432
"""The trace id of the Sentry trace."""
427433
if not self._trace_id:
434+
# New trace, don't fill in sample_rand
428435
self._trace_id = uuid.uuid4().hex
429436

430437
return self._trace_id
@@ -469,6 +476,46 @@ def __repr__(self):
469476
self.dynamic_sampling_context,
470477
)
471478

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

473520
class Baggage:
474521
"""
@@ -748,6 +795,35 @@ def get_current_span(scope=None):
748795
return current_span
749796

750797

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

tests/test_api.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def test_traceparent_with_tracing_disabled(sentry_init):
7979
assert get_traceparent() == expected_traceparent
8080

8181

82-
@pytest.mark.forked
82+
# @pytest.mark.forked
8383
def test_baggage_with_tracing_disabled(sentry_init):
8484
sentry_init(release="1.0.0", environment="dev")
8585
propagation_context = get_isolation_scope()._propagation_context
@@ -111,7 +111,7 @@ def test_continue_trace(sentry_init):
111111
transaction = continue_trace(
112112
{
113113
"sentry-trace": "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled),
114-
"baggage": "sentry-trace_id=566e3688a61d4bc888951642d6f14a19",
114+
"baggage": "sentry-trace_id=566e3688a61d4bc888951642d6f14a19,sentry-sample_rand=0.1234567890",
115115
},
116116
name="some name",
117117
)
@@ -123,7 +123,8 @@ def test_continue_trace(sentry_init):
123123
assert propagation_context.parent_span_id == parent_span_id
124124
assert propagation_context.parent_sampled == parent_sampled
125125
assert propagation_context.dynamic_sampling_context == {
126-
"trace_id": "566e3688a61d4bc888951642d6f14a19"
126+
"trace_id": "566e3688a61d4bc888951642d6f14a19",
127+
"sample_rand": "0.1234567890",
127128
}
128129

129130

tests/test_propagationcontext.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import pytest
2+
13
from sentry_sdk.tracing_utils import PropagationContext
24

35

@@ -12,6 +14,8 @@ def test_empty_context():
1214

1315
assert ctx.parent_span_id is None
1416
assert ctx.parent_sampled is None
17+
18+
# Don't fill in sample_rand on empty context
1519
assert ctx.dynamic_sampling_context is None
1620

1721

@@ -32,6 +36,8 @@ def test_context_with_values():
3236
assert ctx.parent_sampled
3337
assert ctx.dynamic_sampling_context == {
3438
"foo": "bar",
39+
# sample_rand deterministically generated from trace_id
40+
"sample_rand": "0.20286121767364262",
3541
}
3642

3743

@@ -51,6 +57,8 @@ def test_lazy_uuids():
5157

5258
def test_property_setters():
5359
ctx = PropagationContext()
60+
61+
# this setter also generates the sample_rand
5462
ctx.trace_id = "X234567890abcdef1234567890abcdef"
5563
ctx.span_id = "X234567890abcdef"
5664

@@ -59,6 +67,9 @@ def test_property_setters():
5967
assert ctx._span_id == "X234567890abcdef"
6068
assert ctx.span_id == "X234567890abcdef"
6169

70+
# Setters should not backfill sample_rand
71+
assert ctx.dynamic_sampling_context is None
72+
6273

6374
def test_update():
6475
ctx = PropagationContext()
@@ -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)