From e1956926b744f592db1de8ed34a2dadd3c5c67d2 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 14 Apr 2026 12:41:18 +0100 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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=[], From 4809d808a233c11bfdad66e5d47003b25f105662 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 20 Apr 2026 13:57:35 +0100 Subject: [PATCH 5/6] revert models.py changes, use raw dicts without modifying codegen Python dataclasses don't enforce types at runtime, so factory functions can accept raw dicts without changing the generated models. This keeps models.py identical to the codegen output. Assisted-by: Claude Opus 4.6 (1M context) --- .../sdk/_configuration/models.py | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py index 4446a9af3b..5159137228 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py @@ -759,13 +759,24 @@ class ExperimentalJaegerRemoteSampler: interval: int | 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 +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 @dataclass From b205580afa103febe064c1c09dd8fc615cc00a2c Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 29 Apr 2026 14:42:54 +0100 Subject: [PATCH 6/6] update sampler plugin loading to use additional_properties Use typed SamplerConfig and ParentBasedSamplerConfig with additional_properties from the @_additional_properties decorator instead of raw dict iteration. Known sampler types are checked via typed fields. Unknown plugin names from additional_properties are loaded via the opentelemetry_sampler entry point group. Assisted-by: Claude Opus 4.6 --- .../sdk/_configuration/_tracer_provider.py | 86 +++++++++++-------- .../_configuration/test_tracer_provider.py | 59 +++++++++---- 2 files changed, 90 insertions(+), 55 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py index 6a6295660c..6ebda1d959 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 Any, Optional +from typing import Optional from opentelemetry import trace from opentelemetry.sdk._configuration._common import ( @@ -29,6 +29,12 @@ 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, ) @@ -180,46 +186,54 @@ def _create_span_processor( ) -_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_sampler(config: SamplerConfig) -> Sampler: + """Create a sampler from config. - -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. + Known sampler types are checked via typed fields on the Sampler + dataclass. Unknown sampler names captured in additional_properties + by the @_additional_properties decorator are loaded via the + ``opentelemetry_sampler`` entry point group. """ - 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)() + 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) + 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." + ) -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 +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} - 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]) + 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 + ) return ParentBased(**kwargs) diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index 8c9cfc46e6..451a473a7c 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -34,6 +34,12 @@ 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, ) @@ -46,6 +52,9 @@ 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, ) @@ -150,47 +159,57 @@ def _make_provider(sampler_config): ) def test_always_on(self): - provider = self._make_provider({"always_on": {}}) + provider = self._make_provider(SamplerConfig(always_on={})) self.assertIs(provider.sampler, ALWAYS_ON) def test_always_off(self): - provider = self._make_provider({"always_off": {}}) + provider = self._make_provider(SamplerConfig(always_off={})) self.assertIs(provider.sampler, ALWAYS_OFF) def test_trace_id_ratio_based(self): provider = self._make_provider( - {"trace_id_ratio_based": {"ratio": 0.5}} + SamplerConfig( + trace_id_ratio_based=TraceIdRatioBasedConfig(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({"trace_id_ratio_based": {}}) + provider = self._make_provider( + SamplerConfig(trace_id_ratio_based=TraceIdRatioBasedConfig()) + ) self.assertIsInstance(provider.sampler, TraceIdRatioBased) self.assertAlmostEqual(provider.sampler._rate, 1.0) def test_parent_based_with_root(self): provider = self._make_provider( - {"parent_based": {"root": {"always_on": {}}}} + SamplerConfig( + parent_based=ParentBasedSamplerConfig( + root=SamplerConfig(always_on={}) + ) + ) ) self.assertIsInstance(provider.sampler, ParentBased) def test_parent_based_no_root_defaults_to_always_on(self): - provider = self._make_provider({"parent_based": {}}) + provider = self._make_provider( + SamplerConfig(parent_based=ParentBasedSamplerConfig()) + ) self.assertIsInstance(provider.sampler, ParentBased) self.assertIs(provider.sampler._root, ALWAYS_ON) def test_parent_based_with_delegate_samplers(self): provider = self._make_provider( - { - "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": {}}, - } - } + 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={}), + ) + ) ) sampler = provider.sampler self.assertIsInstance(sampler, ParentBased) @@ -199,9 +218,9 @@ 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_multiple_keys_raises_configuration_error(self): + def test_unknown_sampler_raises_configuration_error(self): with self.assertRaises(ConfigurationError): - self._make_provider({"always_on": {}, "always_off": {}}) + self._make_provider(SamplerConfig()) def test_plugin_sampler_loaded_via_entry_point(self): mock_sampler = MagicMock(spec=Sampler) @@ -210,7 +229,8 @@ def test_plugin_sampler_loaded_via_entry_point(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[MagicMock(**{"load.return_value": mock_class})], ): - provider = self._make_provider({"my_custom_sampler": {}}) + # pylint: disable=unexpected-keyword-arg + provider = self._make_provider(SamplerConfig(my_custom_sampler={})) self.assertIs(provider.sampler, mock_sampler) def test_unknown_plugin_raises_configuration_error(self): @@ -219,7 +239,8 @@ def test_unknown_plugin_raises_configuration_error(self): return_value=[], ): with self.assertRaises(ConfigurationError): - self._make_provider({"no_such_sampler": {}}) + # pylint: disable=unexpected-keyword-arg + self._make_provider(SamplerConfig(no_such_sampler={})) class TestCreateSpanExporterAndProcessor(unittest.TestCase):