55import sys
66from collections .abc import Mapping
77from datetime import timedelta
8+ from decimal import ROUND_DOWN , Decimal
89from functools import wraps
10+ from random import Random
911from urllib .parse import quote , unquote
1012import uuid
1113
1921 match_regex_list ,
2022 qualname_from_function ,
2123 to_string ,
24+ try_convert ,
2225 is_sentry_url ,
2326 _is_external_source ,
2427 _is_in_project_root ,
4548 "[ \t ]*$" # whitespace
4649)
4750
51+
4852# This is a normal base64 regex, modified to reflect that fact that we strip the
4953# trailing = or == off
5054base64_stripped = (
@@ -418,13 +422,17 @@ def from_incoming_data(cls, incoming_data):
418422 propagation_context = PropagationContext ()
419423 propagation_context .update (sentrytrace_data )
420424
425+ if propagation_context is not None :
426+ propagation_context ._fill_sample_rand ()
427+
421428 return propagation_context
422429
423430 @property
424431 def trace_id (self ):
425432 # type: () -> str
426433 """The trace id of the Sentry trace."""
427434 if not self ._trace_id :
435+ # New trace, don't fill in sample_rand
428436 self ._trace_id = uuid .uuid4 ().hex
429437
430438 return self ._trace_id
@@ -469,6 +477,68 @@ def __repr__(self):
469477 self .dynamic_sampling_context ,
470478 )
471479
480+ def _fill_sample_rand (self ):
481+ # type: () -> None
482+ """
483+ Ensure that there is a valid sample_rand value in the dynamic_sampling_context.
484+
485+ If there is a valid sample_rand value in the dynamic_sampling_context, we keep it.
486+ Otherwise, we generate a sample_rand value according to the following:
487+
488+ - If we have a parent_sampled value and a sample_rate in the DSC, we compute
489+ a sample_rand value randomly in the range:
490+ - [0, sample_rate) if parent_sampled is True,
491+ - or, in the range [sample_rate, 1) if parent_sampled is False.
492+
493+ - If either parent_sampled or sample_rate is missing, we generate a random
494+ value in the range [0, 1).
495+
496+ The sample_rand is deterministically generated from the trace_id, if present.
497+
498+ This function does nothing if there is no dynamic_sampling_context.
499+ """
500+ if self .dynamic_sampling_context is None :
501+ return
502+
503+ sample_rand = try_convert (
504+ Decimal , self .dynamic_sampling_context .get ("sample_rand" )
505+ )
506+ if sample_rand is not None and 0 <= sample_rand < 1 :
507+ # sample_rand is present and valid, so don't overwrite it
508+ return
509+
510+ # Get the sample rate and compute the transformation that will map the random value
511+ # to the desired range: [0, 1), [0, sample_rate), or [sample_rate, 1).
512+ sample_rate = try_convert (
513+ float , self .dynamic_sampling_context .get ("sample_rate" )
514+ )
515+ lower , upper = _sample_rand_range (self .parent_sampled , sample_rate )
516+
517+ try :
518+ sample_rand = _generate_sample_rand (self .trace_id , interval = (lower , upper ))
519+ except ValueError :
520+ # ValueError is raised if the interval is invalid, i.e. lower >= upper.
521+ # lower >= upper might happen if the incoming trace's sampled flag
522+ # and sample_rate are inconsistent, e.g. sample_rate=0.0 but sampled=True.
523+ # We cannot generate a sensible sample_rand value in this case.
524+ logger .debug (
525+ f"Could not backfill sample_rand, since parent_sampled={ self .parent_sampled } "
526+ f"and sample_rate={ sample_rate } ."
527+ )
528+ return
529+
530+ self .dynamic_sampling_context ["sample_rand" ] = (
531+ f"{ sample_rand :.6f} " # noqa: E231
532+ )
533+
534+ def _sample_rand (self ):
535+ # type: () -> Optional[str]
536+ """Convenience method to get the sample_rand value from the dynamic_sampling_context."""
537+ if self .dynamic_sampling_context is None :
538+ return None
539+
540+ return self .dynamic_sampling_context .get ("sample_rand" )
541+
472542
473543class Baggage :
474544 """
@@ -491,8 +561,13 @@ def __init__(
491561 self .mutable = mutable
492562
493563 @classmethod
494- def from_incoming_header (cls , header ):
495- # type: (Optional[str]) -> Baggage
564+ def from_incoming_header (
565+ cls ,
566+ header , # type: Optional[str]
567+ * ,
568+ _sample_rand = None , # type: Optional[str]
569+ ):
570+ # type: (...) -> Baggage
496571 """
497572 freeze if incoming header already has sentry baggage
498573 """
@@ -515,6 +590,10 @@ def from_incoming_header(cls, header):
515590 else :
516591 third_party_items += ("," if third_party_items else "" ) + item
517592
593+ if _sample_rand is not None :
594+ sentry_items ["sample_rand" ] = str (_sample_rand )
595+ mutable = False
596+
518597 return Baggage (sentry_items , third_party_items , mutable )
519598
520599 @classmethod
@@ -566,6 +645,7 @@ def populate_from_transaction(cls, transaction):
566645 options = client .options or {}
567646
568647 sentry_items ["trace_id" ] = transaction .trace_id
648+ sentry_items ["sample_rand" ] = str (transaction ._sample_rand )
569649
570650 if options .get ("environment" ):
571651 sentry_items ["environment" ] = options ["environment" ]
@@ -638,6 +718,20 @@ def strip_sentry_baggage(header):
638718 )
639719 )
640720
721+ def _sample_rand (self ):
722+ # type: () -> Optional[Decimal]
723+ """Convenience method to get the sample_rand value from the sentry_items.
724+
725+ We validate the value and parse it as a Decimal before returning it. The value is considered
726+ valid if it is a Decimal in the range [0, 1).
727+ """
728+ sample_rand = try_convert (Decimal , self .sentry_items .get ("sample_rand" ))
729+
730+ if sample_rand is not None and Decimal (0 ) <= sample_rand < Decimal (1 ):
731+ return sample_rand
732+
733+ return None
734+
641735 def __repr__ (self ):
642736 # type: () -> str
643737 return f'<Baggage "{ self .serialize (include_third_party = True )} ", mutable={ self .mutable } >'
@@ -748,6 +842,49 @@ def get_current_span(scope=None):
748842 return current_span
749843
750844
845+ def _generate_sample_rand (
846+ trace_id , # type: Optional[str]
847+ * ,
848+ interval = (0.0 , 1.0 ), # type: tuple[float, float]
849+ ):
850+ # type: (...) -> Decimal
851+ """Generate a sample_rand value from a trace ID.
852+
853+ The generated value will be pseudorandomly chosen from the provided
854+ interval. Specifically, given (lower, upper) = interval, the generated
855+ value will be in the range [lower, upper). The value has 6-digit precision,
856+ so when printing with .6f, the value will never be rounded up.
857+
858+ The pseudorandom number generator is seeded with the trace ID.
859+ """
860+ lower , upper = interval
861+ if not lower < upper : # using `if lower >= upper` would handle NaNs incorrectly
862+ raise ValueError ("Invalid interval: lower must be less than upper" )
863+
864+ rng = Random (trace_id )
865+ sample_rand = upper
866+ while sample_rand >= upper :
867+ sample_rand = rng .uniform (lower , upper )
868+
869+ # Round down to exactly six decimal-digit precision.
870+ return Decimal (sample_rand ).quantize (Decimal ("0.000001" ), rounding = ROUND_DOWN )
871+
872+
873+ def _sample_rand_range (parent_sampled , sample_rate ):
874+ # type: (Optional[bool], Optional[float]) -> tuple[float, float]
875+ """
876+ Compute the lower (inclusive) and upper (exclusive) bounds of the range of values
877+ that a generated sample_rand value must fall into, given the parent_sampled and
878+ sample_rate values.
879+ """
880+ if parent_sampled is None or sample_rate is None :
881+ return 0.0 , 1.0
882+ elif parent_sampled is True :
883+ return 0.0 , sample_rate
884+ else : # parent_sampled is False
885+ return sample_rate , 1.0
886+
887+
751888# Circular imports
752889from sentry_sdk .tracing import (
753890 BAGGAGE_HEADER_NAME ,
0 commit comments