diff --git a/CHANGELOG.md b/CHANGELOG.md index 941551d8a9..b24020a159 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 `additional_properties` support to generated config models via custom `datamodel-codegen` template, enabling plugin/custom component names to flow through typed dataclasses ([#5131](https://github.com/open-telemetry/opentelemetry-python/pull/5131)) - Fix incorrect code example in `create_tracer()` docstring diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py index 13ef0a234a..8a70afd83d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -14,6 +14,7 @@ from __future__ import annotations +import dataclasses import fnmatch import logging import os @@ -21,6 +22,7 @@ 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, @@ -37,7 +39,6 @@ Resource, _HostResourceDetector, ) -from opentelemetry.util._importlib_metadata import entry_points _logger = logging.getLogger(__name__) @@ -146,55 +147,56 @@ 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, 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. + Known detectors (service, host, process) are handled directly via + _RESOURCE_DETECTOR_REGISTRY. All other detectors — including known + schema fields like container that require contrib packages, and + unknown plugin detectors captured in additional_properties — are + loaded via the ``opentelemetry_resource_detector`` entry point group. + + 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 in dataclasses.fields(detector_config): + value = getattr(detector_config, name.name, None) + if value is None: + continue + if name.name in _RESOURCE_DETECTOR_REGISTRY: + detected_attrs.update( + _RESOURCE_DETECTOR_REGISTRY[name.name](value) ) else: - detected_attrs.update(ep.load()().detect().attributes) + cls = load_entry_point( + "opentelemetry_resource_detector", name.name + ) + detected_attrs.update(cls().detect().attributes) - if detector_config.process is not None: - detected_attrs.update(ProcessResourceDetector().detect().attributes) + for name in detector_config.additional_properties: + 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..42207b50aa 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,40 @@ 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( + # pylint: disable=unexpected-keyword-arg + detectors=[ExperimentalResourceDetector(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( + # pylint: disable=unexpected-keyword-arg + detectors=[ExperimentalResourceDetector(no_such_detector={})] + ) + ) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + with self.assertRaises(ConfigurationError): + create_resource(config)