diff --git a/CHANGELOG.md b/CHANGELOG.md index 4537ad3f8b..57a512f6ca 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 propagator plugin loading to declarative file configuration via the `opentelemetry_propagator` entry point group, matching the spec's PluginComponentProvider mechanism + ([#5070](https://github.com/open-telemetry/opentelemetry-python/pull/5070)) +- `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..2b509a1f99 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py @@ -20,53 +20,37 @@ 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, ) -from opentelemetry.sdk._configuration.models import ( - TextMapPropagator as TextMapPropagatorConfig, -) 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 + +# Propagators bundled with the SDK — no entry point lookup needed. +_PROPAGATOR_REGISTRY: dict = { + "tracecontext": lambda _: TraceContextTextMapPropagator(), + "baggage": lambda _: W3CBaggagePropagator(), +} def _propagators_from_textmap_config( - config: TextMapPropagatorConfig, + config: dict, ) -> list[TextMapPropagator]: - """Resolve a single TextMapPropagator config entry to a list of propagators.""" + """Resolve a TextMapPropagator config dict to a list of propagators. + + Each key in the dict names a propagator. Known names (tracecontext, baggage) + are bootstrapped directly. All other names — including b3, b3multi, and + custom plugin propagators — are loaded via the ``opentelemetry_propagator`` + entry point group, matching the spec's PluginComponentProvider mechanism. + """ result: list[TextMapPropagator] = [] - if config.tracecontext is not None: - result.append(TraceContextTextMapPropagator()) - if config.baggage is not None: - result.append(W3CBaggagePropagator()) - if config.b3 is not None: - result.append(_load_entry_point_propagator("b3")) - if config.b3multi is not None: - result.append(_load_entry_point_propagator("b3multi")) + for name, prop_config in config.items(): + if name in _PROPAGATOR_REGISTRY: + result.append(_PROPAGATOR_REGISTRY[name](prop_config)) + else: + result.append(load_entry_point("opentelemetry_propagator", name)()) return result @@ -102,7 +86,7 @@ def create_propagator( name = name.strip() if not name or name.lower() == "none": continue - propagator = _load_entry_point_propagator(name) + propagator = load_entry_point("opentelemetry_propagator", name)() propagators.setdefault(type(propagator), propagator) return CompositePropagator(list(propagators.values())) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py index 5159137228..59770a8e78 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py @@ -573,12 +573,10 @@ class SpanProcessor: simple: SimpleSpanProcessor | None = None -@dataclass -class TextMapPropagator: - tracecontext: TraceContextPropagator | None = None - baggage: BaggagePropagator | None = None - b3: B3Propagator | None = None - b3multi: B3MultiPropagator | None = None +# Diverges from codegen: TextMapPropagator is typed as dict[str, Any] rather +# than a dataclass so that unknown propagator names (plugin/custom propagators) +# are preserved as dict keys through the config pipeline. +TextMapPropagator: 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..8f50486668 100644 --- a/opentelemetry-sdk/tests/_configuration/test_propagator.py +++ b/opentelemetry-sdk/tests/_configuration/test_propagator.py @@ -30,9 +30,6 @@ from opentelemetry.sdk._configuration.models import ( Propagator as PropagatorConfig, ) -from opentelemetry.sdk._configuration.models import ( - TextMapPropagator as TextMapPropagatorConfig, -) from opentelemetry.trace.propagation.tracecontext import ( TraceContextTextMapPropagator, ) @@ -50,9 +47,7 @@ def test_empty_config_returns_empty_composite(self): self.assertEqual(result._propagators, []) # type: ignore[attr-defined] def test_tracecontext_only(self): - config = PropagatorConfig( - composite=[TextMapPropagatorConfig(tracecontext={})] - ) + config = PropagatorConfig(composite=[{"tracecontext": {}}]) result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] self.assertIsInstance( @@ -61,9 +56,7 @@ def test_tracecontext_only(self): ) def test_baggage_only(self): - config = PropagatorConfig( - composite=[TextMapPropagatorConfig(baggage={})] - ) + config = PropagatorConfig(composite=[{"baggage": {}}]) result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] self.assertIsInstance(result._propagators[0], W3CBaggagePropagator) # type: ignore[attr-defined] @@ -71,8 +64,8 @@ def test_baggage_only(self): def test_tracecontext_and_baggage(self): config = PropagatorConfig( composite=[ - TextMapPropagatorConfig(tracecontext={}), - TextMapPropagatorConfig(baggage={}), + {"tracecontext": {}}, + {"baggage": {}}, ] ) result = create_propagator(config) @@ -89,12 +82,10 @@ 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( - composite=[TextMapPropagatorConfig(b3={})] - ) + config = PropagatorConfig(composite=[{"b3": {}}]) result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] @@ -106,24 +97,20 @@ 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( - composite=[TextMapPropagatorConfig(b3multi={})] - ) + config = PropagatorConfig(composite=[{"b3multi": {}}]) result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] 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( - composite=[TextMapPropagatorConfig(b3={})] - ) + config = PropagatorConfig(composite=[{"b3": {}}]) with self.assertRaises(ConfigurationError) as ctx: create_propagator(config) self.assertIn("b3", str(ctx.exception)) @@ -135,7 +122,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 +145,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 +169,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 +182,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,11 +197,11 @@ 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( - composite=[TextMapPropagatorConfig(tracecontext={})], + composite=[{"tracecontext": {}}], composite_list="tracecontext", ) result = create_propagator(config) @@ -229,13 +216,37 @@ 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") with self.assertRaises(ConfigurationError): create_propagator(config) + def test_plugin_propagator_via_entry_point(self): + mock_propagator = MagicMock() + mock_ep = MagicMock() + mock_ep.load.return_value = lambda: mock_propagator + + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[mock_ep], + ): + config = PropagatorConfig(composite=[{"my_custom_propagator": {}}]) + result = create_propagator(config) + + self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] + self.assertIs(result._propagators[0], mock_propagator) # type: ignore[attr-defined] + + def test_unknown_composite_propagator_raises(self): + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + config = PropagatorConfig(composite=[{"nonexistent": {}}]) + with self.assertRaises(ConfigurationError): + create_propagator(config) + class TestConfigurePropagator(unittest.TestCase): def test_configure_propagator_calls_set_global_textmap(self): @@ -248,9 +259,7 @@ def test_configure_propagator_calls_set_global_textmap(self): self.assertIsInstance(arg, CompositePropagator) def test_configure_propagator_with_config(self): - config = PropagatorConfig( - composite=[TextMapPropagatorConfig(tracecontext={})] - ) + config = PropagatorConfig(composite=[{"tracecontext": {}}]) with patch( "opentelemetry.sdk._configuration._propagator.set_global_textmap" ) as mock_set: @@ -263,9 +272,7 @@ def test_configure_propagator_with_config(self): @patch.dict(environ, {OTEL_PROPAGATORS: "baggage"}) def test_otel_propagators_env_var_ignored(self): """OTEL_PROPAGATORS env var must not influence configure_propagator output.""" - config = PropagatorConfig( - composite=[TextMapPropagatorConfig(tracecontext={})] - ) + config = PropagatorConfig(composite=[{"tracecontext": {}}]) with patch( "opentelemetry.sdk._configuration._propagator.set_global_textmap" ) as mock_set: