Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#4806](https://github.com/open-telemetry/opentelemetry-python/pull/4806))
- 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))
- Make ConcurrentMultiSpanProcessor fork safe
([#4862](https://github.com/open-telemetry/opentelemetry-python/pull/4862))
- `opentelemetry-exporter-otlp-proto-http`: fix retry logic and error handling for connection failures in trace, metric, and log exporters
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"composable_always_off",
"composable_always_on",
"composable_parent_threshold",
"composable_rule_based",
"composable_traceid_ratio_based",
"composite_sampler",
]
Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# 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)
182 changes: 182 additions & 0 deletions opentelemetry-sdk/tests/trace/composite_sampler/test_rule_based.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# 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_match_multiple_rules():
rules = [
(AttributePredicate("foo", "bar"), composable_always_off()),
(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
)
Loading