From 2e881630850f133113ea40d1354274c2420b6203 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Fri, 16 Jan 2026 15:07:07 +0100 Subject: [PATCH 1/5] opentelemetry-sdk: add experimental composable rule based sampler --- .../trace/_sampling_experimental/__init__.py | 2 + .../_sampling_experimental/_rule_based.py | 91 +++++++++++++++ .../composite_sampler/test_rule_based.py | 104 ++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py create mode 100644 opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.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 index 1a8c372276d..4dc08da9798 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/__init__.py @@ -18,6 +18,7 @@ "composable_always_off", "composable_always_on", "composable_parent_threshold", + "composable_rule_based", "composable_traceid_ratio_based", "composite_sampler", ] @@ -27,5 +28,6 @@ from ._always_on import composable_always_on from ._composable import ComposableSampler, SamplingIntent from ._parent_threshold import composable_parent_threshold +from ._rule_based import composable_rule_based from ._sampler import composite_sampler from ._traceid_ratio import composable_traceid_ratio_based diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py new file mode 100644 index 00000000000..9ab7b4b47fa --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py @@ -0,0 +1,91 @@ +# 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 Callable, 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 ._util import INVALID_THRESHOLD + +PredicateT = Callable[ + [ + Context | None, + str, + SpanKind | None, + Attributes, + Sequence[Link] | None, + TraceState | None, + ], + bool, +] +RulesT = Sequence[tuple[PredicateT, ComposableSampler]] + +_non_sampling_intent = SamplingIntent( + threshold=INVALID_THRESHOLD, threshold_reliable=False +) + + +class _ComposableRuleBased(ComposableSampler): + def __init__(self, rules: RulesT): + self._rules = rules + + 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: + for predicate, sampler in self._rules: + if predicate( + parent_ctx=parent_ctx, + name=name, + span_kind=span_kind, + attributes=attributes, + links=links, + trace_state=trace_state, + ): + return sampler.sampling_intent( + parent_ctx=parent_ctx, + name=name, + span_kind=span_kind, + attributes=attributes, + links=links, + trace_state=trace_state, + ) + return _non_sampling_intent + + def get_description(self) -> str: + return "ComposableRuleBased" + + +def composable_rule_based( + rules: RulesT, +) -> ComposableSampler: + """Returns a consistent sampler that: + + - Evaluates a series of rules based on predicates and returns the SamplingIntent from the first matching sampler + - If no rules match, returns a non-sampling intent + + Args: + rules: A list of (Predicate, ComposableSampler) pairs, where Predicate is a function that evaluates whether a rule applies + """ + return _ComposableRuleBased(rules) diff --git a/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py b/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py new file mode 100644 index 00000000000..12d02127df5 --- /dev/null +++ b/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py @@ -0,0 +1,104 @@ +# 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, + composable_rule_based, + composite_sampler, +) +from opentelemetry.sdk.trace.id_generator import RandomIdGenerator +from opentelemetry.sdk.trace.sampling import Decision + + +def _rule_name_is_foo( + parent_ctx, + name, + span_kind, + attributes, + links, + trace_state, +): + return name == "foo" + + +def test_description(): + assert ( + composable_rule_based(rules=[]).get_description() + == "ComposableRuleBased" + ) + + +def test_sampling_intent_match(): + rules = [ + (_rule_name_is_foo, composable_always_on()), + ] + assert ( + composable_rule_based(rules=rules) + .sampling_intent(None, "foo", None, {}, None, None) + .threshold + == 0 + ) + + +def test_sampling_intent_no_match(): + rules = [ + (_rule_name_is_foo, composable_always_on()), + ] + assert ( + composable_rule_based(rules=rules) + .sampling_intent(None, "test", None, {}, None, None) + .threshold + == -1 + ) + + +def test_should_sample_match(): + rules = [ + (_rule_name_is_foo, composable_always_on()), + ] + sampler = composite_sampler(composable_rule_based(rules=rules)) + + res = sampler.should_sample( + None, + RandomIdGenerator().generate_trace_id(), + "foo", + None, + None, + None, + None, + ) + + assert res.decision == Decision.RECORD_AND_SAMPLE + assert res.trace_state is not None + assert res.trace_state.get("ot", "") == "th:0" + + +def test_should_sample_no_match(): + rules = [ + (_rule_name_is_foo, composable_always_on()), + ] + sampler = composite_sampler(composable_rule_based(rules=rules)) + + res = sampler.should_sample( + None, + RandomIdGenerator().generate_trace_id(), + "test", + None, + None, + None, + None, + ) + + assert res.decision == Decision.DROP + assert res.trace_state is None From c3f82f8311d863252ecfc043aaf6260c5fc6c726 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Mon, 19 Jan 2026 10:55:30 +0100 Subject: [PATCH 2/5] Add CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af3f65a62e3..11268af9f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4847](https://github.com/open-telemetry/opentelemetry-python/pull/4847)) - Prevent possible endless recursion from happening in `SimpleLogRecordProcessor.on_emit`, ([#4799](https://github.com/open-telemetry/opentelemetry-python/pull/4799)) and ([#4867](https://github.com/open-telemetry/opentelemetry-python/pull/4867)). +- Add experimental composable rule based sampler + ([#4882](https://github.com/open-telemetry/opentelemetry-python/pull/4882)) ## Version 1.39.0/0.60b0 (2025-12-03) From d74e401de772ba32d2ba57a35bb4fb767c78252c Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Mon, 19 Jan 2026 11:06:57 +0100 Subject: [PATCH 3/5] Use a protocol for PredicateT --- .../_sampling_experimental/_rule_based.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py index 9ab7b4b47fa..10344bcc75e 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py @@ -14,7 +14,7 @@ from __future__ import annotations -from typing import Callable, Sequence +from typing import Protocol, Sequence from opentelemetry.context import Context from opentelemetry.trace import Link, SpanKind, TraceState @@ -23,17 +23,19 @@ from ._composable import ComposableSampler, SamplingIntent from ._util import INVALID_THRESHOLD -PredicateT = Callable[ - [ - Context | None, - str, - SpanKind | None, - Attributes, - Sequence[Link] | None, - TraceState | None, - ], - bool, -] + +class PredicateT(Protocol): + def __call__( + self, + parent_ctx: Context | None, + name: str, + span_kind: SpanKind | None, + attributes: Attributes, + links: Sequence[Link] | None, + trace_state: TraceState | None, + ) -> bool: ... + + RulesT = Sequence[tuple[PredicateT, ComposableSampler]] _non_sampling_intent = SamplingIntent( From 766b3ef199710ec777926a06f1a0a8d138ac8359 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Tue, 20 Jan 2026 09:49:21 +0100 Subject: [PATCH 4/5] Update opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py Co-authored-by: Anuraag (Rag) Agrawal --- .../sdk/trace/_sampling_experimental/_rule_based.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py index 10344bcc75e..47626a15efa 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py @@ -45,7 +45,7 @@ def __call__( class _ComposableRuleBased(ComposableSampler): def __init__(self, rules: RulesT): - self._rules = rules + self._rules = list(rules) def sampling_intent( self, From 9a43146ad29b2821baf8550ad9e688e52051785f Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Tue, 20 Jan 2026 10:38:59 +0100 Subject: [PATCH 5/5] Extend Predicate protocol to require __str__ and use it on sampler get_description While at it also provide AttributePredicate --- .../_sampling_experimental/_rule_based.py | 37 +++++++- .../composite_sampler/test_rule_based.py | 86 +++++++++++++++---- 2 files changed, 106 insertions(+), 17 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py index 47626a15efa..1e6fc3fbdf8 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py @@ -18,7 +18,7 @@ from opentelemetry.context import Context from opentelemetry.trace import Link, SpanKind, TraceState -from opentelemetry.util.types import Attributes +from opentelemetry.util.types import AnyValue, Attributes from ._composable import ComposableSampler, SamplingIntent from ._util import INVALID_THRESHOLD @@ -35,6 +35,32 @@ def __call__( trace_state: TraceState | None, ) -> bool: ... + def __str__(self) -> str: ... + + +class AttributePredicate: + """An exact match of an attribute value""" + + def __init__(self, key: str, value: AnyValue): + self.key = key + self.value = value + + def __call__( + self, + parent_ctx: Context | None, + name: str, + span_kind: SpanKind | None, + attributes: Attributes, + links: Sequence[Link] | None, + trace_state: TraceState | None, + ) -> bool: + if not attributes: + return False + return attributes.get(self.key) == self.value + + def __str__(self): + return f"{self.key}={self.value}" + RulesT = Sequence[tuple[PredicateT, ComposableSampler]] @@ -45,6 +71,7 @@ def __call__( class _ComposableRuleBased(ComposableSampler): def __init__(self, rules: RulesT): + # work on an internal copy of the rules self._rules = list(rules) def sampling_intent( @@ -76,7 +103,13 @@ def sampling_intent( return _non_sampling_intent def get_description(self) -> str: - return "ComposableRuleBased" + rules_str = ",".join( + [ + f"({predicate}:{sampler.get_description()})" + for predicate, sampler in self._rules + ] + ) + return f"ComposableRuleBased{{[{rules_str}]}}" def composable_rule_based( diff --git a/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py b/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py index 12d02127df5..3ec085f691e 100644 --- a/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py +++ b/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py @@ -13,35 +13,55 @@ # limitations under the License. from opentelemetry.sdk.trace._sampling_experimental import ( + composable_always_off, composable_always_on, composable_rule_based, composite_sampler, ) +from opentelemetry.sdk.trace._sampling_experimental._rule_based import ( + AttributePredicate, +) from opentelemetry.sdk.trace.id_generator import RandomIdGenerator from opentelemetry.sdk.trace.sampling import Decision -def _rule_name_is_foo( - parent_ctx, - name, - span_kind, - attributes, - links, - trace_state, -): - return name == "foo" +class NameIsFooPredicate: + def __call__( + self, + parent_ctx, + name, + span_kind, + attributes, + links, + trace_state, + ): + return name == "foo" + + def __str__(self): + return "NameIsFooPredicate" -def test_description(): +def test_description_with_no_rules(): assert ( composable_rule_based(rules=[]).get_description() - == "ComposableRuleBased" + == "ComposableRuleBased{[]}" + ) + + +def test_description_with_rules(): + rules = [ + (AttributePredicate("foo", "bar"), composable_always_on()), + (NameIsFooPredicate(), composable_always_off()), + ] + assert ( + composable_rule_based(rules=rules).get_description() + == "ComposableRuleBased{[(foo=bar:ComposableAlwaysOn),(NameIsFooPredicate:ComposableAlwaysOff)]}" ) def test_sampling_intent_match(): rules = [ - (_rule_name_is_foo, composable_always_on()), + (NameIsFooPredicate(), composable_always_on()), ] assert ( composable_rule_based(rules=rules) @@ -53,7 +73,7 @@ def test_sampling_intent_match(): def test_sampling_intent_no_match(): rules = [ - (_rule_name_is_foo, composable_always_on()), + (NameIsFooPredicate(), composable_always_on()), ] assert ( composable_rule_based(rules=rules) @@ -65,7 +85,7 @@ def test_sampling_intent_no_match(): def test_should_sample_match(): rules = [ - (_rule_name_is_foo, composable_always_on()), + (NameIsFooPredicate(), composable_always_on()), ] sampler = composite_sampler(composable_rule_based(rules=rules)) @@ -86,7 +106,7 @@ def test_should_sample_match(): def test_should_sample_no_match(): rules = [ - (_rule_name_is_foo, composable_always_on()), + (NameIsFooPredicate(), composable_always_on()), ] sampler = composite_sampler(composable_rule_based(rules=rules)) @@ -102,3 +122,39 @@ def test_should_sample_no_match(): assert res.decision == Decision.DROP assert res.trace_state is None + + +def test_attribute_predicate_no_attributes(): + rules = [ + (AttributePredicate("foo", "bar"), composable_always_on()), + ] + assert ( + composable_rule_based(rules=rules) + .sampling_intent(None, "span", None, None, None, None) + .threshold + == -1 + ) + + +def test_attribute_predicate_no_match(): + rules = [ + (AttributePredicate("foo", "bar"), composable_always_on()), + ] + assert ( + composable_rule_based(rules=rules) + .sampling_intent(None, "span", None, {"foo": "foo"}, None, None) + .threshold + == -1 + ) + + +def test_attribute_predicate_match(): + rules = [ + (AttributePredicate("foo", "bar"), composable_always_on()), + ] + assert ( + composable_rule_based(rules=rules) + .sampling_intent(None, "span", None, {"foo": "bar"}, None, None) + .threshold + == 0 + )