From 5c5e868e252b241f5f640a9e9598e8f0732afb60 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 20 Apr 2026 12:37:34 +0100 Subject: [PATCH 1/3] add generic resource detector plugin loading to declarative config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExperimentalResourceDetector is changed from @dataclass to TypeAlias = dict[str, Any] in models.py, preserving unknown detector names as dict keys through the config pipeline. _run_detectors() now iterates the dict's key-value pairs directly via _RESOURCE_DETECTOR_REGISTRY. Known names (service, host, process) are bootstrapped directly from the SDK. Unknown names — including container and custom plugin detectors — are loaded via the opentelemetry_resource_detector entry point group, matching the spec's PluginComponentProvider mechanism. The container detector behavior changes from warning-when-missing to raising ConfigurationError, consistent with the fail-fast approach agreed on in the issue discussion. Assisted-by: Claude Opus 4.6 --- CHANGELOG.md | 2 + .../sdk/_configuration/_resource.py | 78 ++++++++----------- .../sdk/_configuration/models.py | 10 +-- .../tests/_configuration/test_resource.py | 59 ++++++++++---- 4 files changed, 82 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6024431107..68c59a06cb 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 generic resource detector plugin loading to declarative file configuration via the `opentelemetry_resource_detector` entry point group + ([#XXXX](https://github.com/open-telemetry/opentelemetry-python/pull/XXXX)) - `opentelemetry-sdk`: add `load_entry_point` shared utility to declarative file configuration for loading plugins via entry points; refactor propagator loading to use it ([#5093](https://github.com/open-telemetry/opentelemetry-python/pull/5093)) - `opentelemetry-sdk`: fix YAML structure injection via environment variable substitution in declarative file configuration; values containing newlines are now emitted as quoted YAML scalars per spec requirement diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py index 13ef0a234a..3046cc162f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -21,10 +21,10 @@ from typing import Callable, Optional from urllib import parse +from opentelemetry.sdk._configuration._common import load_entry_point from opentelemetry.sdk._configuration.models import ( AttributeNameValue, AttributeType, - ExperimentalResourceDetector, IncludeExclude, ) from opentelemetry.sdk._configuration.models import Resource as ResourceConfig @@ -37,7 +37,6 @@ Resource, _HostResourceDetector, ) -from opentelemetry.util._importlib_metadata import entry_points _logger = logging.getLogger(__name__) @@ -146,55 +145,44 @@ def create_resource(config: Optional[ResourceConfig]) -> Resource: return result.merge(config_resource) +def _detect_service(_config) -> dict[str, object]: + """Service detector: generates instance ID and reads OTEL_SERVICE_NAME.""" + attrs: dict[str, object] = { + SERVICE_INSTANCE_ID: str(uuid.uuid4()), + } + service_name = os.environ.get(OTEL_SERVICE_NAME) + if service_name: + attrs[SERVICE_NAME] = service_name + return attrs + + +_RESOURCE_DETECTOR_REGISTRY: dict = { + "service": _detect_service, + "host": lambda _: dict(_HostResourceDetector().detect().attributes), + "process": lambda _: dict(ProcessResourceDetector().detect().attributes), +} + + def _run_detectors( - detector_config: ExperimentalResourceDetector, + detector_config: dict, detected_attrs: dict[str, object], ) -> None: - """Run any detectors present in a single detector config entry. + """Run detectors present in a single detector config entry. - Each detector PR adds its own branch here. The detected_attrs dict - is updated in-place; later detectors overwrite earlier ones for the - same key. + Each key in the dict names a detector. Known names (service, host, process) + are bootstrapped directly. Unknown names — including container and custom + plugin detectors — are loaded via the ``opentelemetry_resource_detector`` + entry point group, matching the spec's PluginComponentProvider mechanism. + + The detected_attrs dict is updated in-place; later detectors overwrite + earlier ones for the same key. """ - if detector_config.service is not None: - attrs: dict[str, object] = { - SERVICE_INSTANCE_ID: str(uuid.uuid4()), - } - service_name = os.environ.get(OTEL_SERVICE_NAME) - if service_name: - attrs[SERVICE_NAME] = service_name - detected_attrs.update(attrs) - - if detector_config.host is not None: - detected_attrs.update(_HostResourceDetector().detect().attributes) - - if detector_config.container is not None: - # The container detector is not part of the core SDK. It is provided - # by the opentelemetry-resource-detector-containerid contrib package, - # which registers itself under the opentelemetry_resource_detector - # entry point group as "container". Loading via entry point matches - # the env-var config counterpart (OTEL_EXPERIMENTAL_RESOURCE_DETECTORS) - # and avoids a hard import dependency on contrib. See also: - # https://github.com/open-telemetry/opentelemetry-configuration/issues/570 - ep = next( - iter( - entry_points( - group="opentelemetry_resource_detector", name="container" - ) - ), - None, - ) - if ep is None: - _logger.warning( - "container resource detector requested but " - "'opentelemetry-resource-detector-containerid' is not " - "installed; install it to enable container detection" - ) + for name, config in detector_config.items(): + if name in _RESOURCE_DETECTOR_REGISTRY: + detected_attrs.update(_RESOURCE_DETECTOR_REGISTRY[name](config)) else: - detected_attrs.update(ep.load()().detect().attributes) - - if detector_config.process is not None: - detected_attrs.update(ProcessResourceDetector().detect().attributes) + cls = load_entry_point("opentelemetry_resource_detector", name) + detected_attrs.update(cls().detect().attributes) def _filter_attributes( diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py index 5159137228..55dd65b8a4 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py @@ -500,12 +500,10 @@ class ExperimentalPrometheusMetricExporter: ) -@dataclass -class ExperimentalResourceDetector: - container: ExperimentalContainerResourceDetector | None = None - host: ExperimentalHostResourceDetector | None = None - process: ExperimentalProcessResourceDetector | None = None - service: ExperimentalServiceResourceDetector | None = None +# Diverges from codegen: ExperimentalResourceDetector is typed as dict[str, Any] +# rather than a dataclass so that unknown detector names (plugin/custom detectors) +# are preserved as dict keys through the config pipeline. +ExperimentalResourceDetector: TypeAlias = dict[str, Any] @dataclass diff --git a/opentelemetry-sdk/tests/_configuration/test_resource.py b/opentelemetry-sdk/tests/_configuration/test_resource.py index ad9dc0ef20..b50b134f0a 100644 --- a/opentelemetry-sdk/tests/_configuration/test_resource.py +++ b/opentelemetry-sdk/tests/_configuration/test_resource.py @@ -18,6 +18,7 @@ import unittest from unittest.mock import MagicMock, patch +from opentelemetry.sdk._configuration._exceptions import ConfigurationError from opentelemetry.sdk._configuration._resource import create_resource from opentelemetry.sdk._configuration.models import ( AttributeNameValue, @@ -478,23 +479,14 @@ def test_container_detector_not_run_when_detectors_list_empty(self): resource = create_resource(config) self.assertNotIn(CONTAINER_ID, resource.attributes) - def test_container_detector_warns_when_package_missing(self): - """A warning is logged when the contrib entry point is not found.""" + def test_container_detector_raises_when_package_missing(self): + """ConfigurationError is raised when the contrib entry point is not found.""" with patch( - "opentelemetry.sdk._configuration._resource.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[], ): - with self.assertLogs( - "opentelemetry.sdk._configuration._resource", level="WARNING" - ) as cm: - resource = create_resource(self._config_with_container()) - self.assertNotIn(CONTAINER_ID, resource.attributes) - self.assertTrue( - any( - "opentelemetry-resource-detector-containerid" in msg - for msg in cm.output - ) - ) + with self.assertRaises(ConfigurationError): + create_resource(self._config_with_container()) def test_container_detector_uses_contrib_when_available(self): """When the contrib entry point is registered, container.id is detected.""" @@ -505,7 +497,7 @@ def test_container_detector_uses_contrib_when_available(self): mock_ep.load.return_value = mock_detector with patch( - "opentelemetry.sdk._configuration._resource.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): resource = create_resource(self._config_with_container()) @@ -529,7 +521,7 @@ def test_explicit_attributes_override_container_detector(self): ), ) with patch( - "opentelemetry.sdk._configuration._resource.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): resource = create_resource(config) @@ -602,3 +594,38 @@ def test_multiple_detector_entries_run_process_once(self): ) resource = create_resource(config) self.assertEqual(resource.attributes[PROCESS_PID], os.getpid()) + + +class TestPluginResourceDetector(unittest.TestCase): + def test_plugin_detector_loaded_via_entry_point(self): + mock_resource = Resource({"custom.attr": "value"}) + mock_detector = MagicMock() + mock_detector.return_value.detect.return_value = mock_resource + mock_ep = MagicMock() + mock_ep.load.return_value = mock_detector + + config = ResourceConfig( + detection_development=ExperimentalResourceDetection( + detectors=[{"my_custom_detector": {}}] + ) + ) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[mock_ep], + ): + resource = create_resource(config) + + self.assertEqual(resource.attributes["custom.attr"], "value") + + def test_unknown_detector_raises_configuration_error(self): + config = ResourceConfig( + detection_development=ExperimentalResourceDetection( + detectors=[{"no_such_detector": {}}] + ) + ) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + with self.assertRaises(ConfigurationError): + create_resource(config) From 7e6739dc8a141e2628c37f02e92c89d69c638d68 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 20 Apr 2026 12:59:34 +0100 Subject: [PATCH 2/3] update CHANGELOG with PR number #5129 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c59a06cb..154436f66b 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 generic resource detector plugin loading to declarative file configuration via the `opentelemetry_resource_detector` entry point group - ([#XXXX](https://github.com/open-telemetry/opentelemetry-python/pull/XXXX)) + ([#5129](https://github.com/open-telemetry/opentelemetry-python/pull/5129)) - `opentelemetry-sdk`: add `load_entry_point` shared utility to declarative file configuration for loading plugins via entry points; refactor propagator loading to use it ([#5093](https://github.com/open-telemetry/opentelemetry-python/pull/5093)) - `opentelemetry-sdk`: fix YAML structure injection via environment variable substitution in declarative file configuration; values containing newlines are now emitted as quoted YAML scalars per spec requirement From 808a956e8273db5f7993136177c8a4894ec7d2dc Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 20 Apr 2026 13:53:39 +0100 Subject: [PATCH 3/3] add generic resource detector plugin loading to declarative config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _run_detectors() now iterates detector config dicts directly via _RESOURCE_DETECTOR_REGISTRY. Known names (service, host, process) are bootstrapped directly from the SDK. Unknown names — including container and custom plugin detectors — are loaded via the opentelemetry_resource_detector entry point group, matching the spec's PluginComponentProvider mechanism. The generated models are unchanged. Python dataclasses don't enforce field types at runtime, so the detectors list naturally accepts raw dicts (from the YAML loader) alongside typed ExperimentalResourceDetector instances. This preserves unknown plugin names as dict keys without diverging from codegen. The container detector behavior changes from warning-when-missing to raising ConfigurationError, consistent with the fail-fast approach agreed on in the issue discussion. Assisted-by: Claude Opus 4.6 --- .../sdk/_configuration/models.py | 10 ++++--- .../tests/_configuration/test_resource.py | 27 +++++++++---------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py index 55dd65b8a4..5159137228 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py @@ -500,10 +500,12 @@ class ExperimentalPrometheusMetricExporter: ) -# Diverges from codegen: ExperimentalResourceDetector is typed as dict[str, Any] -# rather than a dataclass so that unknown detector names (plugin/custom detectors) -# are preserved as dict keys through the config pipeline. -ExperimentalResourceDetector: TypeAlias = dict[str, Any] +@dataclass +class ExperimentalResourceDetector: + container: ExperimentalContainerResourceDetector | None = None + host: ExperimentalHostResourceDetector | None = None + process: ExperimentalProcessResourceDetector | None = None + service: ExperimentalServiceResourceDetector | None = None @dataclass diff --git a/opentelemetry-sdk/tests/_configuration/test_resource.py b/opentelemetry-sdk/tests/_configuration/test_resource.py index b50b134f0a..1d27488ee5 100644 --- a/opentelemetry-sdk/tests/_configuration/test_resource.py +++ b/opentelemetry-sdk/tests/_configuration/test_resource.py @@ -24,7 +24,6 @@ AttributeNameValue, AttributeType, ExperimentalResourceDetection, - ExperimentalResourceDetector, IncludeExclude, ) from opentelemetry.sdk._configuration.models import Resource as ResourceConfig @@ -314,7 +313,7 @@ class TestServiceResourceDetector(unittest.TestCase): def _config_with_service() -> ResourceConfig: return ResourceConfig( detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(service={})] + detectors=[{"service": {}}] ) ) @@ -347,7 +346,7 @@ def test_explicit_service_name_overrides_env_var(self): AttributeNameValue(name="service.name", value="explicit-svc") ], detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(service={})] + detectors=[{"service": {}}] ), ) with patch.dict(os.environ, {"OTEL_SERVICE_NAME": "env-svc"}): @@ -370,7 +369,7 @@ def test_service_detector_also_includes_sdk_defaults(self): def test_included_filter_limits_service_attributes(self): config = ResourceConfig( detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(service={})], + detectors=[{"service": {}}], attributes=IncludeExclude(included=["service.instance.id"]), ) ) @@ -387,7 +386,7 @@ class TestHostResourceDetector(unittest.TestCase): def _config_with_host() -> ResourceConfig: return ResourceConfig( detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(host={})] + detectors=[{"host": {}}] ) ) @@ -424,7 +423,7 @@ def test_explicit_attributes_override_host_detector(self): AttributeNameValue(name="host.name", value="custom-host") ], detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(host={})] + detectors=[{"host": {}}] ), ) resource = create_resource(config) @@ -433,7 +432,7 @@ def test_explicit_attributes_override_host_detector(self): def test_included_filter_limits_host_attributes(self): config = ResourceConfig( detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(host={})], + detectors=[{"host": {}}], attributes=IncludeExclude(included=["host.name"]), ) ) @@ -444,7 +443,7 @@ def test_included_filter_limits_host_attributes(self): def test_excluded_filter_removes_host_attributes(self): config = ResourceConfig( detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(host={})], + detectors=[{"host": {}}], attributes=IncludeExclude(excluded=["host.name"]), ) ) @@ -458,7 +457,7 @@ class TestContainerResourceDetector(unittest.TestCase): def _config_with_container() -> ResourceConfig: return ResourceConfig( detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(container={})] + detectors=[{"container": {}}] ) ) @@ -517,7 +516,7 @@ def test_explicit_attributes_override_container_detector(self): AttributeNameValue(name="container.id", value="explicit-id") ], detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(container={})] + detectors=[{"container": {}}] ), ) with patch( @@ -534,7 +533,7 @@ class TestProcessResourceDetector(unittest.TestCase): def _config_with_process() -> ResourceConfig: return ResourceConfig( detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(process={})] + detectors=[{"process": {}}] ) ) @@ -576,7 +575,7 @@ def test_explicit_attributes_override_process_detector(self): ) ], detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(process={})] + detectors=[{"process": {}}] ), ) resource = create_resource(config) @@ -587,8 +586,8 @@ def test_multiple_detector_entries_run_process_once(self): config = ResourceConfig( detection_development=ExperimentalResourceDetection( detectors=[ - ExperimentalResourceDetector(process={}), - ExperimentalResourceDetector(process={}), + {"process": {}}, + {"process": {}}, ] ) )