diff --git a/CHANGELOG.md b/CHANGELOG.md index 6024431107..154436f66b 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 + ([#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 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/tests/_configuration/test_resource.py b/opentelemetry-sdk/tests/_configuration/test_resource.py index ad9dc0ef20..1d27488ee5 100644 --- a/opentelemetry-sdk/tests/_configuration/test_resource.py +++ b/opentelemetry-sdk/tests/_configuration/test_resource.py @@ -18,12 +18,12 @@ 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, AttributeType, ExperimentalResourceDetection, - ExperimentalResourceDetector, IncludeExclude, ) from opentelemetry.sdk._configuration.models import Resource as ResourceConfig @@ -313,7 +313,7 @@ class TestServiceResourceDetector(unittest.TestCase): def _config_with_service() -> ResourceConfig: return ResourceConfig( detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(service={})] + detectors=[{"service": {}}] ) ) @@ -346,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"}): @@ -369,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"]), ) ) @@ -386,7 +386,7 @@ class TestHostResourceDetector(unittest.TestCase): def _config_with_host() -> ResourceConfig: return ResourceConfig( detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(host={})] + detectors=[{"host": {}}] ) ) @@ -423,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) @@ -432,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"]), ) ) @@ -443,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"]), ) ) @@ -457,7 +457,7 @@ class TestContainerResourceDetector(unittest.TestCase): def _config_with_container() -> ResourceConfig: return ResourceConfig( detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(container={})] + detectors=[{"container": {}}] ) ) @@ -478,23 +478,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 +496,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()) @@ -525,11 +516,11 @@ 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( - "opentelemetry.sdk._configuration._resource.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): resource = create_resource(config) @@ -542,7 +533,7 @@ class TestProcessResourceDetector(unittest.TestCase): def _config_with_process() -> ResourceConfig: return ResourceConfig( detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(process={})] + detectors=[{"process": {}}] ) ) @@ -584,7 +575,7 @@ def test_explicit_attributes_override_process_detector(self): ) ], detection_development=ExperimentalResourceDetection( - detectors=[ExperimentalResourceDetector(process={})] + detectors=[{"process": {}}] ), ) resource = create_resource(config) @@ -595,10 +586,45 @@ def test_multiple_detector_entries_run_process_once(self): config = ResourceConfig( detection_development=ExperimentalResourceDetection( detectors=[ - ExperimentalResourceDetector(process={}), - ExperimentalResourceDetector(process={}), + {"process": {}}, + {"process": {}}, ] ) ) 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)