Skip to content

Commit a5e89b1

Browse files
committed
feat: make resource detector ordering deterministic
1 parent d2288d3 commit a5e89b1

3 files changed

Lines changed: 80 additions & 13 deletions

File tree

opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,13 @@ def channel_credential_provider() -> grpc.ChannelCredentials:
777777
entry points for the ```opentelemetry_resource_detector``` entry point. This is an
778778
experimental feature and the name of this variable and its behavior can change
779779
in a non-backwards compatible way.
780+
781+
Detectors are run in the order they are listed and their attributes are merged
782+
in that order, with later detectors taking precedence over earlier ones on
783+
conflicting keys. The ``otel`` detector (which reads
784+
:envvar:`OTEL_RESOURCE_ATTRIBUTES` and :envvar:`OTEL_SERVICE_NAME`) is always
785+
appended last unless explicitly placed elsewhere in the list, ensuring
786+
environment variable attributes take highest priority among detectors.
780787
"""
781788

782789
OTEL_EXPORTER_PROMETHEUS_HOST = "OTEL_EXPORTER_PROMETHEUS_HOST"

opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
from json import dumps
7171
from os import environ
7272
from types import ModuleType
73-
from typing import List, Optional, Set, cast
73+
from typing import List, Optional, cast
7474
from urllib import parse
7575

7676
from opentelemetry.attributes import BoundedAttributes
@@ -195,22 +195,27 @@ def create(
195195
if not attributes:
196196
attributes = {}
197197

198-
otel_experimental_resource_detectors: Set[str] = {"otel"}.union(
199-
{
200-
otel_experimental_resource_detector.strip()
201-
for otel_experimental_resource_detector in environ.get(
202-
OTEL_EXPERIMENTAL_RESOURCE_DETECTORS, ""
203-
).split(",")
204-
if otel_experimental_resource_detector
205-
}
206-
)
198+
otel_experimental_resource_detectors: list[str] = [
199+
d.strip()
200+
for d in environ.get(
201+
OTEL_EXPERIMENTAL_RESOURCE_DETECTORS, ""
202+
).split(",")
203+
if d.strip()
204+
]
207205

208206
resource_detectors: List[ResourceDetector] = []
209207

210208
if "*" in otel_experimental_resource_detectors:
211-
otel_experimental_resource_detectors = entry_points(
212-
group="opentelemetry_resource_detector"
213-
).names
209+
otel_experimental_resource_detectors = [
210+
name
211+
for name in entry_points(
212+
group="opentelemetry_resource_detector"
213+
).names
214+
if name != "otel"
215+
]
216+
otel_experimental_resource_detectors.append("otel")
217+
elif "otel" not in otel_experimental_resource_detectors:
218+
otel_experimental_resource_detectors.append("otel")
214219

215220
for resource_detector in otel_experimental_resource_detectors:
216221
try:

opentelemetry-sdk/tests/resources/test_resources.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
from opentelemetry.sdk.environment_variables import (
2525
OTEL_EXPERIMENTAL_RESOURCE_DETECTORS,
2626
)
27+
from opentelemetry.util._importlib_metadata import (
28+
entry_points as real_entry_points,
29+
)
2730
from opentelemetry.sdk.resources import (
2831
_DEFAULT_RESOURCE,
2932
_EMPTY_RESOURCE,
@@ -475,6 +478,16 @@ def test_service_name_env(self):
475478

476479

477480
# pylint: disable=too-many-public-methods
481+
def _make_detector_ep(resource):
482+
return Mock(
483+
**{
484+
"load.return_value": Mock(
485+
return_value=Mock(**{"detect.return_value": resource})
486+
)
487+
}
488+
)
489+
490+
478491
class TestOTELResourceDetector(unittest.TestCase):
479492
def setUp(self) -> None:
480493
environ[OTEL_RESOURCE_ATTRIBUTES] = ""
@@ -774,6 +787,48 @@ def test_resource_detector_entry_points_otel(self):
774787
self.assertIn(PROCESS_RUNTIME_VERSION, resource.attributes.keys())
775788
self.assertEqual(resource.schema_url, "")
776789

790+
@patch.dict(
791+
environ,
792+
{OTEL_EXPERIMENTAL_RESOURCE_DETECTORS: "mock_a,mock_b"},
793+
clear=True,
794+
)
795+
def test_resource_detector_ordering_last_wins(self):
796+
"""Last detector in OTEL_EXPERIMENTAL_RESOURCE_DETECTORS wins on conflict."""
797+
ep_a = _make_detector_ep(Resource({"conflict_key": "from_a"}))
798+
ep_b = _make_detector_ep(Resource({"conflict_key": "from_b"}))
799+
800+
def side_effect(*args, **kwargs):
801+
return {"mock_a": [ep_a], "mock_b": [ep_b]}.get(
802+
kwargs.get("name", ""), []
803+
)
804+
805+
with patch("opentelemetry.sdk.resources.entry_points", side_effect=side_effect):
806+
resource = Resource({}).create()
807+
808+
self.assertEqual(resource.attributes["conflict_key"], "from_b")
809+
810+
@patch.dict(
811+
environ,
812+
{
813+
OTEL_EXPERIMENTAL_RESOURCE_DETECTORS: "mock",
814+
OTEL_RESOURCE_ATTRIBUTES: "conflict_key=otel_value",
815+
},
816+
clear=True,
817+
)
818+
def test_otel_detector_appended_last(self):
819+
"""'otel' detector is always appended last, so its attributes win over earlier detectors."""
820+
ep_mock = _make_detector_ep(Resource({"conflict_key": "mock_value"}))
821+
822+
def side_effect(*args, **kwargs):
823+
if kwargs.get("name") == "mock":
824+
return [ep_mock]
825+
return real_entry_points(*args, **kwargs)
826+
827+
with patch("opentelemetry.sdk.resources.entry_points", side_effect=side_effect):
828+
resource = Resource({}).create()
829+
830+
self.assertEqual(resource.attributes["conflict_key"], "otel_value")
831+
777832
@patch("platform.system", lambda: "Linux")
778833
@patch("platform.release", lambda: "666.5.0-35-generic")
779834
def test_os_detector_linux(self):

0 commit comments

Comments
 (0)