diff --git a/CHANGELOG.md b/CHANGELOG.md index 60244311072..6b7b06c7015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#5076](https://github.com/open-telemetry/opentelemetry-python/pull/5076)) - `opentelemetry-semantic-conventions`: use `X | Y` union annotation ([#5096](https://github.com/open-telemetry/opentelemetry-python/pull/5096)) +- `opentelemetry-sdk`: make resource detector ordering deterministic + ([#5120](https://github.com/open-telemetry/opentelemetry-python/pull/5120)) ## Version 1.41.0/0.62b0 (2026-04-09) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index 2959163eed8..2e5f350512b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -777,6 +777,13 @@ def channel_credential_provider() -> grpc.ChannelCredentials: entry points for the ```opentelemetry_resource_detector``` entry point. This is an experimental feature and the name of this variable and its behavior can change in a non-backwards compatible way. + +Detectors are run in the order they are listed and their attributes are merged +in that order, with later detectors taking precedence over earlier ones on +conflicting keys. The ``otel`` detector (which reads +:envvar:`OTEL_RESOURCE_ATTRIBUTES` and :envvar:`OTEL_SERVICE_NAME`) is always +appended last unless explicitly placed elsewhere in the list, ensuring +environment variable attributes take highest priority among detectors. """ OTEL_EXPORTER_PROMETHEUS_HOST = "OTEL_EXPORTER_PROMETHEUS_HOST" diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py index a04d27e9ab1..8d00f0cc3b8 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py @@ -70,7 +70,7 @@ from json import dumps from os import environ from types import ModuleType -from typing import List, Optional, Set, cast +from typing import List, Optional, cast from urllib import parse from opentelemetry.attributes import BoundedAttributes @@ -195,22 +195,27 @@ def create( if not attributes: attributes = {} - otel_experimental_resource_detectors: Set[str] = {"otel"}.union( - { - otel_experimental_resource_detector.strip() - for otel_experimental_resource_detector in environ.get( - OTEL_EXPERIMENTAL_RESOURCE_DETECTORS, "" - ).split(",") - if otel_experimental_resource_detector - } - ) + otel_experimental_resource_detectors: list[str] = [ + d.strip() + for d in environ.get( + OTEL_EXPERIMENTAL_RESOURCE_DETECTORS, "" + ).split(",") + if d.strip() + ] resource_detectors: List[ResourceDetector] = [] if "*" in otel_experimental_resource_detectors: - otel_experimental_resource_detectors = entry_points( - group="opentelemetry_resource_detector" - ).names + otel_experimental_resource_detectors = [ + name + for name in entry_points( + group="opentelemetry_resource_detector" + ).names + if name != "otel" + ] + otel_experimental_resource_detectors.append("otel") + elif "otel" not in otel_experimental_resource_detectors: + otel_experimental_resource_detectors.append("otel") for resource_detector in otel_experimental_resource_detectors: try: diff --git a/opentelemetry-sdk/tests/resources/test_resources.py b/opentelemetry-sdk/tests/resources/test_resources.py index c083eff1460..fa72d96cdf3 100644 --- a/opentelemetry-sdk/tests/resources/test_resources.py +++ b/opentelemetry-sdk/tests/resources/test_resources.py @@ -57,6 +57,9 @@ _HostResourceDetector, get_aggregated_resources, ) +from opentelemetry.util._importlib_metadata import ( + entry_points as real_entry_points, +) try: import psutil @@ -475,6 +478,16 @@ def test_service_name_env(self): # pylint: disable=too-many-public-methods +def _make_detector_ep(resource): + return Mock( + **{ + "load.return_value": Mock( + return_value=Mock(**{"detect.return_value": resource}) + ) + } + ) + + class TestOTELResourceDetector(unittest.TestCase): def setUp(self) -> None: environ[OTEL_RESOURCE_ATTRIBUTES] = "" @@ -774,6 +787,52 @@ def test_resource_detector_entry_points_otel(self): self.assertIn(PROCESS_RUNTIME_VERSION, resource.attributes.keys()) self.assertEqual(resource.schema_url, "") + @patch.dict( + environ, + {OTEL_EXPERIMENTAL_RESOURCE_DETECTORS: "mock_a,mock_b"}, + clear=True, + ) + def test_resource_detector_ordering_last_wins(self): + """Last detector in OTEL_EXPERIMENTAL_RESOURCE_DETECTORS wins on conflict.""" + ep_a = _make_detector_ep(Resource({"conflict_key": "from_a"})) + ep_b = _make_detector_ep(Resource({"conflict_key": "from_b"})) + + def side_effect(*args, **kwargs): + return {"mock_a": [ep_a], "mock_b": [ep_b]}.get( + kwargs.get("name", ""), [] + ) + + with patch( + "opentelemetry.sdk.resources.entry_points", side_effect=side_effect + ): + resource = Resource({}).create() + + self.assertEqual(resource.attributes["conflict_key"], "from_b") + + @patch.dict( + environ, + { + OTEL_EXPERIMENTAL_RESOURCE_DETECTORS: "mock", + OTEL_RESOURCE_ATTRIBUTES: "conflict_key=otel_value", + }, + clear=True, + ) + def test_otel_detector_appended_last(self): + """'otel' detector is always appended last, so its attributes win over earlier detectors.""" + ep_mock = _make_detector_ep(Resource({"conflict_key": "mock_value"})) + + def side_effect(*args, **kwargs): + if kwargs.get("name") == "mock": + return [ep_mock] + return real_entry_points(*args, **kwargs) + + with patch( + "opentelemetry.sdk.resources.entry_points", side_effect=side_effect + ): + resource = Resource({}).create() + + self.assertEqual(resource.attributes["conflict_key"], "otel_value") + @patch("platform.system", lambda: "Linux") @patch("platform.release", lambda: "666.5.0-35-generic") def test_os_detector_linux(self):