diff --git a/CHANGELOG.md b/CHANGELOG.md index da948dc6550..c70467af930 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)) - `opentelemetry-exporter-otlp-proto-http`: fix retry logic and error handling for connection failures in trace, metric, and log exporters ([#4709](https://github.com/open-telemetry/opentelemetry-python/pull/4709)) 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..1e6fc3fbdf8 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/_sampling_experimental/_rule_based.py @@ -0,0 +1,126 @@ +# 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 Protocol, Sequence + +from opentelemetry.context import Context +from opentelemetry.trace import Link, SpanKind, TraceState +from opentelemetry.util.types import AnyValue, Attributes + +from ._composable import ComposableSampler, SamplingIntent +from ._util import INVALID_THRESHOLD + + +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: ... + + 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]] + +_non_sampling_intent = SamplingIntent( + threshold=INVALID_THRESHOLD, threshold_reliable=False +) + + +class _ComposableRuleBased(ComposableSampler): + def __init__(self, rules: RulesT): + # work on an internal copy of the rules + self._rules = list(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: + rules_str = ",".join( + [ + f"({predicate}:{sampler.get_description()})" + for predicate, sampler in self._rules + ] + ) + return f"ComposableRuleBased{{[{rules_str}]}}" + + +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..3ec085f691e --- /dev/null +++ b/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py @@ -0,0 +1,160 @@ +# 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, + 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 + + +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_with_no_rules(): + assert ( + composable_rule_based(rules=[]).get_description() + == "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 = [ + (NameIsFooPredicate(), 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 = [ + (NameIsFooPredicate(), composable_always_on()), + ] + assert ( + composable_rule_based(rules=rules) + .sampling_intent(None, "test", None, {}, None, None) + .threshold + == -1 + ) + + +def test_should_sample_match(): + rules = [ + (NameIsFooPredicate(), 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 = [ + (NameIsFooPredicate(), 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 + + +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 + )