|
1 | 1 | import contextlib
|
| 2 | +from decimal import Decimal |
2 | 3 | import inspect
|
3 | 4 | import os
|
4 | 5 | import re
|
5 | 6 | import sys
|
6 | 7 | from collections.abc import Mapping
|
7 | 8 | from datetime import timedelta
|
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_decimal, |
22 | 25 | is_sentry_url,
|
23 | 26 | _is_external_source,
|
24 | 27 | _is_in_project_root,
|
@@ -418,13 +421,17 @@ def from_incoming_data(cls, incoming_data):
|
418 | 421 | propagation_context = PropagationContext()
|
419 | 422 | propagation_context.update(sentrytrace_data)
|
420 | 423 |
|
| 424 | + if propagation_context is not None: |
| 425 | + propagation_context._fill_sample_rand() |
| 426 | + |
421 | 427 | return propagation_context
|
422 | 428 |
|
423 | 429 | @property
|
424 | 430 | def trace_id(self):
|
425 | 431 | # type: () -> str
|
426 | 432 | """The trace id of the Sentry trace."""
|
427 | 433 | if not self._trace_id:
|
| 434 | + # New trace, don't fill in sample_rand |
428 | 435 | self._trace_id = uuid.uuid4().hex
|
429 | 436 |
|
430 | 437 | return self._trace_id
|
@@ -469,6 +476,54 @@ def __repr__(self):
|
469 | 476 | self.dynamic_sampling_context,
|
470 | 477 | )
|
471 | 478 |
|
| 479 | + def _fill_sample_rand(self): |
| 480 | + # type: () -> None |
| 481 | + """ |
| 482 | + Ensure that there is a valid sample_rand value in the dynamic_sampling_context. |
| 483 | +
|
| 484 | + If there is a valid sample_rand value in the dynamic_sampling_context, we keep it. |
| 485 | + Otherwise, we generate a sample_rand value according to the following: |
| 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: |
| 489 | + - [0, sample_rate) if parent_sampled is True, |
| 490 | + - or, in the range [sample_rate, 1) if parent_sampled is False. |
| 491 | +
|
| 492 | + - If either parent_sampled or sample_rate is missing, we generate a random |
| 493 | + value in the range [0, 1). |
| 494 | +
|
| 495 | + The sample_rand is deterministically generated from the trace_id, if present. |
| 496 | +
|
| 497 | + This function does nothing if there is no dynamic_sampling_context. |
| 498 | + """ |
| 499 | + if self.dynamic_sampling_context is None: |
| 500 | + return |
| 501 | + |
| 502 | + sample_rand = SampleRandValue.try_from_incoming( |
| 503 | + self.dynamic_sampling_context.get("sample_rand") |
| 504 | + ) |
| 505 | + if sample_rand is not None and 0 <= sample_rand.inner() < 1: |
| 506 | + # sample_rand is present and valid, so don't overwrite it |
| 507 | + return |
| 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_decimal(self.dynamic_sampling_context.get("sample_rate")) |
| 512 | + lower, upper = _sample_rand_range(self.parent_sampled, sample_rate) |
| 513 | + |
| 514 | + try: |
| 515 | + sample_rand = SampleRandValue.generate( |
| 516 | + self.trace_id, interval=(lower, upper) |
| 517 | + ) |
| 518 | + except ValueError: |
| 519 | + # ValueError is raised if the interval is invalid, i.e. lower >= upper. |
| 520 | + # lower >= upper might happen if the incoming trace's sampled flag |
| 521 | + # and sample_rate are inconsistent, e.g. sample_rate=0.0 but sampled=True. |
| 522 | + # We cannot generate a sensible sample_rand value in this case. |
| 523 | + return |
| 524 | + |
| 525 | + self.dynamic_sampling_context["sample_rand"] = str(sample_rand) |
| 526 | + |
472 | 527 |
|
473 | 528 | class Baggage:
|
474 | 529 | """
|
@@ -643,9 +698,100 @@ def __repr__(self):
|
643 | 698 | return f'<Baggage "{self.serialize(include_third_party=True)}", mutable={self.mutable}>'
|
644 | 699 |
|
645 | 700 |
|
| 701 | +class SampleRandValue: |
| 702 | + """ |
| 703 | + Lightweight wrapper around a Decimal value, with utilities for |
| 704 | + generating a sample rand value from a trace ID, parsing incoming |
| 705 | + sample_rand values, and for consistent serialization to a string. |
| 706 | +
|
| 707 | + SampleRandValue instances are immutable. |
| 708 | + """ |
| 709 | + |
| 710 | + DECIMAL_0 = Decimal(0) |
| 711 | + DECIMAL_1 = Decimal(1) |
| 712 | + |
| 713 | + def __init__(self, value): |
| 714 | + # type: (Decimal) -> None |
| 715 | + """ |
| 716 | + Initialize SampleRandValue from a Decimal value. This constructor |
| 717 | + should only be called internally by the SampleRandValue class. |
| 718 | + """ |
| 719 | + self._value = value |
| 720 | + |
| 721 | + @classmethod |
| 722 | + def try_from_incoming(cls, incoming_value): |
| 723 | + # type: (Optional[str]) -> Optional[SampleRandValue] |
| 724 | + """ |
| 725 | + Attempt to parse an incoming sample_rand value from a string. |
| 726 | +
|
| 727 | + Returns None if the incoming value is None or cannot be parsed as a Decimal. |
| 728 | + """ |
| 729 | + value = try_decimal(incoming_value) |
| 730 | + if value is not None and cls.DECIMAL_0 <= value < cls.DECIMAL_1: |
| 731 | + return cls(value) |
| 732 | + |
| 733 | + return None |
| 734 | + |
| 735 | + @classmethod |
| 736 | + def generate( |
| 737 | + cls, |
| 738 | + trace_id, # type: Optional[str] |
| 739 | + *, |
| 740 | + interval=(DECIMAL_0, DECIMAL_1), # type: tuple[Decimal, Decimal] |
| 741 | + ): |
| 742 | + # type: (...) -> SampleRandValue |
| 743 | + """Generate a sample_rand value from a trace ID. |
| 744 | +
|
| 745 | + The generated value will be pseudorandomly chosen from the provided |
| 746 | + interval. Specifically, given (lower, upper) = interval, the generated |
| 747 | + value will be in the range [lower, upper). |
| 748 | +
|
| 749 | + The pseudorandom number generator is seeded with the trace ID. |
| 750 | + """ |
| 751 | + # Since sample_rand values have 6-digit precision, we generate the |
| 752 | + # value as an integer in the range [0, 10**6), and then scale it |
| 753 | + # to the desired range. |
| 754 | + SCALE_EXPONENT = 6 |
| 755 | + |
| 756 | + lower_decimal, upper_decimal = interval |
| 757 | + if not lower_decimal < upper_decimal: |
| 758 | + raise ValueError("Invalid interval: lower must be less than upper") |
| 759 | + |
| 760 | + lower_int = int(lower_decimal.scaleb(SCALE_EXPONENT)) |
| 761 | + upper_int = int(upper_decimal.scaleb(SCALE_EXPONENT)) |
| 762 | + |
| 763 | + if lower_int == upper_int: |
| 764 | + # Edge case: lower_decimal < upper_decimal, but due to rounding, |
| 765 | + # lower_int == upper_int. In this case, we return |
| 766 | + # lower_int.scaleb(-SCALE_EXPONENT) here, since calling randrange() |
| 767 | + # with the same lower and upper bounds will raise an error. |
| 768 | + return cls(Decimal(lower_int).scaleb(-SCALE_EXPONENT)) |
| 769 | + |
| 770 | + value = Random(trace_id).randrange(lower_int, upper_int) |
| 771 | + return cls(Decimal(value).scaleb(-SCALE_EXPONENT)) |
| 772 | + |
| 773 | + def inner(self): |
| 774 | + # type: () -> Decimal |
| 775 | + """ |
| 776 | + Return the inner Decimal value. |
| 777 | + """ |
| 778 | + return self._value |
| 779 | + |
| 780 | + def __str__(self): |
| 781 | + # type: () -> str |
| 782 | + """ |
| 783 | + Return a string representation of the SampleRandValue. |
| 784 | +
|
| 785 | + The string representation has 6 decimal places. |
| 786 | + """ |
| 787 | + # Lint E231 is a false-positive here. If we add a space after the :, |
| 788 | + # then the formatter puts an extra space before the decimal numbers. |
| 789 | + return f"{self._value:.6f}" # noqa: E231 |
| 790 | + |
| 791 | + |
646 | 792 | def should_propagate_trace(client, url):
|
647 | 793 | # type: (sentry_sdk.client.BaseClient, str) -> bool
|
648 |
| - """ |
| 794 | + """u |
649 | 795 | Returns True if url matches trace_propagation_targets configured in the given client. Otherwise, returns False.
|
650 | 796 | """
|
651 | 797 | trace_propagation_targets = client.options["trace_propagation_targets"]
|
@@ -748,6 +894,21 @@ def get_current_span(scope=None):
|
748 | 894 | return current_span
|
749 | 895 |
|
750 | 896 |
|
| 897 | +def _sample_rand_range(parent_sampled, sample_rate): |
| 898 | + # type: (Optional[bool], Optional[Decimal]) -> tuple[Decimal, Decimal] |
| 899 | + """ |
| 900 | + Compute the lower (inclusive) and upper (exclusive) bounds of the range of values |
| 901 | + that a generated sample_rand value must fall into, given the parent_sampled and |
| 902 | + sample_rate values. |
| 903 | + """ |
| 904 | + if parent_sampled is None or sample_rate is None: |
| 905 | + return Decimal(0), Decimal(1) |
| 906 | + elif parent_sampled is True: |
| 907 | + return Decimal(0), sample_rate |
| 908 | + else: # parent_sampled is False |
| 909 | + return sample_rate, Decimal(1) |
| 910 | + |
| 911 | + |
751 | 912 | # Circular imports
|
752 | 913 | from sentry_sdk.tracing import (
|
753 | 914 | BAGGAGE_HEADER_NAME,
|
|
0 commit comments