diff --git a/CHANGELOG.md b/CHANGELOG.md index 6024431107..4e953526c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `opentelemetry-sdk`: add sampler plugin loading to declarative file configuration via the `opentelemetry_sampler` entry point group, matching the spec's PluginComponentProvider mechanism + ([#5071](https://github.com/open-telemetry/opentelemetry-python/pull/5071)) - `opentelemetry-sdk`: add `load_entry_point` shared utility to declarative file configuration for loading plugins via entry points; refactor propagator loading to use it ([#5093](https://github.com/open-telemetry/opentelemetry-python/pull/5093)) - `opentelemetry-sdk`: fix YAML structure injection via environment variable substitution in declarative file configuration; values containing newlines are now emitted as quoted YAML scalars per spec requirement diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py index 32dfd96567..6a6295660c 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py @@ -15,10 +15,13 @@ from __future__ import annotations import logging -from typing import Optional +from typing import Any, Optional from opentelemetry import trace -from opentelemetry.sdk._configuration._common import _parse_headers +from opentelemetry.sdk._configuration._common import ( + _parse_headers, + load_entry_point, +) from opentelemetry.sdk._configuration._exceptions import ConfigurationError from opentelemetry.sdk._configuration.models import ( OtlpGrpcExporter as OtlpGrpcExporterConfig, @@ -26,12 +29,6 @@ from opentelemetry.sdk._configuration.models import ( OtlpHttpExporter as OtlpHttpExporterConfig, ) -from opentelemetry.sdk._configuration.models import ( - ParentBasedSampler as ParentBasedSamplerConfig, -) -from opentelemetry.sdk._configuration.models import ( - Sampler as SamplerConfig, -) from opentelemetry.sdk._configuration.models import ( SpanExporter as SpanExporterConfig, ) @@ -183,45 +180,46 @@ def _create_span_processor( ) -def _create_sampler(config: SamplerConfig) -> Sampler: - """Create a sampler from config.""" - if config.always_on is not None: - return ALWAYS_ON - if config.always_off is not None: - return ALWAYS_OFF - 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.parent_based is not None: - return _create_parent_based_sampler(config.parent_based) - raise ConfigurationError( - f"Unknown or unsupported sampler type in config: {config!r}. " - "Supported types: always_on, always_off, trace_id_ratio_based, parent_based." - ) +_SAMPLER_REGISTRY: dict[str, Any] = { + "always_on": lambda _: ALWAYS_ON, + "always_off": lambda _: ALWAYS_OFF, + "trace_id_ratio_based": lambda c: TraceIdRatioBased( + (c or {}).get("ratio", 1.0) + ), + "parent_based": lambda c: _create_parent_based_sampler(c or {}), +} -def _create_parent_based_sampler(config: ParentBasedSamplerConfig) -> Sampler: - """Create a ParentBased sampler from config, applying SDK defaults for absent delegates.""" - root = ( - _create_sampler(config.root) if config.root is not None else ALWAYS_ON - ) - kwargs: dict = {"root": root} - if config.remote_parent_sampled is not None: - kwargs["remote_parent_sampled"] = _create_sampler( - config.remote_parent_sampled - ) - if config.remote_parent_not_sampled is not None: - kwargs["remote_parent_not_sampled"] = _create_sampler( - config.remote_parent_not_sampled - ) - if config.local_parent_sampled is not None: - kwargs["local_parent_sampled"] = _create_sampler( - config.local_parent_sampled - ) - if config.local_parent_not_sampled is not None: - kwargs["local_parent_not_sampled"] = _create_sampler( - config.local_parent_not_sampled +def _create_sampler(config: dict) -> Sampler: + """Create a sampler from a config dict with a single key naming the sampler type. + + Known names (always_on, always_off, trace_id_ratio_based, parent_based) are + bootstrapped directly. Unknown names are looked up via the + ``opentelemetry_sampler`` entry point group, matching the spec's + PluginComponentProvider mechanism. + """ + if len(config) != 1: + raise ConfigurationError( + f"Sampler config must have exactly one key, got: {list(config.keys())}" ) + name, sampler_config = next(iter(config.items())) + if name in _SAMPLER_REGISTRY: + return _SAMPLER_REGISTRY[name](sampler_config) + return load_entry_point("opentelemetry_sampler", name)() + + +def _create_parent_based_sampler(config: dict) -> Sampler: + """Create a ParentBased sampler from a config dict, applying SDK defaults for absent delegates.""" + root = _create_sampler(config["root"]) if "root" in config else ALWAYS_ON + kwargs: dict = {"root": root} + for key in ( + "remote_parent_sampled", + "remote_parent_not_sampled", + "local_parent_sampled", + "local_parent_not_sampled", + ): + if key in config: + kwargs[key] = _create_sampler(config[key]) return ParentBased(**kwargs) diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index 5caf077cd5..8c9cfc46e6 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -34,12 +34,6 @@ from opentelemetry.sdk._configuration.models import ( OtlpHttpExporter as OtlpHttpExporterConfig, ) -from opentelemetry.sdk._configuration.models import ( - ParentBasedSampler as ParentBasedSamplerConfig, -) -from opentelemetry.sdk._configuration.models import ( - Sampler as SamplerConfig, -) from opentelemetry.sdk._configuration.models import ( SimpleSpanProcessor as SimpleSpanProcessorConfig, ) @@ -52,9 +46,6 @@ from opentelemetry.sdk._configuration.models import ( SpanProcessor as SpanProcessorConfig, ) -from opentelemetry.sdk._configuration.models import ( - TraceIdRatioBasedSampler as TraceIdRatioBasedConfig, -) from opentelemetry.sdk._configuration.models import ( TracerProvider as TracerProviderConfig, ) @@ -69,6 +60,7 @@ ALWAYS_OFF, ALWAYS_ON, ParentBased, + Sampler, TraceIdRatioBased, ) @@ -158,57 +150,47 @@ def _make_provider(sampler_config): ) def test_always_on(self): - provider = self._make_provider(SamplerConfig(always_on={})) + provider = self._make_provider({"always_on": {}}) self.assertIs(provider.sampler, ALWAYS_ON) def test_always_off(self): - provider = self._make_provider(SamplerConfig(always_off={})) + provider = self._make_provider({"always_off": {}}) self.assertIs(provider.sampler, ALWAYS_OFF) def test_trace_id_ratio_based(self): provider = self._make_provider( - SamplerConfig( - trace_id_ratio_based=TraceIdRatioBasedConfig(ratio=0.5) - ) + {"trace_id_ratio_based": {"ratio": 0.5}} ) self.assertIsInstance(provider.sampler, TraceIdRatioBased) self.assertAlmostEqual(provider.sampler._rate, 0.5) def test_trace_id_ratio_based_none_ratio_defaults_to_1(self): - provider = self._make_provider( - SamplerConfig(trace_id_ratio_based=TraceIdRatioBasedConfig()) - ) + provider = self._make_provider({"trace_id_ratio_based": {}}) self.assertIsInstance(provider.sampler, TraceIdRatioBased) self.assertAlmostEqual(provider.sampler._rate, 1.0) def test_parent_based_with_root(self): provider = self._make_provider( - SamplerConfig( - parent_based=ParentBasedSamplerConfig( - root=SamplerConfig(always_on={}) - ) - ) + {"parent_based": {"root": {"always_on": {}}}} ) self.assertIsInstance(provider.sampler, ParentBased) def test_parent_based_no_root_defaults_to_always_on(self): - provider = self._make_provider( - SamplerConfig(parent_based=ParentBasedSamplerConfig()) - ) + provider = self._make_provider({"parent_based": {}}) self.assertIsInstance(provider.sampler, ParentBased) self.assertIs(provider.sampler._root, ALWAYS_ON) def test_parent_based_with_delegate_samplers(self): provider = self._make_provider( - SamplerConfig( - parent_based=ParentBasedSamplerConfig( - root=SamplerConfig(always_on={}), - remote_parent_sampled=SamplerConfig(always_on={}), - remote_parent_not_sampled=SamplerConfig(always_off={}), - local_parent_sampled=SamplerConfig(always_on={}), - local_parent_not_sampled=SamplerConfig(always_off={}), - ) - ) + { + "parent_based": { + "root": {"always_on": {}}, + "remote_parent_sampled": {"always_on": {}}, + "remote_parent_not_sampled": {"always_off": {}}, + "local_parent_sampled": {"always_on": {}}, + "local_parent_not_sampled": {"always_off": {}}, + } + } ) sampler = provider.sampler self.assertIsInstance(sampler, ParentBased) @@ -217,11 +199,27 @@ def test_parent_based_with_delegate_samplers(self): self.assertIs(sampler._local_parent_sampled, ALWAYS_ON) self.assertIs(sampler._local_parent_not_sampled, ALWAYS_OFF) - def test_unknown_sampler_raises_configuration_error(self): + def test_multiple_keys_raises_configuration_error(self): with self.assertRaises(ConfigurationError): - create_tracer_provider( - TracerProviderConfig(processors=[], sampler=SamplerConfig()) - ) + self._make_provider({"always_on": {}, "always_off": {}}) + + def test_plugin_sampler_loaded_via_entry_point(self): + mock_sampler = MagicMock(spec=Sampler) + mock_class = MagicMock(return_value=mock_sampler) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[MagicMock(**{"load.return_value": mock_class})], + ): + provider = self._make_provider({"my_custom_sampler": {}}) + self.assertIs(provider.sampler, mock_sampler) + + def test_unknown_plugin_raises_configuration_error(self): + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + with self.assertRaises(ConfigurationError): + self._make_provider({"no_such_sampler": {}}) class TestCreateSpanExporterAndProcessor(unittest.TestCase):