diff --git a/CHANGELOG.md b/CHANGELOG.md index 4537ad3f8b..6fed34d18e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ 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 ([#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/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/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_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") 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):