Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/5201.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-sdk`: Add `composite/development` samplers support to declarative file configuration
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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,
)
Expand Down Expand Up @@ -46,6 +55,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,
Expand All @@ -59,6 +87,7 @@
Sampler,
TraceIdRatioBased,
)
from opentelemetry.trace import SpanKind as TraceSpanKind

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -185,6 +214,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.

Expand All @@ -200,14 +311,21 @@ 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:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if this is still in development this is not gated under an option to filter stable only components because with declarative config the enablement of experimental stuff is explicit by the user

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:
name = next(iter(config.additional_properties))
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."
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@

from __future__ import annotations

import logging
import re
from collections.abc import Sequence
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
Expand All @@ -32,6 +39,9 @@ class AttributePredicate:
"""An exact match of an attribute value"""

def __init__(self, key: str, value: AnyValue):
logging.warning(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a predicate I had around when implemented the rule based sampler, does not make much sense to keep around since we have one that implements a superset of its features

"This is deprecated, use AttributeValuesPredicate instead"
)
self.key = key
self.value = value

Expand All @@ -52,6 +62,184 @@ def __str__(self):
return f"{self.key}={self.value}"


class AlwaysMatchPredicate:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if these are not spec'ed in the SDK specification I've added these predicates near the rule based sampler. It looked more natural to have them here instead of in sdk configuration side for discoverability if you are not using declarative config. I think Java does not do that though.

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(
_glob_matches(value, pattern) for pattern in self._included
)
excluded = any(
_glob_matches(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,)


def _glob_matches(value: str, glob_pattern: str) -> bool:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use fnmatch here? Also, do we need to handle escaping and such?

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(
Expand Down
Loading
Loading