From 49a5351eeaa33a5e92d27d1eb7da9cf60a366769 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 7 Aug 2025 13:15:06 +0900 Subject: [PATCH 1/9] Add experimental consistent sampler --- .../trace/_sampling_experimental/__init__.py | 17 ++ .../_sampling_experimental/_always_off.py | 37 +++++ .../_sampling_experimental/_always_on.py | 35 ++++ .../_sampling_experimental/_composable.py | 38 +++++ .../_fixed_threshold.py | 53 ++++++ .../_sampling_experimental/_parent_based.py | 65 ++++++++ .../trace/_sampling_experimental/_sampler.py | 83 ++++++++++ .../_sampling_experimental/_trace_state.py | 120 ++++++++++++++ .../sdk/trace/_sampling_experimental/_util.py | 22 +++ opentelemetry-sdk/tests/conftest.py | 9 + .../consistent_sampler/test_always_off.py | 37 +++++ .../consistent_sampler/test_always_on.py | 35 ++++ .../test_fixed_threshold.py | 60 +++++++ .../trace/consistent_sampler/test_sampler.py | 155 ++++++++++++++++++ .../consistent_sampler/test_tracestate.py | 55 +++++++ .../trace/consistent_sampler/testutil.py | 5 + 16 files changed, 826 insertions(+) create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_off.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_on.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_fixed_threshold.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_based.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_util.py create mode 100644 opentelemetry-sdk/tests/trace/consistent_sampler/test_always_off.py create mode 100644 opentelemetry-sdk/tests/trace/consistent_sampler/test_always_on.py create mode 100644 opentelemetry-sdk/tests/trace/consistent_sampler/test_fixed_threshold.py create mode 100644 opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py create mode 100644 opentelemetry-sdk/tests/trace/consistent_sampler/test_tracestate.py create mode 100644 opentelemetry-sdk/tests/trace/consistent_sampler/testutil.py diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py new file mode 100644 index 00000000000..4022667957b --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py @@ -0,0 +1,17 @@ +__all__ = [ + "ComposableSampler", + "ConsistentSampler", + "SamplingIntent", + "consistent_always_off", + "consistent_always_on", + "consistent_parent_based", + "consistent_probability_based", +] + + +from ._always_off import consistent_always_off +from ._always_on import consistent_always_on +from ._composable import ComposableSampler, SamplingIntent +from ._fixed_threshold import consistent_probability_based +from ._parent_based import consistent_parent_based +from ._sampler import ConsistentSampler diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_off.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_off.py new file mode 100644 index 00000000000..f8b2d9318ad --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_off.py @@ -0,0 +1,37 @@ +from typing import Optional, Sequence + +from opentelemetry.context import Context +from opentelemetry.trace import Link, SpanKind, TraceState +from opentelemetry.util.types import Attributes + +from ._composable import ComposableSampler, SamplingIntent +from ._sampler import ConsistentSampler +from ._util import INVALID_THRESHOLD + +_intent = SamplingIntent( + threshold=INVALID_THRESHOLD, adjusted_count_reliable=False +) + + +class ConsistentAlwaysOffSampler(ComposableSampler): + def sampling_intent( + self, + parent_ctx: Optional[Context], + name: str, + span_kind: Optional[SpanKind], + attributes: Attributes, + links: Optional[Sequence[Link]], + trace_state: Optional[TraceState] = None, + ) -> SamplingIntent: + return _intent + + def get_description(self) -> str: + return "ConsistentAlwaysOffSampler" + + +_always_off = ConsistentSampler(ConsistentAlwaysOffSampler()) + + +def consistent_always_off() -> ConsistentSampler: + """Returns a consistent sampler that does not sample any span.""" + return _always_off diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_on.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_on.py new file mode 100644 index 00000000000..2586c6bdeb0 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_on.py @@ -0,0 +1,35 @@ +from typing import Optional, Sequence + +from opentelemetry.context import Context +from opentelemetry.trace import Link, SpanKind, TraceState +from opentelemetry.util.types import Attributes + +from ._composable import ComposableSampler, SamplingIntent +from ._sampler import ConsistentSampler +from ._util import MIN_THRESHOLD + +_intent = SamplingIntent(threshold=MIN_THRESHOLD) + + +class ConsistentAlwaysOnSampler(ComposableSampler): + def sampling_intent( + self, + parent_ctx: Optional[Context], + name: str, + span_kind: Optional[SpanKind], + attributes: Attributes, + links: Optional[Sequence[Link]], + trace_state: Optional[TraceState] = None, + ) -> SamplingIntent: + return _intent + + def get_description(self) -> str: + return "ConsistentAlwaysOnSampler" + + +_always_on = ConsistentSampler(ConsistentAlwaysOnSampler()) + + +def consistent_always_on() -> ConsistentSampler: + """Returns a consistent sampler that samples all spans.""" + return _always_on diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py new file mode 100644 index 00000000000..508d574e772 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass, field +from typing import Callable, Optional, Protocol, Sequence + +from opentelemetry.context import Context +from opentelemetry.trace import Link, SpanKind, TraceState +from opentelemetry.util.types import Attributes + + +@dataclass(frozen=True) +class SamplingIntent: + """Information to make a consistent sampling decision.""" + + threshold: int + adjusted_count_reliable: bool = field(default=True) + attributes: Attributes = field(default=None) + update_trace_state: Callable[[TraceState], TraceState] = field( + default=lambda ts: ts + ) + + +class ComposableSampler(Protocol): + """A sampler that can be composed to make a final consistent sampling decision.""" + + def sampling_intent( + self, + parent_ctx: Optional[Context], + name: str, + span_kind: Optional[SpanKind], + attributes: Attributes, + links: Optional[Sequence[Link]], + trace_state: Optional[TraceState], + ) -> SamplingIntent: + """Returns information to make a consistent sampling decision.""" + ... + + def get_description(self) -> str: + """Returns a description of the sampler.""" + ... diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_fixed_threshold.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_fixed_threshold.py new file mode 100644 index 00000000000..84eaa562715 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_fixed_threshold.py @@ -0,0 +1,53 @@ +from typing import Optional, Sequence + +from opentelemetry.context import Context +from opentelemetry.trace import Link, SpanKind, TraceState +from opentelemetry.util.types import Attributes + +from ._composable import ComposableSampler, SamplingIntent +from ._sampler import ConsistentSampler +from ._trace_state import serialize_th +from ._util import INVALID_THRESHOLD, MAX_THRESHOLD, calculate_threshold + + +class ConsistentFixedThresholdSampler(ComposableSampler): + _threshold: int + _description: str + + def __init__(self, sampling_probability: float): + threshold = calculate_threshold(sampling_probability) + if threshold == MAX_THRESHOLD: + threshold_str = "max" + else: + threshold_str = serialize_th(threshold) + threshold = ( + INVALID_THRESHOLD if threshold == MAX_THRESHOLD else threshold + ) + self._intent = SamplingIntent(threshold=threshold) + self._description = f"ConsistentFixedThresholdSampler{{threshold={threshold_str}, sampling probability={sampling_probability}}}" + + def sampling_intent( + self, + parent_ctx: Optional[Context], + name: str, + span_kind: Optional[SpanKind], + attributes: Attributes, + links: Optional[Sequence[Link]], + trace_state: Optional[TraceState] = None, + ) -> SamplingIntent: + return self._intent + + def get_description(self) -> str: + return self._description + + +def consistent_probability_based( + sampling_probability: float, +) -> ConsistentSampler: + """Returns a consistent sampler that samples each span with a fixed probability.""" + if not (0.0 <= sampling_probability <= 1.0): + raise ValueError("Sampling probability must be between 0.0 and 1.0") + + return ConsistentSampler( + ConsistentFixedThresholdSampler(sampling_probability) + ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_based.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_based.py new file mode 100644 index 00000000000..ccd1eb8bd75 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_based.py @@ -0,0 +1,65 @@ +from typing import Optional, Sequence + +from opentelemetry.context import Context +from opentelemetry.trace import Link, SpanKind, TraceState, get_current_span +from opentelemetry.util.types import Attributes + +from ._composable import ComposableSampler, SamplingIntent +from ._sampler import ConsistentSampler +from ._trace_state import OtelTraceState +from ._util import ( + INVALID_THRESHOLD, + MIN_THRESHOLD, + is_valid_threshold, +) + + +class ConsistentParentBasedSampler(ComposableSampler): + def __init__(self, root_sampler: ComposableSampler): + self._root_sampler = root_sampler + self._description = f"ConsistentParentBasedSampler{{root_sampler={root_sampler.get_description()}}}" + + def sampling_intent( + self, + parent_ctx: Optional[Context], + name: str, + span_kind: Optional[SpanKind], + attributes: Attributes, + links: Optional[Sequence[Link]], + trace_state: Optional[TraceState] = None, + ) -> SamplingIntent: + parent_span = get_current_span(parent_ctx) + parent_span_ctx = parent_span.get_span_context() + is_root = not parent_span_ctx.is_valid + if is_root: + return self._root_sampler.sampling_intent( + parent_ctx, name, span_kind, attributes, links, trace_state + ) + + ot_trace_state = OtelTraceState.parse(trace_state) + + if is_valid_threshold(ot_trace_state.threshold): + return SamplingIntent( + threshold=ot_trace_state.threshold, + adjusted_count_reliable=True, + ) + else: + threshold = ( + MIN_THRESHOLD + if parent_span_ctx.trace_flags.sampled + else INVALID_THRESHOLD + ) + return SamplingIntent( + threshold=threshold, adjusted_count_reliable=False + ) + + def get_description(self) -> str: + return self._description + + +def consistent_parent_based( + root_sampler: ComposableSampler, +) -> ConsistentSampler: + """Returns a consistent sampler that respects the sampling decision of + the parent span or falls-back to the given sampler if it is a root span.""" + return ConsistentSampler(ConsistentParentBasedSampler(root_sampler)) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py new file mode 100644 index 00000000000..527684f7111 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py @@ -0,0 +1,83 @@ +from typing import Optional, Sequence + +from opentelemetry.context import Context +from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult +from opentelemetry.trace import Link, SpanKind, TraceState +from opentelemetry.util.types import Attributes + +from ._composable import ComposableSampler, SamplingIntent +from ._trace_state import OTEL_TRACE_STATE_KEY, OtelTraceState +from ._util import INVALID_THRESHOLD, is_valid_random_value, is_valid_threshold + + +class ConsistentSampler(Sampler, ComposableSampler): + """A sampler that uses a consistent sampling strategy based on a delegate sampler.""" + + def __init__(self, delegate: ComposableSampler): + self._delegate = delegate + + def should_sample( + self, + parent_context: Optional[Context], + trace_id: int, + name: str, + kind: Optional[SpanKind] = None, + attributes: Attributes = None, + links: Optional[Sequence[Link]] = None, + trace_state: Optional[TraceState] = None, + ) -> SamplingResult: + ot_trace_state = OtelTraceState.parse(trace_state) + + intent = self._delegate.sampling_intent( + parent_context, name, kind, attributes, links, trace_state + ) + threshold = intent.threshold + + if is_valid_threshold(threshold): + adjusted_count_correct = intent.adjusted_count_reliable + if is_valid_random_value(ot_trace_state.random_value): + randomness = ot_trace_state.random_value + else: + # Use last 56 bits of trace_id as randomness + randomness = trace_id & 0x00FFFFFFFFFFFFFF + sampled = threshold <= randomness + else: + sampled = False + adjusted_count_correct = False + + decision = Decision.RECORD_AND_SAMPLE if sampled else Decision.DROP + if sampled and adjusted_count_correct: + ot_trace_state.threshold = threshold + else: + ot_trace_state.threshold = INVALID_THRESHOLD + + otts = ot_trace_state.serialize() + if not trace_state: + if otts: + new_trace_state = TraceState(((OTEL_TRACE_STATE_KEY, otts),)) + else: + new_trace_state = None + else: + new_trace_state = intent.update_trace_state(trace_state) + if otts: + new_trace_state = new_trace_state.update( + OTEL_TRACE_STATE_KEY, otts + ) + + return SamplingResult(decision, intent.attributes, new_trace_state) + + def sampling_intent( + self, + parent_ctx: Optional[Context], + name: str, + span_kind: Optional[SpanKind], + attributes: Attributes, + links: Optional[Sequence[Link]], + trace_state: Optional[TraceState], + ) -> SamplingIntent: + return self._delegate.sampling_intent( + parent_ctx, name, span_kind, attributes, links, trace_state + ) + + def get_description(self) -> str: + return self._delegate.get_description() diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py new file mode 100644 index 00000000000..ada8fc04928 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py @@ -0,0 +1,120 @@ +from dataclasses import dataclass +from typing import Optional, Sequence + +from opentelemetry.trace import TraceState + +from ._util import ( + INVALID_RANDOM_VALUE, + INVALID_THRESHOLD, + MAX_THRESHOLD, + is_valid_random_value, + is_valid_threshold, +) + +OTEL_TRACE_STATE_KEY = "ot" + +_TRACE_STATE_SIZE_LIMIT = 256 +_MAX_VALUE_LENGTH = 14 # 56 bits, 4 bits per hex digit + + +@dataclass +class OtelTraceState: + random_value: int + threshold: int + rest: Sequence[str] + + @staticmethod + def invalid() -> "OtelTraceState": + return OtelTraceState(INVALID_RANDOM_VALUE, INVALID_THRESHOLD, ()) + + @staticmethod + def parse(trace_state: Optional[TraceState]) -> "OtelTraceState": + if not trace_state: + return OtelTraceState.invalid() + + ot = trace_state.get(OTEL_TRACE_STATE_KEY, "") + + if not ot or len(ot) > _TRACE_STATE_SIZE_LIMIT: + return OtelTraceState.invalid() + + threshold = INVALID_THRESHOLD + random_value = INVALID_RANDOM_VALUE + + members = ot.split(";") + rest: Optional[list[str]] = None + for member in members: + if member.startswith("th:"): + threshold = _parse_th(member[len("th:") :], INVALID_THRESHOLD) + continue + if member.startswith("rv:"): + random_value = _parse_rv( + member[len("rv:") :], INVALID_RANDOM_VALUE + ) + continue + if rest is None: + rest = [member] + else: + rest.append(member) + + return OtelTraceState( + random_value=random_value, threshold=threshold, rest=rest or () + ) + + def serialize(self) -> str: + if ( + not is_valid_threshold(self.threshold) + and not is_valid_random_value(self.random_value) + and not self.rest + ): + return "" + + parts: list[str] = [] + if ( + is_valid_threshold(self.threshold) + and self.threshold != MAX_THRESHOLD + ): + parts.append(f"th:{serialize_th(self.threshold)}") + if is_valid_random_value(self.random_value): + parts.append(f"rv:{_serialize_rv(self.random_value)}") + if self.rest: + parts.extend(self.rest) + res = ";".join(parts) + while len(res) > _TRACE_STATE_SIZE_LIMIT: + delim_idx = res.rfind(";") + if delim_idx == -1: + break + res = res[:delim_idx] + return res + + +def _parse_th(value: str, default: int) -> int: + if not value or len(value) > _MAX_VALUE_LENGTH: + return default + + try: + parsed = int(value, 16) + except ValueError: + return default + + trailing_zeros = _MAX_VALUE_LENGTH - len(value) + return parsed << (trailing_zeros * 4) + + +def _parse_rv(value: str, default: int) -> int: + if not value or len(value) != _MAX_VALUE_LENGTH: + return default + + try: + return int(value, 16) + except ValueError: + return default + + +def serialize_th(threshold: int) -> str: + if not threshold: + return "0" + return f"{threshold:014x}".rstrip("0") + + +def _serialize_rv(random_value: int) -> str: + return f"{random_value:014x}" diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_util.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_util.py new file mode 100644 index 00000000000..5b2a3f169e1 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_util.py @@ -0,0 +1,22 @@ +RANDOM_VALUE_BITS = 56 +MAX_THRESHOLD = 1 << RANDOM_VALUE_BITS # 0% sampling +MIN_THRESHOLD = 0 # 100% sampling +MAX_RANDOM_VALUE = MAX_THRESHOLD - 1 +INVALID_THRESHOLD = -1 +INVALID_RANDOM_VALUE = -1 + +_probability_threshold_scale = float.fromhex("0x1p56") + + +def calculate_threshold(sampling_probability: float) -> int: + return MAX_THRESHOLD - round( + sampling_probability * _probability_threshold_scale + ) + + +def is_valid_threshold(threshold: int) -> bool: + return MIN_THRESHOLD <= threshold <= MAX_THRESHOLD + + +def is_valid_random_value(random_value: int) -> bool: + return 0 <= random_value <= MAX_RANDOM_VALUE diff --git a/opentelemetry-sdk/tests/conftest.py b/opentelemetry-sdk/tests/conftest.py index 92fd7a734de..b0b633982e0 100644 --- a/opentelemetry-sdk/tests/conftest.py +++ b/opentelemetry-sdk/tests/conftest.py @@ -12,8 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import random from os import environ +import pytest + from opentelemetry.environment_variables import OTEL_PYTHON_CONTEXT @@ -25,3 +28,9 @@ def pytest_sessionstart(session): def pytest_sessionfinish(session): # pylint: disable=unused-argument environ.pop(OTEL_PYTHON_CONTEXT) + + +@pytest.fixture(autouse=True) +def random_seed(): + # We use random numbers a lot in sampling tests, make sure they are always the same. + random.seed(0) diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_off.py b/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_off.py new file mode 100644 index 00000000000..3d016a148aa --- /dev/null +++ b/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_off.py @@ -0,0 +1,37 @@ +from testutil import random_trace_id + +from opentelemetry.sdk.trace._sampling_experimental import ( + consistent_always_off, +) +from opentelemetry.sdk.trace.sampling import Decision + + +def test_description(): + assert ( + consistent_always_off().get_description() + == "ConsistentAlwaysOffSampler" + ) + + +def test_threshold(): + assert ( + consistent_always_off() + .sampling_intent(None, "test", None, {}, None, None) + .threshold + == -1 + ) + + +def test_sampling(): + sampler = consistent_always_off() + + num_sampled = 0 + for _ in range(10000): + res = sampler.should_sample( + None, random_trace_id(), "span", None, None, None, None + ) + if res.decision == Decision.RECORD_AND_SAMPLE: + num_sampled += 1 + assert not res.trace_state + + assert num_sampled == 0 diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_on.py b/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_on.py new file mode 100644 index 00000000000..19572e3eaab --- /dev/null +++ b/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_on.py @@ -0,0 +1,35 @@ +from testutil import random_trace_id + +from opentelemetry.sdk.trace._sampling_experimental import consistent_always_on +from opentelemetry.sdk.trace.sampling import Decision + + +def test_description(): + assert ( + consistent_always_on().get_description() == "ConsistentAlwaysOnSampler" + ) + + +def test_threshold(): + assert ( + consistent_always_on() + .sampling_intent(None, "test", None, {}, None, None) + .threshold + == 0 + ) + + +def test_sampling(): + sampler = consistent_always_on() + + num_sampled = 0 + for _ in range(10000): + res = sampler.should_sample( + None, random_trace_id(), "span", None, None, None, None + ) + if res.decision == Decision.RECORD_AND_SAMPLE: + num_sampled += 1 + assert res.trace_state is not None + assert res.trace_state.get("ot", "") == "th:0" + + assert num_sampled == 10000 diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_fixed_threshold.py b/opentelemetry-sdk/tests/trace/consistent_sampler/test_fixed_threshold.py new file mode 100644 index 00000000000..8cc32a04df2 --- /dev/null +++ b/opentelemetry-sdk/tests/trace/consistent_sampler/test_fixed_threshold.py @@ -0,0 +1,60 @@ +import pytest +from testutil import random_trace_id + +from opentelemetry.sdk.trace._sampling_experimental import ( + consistent_probability_based, +) +from opentelemetry.sdk.trace._sampling_experimental._trace_state import ( + OtelTraceState, +) +from opentelemetry.sdk.trace.sampling import Decision + + +@pytest.mark.parametrize( + "probability,threshold", + ( + (1.0, "0"), + (0.5, "8"), + (0.25, "c"), + (1e-300, "max"), + (0, "max"), + ), +) +def test_description(probability: float, threshold: str): + assert ( + consistent_probability_based(probability).get_description() + == f"ConsistentFixedThresholdSampler{{threshold={threshold}, sampling probability={probability}}}" + ) + + +@pytest.mark.parametrize( + "probability,threshold", + ( + (1.0, 0), + (0.5, 36028797018963968), + (0.25, 54043195528445952), + (0.125, 63050394783186944), + (0.0, 72057594037927936), + (0.45, 39631676720860364), + (0.2, 57646075230342348), + (0.13, 62690106812997304), + (0.05, 68454714336031539), + ), +) +def test_sampling(probability: float, threshold: int): + sampler = consistent_probability_based(probability) + + num_sampled = 0 + for _ in range(10000): + res = sampler.should_sample( + None, random_trace_id(), "span", None, None, None, None + ) + if res.decision == Decision.RECORD_AND_SAMPLE: + num_sampled += 1 + assert res.trace_state is not None + otts = OtelTraceState.parse(res.trace_state) + assert otts.threshold == threshold + assert otts.random_value == -1 + + expected_num_sampled = int(10000 * probability) + assert abs(num_sampled - expected_num_sampled) < 50 diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py b/opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py new file mode 100644 index 00000000000..02bce0b1d85 --- /dev/null +++ b/opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py @@ -0,0 +1,155 @@ +from typing import Optional + +import pytest +from pytest import param as p + +from opentelemetry.sdk.trace._sampling_experimental import ( + consistent_always_off, + consistent_always_on, + consistent_parent_based, + consistent_probability_based, +) +from opentelemetry.sdk.trace._sampling_experimental._trace_state import ( + OtelTraceState, +) +from opentelemetry.sdk.trace._sampling_experimental._util import ( + INVALID_RANDOM_VALUE, + INVALID_THRESHOLD, +) +from opentelemetry.sdk.trace.sampling import Decision, Sampler +from opentelemetry.trace import ( + NonRecordingSpan, + SpanContext, + TraceFlags, + TraceState, + set_span_in_context, +) + +TRACE_ID = int("00112233445566778800000000000000", 16) +SPAN_ID = int("0123456789abcdef", 16) + + +@pytest.mark.parametrize( + "sampler,parent_sampled,parent_threshold,parent_random_value,sampled,threshold,random_value", + ( + p( + consistent_always_on(), + True, + None, + None, + True, + 0, + INVALID_RANDOM_VALUE, + id="min threshold no parent random value", + ), + p( + consistent_always_on(), + True, + None, + 0x7F99AA40C02744, + True, + 0, + 0x7F99AA40C02744, + id="min threshold with parent random value", + ), + p( + consistent_always_off(), + True, + None, + None, + False, + INVALID_THRESHOLD, + INVALID_RANDOM_VALUE, + id="max threshold", + ), + p( + consistent_parent_based(consistent_always_on()), + False, # should be ignored + 0x7F99AA40C02744, + 0x7F99AA40C02744, + True, + 0x7F99AA40C02744, + 0x7F99AA40C02744, + id="parent based in consistent mode", + ), + p( + consistent_parent_based(consistent_always_on()), + True, + None, + None, + True, + INVALID_THRESHOLD, + INVALID_RANDOM_VALUE, + id="parent based in legacy mode", + ), + p( + consistent_probability_based(0.5), + True, + None, + 0x7FFFFFFFFFFFFF, + False, + INVALID_THRESHOLD, + 0x7FFFFFFFFFFFFF, + id="half threshold not sampled", + ), + p( + consistent_probability_based(0.5), + False, + None, + 0x80000000000000, + True, + 0x80000000000000, + 0x80000000000000, + id="half threshold sampled", + ), + p( + consistent_probability_based(1.0), + False, + 0x80000000000000, + 0x80000000000000, + True, + 0, + 0x80000000000000, + id="half threshold sampled", + ), + ), +) +def test_sample( + sampler: Sampler, + parent_sampled: bool, + parent_threshold: Optional[int], + parent_random_value: Optional[int], + sampled: bool, + threshold: float, + random_value: float, +): + parent_state = OtelTraceState.invalid() + if parent_threshold is not None: + parent_state.threshold = parent_threshold + if parent_random_value is not None: + parent_state.random_value = parent_random_value + parent_state_str = parent_state.serialize() + parent_trace_state = ( + TraceState((("ot", parent_state_str),)) if parent_state_str else None + ) + flags = ( + TraceFlags(TraceFlags.SAMPLED) + if parent_sampled + else TraceFlags.get_default() + ) + parent_span_context = SpanContext( + TRACE_ID, SPAN_ID, False, flags, parent_trace_state + ) + parent_span = NonRecordingSpan(parent_span_context) + parent_context = set_span_in_context(parent_span) + + result = sampler.should_sample( + parent_context, TRACE_ID, "name", trace_state=parent_trace_state + ) + + decision = Decision.RECORD_AND_SAMPLE if sampled else Decision.DROP + state = OtelTraceState.parse(result.trace_state) + + assert result.decision == decision + assert state.threshold == threshold + assert state.random_value == random_value diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_tracestate.py b/opentelemetry-sdk/tests/trace/consistent_sampler/test_tracestate.py new file mode 100644 index 00000000000..ffb960b56c7 --- /dev/null +++ b/opentelemetry-sdk/tests/trace/consistent_sampler/test_tracestate.py @@ -0,0 +1,55 @@ +import pytest + +from opentelemetry.sdk.trace._sampling_experimental._trace_state import ( + OtelTraceState, +) +from opentelemetry.trace import TraceState + + +@pytest.mark.parametrize( + "input_str,output_str", + ( + ("a", "a"), + ("#", "#"), + ("rv:1234567890abcd", "rv:1234567890abcd"), + ("rv:01020304050607", "rv:01020304050607"), + ("rv:1234567890abcde", ""), + ("th:1234567890abcd", "th:1234567890abcd"), + ("th:1234567890abcd", "th:1234567890abcd"), + ("th:10000000000000", "th:1"), + ("th:1234500000000", "th:12345"), + ("th:0", "th:0"), + ("th:100000000000000", ""), + ("th:1234567890abcde", ""), + pytest.param( + f"a:{'X' * 214};rv:1234567890abcd;th:1234567890abcd;x:3", + f"th:1234567890abcd;rv:1234567890abcd;a:{'X' * 214};x:3", + id="long", + ), + ("th:x", ""), + ("th:100000000000000", ""), + ("th:10000000000000", "th:1"), + ("th:1000000000000", "th:1"), + ("th:100000000000", "th:1"), + ("th:10000000000", "th:1"), + ("th:1000000000", "th:1"), + ("th:100000000", "th:1"), + ("th:10000000", "th:1"), + ("th:1000000", "th:1"), + ("th:100000", "th:1"), + ("th:10000", "th:1"), + ("th:1000", "th:1"), + ("th:100", "th:1"), + ("th:10", "th:1"), + ("th:1", "th:1"), + ("th:10000000000001", "th:10000000000001"), + ("th:10000000000010", "th:1000000000001"), + ("rv:x", ""), + ("rv:100000000000000", ""), + ("rv:10000000000000", "rv:10000000000000"), + ("rv:1000000000000", ""), + ), +) +def test_marshal(input_str: str, output_str: str): + state = OtelTraceState.parse(TraceState((("ot", input_str),))).serialize() + assert state == output_str diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/testutil.py b/opentelemetry-sdk/tests/trace/consistent_sampler/testutil.py new file mode 100644 index 00000000000..6b9589fe697 --- /dev/null +++ b/opentelemetry-sdk/tests/trace/consistent_sampler/testutil.py @@ -0,0 +1,5 @@ +import random + + +def random_trace_id() -> int: + return random.getrandbits(128) From da6d90e784beb73a130de3701989913b998e23a9 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Fri, 8 Aug 2025 09:26:43 +0900 Subject: [PATCH 2/9] Fix import --- opentelemetry-sdk/tests/trace/consistent_sampler/__init__.py | 0 .../tests/trace/consistent_sampler/test_always_off.py | 4 ++-- .../tests/trace/consistent_sampler/test_always_on.py | 4 ++-- .../tests/trace/consistent_sampler/test_fixed_threshold.py | 3 ++- 4 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 opentelemetry-sdk/tests/trace/consistent_sampler/__init__.py diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/__init__.py b/opentelemetry-sdk/tests/trace/consistent_sampler/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_off.py b/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_off.py index 3d016a148aa..0bfa986b363 100644 --- a/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_off.py +++ b/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_off.py @@ -1,10 +1,10 @@ -from testutil import random_trace_id - from opentelemetry.sdk.trace._sampling_experimental import ( consistent_always_off, ) from opentelemetry.sdk.trace.sampling import Decision +from .testutil import random_trace_id + def test_description(): assert ( diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_on.py b/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_on.py index 19572e3eaab..5d1f1ea212c 100644 --- a/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_on.py +++ b/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_on.py @@ -1,8 +1,8 @@ -from testutil import random_trace_id - from opentelemetry.sdk.trace._sampling_experimental import consistent_always_on from opentelemetry.sdk.trace.sampling import Decision +from .testutil import random_trace_id + def test_description(): assert ( diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_fixed_threshold.py b/opentelemetry-sdk/tests/trace/consistent_sampler/test_fixed_threshold.py index 8cc32a04df2..a71d64f096b 100644 --- a/opentelemetry-sdk/tests/trace/consistent_sampler/test_fixed_threshold.py +++ b/opentelemetry-sdk/tests/trace/consistent_sampler/test_fixed_threshold.py @@ -1,5 +1,4 @@ import pytest -from testutil import random_trace_id from opentelemetry.sdk.trace._sampling_experimental import ( consistent_probability_based, @@ -9,6 +8,8 @@ ) from opentelemetry.sdk.trace.sampling import Decision +from .testutil import random_trace_id + @pytest.mark.parametrize( "probability,threshold", From 3af3709b2d4983af593c2f68a78f08a459b051a9 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Fri, 8 Aug 2025 09:45:07 +0900 Subject: [PATCH 3/9] Fix lint --- .../_sampling_experimental/_composable.py | 2 - .../_fixed_threshold.py | 2 +- .../_sampling_experimental/_parent_based.py | 18 +- .../trace/_sampling_experimental/_sampler.py | 35 ++-- .../trace/consistent_sampler/test_sampler.py | 180 ++++++++++-------- 5 files changed, 136 insertions(+), 101 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py index 508d574e772..c056e0577a5 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py @@ -31,8 +31,6 @@ def sampling_intent( trace_state: Optional[TraceState], ) -> SamplingIntent: """Returns information to make a consistent sampling decision.""" - ... def get_description(self) -> str: """Returns a description of the sampler.""" - ... diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_fixed_threshold.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_fixed_threshold.py index 84eaa562715..aa09c276b81 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_fixed_threshold.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_fixed_threshold.py @@ -45,7 +45,7 @@ def consistent_probability_based( sampling_probability: float, ) -> ConsistentSampler: """Returns a consistent sampler that samples each span with a fixed probability.""" - if not (0.0 <= sampling_probability <= 1.0): + if not 0.0 <= sampling_probability <= 1.0: raise ValueError("Sampling probability must be between 0.0 and 1.0") return ConsistentSampler( diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_based.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_based.py index ccd1eb8bd75..376b979eb59 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_based.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_based.py @@ -43,15 +43,15 @@ def sampling_intent( threshold=ot_trace_state.threshold, adjusted_count_reliable=True, ) - else: - threshold = ( - MIN_THRESHOLD - if parent_span_ctx.trace_flags.sampled - else INVALID_THRESHOLD - ) - return SamplingIntent( - threshold=threshold, adjusted_count_reliable=False - ) + + threshold = ( + MIN_THRESHOLD + if parent_span_ctx.trace_flags.sampled + else INVALID_THRESHOLD + ) + return SamplingIntent( + threshold=threshold, adjusted_count_reliable=False + ) def get_description(self) -> str: return self._description diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py index 527684f7111..9eceacc402d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py @@ -51,20 +51,11 @@ def should_sample( else: ot_trace_state.threshold = INVALID_THRESHOLD - otts = ot_trace_state.serialize() - if not trace_state: - if otts: - new_trace_state = TraceState(((OTEL_TRACE_STATE_KEY, otts),)) - else: - new_trace_state = None - else: - new_trace_state = intent.update_trace_state(trace_state) - if otts: - new_trace_state = new_trace_state.update( - OTEL_TRACE_STATE_KEY, otts - ) - - return SamplingResult(decision, intent.attributes, new_trace_state) + return SamplingResult( + decision, + intent.attributes, + _update_trace_state(trace_state, ot_trace_state, intent), + ) def sampling_intent( self, @@ -81,3 +72,19 @@ def sampling_intent( def get_description(self) -> str: return self._delegate.get_description() + + +def _update_trace_state( + trace_state: Optional[TraceState], + ot_trace_state: OtelTraceState, + intent: SamplingIntent, +) -> Optional[TraceState]: + otts = ot_trace_state.serialize() + if not trace_state: + if otts: + return TraceState(((OTEL_TRACE_STATE_KEY, otts),)) + return None + new_trace_state = intent.update_trace_state(trace_state) + if otts: + return new_trace_state.update(OTEL_TRACE_STATE_KEY, otts) + return new_trace_state diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py b/opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py index 02bce0b1d85..b25571709d0 100644 --- a/opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py +++ b/opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Optional import pytest @@ -29,112 +30,141 @@ SPAN_ID = int("0123456789abcdef", 16) +@dataclass +class Input: + sampler: Sampler + sampled: bool + threshold: Optional[int] + random_value: Optional[int] + + +@dataclass +class Output: + sampled: bool + threshold: int + random_value: int + + @pytest.mark.parametrize( - "sampler,parent_sampled,parent_threshold,parent_random_value,sampled,threshold,random_value", + "input,output", ( p( - consistent_always_on(), - True, - None, - None, - True, - 0, - INVALID_RANDOM_VALUE, + Input( + sampler=consistent_always_on(), + sampled=True, + threshold=None, + random_value=None, + ), + Output( + sampled=True, threshold=0, random_value=INVALID_RANDOM_VALUE + ), id="min threshold no parent random value", ), p( - consistent_always_on(), - True, - None, - 0x7F99AA40C02744, - True, - 0, - 0x7F99AA40C02744, + Input( + sampler=consistent_always_on(), + sampled=True, + threshold=None, + random_value=0x7F99AA40C02744, + ), + Output(sampled=True, threshold=0, random_value=0x7F99AA40C02744), id="min threshold with parent random value", ), p( - consistent_always_off(), - True, - None, - None, - False, - INVALID_THRESHOLD, - INVALID_RANDOM_VALUE, + Input( + sampler=consistent_always_off(), + sampled=True, + threshold=None, + random_value=None, + ), + Output( + sampled=False, + threshold=INVALID_THRESHOLD, + random_value=INVALID_RANDOM_VALUE, + ), id="max threshold", ), p( - consistent_parent_based(consistent_always_on()), - False, # should be ignored - 0x7F99AA40C02744, - 0x7F99AA40C02744, - True, - 0x7F99AA40C02744, - 0x7F99AA40C02744, + Input( + sampler=consistent_parent_based(consistent_always_on()), + sampled=False, + threshold=0x7F99AA40C02744, + random_value=0x7F99AA40C02744, + ), + Output( + sampled=True, + threshold=0x7F99AA40C02744, + random_value=0x7F99AA40C02744, + ), id="parent based in consistent mode", ), p( - consistent_parent_based(consistent_always_on()), - True, - None, - None, - True, - INVALID_THRESHOLD, - INVALID_RANDOM_VALUE, + Input( + sampler=consistent_parent_based(consistent_always_on()), + sampled=True, + threshold=None, + random_value=None, + ), + Output( + sampled=True, + threshold=INVALID_THRESHOLD, + random_value=INVALID_RANDOM_VALUE, + ), id="parent based in legacy mode", ), p( - consistent_probability_based(0.5), - True, - None, - 0x7FFFFFFFFFFFFF, - False, - INVALID_THRESHOLD, - 0x7FFFFFFFFFFFFF, + Input( + sampler=consistent_probability_based(0.5), + sampled=True, + threshold=None, + random_value=0x7FFFFFFFFFFFFF, + ), + Output( + sampled=False, + threshold=INVALID_THRESHOLD, + random_value=0x7FFFFFFFFFFFFF, + ), id="half threshold not sampled", ), p( - consistent_probability_based(0.5), - False, - None, - 0x80000000000000, - True, - 0x80000000000000, - 0x80000000000000, + Input( + sampler=consistent_probability_based(0.5), + sampled=False, + threshold=None, + random_value=0x80000000000000, + ), + Output( + sampled=True, + threshold=0x80000000000000, + random_value=0x80000000000000, + ), id="half threshold sampled", ), p( - consistent_probability_based(1.0), - False, - 0x80000000000000, - 0x80000000000000, - True, - 0, - 0x80000000000000, + Input( + sampler=consistent_probability_based(1.0), + sampled=False, + threshold=0x80000000000000, + random_value=0x80000000000000, + ), + Output(sampled=True, threshold=0, random_value=0x80000000000000), id="half threshold sampled", ), ), ) -def test_sample( - sampler: Sampler, - parent_sampled: bool, - parent_threshold: Optional[int], - parent_random_value: Optional[int], - sampled: bool, - threshold: float, - random_value: float, -): +def test_sample(input: Input, output: Output): parent_state = OtelTraceState.invalid() - if parent_threshold is not None: - parent_state.threshold = parent_threshold - if parent_random_value is not None: - parent_state.random_value = parent_random_value + if input.threshold is not None: + parent_state.threshold = input.threshold + if input.random_value is not None: + parent_state.random_value = input.random_value parent_state_str = parent_state.serialize() parent_trace_state = ( TraceState((("ot", parent_state_str),)) if parent_state_str else None ) flags = ( TraceFlags(TraceFlags.SAMPLED) - if parent_sampled + if input.sampled else TraceFlags.get_default() ) parent_span_context = SpanContext( @@ -143,13 +173,13 @@ def test_sample( parent_span = NonRecordingSpan(parent_span_context) parent_context = set_span_in_context(parent_span) - result = sampler.should_sample( + result = input.sampler.should_sample( parent_context, TRACE_ID, "name", trace_state=parent_trace_state ) - decision = Decision.RECORD_AND_SAMPLE if sampled else Decision.DROP + decision = Decision.RECORD_AND_SAMPLE if output.sampled else Decision.DROP state = OtelTraceState.parse(result.trace_state) assert result.decision == decision - assert state.threshold == threshold - assert state.random_value == random_value + assert state.threshold == output.threshold + assert state.random_value == output.random_value From 38305dc98fb2f7697755573636f6570dd04c0a70 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Fri, 8 Aug 2025 09:46:17 +0900 Subject: [PATCH 4/9] CHANGELOG --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc9179da34..df6f6e25de1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Add experimental consistent samplers + ([#4714](https://github.com/open-telemetry/opentelemetry-python/pull/4714)) + ## Version 1.36.0/0.57b0 (2025-07-29) - Add missing Prometheus exporter documentation From 3c11c992c17c81d2afc204c4d59b5241ab66deaa Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Fri, 8 Aug 2025 13:37:49 +0900 Subject: [PATCH 5/9] Fix test name --- .../tests/trace/consistent_sampler/test_sampler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py b/opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py index b25571709d0..a826591ec54 100644 --- a/opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py +++ b/opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py @@ -148,7 +148,7 @@ class Output: random_value=0x80000000000000, ), Output(sampled=True, threshold=0, random_value=0x80000000000000), - id="half threshold sampled", + id="parent violating invariant", ), ), ) From 9890ddfb7be8c4f826810da0f6211022ebc5d9a1 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 14 Aug 2025 16:26:53 +0900 Subject: [PATCH 6/9] Cleanup and match spec better --- CHANGELOG.md | 2 +- .../trace/_sampling_experimental/__init__.py | 34 ++++--- .../_sampling_experimental/_always_off.py | 46 +++++++--- .../_sampling_experimental/_always_on.py | 42 ++++++--- .../_sampling_experimental/_composable.py | 39 ++++++-- .../_fixed_threshold.py | 53 ----------- .../_sampling_experimental/_parent_based.py | 65 -------------- .../_parent_threshold.py | 89 +++++++++++++++++++ .../trace/_sampling_experimental/_sampler.py | 61 +++++++------ .../_sampling_experimental/_trace_state.py | 27 +++++- .../_sampling_experimental/_traceid_ratio.py | 80 +++++++++++++++++ .../sdk/trace/_sampling_experimental/_util.py | 14 +++ .../__init__.py | 0 .../composite_sampler/test_always_off.py | 54 +++++++++++ .../trace/composite_sampler/test_always_on.py | 55 ++++++++++++ .../test_sampler.py | 53 +++++++---- .../composite_sampler/test_traceid_ratio.py | 81 +++++++++++++++++ .../test_tracestate.py | 14 +++ .../consistent_sampler/test_always_off.py | 37 -------- .../consistent_sampler/test_always_on.py | 35 -------- .../test_fixed_threshold.py | 61 ------------- .../trace/consistent_sampler/testutil.py | 5 -- 22 files changed, 601 insertions(+), 346 deletions(-) delete mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_fixed_threshold.py delete mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_based.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_threshold.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_traceid_ratio.py rename opentelemetry-sdk/tests/trace/{consistent_sampler => composite_sampler}/__init__.py (100%) create mode 100644 opentelemetry-sdk/tests/trace/composite_sampler/test_always_off.py create mode 100644 opentelemetry-sdk/tests/trace/composite_sampler/test_always_on.py rename opentelemetry-sdk/tests/trace/{consistent_sampler => composite_sampler}/test_sampler.py (75%) create mode 100644 opentelemetry-sdk/tests/trace/composite_sampler/test_traceid_ratio.py rename opentelemetry-sdk/tests/trace/{consistent_sampler => composite_sampler}/test_tracestate.py (75%) delete mode 100644 opentelemetry-sdk/tests/trace/consistent_sampler/test_always_off.py delete mode 100644 opentelemetry-sdk/tests/trace/consistent_sampler/test_always_on.py delete mode 100644 opentelemetry-sdk/tests/trace/consistent_sampler/test_fixed_threshold.py delete mode 100644 opentelemetry-sdk/tests/trace/consistent_sampler/testutil.py diff --git a/CHANGELOG.md b/CHANGELOG.md index df6f6e25de1..cf37896a285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- Add experimental consistent samplers +- Add experimental composite samplers ([#4714](https://github.com/open-telemetry/opentelemetry-python/pull/4714)) ## Version 1.36.0/0.57b0 (2025-07-29) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py index 4022667957b..1a8c372276d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py @@ -1,17 +1,31 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + __all__ = [ "ComposableSampler", - "ConsistentSampler", "SamplingIntent", - "consistent_always_off", - "consistent_always_on", - "consistent_parent_based", - "consistent_probability_based", + "composable_always_off", + "composable_always_on", + "composable_parent_threshold", + "composable_traceid_ratio_based", + "composite_sampler", ] -from ._always_off import consistent_always_off -from ._always_on import consistent_always_on +from ._always_off import composable_always_off +from ._always_on import composable_always_on from ._composable import ComposableSampler, SamplingIntent -from ._fixed_threshold import consistent_probability_based -from ._parent_based import consistent_parent_based -from ._sampler import ConsistentSampler +from ._parent_threshold import composable_parent_threshold +from ._sampler import composite_sampler +from ._traceid_ratio import composable_traceid_ratio_based diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_off.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_off.py index f8b2d9318ad..eaafe164161 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_off.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_off.py @@ -1,37 +1,55 @@ -from typing import Optional, Sequence +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Sequence from opentelemetry.context import Context from opentelemetry.trace import Link, SpanKind, TraceState from opentelemetry.util.types import Attributes from ._composable import ComposableSampler, SamplingIntent -from ._sampler import ConsistentSampler from ._util import INVALID_THRESHOLD -_intent = SamplingIntent( - threshold=INVALID_THRESHOLD, adjusted_count_reliable=False -) +_intent = SamplingIntent(threshold=INVALID_THRESHOLD, threshold_reliable=False) -class ConsistentAlwaysOffSampler(ComposableSampler): +class _ComposableAlwaysOffSampler(ComposableSampler): def sampling_intent( self, - parent_ctx: Optional[Context], + parent_ctx: Context | None, name: str, - span_kind: Optional[SpanKind], + span_kind: SpanKind | None, attributes: Attributes, - links: Optional[Sequence[Link]], - trace_state: Optional[TraceState] = None, + links: Sequence[Link] | None, + trace_state: TraceState | None = None, ) -> SamplingIntent: return _intent def get_description(self) -> str: - return "ConsistentAlwaysOffSampler" + return "ComposableAlwaysOff" -_always_off = ConsistentSampler(ConsistentAlwaysOffSampler()) +_always_off = _ComposableAlwaysOffSampler() -def consistent_always_off() -> ConsistentSampler: - """Returns a consistent sampler that does not sample any span.""" +def composable_always_off() -> ComposableSampler: + """Returns a composable sampler that does not sample any span. + + - Always returns a SamplingIntent with no threshold, indicating all spans should be dropped + - Sets threshold_reliable to false + - Does not add any attributes + """ return _always_off diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_on.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_on.py index 2586c6bdeb0..88ac61c5d37 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_on.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_always_on.py @@ -1,35 +1,55 @@ -from typing import Optional, Sequence +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Sequence from opentelemetry.context import Context from opentelemetry.trace import Link, SpanKind, TraceState from opentelemetry.util.types import Attributes from ._composable import ComposableSampler, SamplingIntent -from ._sampler import ConsistentSampler from ._util import MIN_THRESHOLD _intent = SamplingIntent(threshold=MIN_THRESHOLD) -class ConsistentAlwaysOnSampler(ComposableSampler): +class _ComposableAlwaysOnSampler(ComposableSampler): def sampling_intent( self, - parent_ctx: Optional[Context], + parent_ctx: Context | None, name: str, - span_kind: Optional[SpanKind], + span_kind: SpanKind | None, attributes: Attributes, - links: Optional[Sequence[Link]], - trace_state: Optional[TraceState] = None, + links: Sequence[Link] | None, + trace_state: TraceState | None = None, ) -> SamplingIntent: return _intent def get_description(self) -> str: - return "ConsistentAlwaysOnSampler" + return "ComposableAlwaysOn" -_always_on = ConsistentSampler(ConsistentAlwaysOnSampler()) +_always_on = _ComposableAlwaysOnSampler() -def consistent_always_on() -> ConsistentSampler: - """Returns a consistent sampler that samples all spans.""" +def composable_always_on() -> ComposableSampler: + """Returns a composable sampler that samples all spans. + + - Always returns a SamplingIntent with threshold set to sample all spans (threshold = 0) + - Sets threshold_reliable to true + - Does not add any attributes + """ return _always_on diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py index c056e0577a5..80c0462f9b7 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py @@ -1,5 +1,21 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + from dataclasses import dataclass, field -from typing import Callable, Optional, Protocol, Sequence +from typing import Callable, Protocol, Sequence from opentelemetry.context import Context from opentelemetry.trace import Link, SpanKind, TraceState @@ -11,26 +27,33 @@ class SamplingIntent: """Information to make a consistent sampling decision.""" threshold: int - adjusted_count_reliable: bool = field(default=True) + """The sampling threshold value. A lower threshold increases the likelihood of sampling.""" + + threshold_reliable: bool = field(default=True) + """Indicates whether the threshold is reliable for Span-to-Metrics estimation.""" + attributes: Attributes = field(default=None) + """Any attributes to be added to a sampled span.""" + update_trace_state: Callable[[TraceState], TraceState] = field( default=lambda ts: ts ) + """Any updates to be made to trace state.""" class ComposableSampler(Protocol): - """A sampler that can be composed to make a final consistent sampling decision.""" + """A sampler that can be composed to make a final sampling decision.""" def sampling_intent( self, - parent_ctx: Optional[Context], + parent_ctx: Context | None, name: str, - span_kind: Optional[SpanKind], + span_kind: SpanKind | None, attributes: Attributes, - links: Optional[Sequence[Link]], - trace_state: Optional[TraceState], + links: Sequence[Link] | None, + trace_state: TraceState | None, ) -> SamplingIntent: - """Returns information to make a consistent sampling decision.""" + """Returns information to make a sampling decision.""" def get_description(self) -> str: """Returns a description of the sampler.""" diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_fixed_threshold.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_fixed_threshold.py deleted file mode 100644 index aa09c276b81..00000000000 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_fixed_threshold.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Optional, Sequence - -from opentelemetry.context import Context -from opentelemetry.trace import Link, SpanKind, TraceState -from opentelemetry.util.types import Attributes - -from ._composable import ComposableSampler, SamplingIntent -from ._sampler import ConsistentSampler -from ._trace_state import serialize_th -from ._util import INVALID_THRESHOLD, MAX_THRESHOLD, calculate_threshold - - -class ConsistentFixedThresholdSampler(ComposableSampler): - _threshold: int - _description: str - - def __init__(self, sampling_probability: float): - threshold = calculate_threshold(sampling_probability) - if threshold == MAX_THRESHOLD: - threshold_str = "max" - else: - threshold_str = serialize_th(threshold) - threshold = ( - INVALID_THRESHOLD if threshold == MAX_THRESHOLD else threshold - ) - self._intent = SamplingIntent(threshold=threshold) - self._description = f"ConsistentFixedThresholdSampler{{threshold={threshold_str}, sampling probability={sampling_probability}}}" - - def sampling_intent( - self, - parent_ctx: Optional[Context], - name: str, - span_kind: Optional[SpanKind], - attributes: Attributes, - links: Optional[Sequence[Link]], - trace_state: Optional[TraceState] = None, - ) -> SamplingIntent: - return self._intent - - def get_description(self) -> str: - return self._description - - -def consistent_probability_based( - sampling_probability: float, -) -> ConsistentSampler: - """Returns a consistent sampler that samples each span with a fixed probability.""" - if not 0.0 <= sampling_probability <= 1.0: - raise ValueError("Sampling probability must be between 0.0 and 1.0") - - return ConsistentSampler( - ConsistentFixedThresholdSampler(sampling_probability) - ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_based.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_based.py deleted file mode 100644 index 376b979eb59..00000000000 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_based.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Optional, Sequence - -from opentelemetry.context import Context -from opentelemetry.trace import Link, SpanKind, TraceState, get_current_span -from opentelemetry.util.types import Attributes - -from ._composable import ComposableSampler, SamplingIntent -from ._sampler import ConsistentSampler -from ._trace_state import OtelTraceState -from ._util import ( - INVALID_THRESHOLD, - MIN_THRESHOLD, - is_valid_threshold, -) - - -class ConsistentParentBasedSampler(ComposableSampler): - def __init__(self, root_sampler: ComposableSampler): - self._root_sampler = root_sampler - self._description = f"ConsistentParentBasedSampler{{root_sampler={root_sampler.get_description()}}}" - - def sampling_intent( - self, - parent_ctx: Optional[Context], - name: str, - span_kind: Optional[SpanKind], - attributes: Attributes, - links: Optional[Sequence[Link]], - trace_state: Optional[TraceState] = None, - ) -> SamplingIntent: - parent_span = get_current_span(parent_ctx) - parent_span_ctx = parent_span.get_span_context() - is_root = not parent_span_ctx.is_valid - if is_root: - return self._root_sampler.sampling_intent( - parent_ctx, name, span_kind, attributes, links, trace_state - ) - - ot_trace_state = OtelTraceState.parse(trace_state) - - if is_valid_threshold(ot_trace_state.threshold): - return SamplingIntent( - threshold=ot_trace_state.threshold, - adjusted_count_reliable=True, - ) - - threshold = ( - MIN_THRESHOLD - if parent_span_ctx.trace_flags.sampled - else INVALID_THRESHOLD - ) - return SamplingIntent( - threshold=threshold, adjusted_count_reliable=False - ) - - def get_description(self) -> str: - return self._description - - -def consistent_parent_based( - root_sampler: ComposableSampler, -) -> ConsistentSampler: - """Returns a consistent sampler that respects the sampling decision of - the parent span or falls-back to the given sampler if it is a root span.""" - return ConsistentSampler(ConsistentParentBasedSampler(root_sampler)) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_threshold.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_threshold.py new file mode 100644 index 00000000000..83b7b7d3005 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_parent_threshold.py @@ -0,0 +1,89 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Sequence + +from opentelemetry.context import Context +from opentelemetry.trace import Link, SpanKind, TraceState, get_current_span +from opentelemetry.util.types import Attributes + +from ._composable import ComposableSampler, SamplingIntent +from ._trace_state import OtelTraceState +from ._util import ( + INVALID_THRESHOLD, + MIN_THRESHOLD, + is_valid_threshold, +) + + +class _ComposableParentThreshold(ComposableSampler): + def __init__(self, root_sampler: ComposableSampler): + self._root_sampler = root_sampler + self._description = f"ComposableParentThreshold{{root={root_sampler.get_description()}}}" + + def sampling_intent( + self, + parent_ctx: Context | None, + name: str, + span_kind: SpanKind | None, + attributes: Attributes, + links: Sequence[Link] | None, + trace_state: TraceState | None = None, + ) -> SamplingIntent: + parent_span = get_current_span(parent_ctx) + parent_span_ctx = parent_span.get_span_context() + is_root = not parent_span_ctx.is_valid + if is_root: + return self._root_sampler.sampling_intent( + parent_ctx, name, span_kind, attributes, links, trace_state + ) + + ot_trace_state = OtelTraceState.parse(trace_state) + + if is_valid_threshold(ot_trace_state.threshold): + return SamplingIntent( + threshold=ot_trace_state.threshold, + threshold_reliable=True, + ) + + threshold = ( + MIN_THRESHOLD + if parent_span_ctx.trace_flags.sampled + else INVALID_THRESHOLD + ) + return SamplingIntent(threshold=threshold, threshold_reliable=False) + + def get_description(self) -> str: + return self._description + + +def composable_parent_threshold( + root_sampler: ComposableSampler, +) -> ComposableSampler: + """Returns a consistent sampler that respects the sampling decision of + the parent span or falls-back to the given sampler if it is a root span. + + - For spans without a parent context, delegate to the root sampler + - For spans with a parent context, returns a SamplingIntent that propagates the parent's sampling decision + - Returns the parent's threshold if available; otherwise, if the parent's sampled flag is set, + returns threshold=0; otherwise, if the parent's sampled flag is not set, no threshold is returned. + - Sets threshold_reliable to match the parent’s reliability, which is true if the parent had a threshold. + - Does not add any attributes + + Args: + root_sampler: The root sampler to use for spans without a parent context. + """ + return _ComposableParentThreshold(root_sampler) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py index 9eceacc402d..989cc36019d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_sampler.py @@ -1,4 +1,20 @@ -from typing import Optional, Sequence +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Sequence from opentelemetry.context import Context from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult @@ -10,21 +26,19 @@ from ._util import INVALID_THRESHOLD, is_valid_random_value, is_valid_threshold -class ConsistentSampler(Sampler, ComposableSampler): - """A sampler that uses a consistent sampling strategy based on a delegate sampler.""" - +class _CompositeSampler(Sampler): def __init__(self, delegate: ComposableSampler): self._delegate = delegate def should_sample( self, - parent_context: Optional[Context], + parent_context: Context | None, trace_id: int, name: str, - kind: Optional[SpanKind] = None, - attributes: Attributes = None, - links: Optional[Sequence[Link]] = None, - trace_state: Optional[TraceState] = None, + kind: SpanKind | None = None, + attributes: Attributes | None = None, + links: Sequence[Link] | None = None, + trace_state: TraceState | None = None, ) -> SamplingResult: ot_trace_state = OtelTraceState.parse(trace_state) @@ -34,7 +48,7 @@ def should_sample( threshold = intent.threshold if is_valid_threshold(threshold): - adjusted_count_correct = intent.adjusted_count_reliable + adjusted_count_correct = intent.threshold_reliable if is_valid_random_value(ot_trace_state.random_value): randomness = ot_trace_state.random_value else: @@ -57,28 +71,15 @@ def should_sample( _update_trace_state(trace_state, ot_trace_state, intent), ) - def sampling_intent( - self, - parent_ctx: Optional[Context], - name: str, - span_kind: Optional[SpanKind], - attributes: Attributes, - links: Optional[Sequence[Link]], - trace_state: Optional[TraceState], - ) -> SamplingIntent: - return self._delegate.sampling_intent( - parent_ctx, name, span_kind, attributes, links, trace_state - ) - def get_description(self) -> str: return self._delegate.get_description() def _update_trace_state( - trace_state: Optional[TraceState], + trace_state: TraceState | None, ot_trace_state: OtelTraceState, intent: SamplingIntent, -) -> Optional[TraceState]: +) -> TraceState | None: otts = ot_trace_state.serialize() if not trace_state: if otts: @@ -88,3 +89,13 @@ def _update_trace_state( if otts: return new_trace_state.update(OTEL_TRACE_STATE_KEY, otts) return new_trace_state + + +def composite_sampler(delegate: ComposableSampler) -> Sampler: + """A sampler that uses a a composable sampler to make its decision while + handling tracestate. + + Args: + delegate: The composable sampler to use for making sampling decisions. + """ + return _CompositeSampler(delegate) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py index ada8fc04928..79d0cee413f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py @@ -1,5 +1,21 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + from dataclasses import dataclass -from typing import Optional, Sequence +from typing import Sequence from opentelemetry.trace import TraceState @@ -19,6 +35,11 @@ @dataclass class OtelTraceState: + """Marshals OpenTelemetry tracestate for sampling parameters. + + https://opentelemetry.io/docs/specs/otel/trace/tracestate-probability-sampling/ + """ + random_value: int threshold: int rest: Sequence[str] @@ -28,7 +49,7 @@ def invalid() -> "OtelTraceState": return OtelTraceState(INVALID_RANDOM_VALUE, INVALID_THRESHOLD, ()) @staticmethod - def parse(trace_state: Optional[TraceState]) -> "OtelTraceState": + def parse(trace_state: TraceState | None) -> "OtelTraceState": if not trace_state: return OtelTraceState.invalid() @@ -41,7 +62,7 @@ def parse(trace_state: Optional[TraceState]) -> "OtelTraceState": random_value = INVALID_RANDOM_VALUE members = ot.split(";") - rest: Optional[list[str]] = None + rest: list[str] | None = None for member in members: if member.startswith("th:"): threshold = _parse_th(member[len("th:") :], INVALID_THRESHOLD) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_traceid_ratio.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_traceid_ratio.py new file mode 100644 index 00000000000..d63b6f8a8d7 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_traceid_ratio.py @@ -0,0 +1,80 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Sequence + +from opentelemetry.context import Context +from opentelemetry.trace import Link, SpanKind, TraceState +from opentelemetry.util.types import Attributes + +from ._composable import ComposableSampler, SamplingIntent +from ._trace_state import serialize_th +from ._util import INVALID_THRESHOLD, MAX_THRESHOLD, calculate_threshold + + +class ComposableTraceIDRatioBased(ComposableSampler): + _threshold: int + _description: str + + def __init__(self, ratio: float): + threshold = calculate_threshold(ratio) + if threshold == MAX_THRESHOLD: + threshold_str = "max" + else: + threshold_str = serialize_th(threshold) + if threshold != MAX_THRESHOLD: + intent = SamplingIntent(threshold=threshold) + else: + intent = SamplingIntent( + threshold=INVALID_THRESHOLD, threshold_reliable=False + ) + self._intent = intent + self._description = f"ComposableTraceIDRatioBased{{threshold={threshold_str}, ratio={ratio}}}" + + def sampling_intent( + self, + parent_ctx: Context | None, + name: str, + span_kind: SpanKind | None, + attributes: Attributes, + links: Sequence[Link] | None, + trace_state: TraceState | None, + ) -> SamplingIntent: + return self._intent + + def get_description(self) -> str: + return self._description + + +def composable_traceid_ratio_based( + ratio: float, +) -> ComposableSampler: + """Returns a composable sampler that samples each span with a fixed ratio. + + - Returns a SamplingIntent with threshold determined by the configured sampling ratio + - Sets threshold_reliable to true + - Does not add any attributes + + Note: + If the ratio is 0, it will behave as an ComposableAlwaysOff sampler instead. + + Args: + ratio: The sampling ratio to use (between 0.0 and 1.0). + """ + if not 0.0 <= ratio <= 1.0: + raise ValueError("Sampling ratio must be between 0.0 and 1.0") + + return ComposableTraceIDRatioBased(ratio) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_util.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_util.py index 5b2a3f169e1..4e9fd7d2343 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_util.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_util.py @@ -1,3 +1,17 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + RANDOM_VALUE_BITS = 56 MAX_THRESHOLD = 1 << RANDOM_VALUE_BITS # 0% sampling MIN_THRESHOLD = 0 # 100% sampling diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/__init__.py b/opentelemetry-sdk/tests/trace/composite_sampler/__init__.py similarity index 100% rename from opentelemetry-sdk/tests/trace/consistent_sampler/__init__.py rename to opentelemetry-sdk/tests/trace/composite_sampler/__init__.py diff --git a/opentelemetry-sdk/tests/trace/composite_sampler/test_always_off.py b/opentelemetry-sdk/tests/trace/composite_sampler/test_always_off.py new file mode 100644 index 00000000000..0a03344f883 --- /dev/null +++ b/opentelemetry-sdk/tests/trace/composite_sampler/test_always_off.py @@ -0,0 +1,54 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from opentelemetry.sdk.trace._sampling_experimental import ( + composable_always_off, + composite_sampler, +) +from opentelemetry.sdk.trace.id_generator import RandomIdGenerator +from opentelemetry.sdk.trace.sampling import Decision + + +def test_description(): + assert composable_always_off().get_description() == "ComposableAlwaysOff" + + +def test_threshold(): + assert ( + composable_always_off() + .sampling_intent(None, "test", None, {}, None, None) + .threshold + == -1 + ) + + +def test_sampling(): + sampler = composite_sampler(composable_always_off()) + + num_sampled = 0 + for _ in range(10000): + res = sampler.should_sample( + None, + RandomIdGenerator().generate_trace_id(), + "span", + None, + None, + None, + None, + ) + if res.decision == Decision.RECORD_AND_SAMPLE: + num_sampled += 1 + assert not res.trace_state + + assert num_sampled == 0 diff --git a/opentelemetry-sdk/tests/trace/composite_sampler/test_always_on.py b/opentelemetry-sdk/tests/trace/composite_sampler/test_always_on.py new file mode 100644 index 00000000000..1db8c12c64a --- /dev/null +++ b/opentelemetry-sdk/tests/trace/composite_sampler/test_always_on.py @@ -0,0 +1,55 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from opentelemetry.sdk.trace._sampling_experimental import ( + composable_always_on, + composite_sampler, +) +from opentelemetry.sdk.trace.sampling import Decision +from opentelemetry.sdk.trace.id_generator import RandomIdGenerator + + +def test_description(): + assert composable_always_on().get_description() == "ComposableAlwaysOn" + + +def test_threshold(): + assert ( + composable_always_on() + .sampling_intent(None, "test", None, {}, None, None) + .threshold + == 0 + ) + + +def test_sampling(): + sampler = composite_sampler(composable_always_on()) + + num_sampled = 0 + for _ in range(10000): + res = sampler.should_sample( + None, + RandomIdGenerator().generate_trace_id(), + "span", + None, + None, + None, + None, + ) + if res.decision == Decision.RECORD_AND_SAMPLE: + num_sampled += 1 + assert res.trace_state is not None + assert res.trace_state.get("ot", "") == "th:0" + + assert num_sampled == 10000 diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py b/opentelemetry-sdk/tests/trace/composite_sampler/test_sampler.py similarity index 75% rename from opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py rename to opentelemetry-sdk/tests/trace/composite_sampler/test_sampler.py index a826591ec54..4bd45fd45c1 100644 --- a/opentelemetry-sdk/tests/trace/consistent_sampler/test_sampler.py +++ b/opentelemetry-sdk/tests/trace/composite_sampler/test_sampler.py @@ -1,14 +1,31 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + from dataclasses import dataclass -from typing import Optional import pytest from pytest import param as p from opentelemetry.sdk.trace._sampling_experimental import ( - consistent_always_off, - consistent_always_on, - consistent_parent_based, - consistent_probability_based, + ComposableSampler, + composable_always_off, + composable_always_on, + composable_parent_threshold, + composable_traceid_ratio_based, + composite_sampler, ) from opentelemetry.sdk.trace._sampling_experimental._trace_state import ( OtelTraceState, @@ -17,7 +34,7 @@ INVALID_RANDOM_VALUE, INVALID_THRESHOLD, ) -from opentelemetry.sdk.trace.sampling import Decision, Sampler +from opentelemetry.sdk.trace.sampling import Decision from opentelemetry.trace import ( NonRecordingSpan, SpanContext, @@ -32,10 +49,10 @@ @dataclass class Input: - sampler: Sampler + sampler: ComposableSampler sampled: bool - threshold: Optional[int] - random_value: Optional[int] + threshold: int | None + random_value: int | None @dataclass @@ -50,7 +67,7 @@ class Output: ( p( Input( - sampler=consistent_always_on(), + sampler=composable_always_on(), sampled=True, threshold=None, random_value=None, @@ -62,7 +79,7 @@ class Output: ), p( Input( - sampler=consistent_always_on(), + sampler=composable_always_on(), sampled=True, threshold=None, random_value=0x7F99AA40C02744, @@ -72,7 +89,7 @@ class Output: ), p( Input( - sampler=consistent_always_off(), + sampler=composable_always_off(), sampled=True, threshold=None, random_value=None, @@ -86,7 +103,7 @@ class Output: ), p( Input( - sampler=consistent_parent_based(consistent_always_on()), + sampler=composable_parent_threshold(composable_always_on()), sampled=False, threshold=0x7F99AA40C02744, random_value=0x7F99AA40C02744, @@ -100,7 +117,7 @@ class Output: ), p( Input( - sampler=consistent_parent_based(consistent_always_on()), + sampler=composable_parent_threshold(composable_always_on()), sampled=True, threshold=None, random_value=None, @@ -114,7 +131,7 @@ class Output: ), p( Input( - sampler=consistent_probability_based(0.5), + sampler=composable_traceid_ratio_based(0.5), sampled=True, threshold=None, random_value=0x7FFFFFFFFFFFFF, @@ -128,7 +145,7 @@ class Output: ), p( Input( - sampler=consistent_probability_based(0.5), + sampler=composable_traceid_ratio_based(0.5), sampled=False, threshold=None, random_value=0x80000000000000, @@ -142,7 +159,7 @@ class Output: ), p( Input( - sampler=consistent_probability_based(1.0), + sampler=composable_traceid_ratio_based(1.0), sampled=False, threshold=0x80000000000000, random_value=0x80000000000000, @@ -173,7 +190,7 @@ def test_sample(input: Input, output: Output): parent_span = NonRecordingSpan(parent_span_context) parent_context = set_span_in_context(parent_span) - result = input.sampler.should_sample( + result = composite_sampler(input.sampler).should_sample( parent_context, TRACE_ID, "name", trace_state=parent_trace_state ) diff --git a/opentelemetry-sdk/tests/trace/composite_sampler/test_traceid_ratio.py b/opentelemetry-sdk/tests/trace/composite_sampler/test_traceid_ratio.py new file mode 100644 index 00000000000..3456c57709b --- /dev/null +++ b/opentelemetry-sdk/tests/trace/composite_sampler/test_traceid_ratio.py @@ -0,0 +1,81 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from opentelemetry.sdk.trace._sampling_experimental import ( + composable_traceid_ratio_based, + composite_sampler, +) +from opentelemetry.sdk.trace._sampling_experimental._trace_state import ( + OtelTraceState, +) +from opentelemetry.sdk.trace.sampling import Decision +from opentelemetry.sdk.trace.id_generator import RandomIdGenerator + + +@pytest.mark.parametrize( + ("ratio", "threshold"), + ( + (1.0, "0"), + (0.5, "8"), + (0.25, "c"), + (1e-300, "max"), + (0, "max"), + ), +) +def test_description(ratio: float, threshold: str): + assert ( + composable_traceid_ratio_based(ratio).get_description() + == f"ComposableTraceIDRatioBased{{threshold={threshold}, ratio={ratio}}}" + ) + + +@pytest.mark.parametrize( + ("ratio", "threshold"), + ( + (1.0, 0), + (0.5, 36028797018963968), + (0.25, 54043195528445952), + (0.125, 63050394783186944), + (0.0, 72057594037927936), + (0.45, 39631676720860364), + (0.2, 57646075230342348), + (0.13, 62690106812997304), + (0.05, 68454714336031539), + ), +) +def test_sampling(ratio: float, threshold: int): + sampler = composite_sampler(composable_traceid_ratio_based(ratio)) + + num_sampled = 0 + for _ in range(10000): + res = sampler.should_sample( + None, + RandomIdGenerator().generate_trace_id(), + "span", + None, + None, + None, + None, + ) + if res.decision == Decision.RECORD_AND_SAMPLE: + num_sampled += 1 + assert res.trace_state is not None + otts = OtelTraceState.parse(res.trace_state) + assert otts.threshold == threshold + assert otts.random_value == -1 + + expected_num_sampled = int(10000 * ratio) + assert abs(num_sampled - expected_num_sampled) < 50 diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_tracestate.py b/opentelemetry-sdk/tests/trace/composite_sampler/test_tracestate.py similarity index 75% rename from opentelemetry-sdk/tests/trace/consistent_sampler/test_tracestate.py rename to opentelemetry-sdk/tests/trace/composite_sampler/test_tracestate.py index ffb960b56c7..0af527cf3a3 100644 --- a/opentelemetry-sdk/tests/trace/consistent_sampler/test_tracestate.py +++ b/opentelemetry-sdk/tests/trace/composite_sampler/test_tracestate.py @@ -1,3 +1,17 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import pytest from opentelemetry.sdk.trace._sampling_experimental._trace_state import ( diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_off.py b/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_off.py deleted file mode 100644 index 0bfa986b363..00000000000 --- a/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_off.py +++ /dev/null @@ -1,37 +0,0 @@ -from opentelemetry.sdk.trace._sampling_experimental import ( - consistent_always_off, -) -from opentelemetry.sdk.trace.sampling import Decision - -from .testutil import random_trace_id - - -def test_description(): - assert ( - consistent_always_off().get_description() - == "ConsistentAlwaysOffSampler" - ) - - -def test_threshold(): - assert ( - consistent_always_off() - .sampling_intent(None, "test", None, {}, None, None) - .threshold - == -1 - ) - - -def test_sampling(): - sampler = consistent_always_off() - - num_sampled = 0 - for _ in range(10000): - res = sampler.should_sample( - None, random_trace_id(), "span", None, None, None, None - ) - if res.decision == Decision.RECORD_AND_SAMPLE: - num_sampled += 1 - assert not res.trace_state - - assert num_sampled == 0 diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_on.py b/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_on.py deleted file mode 100644 index 5d1f1ea212c..00000000000 --- a/opentelemetry-sdk/tests/trace/consistent_sampler/test_always_on.py +++ /dev/null @@ -1,35 +0,0 @@ -from opentelemetry.sdk.trace._sampling_experimental import consistent_always_on -from opentelemetry.sdk.trace.sampling import Decision - -from .testutil import random_trace_id - - -def test_description(): - assert ( - consistent_always_on().get_description() == "ConsistentAlwaysOnSampler" - ) - - -def test_threshold(): - assert ( - consistent_always_on() - .sampling_intent(None, "test", None, {}, None, None) - .threshold - == 0 - ) - - -def test_sampling(): - sampler = consistent_always_on() - - num_sampled = 0 - for _ in range(10000): - res = sampler.should_sample( - None, random_trace_id(), "span", None, None, None, None - ) - if res.decision == Decision.RECORD_AND_SAMPLE: - num_sampled += 1 - assert res.trace_state is not None - assert res.trace_state.get("ot", "") == "th:0" - - assert num_sampled == 10000 diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/test_fixed_threshold.py b/opentelemetry-sdk/tests/trace/consistent_sampler/test_fixed_threshold.py deleted file mode 100644 index a71d64f096b..00000000000 --- a/opentelemetry-sdk/tests/trace/consistent_sampler/test_fixed_threshold.py +++ /dev/null @@ -1,61 +0,0 @@ -import pytest - -from opentelemetry.sdk.trace._sampling_experimental import ( - consistent_probability_based, -) -from opentelemetry.sdk.trace._sampling_experimental._trace_state import ( - OtelTraceState, -) -from opentelemetry.sdk.trace.sampling import Decision - -from .testutil import random_trace_id - - -@pytest.mark.parametrize( - "probability,threshold", - ( - (1.0, "0"), - (0.5, "8"), - (0.25, "c"), - (1e-300, "max"), - (0, "max"), - ), -) -def test_description(probability: float, threshold: str): - assert ( - consistent_probability_based(probability).get_description() - == f"ConsistentFixedThresholdSampler{{threshold={threshold}, sampling probability={probability}}}" - ) - - -@pytest.mark.parametrize( - "probability,threshold", - ( - (1.0, 0), - (0.5, 36028797018963968), - (0.25, 54043195528445952), - (0.125, 63050394783186944), - (0.0, 72057594037927936), - (0.45, 39631676720860364), - (0.2, 57646075230342348), - (0.13, 62690106812997304), - (0.05, 68454714336031539), - ), -) -def test_sampling(probability: float, threshold: int): - sampler = consistent_probability_based(probability) - - num_sampled = 0 - for _ in range(10000): - res = sampler.should_sample( - None, random_trace_id(), "span", None, None, None, None - ) - if res.decision == Decision.RECORD_AND_SAMPLE: - num_sampled += 1 - assert res.trace_state is not None - otts = OtelTraceState.parse(res.trace_state) - assert otts.threshold == threshold - assert otts.random_value == -1 - - expected_num_sampled = int(10000 * probability) - assert abs(num_sampled - expected_num_sampled) < 50 diff --git a/opentelemetry-sdk/tests/trace/consistent_sampler/testutil.py b/opentelemetry-sdk/tests/trace/consistent_sampler/testutil.py deleted file mode 100644 index 6b9589fe697..00000000000 --- a/opentelemetry-sdk/tests/trace/consistent_sampler/testutil.py +++ /dev/null @@ -1,5 +0,0 @@ -import random - - -def random_trace_id() -> int: - return random.getrandbits(128) From 35a2eff90ae6650d42e1abad7dda80ea52f17cdb Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 14 Aug 2025 16:59:06 +0900 Subject: [PATCH 7/9] Format --- .../tests/trace/composite_sampler/test_always_on.py | 2 +- .../tests/trace/composite_sampler/test_traceid_ratio.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/opentelemetry-sdk/tests/trace/composite_sampler/test_always_on.py b/opentelemetry-sdk/tests/trace/composite_sampler/test_always_on.py index 1db8c12c64a..a787b221a46 100644 --- a/opentelemetry-sdk/tests/trace/composite_sampler/test_always_on.py +++ b/opentelemetry-sdk/tests/trace/composite_sampler/test_always_on.py @@ -16,8 +16,8 @@ composable_always_on, composite_sampler, ) -from opentelemetry.sdk.trace.sampling import Decision from opentelemetry.sdk.trace.id_generator import RandomIdGenerator +from opentelemetry.sdk.trace.sampling import Decision def test_description(): diff --git a/opentelemetry-sdk/tests/trace/composite_sampler/test_traceid_ratio.py b/opentelemetry-sdk/tests/trace/composite_sampler/test_traceid_ratio.py index 3456c57709b..ad0dc1f4912 100644 --- a/opentelemetry-sdk/tests/trace/composite_sampler/test_traceid_ratio.py +++ b/opentelemetry-sdk/tests/trace/composite_sampler/test_traceid_ratio.py @@ -21,8 +21,8 @@ from opentelemetry.sdk.trace._sampling_experimental._trace_state import ( OtelTraceState, ) -from opentelemetry.sdk.trace.sampling import Decision from opentelemetry.sdk.trace.id_generator import RandomIdGenerator +from opentelemetry.sdk.trace.sampling import Decision @pytest.mark.parametrize( From af5786e587d4bb3b2089c08641b6c50ea4cbf463 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Fri, 22 Aug 2025 12:24:44 +0900 Subject: [PATCH 8/9] Cleanup --- .../sdk/trace/_sampling_experimental/_composable.py | 2 ++ .../sdk/trace/_sampling_experimental/_trace_state.py | 2 ++ opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py | 2 +- pyproject.toml | 3 ++- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py index 80c0462f9b7..99135b04da0 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py @@ -54,6 +54,8 @@ def sampling_intent( trace_state: TraceState | None, ) -> SamplingIntent: """Returns information to make a sampling decision.""" + ... # pylint: disable=unnecessary-ellipsis def get_description(self) -> str: """Returns a description of the sampler.""" + ... # pylint: disable=unnecessary-ellipsis diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py index 79d0cee413f..b2fe9fd80af 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_trace_state.py @@ -117,6 +117,8 @@ def _parse_th(value: str, default: int) -> int: except ValueError: return default + # th value is compressed by removing all trailing zeros, + # so we restore them to get the real value. trailing_zeros = _MAX_VALUE_LENGTH - len(value) return parsed << (trailing_zeros * 4) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py index fb6990a0075..68466eb1018 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/sampling.py @@ -435,7 +435,7 @@ def _get_from_env_or_default() -> Sampler: if trace_sampler in ("traceidratio", "parentbased_traceidratio"): try: - rate = float(os.getenv(OTEL_TRACES_SAMPLER_ARG)) + rate = float(os.getenv(OTEL_TRACES_SAMPLER_ARG, "")) except (ValueError, TypeError): _logger.warning("Could not convert TRACES_SAMPLER_ARG to float.") rate = 1.0 diff --git a/pyproject.toml b/pyproject.toml index 0f1cd93be4f..b62b0cdeba6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,7 +113,8 @@ exclude = [ "opentelemetry-sdk/src/opentelemetry/sdk/_logs", "opentelemetry-sdk/src/opentelemetry/sdk/error_handler", "opentelemetry-sdk/src/opentelemetry/sdk/metrics", - "opentelemetry-sdk/src/opentelemetry/sdk/trace", + "opentelemetry-sdk/src/opentelemetry/sdk/trace/export", + "opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py", "opentelemetry-sdk/src/opentelemetry/sdk/util", "opentelemetry-sdk/benchmarks", ] From fcff4af039c0644ef6a1d700abf0e61fc8a6fd85 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 28 Aug 2025 10:38:58 +0900 Subject: [PATCH 9/9] Formatting --- .../sdk/trace/_sampling_experimental/_composable.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py index 99135b04da0..5829601e30d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_composable.py @@ -54,8 +54,8 @@ def sampling_intent( trace_state: TraceState | None, ) -> SamplingIntent: """Returns information to make a sampling decision.""" - ... # pylint: disable=unnecessary-ellipsis + ... # pylint: disable=unnecessary-ellipsis def get_description(self) -> str: """Returns a description of the sampler.""" - ... # pylint: disable=unnecessary-ellipsis + ... # pylint: disable=unnecessary-ellipsis