Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 18 additions & 13 deletions opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't want to sound like pylint but can we call this detector instead of d?

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:
Expand Down
59 changes: 59 additions & 0 deletions opentelemetry-sdk/tests/resources/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
_HostResourceDetector,
get_aggregated_resources,
)
from opentelemetry.util._importlib_metadata import (
entry_points as real_entry_points,
)

try:
import psutil
Expand Down Expand Up @@ -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] = ""
Expand Down Expand Up @@ -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):
Expand Down
Loading