From 2cf6ace678dccceb340e0add8b62731d3765d5d7 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Fri, 24 Apr 2026 17:56:25 +0200 Subject: [PATCH 1/3] opentelemetry-sdk: add declarative config support for experimental rule based sampler Assisted-by: Cursor --- .../sdk/_configuration/_tracer_provider.py | 120 ++++++- .../_sampling_experimental/_rule_based.py | 178 ++++++++++- .../_configuration/test_tracer_provider.py | 292 ++++++++++++++++++ .../composite_sampler/test_rule_based.py | 226 +++++++++++++- 4 files changed, 811 insertions(+), 5 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py index af4200e4748..dbb948a2c88 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py @@ -11,6 +11,15 @@ load_entry_point, ) from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableRuleBasedSampler as RuleBasedSamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableRuleBasedSamplerRule as RuleBasedSamplerRuleConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableSampler as ComposableSamplerConfig, +) from opentelemetry.sdk._configuration.models import ( OtlpGrpcExporter as OtlpGrpcExporterConfig, ) @@ -45,6 +54,25 @@ SpanLimits, TracerProvider, ) +from opentelemetry.sdk.trace._sampling_experimental import ( + ComposableSampler, + composable_always_off, + composable_always_on, + composable_parent_threshold, + composable_rule_based, + composable_traceid_ratio_based, + composite_sampler, +) +from opentelemetry.sdk.trace._sampling_experimental._rule_based import ( + AllPredicate, + AlwaysMatchPredicate, + AttributePatternsPredicate, + AttributeValuesPredicate, + ParentPredicate, + PredicateT, + RulesT, + SpanKindPredicate, +) from opentelemetry.sdk.trace.export import ( BatchSpanProcessor, ConsoleSpanExporter, @@ -58,6 +86,7 @@ Sampler, TraceIdRatioBased, ) +from opentelemetry.trace import SpanKind as TraceSpanKind _logger = logging.getLogger(__name__) @@ -174,6 +203,88 @@ def _create_span_processor( ) +def _create_experimental_composable_sampler( + config: ComposableSamplerConfig, +) -> ComposableSampler: + """Create an experimental composable sampler from config""" + if config.always_on is not None: + return composable_always_on() + if config.always_off is not None: + return composable_always_off() + if config.parent_threshold is not None: + return composable_parent_threshold( + _create_experimental_composable_sampler( + config.parent_threshold.root + ) + ) + if config.probability is not None: + ratio = config.probability.ratio + return composable_traceid_ratio_based( + ratio if ratio is not None else 1.0 + ) + if config.rule_based is not None: + return composable_rule_based( + _create_rule_based_sampler_rules(config.rule_based) + ) + raise ConfigurationError( + f"Unknown or unsupported experimental composable sampler type in config: {config!r}. " + "Supported types: always_on, always_off, parent_threshold, probability, rule_based." + ) + + +def _create_rule_based_sampler_rules( + config: RuleBasedSamplerConfig, +) -> RulesT: + if config.rules is None: + return [] + return [ + ( + _create_rule_based_sampler_rule_predicate(rule), + _create_experimental_composable_sampler(rule.sampler), + ) + for rule in config.rules + ] + + +def _create_rule_based_sampler_rule_predicate( + config: RuleBasedSamplerRuleConfig, +) -> PredicateT: + predicates: list[PredicateT] = [] + if config.attribute_values is not None: + predicates.append( + AttributeValuesPredicate( + config.attribute_values.key, + config.attribute_values.values, + ) + ) + if config.attribute_patterns is not None: + predicates.append( + AttributePatternsPredicate( + config.attribute_patterns.key, + config.attribute_patterns.included, + config.attribute_patterns.excluded, + ) + ) + if config.span_kinds is not None: + predicates.append( + SpanKindPredicate( + [ + TraceSpanKind[span_kind.value.upper()] + for span_kind in config.span_kinds + ] + ) + ) + if config.parent is not None: + predicates.append( + ParentPredicate([parent.value for parent in config.parent]) + ) + if not predicates: + return AlwaysMatchPredicate() + if len(predicates) == 1: + return predicates[0] + return AllPredicate(predicates) + + def _create_sampler(config: SamplerConfig) -> Sampler: """Create a sampler from config. @@ -189,6 +300,12 @@ def _create_sampler(config: SamplerConfig) -> Sampler: if config.trace_id_ratio_based is not None: ratio = config.trace_id_ratio_based.ratio return TraceIdRatioBased(ratio if ratio is not None else 1.0) + if config.composite_development is not None: + return composite_sampler( + _create_experimental_composable_sampler( + config.composite_development + ) + ) if config.parent_based is not None: return _create_parent_based_sampler(config.parent_based) if config.additional_properties: @@ -196,7 +313,8 @@ def _create_sampler(config: SamplerConfig) -> Sampler: return load_entry_point("opentelemetry_sampler", name)() raise ConfigurationError( f"Unknown or unsupported sampler type in config: {config!r}. " - "Supported types: always_on, always_off, trace_id_ratio_based, parent_based." + "Supported types: always_on, always_off, composite_development, " + "trace_id_ratio_based, parent_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 index 747c73aa7de..d5fbdda41ca 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 @@ -3,11 +3,18 @@ from __future__ import annotations +import logging from collections.abc import Sequence +from fnmatch import fnmatchcase from typing import Protocol from opentelemetry.context import Context -from opentelemetry.trace import Link, SpanKind, TraceState +from opentelemetry.trace import ( + Link, + SpanKind, + TraceState, + get_current_span, +) from opentelemetry.util.types import AnyValue, Attributes from ._composable import ComposableSampler, SamplingIntent @@ -32,6 +39,9 @@ class AttributePredicate: """An exact match of an attribute value""" def __init__(self, key: str, value: AnyValue): + logging.warning( + "This is deprecated, use AttributeValuesPredicate instead" + ) self.key = key self.value = value @@ -52,6 +62,172 @@ def __str__(self): return f"{self.key}={self.value}" +class AlwaysMatchPredicate: + def __call__( + self, + parent_ctx: Context | None, + name: str, + span_kind: SpanKind | None, + attributes: Attributes, + links: Sequence[Link] | None, + trace_state: TraceState | None, + ) -> bool: + return True + + def __str__(self) -> str: + return "AlwaysMatch" + + +class AllPredicate: + def __init__(self, predicates: Sequence[PredicateT]): + self._predicates = tuple(predicates) + + def __call__( + self, + parent_ctx: Context | None, + name: str, + span_kind: SpanKind | None, + attributes: Attributes, + links: Sequence[Link] | None, + trace_state: TraceState | None, + ) -> bool: + return all( + predicate( + parent_ctx, + name, + span_kind, + attributes, + links, + trace_state, + ) + for predicate in self._predicates + ) + + def __str__(self) -> str: + return " && ".join(str(predicate) for predicate in self._predicates) + + +class AttributeValuesPredicate: + def __init__(self, key: str, values: Sequence[str]): + self._key = key + self._values = frozenset(values) + + 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 or self._key not in attributes: + return False + return any( + str(value) in self._values + for value in _attribute_values(attributes[self._key]) + ) + + def __str__(self) -> str: + values = ",".join(sorted(self._values)) + return f"{self._key} in [{values}]" + + +class AttributePatternsPredicate: + def __init__( + self, + key: str, + included: Sequence[str] | None = None, + excluded: Sequence[str] | None = None, + ): + self._key = key + self._included = tuple(included or ()) + self._excluded = tuple(excluded or ()) + + 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 or self._key not in attributes: + return False + return any( + self._matches_value(str(value)) + for value in _attribute_values(attributes[self._key]) + ) + + def _matches_value(self, value: str) -> bool: + included = not self._included or any( + fnmatchcase(value, pattern) for pattern in self._included + ) + excluded = any( + fnmatchcase(value, pattern) for pattern in self._excluded + ) + return included and not excluded + + def __str__(self) -> str: + return f"{self._key} matches" + + +class SpanKindPredicate: + def __init__(self, span_kinds: Sequence[SpanKind]): + self._span_kinds = frozenset(span_kinds) + + def __call__( + self, + parent_ctx: Context | None, + name: str, + span_kind: SpanKind | None, + attributes: Attributes, + links: Sequence[Link] | None, + trace_state: TraceState | None, + ) -> bool: + return span_kind in self._span_kinds + + def __str__(self) -> str: + kinds = ",".join(kind.name.lower() for kind in self._span_kinds) + return f"span_kind in [{kinds}]" + + +class ParentPredicate: + def __init__(self, parents: Sequence[str]): + self._parents = frozenset(parents) + + def __call__( + self, + parent_ctx: Context | None, + name: str, + span_kind: SpanKind | None, + attributes: Attributes, + links: Sequence[Link] | None, + trace_state: TraceState | None, + ) -> bool: + parent_span_context = get_current_span(parent_ctx).get_span_context() + if not parent_span_context.is_valid: + parent = "none" + elif parent_span_context.is_remote: + parent = "remote" + else: + parent = "local" + return parent in self._parents + + def __str__(self) -> str: + parents = ",".join(self._parents) + return f"parent in [{parents}]" + + +def _attribute_values(value): + if isinstance(value, Sequence) and not isinstance( + value, (str, bytes, bytearray) + ): + return value + return (value,) + + RulesT = Sequence[tuple[PredicateT, ComposableSampler]] _non_sampling_intent = SamplingIntent( diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index 80267df426f..94902accc03 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -9,6 +9,7 @@ import unittest from unittest.mock import MagicMock, patch +from opentelemetry import trace as trace_api from opentelemetry.sdk._configuration._tracer_provider import ( configure_tracer_provider, create_tracer_provider, @@ -17,6 +18,27 @@ from opentelemetry.sdk._configuration.models import ( BatchSpanProcessor as BatchSpanProcessorConfig, ) +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableProbabilitySampler as ComposableProbabilityConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableRuleBasedSampler as RuleBasedSamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableRuleBasedSamplerRule as RuleBasedSamplerRuleConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableRuleBasedSamplerRuleAttributePatterns as RuleAttributePatternsConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableRuleBasedSamplerRuleAttributeValues as RuleAttributeValuesConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalComposableSampler as ComposableSamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + ExperimentalSpanParent as SpanParentConfig, +) from opentelemetry.sdk._configuration.models import ( OtlpGrpcExporter as OtlpGrpcExporterConfig, ) @@ -35,6 +57,9 @@ from opentelemetry.sdk._configuration.models import ( SpanExporter as SpanExporterConfig, ) +from opentelemetry.sdk._configuration.models import ( + SpanKind as SpanKindConfig, +) from opentelemetry.sdk._configuration.models import ( SpanLimits as SpanLimitsConfig, ) @@ -57,10 +82,16 @@ from opentelemetry.sdk.trace.sampling import ( ALWAYS_OFF, ALWAYS_ON, + Decision, ParentBased, Sampler, TraceIdRatioBased, ) +from opentelemetry.trace import SpanContext, TraceFlags +from opentelemetry.trace import SpanKind as TraceSpanKind + +TRACE_ID = int("00112233445566778800000000000000", 16) +SPAN_ID = int("0123456789abcdef", 16) class TestCreateTracerProviderBasic(unittest.TestCase): @@ -232,6 +263,267 @@ def test_unknown_plugin_raises_configuration_error(self): self._make_provider(SamplerConfig(no_such_sampler={})) +class TestCreateCompositeRuleBasedSampler(unittest.TestCase): + @staticmethod + def _make_provider(rule_based_config): + return create_tracer_provider( + TracerProviderConfig( + processors=[], + sampler=SamplerConfig( + composite_development=ComposableSamplerConfig( + rule_based=rule_based_config + ) + ), + ) + ) + + @staticmethod + def _rule(sampler, **kwargs): + return RuleBasedSamplerRuleConfig(sampler=sampler, **kwargs) + + @staticmethod + def _always_on(): + return ComposableSamplerConfig(always_on={}) + + @staticmethod + def _always_off(): + return ComposableSamplerConfig(always_off={}) + + @staticmethod + def _attribute_values(key, values): + return RuleAttributeValuesConfig(key=key, values=values) + + @staticmethod + def _attribute_patterns(key, included=None, excluded=None): + return RuleAttributePatternsConfig( + key=key, + included=included, + excluded=excluded, + ) + + def _decision( + self, + rule_based_config, + *, + name="span", + kind=None, + attributes=None, + parent_context=None, + ): + provider = self._make_provider(rule_based_config) + return provider.sampler.should_sample( + parent_context, + TRACE_ID, + name, + kind, + attributes, + None, + ).decision + + def test_composite_rule_based_no_rules_drops(self): + decision = self._decision(RuleBasedSamplerConfig()) + + self.assertEqual(decision, Decision.DROP) + + def test_composite_rule_based_no_condition_rule_matches(self): + decision = self._decision( + RuleBasedSamplerConfig(rules=[self._rule(self._always_on())]) + ) + + self.assertEqual(decision, Decision.RECORD_AND_SAMPLE) + + def test_composite_rule_based_first_match_wins(self): + rule_based = RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_off(), + attribute_values=self._attribute_values( + "http.route", ["/health"] + ), + ), + self._rule( + self._always_on(), + attribute_values=self._attribute_values( + "http.route", ["/health"] + ), + ), + ] + ) + + decision = self._decision( + rule_based, attributes={"http.route": "/health"} + ) + + self.assertEqual(decision, Decision.DROP) + + def test_composite_rule_based_attribute_values_stringifies_values(self): + decision = self._decision( + RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_on(), + attribute_values=self._attribute_values( + "http.response.status_code", ["404"] + ), + ) + ] + ), + attributes={"http.response.status_code": 404}, + ) + + self.assertEqual(decision, Decision.RECORD_AND_SAMPLE) + + def test_composite_rule_based_attribute_values_match_array_item(self): + decision = self._decision( + RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_on(), + attribute_values=self._attribute_values( + "http.request.method", ["POST"] + ), + ) + ] + ), + attributes={"http.request.method": ("GET", "POST")}, + ) + + self.assertEqual(decision, Decision.RECORD_AND_SAMPLE) + + def test_composite_rule_based_attribute_patterns_include_exclude(self): + rule_based = RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_on(), + attribute_patterns=self._attribute_patterns( + "http.route", + ["/api/*"], + ["/api/private/*"], + ), + ) + ] + ) + + included = self._decision( + rule_based, attributes={"http.route": "/api/users"} + ) + excluded = self._decision( + rule_based, attributes={"http.route": "/api/private/user"} + ) + case_mismatch = self._decision( + rule_based, attributes={"http.route": "/API/users"} + ) + + self.assertEqual(included, Decision.RECORD_AND_SAMPLE) + self.assertEqual(excluded, Decision.DROP) + self.assertEqual(case_mismatch, Decision.DROP) + + def test_composite_rule_based_span_kind(self): + rule_based = RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_on(), + span_kinds=[SpanKindConfig.server], + ) + ] + ) + + server = self._decision(rule_based, kind=TraceSpanKind.SERVER) + client = self._decision(rule_based, kind=TraceSpanKind.CLIENT) + + self.assertEqual(server, Decision.RECORD_AND_SAMPLE) + self.assertEqual(client, Decision.DROP) + + def test_composite_rule_based_parent(self): + local_parent_context = trace_api.set_span_in_context( + trace_api.NonRecordingSpan( + SpanContext( + TRACE_ID, + SPAN_ID, + is_remote=False, + trace_flags=TraceFlags.get_default(), + ) + ) + ) + remote_parent_context = trace_api.set_span_in_context( + trace_api.NonRecordingSpan( + SpanContext( + TRACE_ID, + SPAN_ID, + is_remote=True, + trace_flags=TraceFlags.get_default(), + ) + ) + ) + rule_based = RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_on(), + parent=[SpanParentConfig.local], + ) + ] + ) + + local = self._decision(rule_based, parent_context=local_parent_context) + remote = self._decision( + rule_based, parent_context=remote_parent_context + ) + no_parent = self._decision(rule_based) + + self.assertEqual(local, Decision.RECORD_AND_SAMPLE) + self.assertEqual(remote, Decision.DROP) + self.assertEqual(no_parent, Decision.DROP) + + def test_composite_rule_based_multiple_conditions_are_anded(self): + rule_based = RuleBasedSamplerConfig( + rules=[ + self._rule( + self._always_on(), + attribute_values=self._attribute_values( + "http.route", ["/users"] + ), + span_kinds=[SpanKindConfig.server], + ) + ] + ) + + matching = self._decision( + rule_based, + kind=TraceSpanKind.SERVER, + attributes={"http.route": "/users"}, + ) + wrong_kind = self._decision( + rule_based, + kind=TraceSpanKind.CLIENT, + attributes={"http.route": "/users"}, + ) + + self.assertEqual(matching, Decision.RECORD_AND_SAMPLE) + self.assertEqual(wrong_kind, Decision.DROP) + + def test_composite_rule_based_nested_probability_sampler(self): + provider = self._make_provider( + RuleBasedSamplerConfig( + rules=[ + self._rule( + ComposableSamplerConfig( + probability=ComposableProbabilityConfig(ratio=0.0) + ) + ) + ] + ) + ) + + expected = ( + "ComposableRuleBased{[(AlwaysMatch:" + "ComposableTraceIDRatioBased{threshold=max, ratio=0.0})]}" + ) + self.assertEqual( + provider.sampler.get_description(), + expected, + ) + + class TestCreateSpanExporterAndProcessor(unittest.TestCase): # pylint: disable=no-self-use 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 a6bc11c2983..0a398f2fefd 100644 --- a/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py +++ b/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py @@ -8,10 +8,26 @@ composite_sampler, ) from opentelemetry.sdk.trace._sampling_experimental._rule_based import ( + AllPredicate, + AlwaysMatchPredicate, + AttributePatternsPredicate, AttributePredicate, + AttributeValuesPredicate, + ParentPredicate, + SpanKindPredicate, ) from opentelemetry.sdk.trace.id_generator import RandomIdGenerator from opentelemetry.sdk.trace.sampling import Decision +from opentelemetry.trace import ( + NonRecordingSpan, + SpanContext, + SpanKind, + TraceFlags, + set_span_in_context, +) + +TRACE_ID = int("00112233445566778800000000000000", 16) +SPAN_ID = int("0123456789abcdef", 16) class NameIsFooPredicate: @@ -30,6 +46,36 @@ def __str__(self): return "NameIsFooPredicate" +def _predicate_result( + predicate, + *, + parent_ctx=None, + span_kind=None, + attributes=None, +): + return predicate( + parent_ctx=parent_ctx, + name="span", + span_kind=span_kind, + attributes=attributes, + links=None, + trace_state=None, + ) + + +def _parent_context(is_remote: bool): + return set_span_in_context( + NonRecordingSpan( + SpanContext( + TRACE_ID, + SPAN_ID, + is_remote=is_remote, + trace_flags=TraceFlags.get_default(), + ) + ) + ) + + def test_description_with_no_rules(): assert ( composable_rule_based(rules=[]).get_description() @@ -37,14 +83,188 @@ def test_description_with_no_rules(): ) +def test_always_match_predicate(): + assert _predicate_result(AlwaysMatchPredicate()) is True + assert str(AlwaysMatchPredicate()) == "AlwaysMatch" + + +def test_all_predicate_matches_when_all_predicates_match(): + predicate = AllPredicate( + [ + AttributeValuesPredicate("http.route", ["/users"]), + SpanKindPredicate([SpanKind.SERVER]), + ] + ) + + assert ( + _predicate_result( + predicate, + span_kind=SpanKind.SERVER, + attributes={"http.route": "/users"}, + ) + is True + ) + + +def test_all_predicate_does_not_match_when_any_predicate_does_not_match(): + predicate = AllPredicate( + [ + AttributeValuesPredicate("http.route", ["/users"]), + SpanKindPredicate([SpanKind.SERVER]), + ] + ) + + assert ( + _predicate_result( + predicate, + span_kind=SpanKind.CLIENT, + attributes={"http.route": "/users"}, + ) + is False + ) + + +def test_attribute_values_predicate_no_attributes(): + assert ( + _predicate_result(AttributeValuesPredicate("http.route", ["/users"])) + is False + ) + + +def test_attribute_values_predicate_stringifies_values(): + assert ( + _predicate_result( + AttributeValuesPredicate("http.response.status_code", ["404"]), + attributes={"http.response.status_code": 404}, + ) + is True + ) + + +def test_attribute_values_predicate_matches_array_item(): + assert ( + _predicate_result( + AttributeValuesPredicate("http.request.method", ["POST"]), + attributes={"http.request.method": ("GET", "POST")}, + ) + is True + ) + + +def test_attribute_values_predicate_no_match(): + assert ( + _predicate_result( + AttributeValuesPredicate("http.route", ["/users"]), + attributes={"http.route": "/health"}, + ) + is False + ) + + +def test_attribute_patterns_predicate_includes_all_by_default(): + assert ( + _predicate_result( + AttributePatternsPredicate("http.route"), + attributes={"http.route": "/users"}, + ) + is True + ) + + +def test_attribute_patterns_predicate_include_exclude_precedence(): + predicate = AttributePatternsPredicate( + "http.route", + included=["/api/*"], + excluded=["/api/private/*"], + ) + + assert ( + _predicate_result(predicate, attributes={"http.route": "/api/users"}) + is True + ) + assert ( + _predicate_result( + predicate, attributes={"http.route": "/api/private/user"} + ) + is False + ) + + +def test_attribute_patterns_predicate_is_case_sensitive(): + assert ( + _predicate_result( + AttributePatternsPredicate("http.route", included=["/api/*"]), + attributes={"http.route": "/API/users"}, + ) + is False + ) + + +def test_attribute_patterns_predicate_matches_array_item(): + assert ( + _predicate_result( + AttributePatternsPredicate("http.request.method", ["POST"]), + attributes={"http.request.method": ("GET", "POST")}, + ) + is True + ) + + +def test_span_kind_predicate(): + predicate = SpanKindPredicate([SpanKind.SERVER]) + + assert _predicate_result(predicate, span_kind=SpanKind.SERVER) is True + assert _predicate_result(predicate, span_kind=SpanKind.CLIENT) is False + assert _predicate_result(predicate) is False + + +def test_parent_predicate_matches_no_parent(): + assert _predicate_result(ParentPredicate(["none"])) is True + assert _predicate_result(ParentPredicate(["local"])) is False + + +def test_parent_predicate_matches_local_parent(): + local_parent_ctx = _parent_context(is_remote=False) + + assert ( + _predicate_result( + ParentPredicate(["local"]), parent_ctx=local_parent_ctx + ) + is True + ) + assert ( + _predicate_result( + ParentPredicate(["remote"]), parent_ctx=local_parent_ctx + ) + is False + ) + + +def test_parent_predicate_matches_remote_parent(): + remote_parent_ctx = _parent_context(is_remote=True) + + assert ( + _predicate_result( + ParentPredicate(["remote"]), parent_ctx=remote_parent_ctx + ) + is True + ) + assert ( + _predicate_result( + ParentPredicate(["local"]), parent_ctx=remote_parent_ctx + ) + is False + ) + + def test_description_with_rules(): rules = [ - (AttributePredicate("foo", "bar"), composable_always_on()), + (AttributeValuesPredicate("foo", ["bar"]), composable_always_on()), (NameIsFooPredicate(), composable_always_off()), ] assert ( composable_rule_based(rules=rules).get_description() - == "ComposableRuleBased{[(foo=bar:ComposableAlwaysOn),(NameIsFooPredicate:ComposableAlwaysOff)]}" + == "ComposableRuleBased{[(foo in [bar]:ComposableAlwaysOn),(NameIsFooPredicate:ComposableAlwaysOff)]}" ) @@ -95,7 +315,7 @@ def test_should_sample_match(): def test_should_sample_match_multiple_rules(): rules = [ - (AttributePredicate("foo", "bar"), composable_always_off()), + (AttributeValuesPredicate("foo", ["bar"]), composable_always_off()), (NameIsFooPredicate(), composable_always_on()), ] sampler = composite_sampler(composable_rule_based(rules=rules)) From 0c1413673f3cab53080a3149d6bd44d1fbc9f412 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Mon, 11 May 2026 18:10:52 +0200 Subject: [PATCH 2/3] Make match similar to java Assisted-by: Cursor --- .../_sampling_experimental/_rule_based.py | 18 +++++++++++++++--- .../trace/composite_sampler/test_rule_based.py | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 3 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 d5fbdda41ca..f25089f2e42 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 @@ -4,8 +4,8 @@ from __future__ import annotations import logging +import re from collections.abc import Sequence -from fnmatch import fnmatchcase from typing import Protocol from opentelemetry.context import Context @@ -162,10 +162,10 @@ def __call__( def _matches_value(self, value: str) -> bool: included = not self._included or any( - fnmatchcase(value, pattern) for pattern in self._included + _glob_matches(value, pattern) for pattern in self._included ) excluded = any( - fnmatchcase(value, pattern) for pattern in self._excluded + _glob_matches(value, pattern) for pattern in self._excluded ) return included and not excluded @@ -228,6 +228,18 @@ def _attribute_values(value): return (value,) +def _glob_matches(value: str, glob_pattern: str) -> bool: + pattern_parts = [] + for char in glob_pattern: + if char == "*": + pattern_parts.append(".*") + elif char == "?": + pattern_parts.append(".") + else: + pattern_parts.append(re.escape(char)) + return re.fullmatch("".join(pattern_parts), value) is not None + + RulesT = Sequence[tuple[PredicateT, ComposableSampler]] _non_sampling_intent = SamplingIntent( 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 0a398f2fefd..4eaff3c0141 100644 --- a/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py +++ b/opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py @@ -200,6 +200,21 @@ def test_attribute_patterns_predicate_is_case_sensitive(): ) +def test_attribute_patterns_predicate_treats_brackets_as_literals(): + predicate = AttributePatternsPredicate( + "http.route", included=["/api/[v1]"] + ) + + assert ( + _predicate_result(predicate, attributes={"http.route": "/api/[v1]"}) + is True + ) + assert ( + _predicate_result(predicate, attributes={"http.route": "/api/v"}) + is False + ) + + def test_attribute_patterns_predicate_matches_array_item(): assert ( _predicate_result( From 240b1e7bd5e2da5809457ea5f602d829b523830b Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Tue, 12 May 2026 16:32:17 +0200 Subject: [PATCH 3/3] Add changelog --- .changelog/5201.added | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changelog/5201.added diff --git a/.changelog/5201.added b/.changelog/5201.added new file mode 100644 index 00000000000..11496f5ed2a --- /dev/null +++ b/.changelog/5201.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: Add `composite/development` samplers support to declarative file configuration