From e1956926b744f592db1de8ed34a2dadd3c5c67d2 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 14 Apr 2026 12:41:18 +0100 Subject: [PATCH 1/4] add shared load_entry_point util for declarative config plugin loading Extracts a generic `load_entry_point(group, name)` helper into `_common` so that resource detector, exporter, propagator, and sampler plugin loading in declarative file config can all use the same entry point lookup pattern rather than duplicating it. Refactors `_propagator.py` to use the new util, removing its inline entry point lookup. Assisted-by: Claude Sonnet 4.6 --- CHANGELOG.md | 2 + .../sdk/_configuration/_common.py | 30 +++++++++++++- .../sdk/_configuration/_propagator.py | 23 ++--------- .../tests/_configuration/test_common.py | 41 ++++++++++++++++++- .../tests/_configuration/test_propagator.py | 18 ++++---- 5 files changed, 83 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4537ad3f8b..d3bf031ef3 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 `load_entry_point` shared utility to declarative file configuration for loading plugins via entry points; refactor propagator loading to use it + ([#TODO](https://github.com/open-telemetry/opentelemetry-python/pull/TODO)) - `opentelemetry-sdk`: Add `create_logger_provider`/`configure_logger_provider` to declarative file configuration, enabling LoggerProvider instantiation from config files without reading env vars ([#4990](https://github.com/open-telemetry/opentelemetry-python/pull/4990)) - `opentelemetry-sdk`: Add `service` resource detector support to declarative file configuration via `detection_development.detectors[].service` diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index 152be1ea01..0498a19e13 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -15,11 +15,39 @@ from __future__ import annotations import logging -from typing import Optional +from typing import Optional, Type + +from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.util._importlib_metadata import entry_points _logger = logging.getLogger(__name__) +def load_entry_point(group: str, name: str) -> Type: + """Load a plugin class from an entry point group by name. + + Returns the loaded class — callers are responsible for instantiation + with whatever arguments their config requires. + + Raises: + ConfigurationError: If the entry point is not found or fails to load. + """ + try: + ep = next(iter(entry_points(group=group, name=name)), None) + if ep is None: + raise ConfigurationError( + f"Plugin '{name}' not found in group '{group}'. " + "Make sure the package providing this plugin is installed." + ) + return ep.load() + except ConfigurationError: + raise + except Exception as exc: + raise ConfigurationError( + f"Failed to load plugin '{name}' from group '{group}': {exc}" + ) from exc + + def _parse_headers( headers: Optional[list], headers_list: Optional[str], diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py index 3c6372bb73..315a4e8bed 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py @@ -20,7 +20,7 @@ from opentelemetry.propagate import set_global_textmap from opentelemetry.propagators.composite import CompositePropagator from opentelemetry.propagators.textmap import TextMapPropagator -from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration._common import load_entry_point from opentelemetry.sdk._configuration.models import ( Propagator as PropagatorConfig, ) @@ -30,28 +30,11 @@ from opentelemetry.trace.propagation.tracecontext import ( TraceContextTextMapPropagator, ) -from opentelemetry.util._importlib_metadata import entry_points def _load_entry_point_propagator(name: str) -> TextMapPropagator: - """Load a propagator by name from the opentelemetry_propagator entry point group.""" - try: - ep = next( - iter(entry_points(group="opentelemetry_propagator", name=name)), - None, - ) - if not ep: - raise ConfigurationError( - f"Propagator '{name}' not found. " - "It may not be installed or may be misspelled." - ) - return ep.load()() - except ConfigurationError: - raise - except Exception as exc: - raise ConfigurationError( - f"Failed to load propagator '{name}': {exc}" - ) from exc + """Load and instantiate a propagator by name.""" + return load_entry_point("opentelemetry_propagator", name)() def _propagators_from_textmap_config( diff --git a/opentelemetry-sdk/tests/_configuration/test_common.py b/opentelemetry-sdk/tests/_configuration/test_common.py index 5c3fcf112b..0e54c02eef 100644 --- a/opentelemetry-sdk/tests/_configuration/test_common.py +++ b/opentelemetry-sdk/tests/_configuration/test_common.py @@ -14,8 +14,13 @@ import unittest from types import SimpleNamespace +from unittest.mock import MagicMock, patch -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 class TestParseHeaders(unittest.TestCase): @@ -79,3 +84,37 @@ def test_struct_headers_override_headers_list(self): def test_both_empty_struct_and_none_list_returns_empty_dict(self): self.assertEqual(_parse_headers([], None), {}) + + +class TestLoadEntryPoint(unittest.TestCase): + def test_returns_loaded_class(self): + mock_class = MagicMock() + mock_ep = MagicMock() + mock_ep.load.return_value = mock_class + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[mock_ep], + ): + result = load_entry_point("some_group", "some_name") + self.assertIs(result, mock_class) + + def test_raises_when_not_found(self): + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + with self.assertRaises(ConfigurationError) as ctx: + load_entry_point("some_group", "missing") + self.assertIn("missing", str(ctx.exception)) + self.assertIn("some_group", str(ctx.exception)) + + def test_wraps_load_exception_in_configuration_error(self): + mock_ep = MagicMock() + mock_ep.load.side_effect = ImportError("bad import") + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[mock_ep], + ): + with self.assertRaises(ConfigurationError) as ctx: + load_entry_point("some_group", "some_name") + self.assertIn("bad import", str(ctx.exception)) diff --git a/opentelemetry-sdk/tests/_configuration/test_propagator.py b/opentelemetry-sdk/tests/_configuration/test_propagator.py index a8ce467e29..d4aab75e74 100644 --- a/opentelemetry-sdk/tests/_configuration/test_propagator.py +++ b/opentelemetry-sdk/tests/_configuration/test_propagator.py @@ -89,7 +89,7 @@ def test_b3_via_entry_point(self): mock_ep.load.return_value = lambda: mock_propagator with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): config = PropagatorConfig( @@ -106,7 +106,7 @@ def test_b3multi_via_entry_point(self): mock_ep.load.return_value = lambda: mock_propagator with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): config = PropagatorConfig( @@ -118,7 +118,7 @@ def test_b3multi_via_entry_point(self): def test_b3_not_installed_raises_configuration_error(self): with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[], ): config = PropagatorConfig( @@ -135,7 +135,7 @@ def test_composite_list_tracecontext(self): mock_ep.load.return_value = lambda: mock_tc with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): result = create_propagator(config) @@ -158,7 +158,7 @@ def fake_entry_points(group, name): return [] with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", side_effect=fake_entry_points, ): config = PropagatorConfig(composite_list="tracecontext,baggage") @@ -182,7 +182,7 @@ def test_composite_list_whitespace_around_names(self): mock_ep.load.return_value = lambda: mock_tc with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): config = PropagatorConfig(composite_list=" tracecontext ") @@ -195,7 +195,7 @@ def test_entry_point_load_exception_raises_configuration_error(self): mock_ep.load.side_effect = RuntimeError("package broken") with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): config = PropagatorConfig(composite_list="broken-prop") @@ -210,7 +210,7 @@ def test_deduplication_across_composite_and_composite_list(self): mock_ep.load.return_value = lambda: mock_tc with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): config = PropagatorConfig( @@ -229,7 +229,7 @@ def test_deduplication_across_composite_and_composite_list(self): def test_unknown_composite_list_propagator_raises(self): with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[], ): config = PropagatorConfig(composite_list="nonexistent") From a3ad87d837250fe46782df1a8d4391ae5632215a Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 14 Apr 2026 12:44:25 +0100 Subject: [PATCH 2/4] update CHANGELOG with PR number #5093 Assisted-by: Claude Sonnet 4.6 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3bf031ef3..1d532ee6a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased - `opentelemetry-sdk`: add `load_entry_point` shared utility to declarative file configuration for loading plugins via entry points; refactor propagator loading to use it - ([#TODO](https://github.com/open-telemetry/opentelemetry-python/pull/TODO)) + ([#5093](https://github.com/open-telemetry/opentelemetry-python/pull/5093)) - `opentelemetry-sdk`: Add `create_logger_provider`/`configure_logger_provider` to declarative file configuration, enabling LoggerProvider instantiation from config files without reading env vars ([#4990](https://github.com/open-telemetry/opentelemetry-python/pull/4990)) - `opentelemetry-sdk`: Add `service` resource detector support to declarative file configuration via `detection_development.detectors[].service` From 09bd216292c442b6a30ad31811722fde71aeccef Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 14 Apr 2026 14:25:25 +0100 Subject: [PATCH 3/4] add sampler plugin loading to declarative config via entry points Extends _create_sampler() to accept raw dicts (from the YAML path) in addition to typed dataclasses (from the direct API path). Unknown sampler names fall back to load_entry_point("opentelemetry_sampler", name), matching the spec's PluginComponentProvider mechanism and Java SDK behaviour. _create_parent_based_sampler() gets the same dict-path treatment so custom samplers can be used as delegate samplers inside parent_based. Assisted-by: Claude Sonnet 4.6 --- CHANGELOG.md | 2 + .../sdk/_configuration/_tracer_provider.py | 64 +++++++++++++++---- .../_configuration/test_tracer_provider.py | 43 +++++++++++++ 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d532ee6a8..6fed34d18e 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`: Add `create_logger_provider`/`configure_logger_provider` to declarative file configuration, enabling LoggerProvider instantiation from config files without reading env vars diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py index 32dfd96567..a361c5a87a 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py @@ -18,7 +18,10 @@ from typing import 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,8 +180,33 @@ def _create_span_processor( ) -def _create_sampler(config: SamplerConfig) -> Sampler: - """Create a sampler from config.""" +def _create_sampler(config) -> Sampler: + """Create a sampler from config. + + Accepts either a SamplerConfig dataclass (direct/test usage) or a raw dict + (from the YAML integration path). For unknown sampler names, falls back to + entry point loading via the ``opentelemetry_sampler`` group — matching the + spec's PluginComponentProvider mechanism and Java SDK behaviour. + """ + if isinstance(config, dict): + if len(config) != 1: + raise ConfigurationError( + f"Sampler config must have exactly one key, got: {list(config.keys())}" + ) + name, plugin_config = next(iter(config.items())) + known = { + "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 {}), + } + if name in known: + return known[name](plugin_config) + return load_entry_point("opentelemetry_sampler", name)() + + # Dataclass path (direct API / unit tests) if config.always_on is not None: return ALWAYS_ON if config.always_off is not None: @@ -200,12 +222,30 @@ def _create_sampler(config: SamplerConfig) -> Sampler: ) -def _create_parent_based_sampler(config: ParentBasedSamplerConfig) -> Sampler: - """Create a ParentBased sampler from config, applying SDK defaults for absent delegates.""" +def _create_parent_based_sampler(config) -> Sampler: + """Create a ParentBased sampler from config, applying SDK defaults for absent delegates. + + Accepts either a ParentBasedSamplerConfig dataclass or a raw dict. + """ + if isinstance(config, dict): + 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) + root = ( _create_sampler(config.root) if config.root is not None else ALWAYS_ON ) - kwargs: dict = {"root": root} + kwargs = {"root": root} if config.remote_parent_sampled is not None: kwargs["remote_parent_sampled"] = _create_sampler( config.remote_parent_sampled diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index 5caf077cd5..544e8f0579 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -69,6 +69,7 @@ ALWAYS_OFF, ALWAYS_ON, ParentBased, + Sampler, TraceIdRatioBased, ) @@ -223,6 +224,48 @@ def test_unknown_sampler_raises_configuration_error(self): TracerProviderConfig(processors=[], sampler=SamplerConfig()) ) + # --- dict path (YAML integration) --- + + def test_dict_always_on(self): + provider = self._make_provider({"always_on": {}}) + self.assertIs(provider.sampler, ALWAYS_ON) + + def test_dict_always_off(self): + provider = self._make_provider({"always_off": {}}) + self.assertIs(provider.sampler, ALWAYS_OFF) + + def test_dict_trace_id_ratio_based(self): + provider = self._make_provider( + {"trace_id_ratio_based": {"ratio": 0.25}} + ) + self.assertIsInstance(provider.sampler, TraceIdRatioBased) + self.assertAlmostEqual(provider.sampler._rate, 0.25) + + def test_dict_parent_based(self): + provider = self._make_provider( + {"parent_based": {"root": {"always_off": {}}}} + ) + self.assertIsInstance(provider.sampler, ParentBased) + self.assertIs(provider.sampler._root, ALWAYS_OFF) + + def test_dict_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_dict_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): # pylint: disable=no-self-use From 1feb28755f5cf5a63d022203a3c551fdae9df7b0 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 14 Apr 2026 14:38:49 +0100 Subject: [PATCH 4/4] add sampler plugin loading to declarative config via entry points Sampler and ParentBasedSampler are changed from @dataclass to TypeAlias = dict[str, Any] in models.py. The generated dataclass representation dropped unknown keys, making plugin sampler names unrecoverable before reaching the factory. The dict type preserves the raw YAML key, which is the plugin name. _create_sampler() now has a single code path: extract the single key as the sampler name, look it up in _SAMPLER_REGISTRY (always_on, always_off, trace_id_ratio_based, parent_based), and fall back to load_entry_point("opentelemetry_sampler", name) for unknown names. This matches the spec's PluginComponentProvider mechanism. Assisted-by: Claude Sonnet 4.6 --- .../sdk/_configuration/_tracer_provider.py | 114 ++++++------------ .../sdk/_configuration/models.py | 25 ++-- .../_configuration/test_tracer_provider.py | 83 +++---------- 3 files changed, 62 insertions(+), 160 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py index a361c5a87a..6a6295660c 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py @@ -15,7 +15,7 @@ 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 ( @@ -180,88 +180,46 @@ def _create_span_processor( ) -def _create_sampler(config) -> Sampler: - """Create a sampler from config. +_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 {}), +} - Accepts either a SamplerConfig dataclass (direct/test usage) or a raw dict - (from the YAML integration path). For unknown sampler names, falls back to - entry point loading via the ``opentelemetry_sampler`` group — matching the - spec's PluginComponentProvider mechanism and Java SDK behaviour. - """ - if isinstance(config, dict): - if len(config) != 1: - raise ConfigurationError( - f"Sampler config must have exactly one key, got: {list(config.keys())}" - ) - name, plugin_config = next(iter(config.items())) - known = { - "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 {}), - } - if name in known: - return known[name](plugin_config) - return load_entry_point("opentelemetry_sampler", name)() - - # Dataclass path (direct API / unit tests) - 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." - ) +def _create_sampler(config: dict) -> Sampler: + """Create a sampler from a config dict with a single key naming the sampler type. -def _create_parent_based_sampler(config) -> Sampler: - """Create a ParentBased sampler from config, applying SDK defaults for absent delegates. - - Accepts either a ParentBasedSamplerConfig dataclass or a raw dict. + 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 isinstance(config, dict): - 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) - - root = ( - _create_sampler(config.root) if config.root is not None else ALWAYS_ON - ) - kwargs = {"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 + 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/src/opentelemetry/sdk/_configuration/models.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py index 5159137228..4446a9af3b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py @@ -759,24 +759,13 @@ class ExperimentalJaegerRemoteSampler: interval: int | None = None -@dataclass -class ParentBasedSampler: - root: Sampler | None = None - remote_parent_sampled: Sampler | None = None - remote_parent_not_sampled: Sampler | None = None - local_parent_sampled: Sampler | None = None - local_parent_not_sampled: Sampler | None = None - - -@dataclass -class Sampler: - always_off: AlwaysOffSampler | None = None - always_on: AlwaysOnSampler | None = None - composite_development: ExperimentalComposableSampler | None = None - jaeger_remote_development: ExperimentalJaegerRemoteSampler | None = None - parent_based: ParentBasedSampler | None = None - probability_development: ExperimentalProbabilitySampler | None = None - trace_id_ratio_based: TraceIdRatioBasedSampler | None = None +# Diverges from codegen: Sampler and ParentBasedSampler are typed as +# dict[str, Any] rather than dataclasses so that unknown sampler names +# (plugin/custom samplers) are preserved as dict keys through the config +# pipeline. The loader stores nested fields as raw dicts anyway, so the +# typed dataclass representation would drop unknown keys silently. +Sampler: TypeAlias = dict[str, Any] +ParentBasedSampler: TypeAlias = dict[str, Any] @dataclass diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index 544e8f0579..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, ) @@ -159,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) @@ -218,37 +199,11 @@ 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()) - ) - - # --- dict path (YAML integration) --- - - def test_dict_always_on(self): - provider = self._make_provider({"always_on": {}}) - self.assertIs(provider.sampler, ALWAYS_ON) - - def test_dict_always_off(self): - provider = self._make_provider({"always_off": {}}) - self.assertIs(provider.sampler, ALWAYS_OFF) - - def test_dict_trace_id_ratio_based(self): - provider = self._make_provider( - {"trace_id_ratio_based": {"ratio": 0.25}} - ) - self.assertIsInstance(provider.sampler, TraceIdRatioBased) - self.assertAlmostEqual(provider.sampler._rate, 0.25) - - def test_dict_parent_based(self): - provider = self._make_provider( - {"parent_based": {"root": {"always_off": {}}}} - ) - self.assertIsInstance(provider.sampler, ParentBased) - self.assertIs(provider.sampler._root, ALWAYS_OFF) + self._make_provider({"always_on": {}, "always_off": {}}) - def test_dict_plugin_sampler_loaded_via_entry_point(self): + def test_plugin_sampler_loaded_via_entry_point(self): mock_sampler = MagicMock(spec=Sampler) mock_class = MagicMock(return_value=mock_sampler) with patch( @@ -258,7 +213,7 @@ def test_dict_plugin_sampler_loaded_via_entry_point(self): provider = self._make_provider({"my_custom_sampler": {}}) self.assertIs(provider.sampler, mock_sampler) - def test_dict_unknown_plugin_raises_configuration_error(self): + def test_unknown_plugin_raises_configuration_error(self): with patch( "opentelemetry.sdk._configuration._common.entry_points", return_value=[],