5
5
import sys
6
6
from collections .abc import Mapping
7
7
from datetime import timedelta
8
+ from decimal import ROUND_DOWN , Decimal
8
9
from functools import wraps
10
+ from random import Random
9
11
from urllib .parse import quote , unquote
10
12
import uuid
11
13
19
21
match_regex_list ,
20
22
qualname_from_function ,
21
23
to_string ,
24
+ try_convert ,
22
25
is_sentry_url ,
23
26
_is_external_source ,
24
27
_is_in_project_root ,
45
48
"[ \t ]*$" # whitespace
46
49
)
47
50
51
+
48
52
# This is a normal base64 regex, modified to reflect that fact that we strip the
49
53
# trailing = or == off
50
54
base64_stripped = (
@@ -418,13 +422,17 @@ def from_incoming_data(cls, incoming_data):
418
422
propagation_context = PropagationContext ()
419
423
propagation_context .update (sentrytrace_data )
420
424
425
+ if propagation_context is not None :
426
+ propagation_context ._fill_sample_rand ()
427
+
421
428
return propagation_context
422
429
423
430
@property
424
431
def trace_id (self ):
425
432
# type: () -> str
426
433
"""The trace id of the Sentry trace."""
427
434
if not self ._trace_id :
435
+ # New trace, don't fill in sample_rand
428
436
self ._trace_id = uuid .uuid4 ().hex
429
437
430
438
return self ._trace_id
@@ -469,6 +477,68 @@ def __repr__(self):
469
477
self .dynamic_sampling_context ,
470
478
)
471
479
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
+
472
542
473
543
class Baggage :
474
544
"""
@@ -491,8 +561,13 @@ def __init__(
491
561
self .mutable = mutable
492
562
493
563
@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
496
571
"""
497
572
freeze if incoming header already has sentry baggage
498
573
"""
@@ -515,6 +590,10 @@ def from_incoming_header(cls, header):
515
590
else :
516
591
third_party_items += ("," if third_party_items else "" ) + item
517
592
593
+ if _sample_rand is not None :
594
+ sentry_items ["sample_rand" ] = str (_sample_rand )
595
+ mutable = False
596
+
518
597
return Baggage (sentry_items , third_party_items , mutable )
519
598
520
599
@classmethod
@@ -566,6 +645,7 @@ def populate_from_transaction(cls, transaction):
566
645
options = client .options or {}
567
646
568
647
sentry_items ["trace_id" ] = transaction .trace_id
648
+ sentry_items ["sample_rand" ] = str (transaction ._sample_rand )
569
649
570
650
if options .get ("environment" ):
571
651
sentry_items ["environment" ] = options ["environment" ]
@@ -638,6 +718,20 @@ def strip_sentry_baggage(header):
638
718
)
639
719
)
640
720
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
+
641
735
def __repr__ (self ):
642
736
# type: () -> str
643
737
return f'<Baggage "{ self .serialize (include_third_party = True )} ", mutable={ self .mutable } >'
@@ -748,6 +842,49 @@ def get_current_span(scope=None):
748
842
return current_span
749
843
750
844
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
+
751
888
# Circular imports
752
889
from sentry_sdk .tracing import (
753
890
BAGGAGE_HEADER_NAME ,
0 commit comments