Skip to content
Draft
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 @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,7 +37,6 @@
Resource,
_HostResourceDetector,
)
from opentelemetry.util._importlib_metadata import entry_points

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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(
Expand Down
86 changes: 56 additions & 30 deletions opentelemetry-sdk/tests/_configuration/test_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -313,7 +313,7 @@ class TestServiceResourceDetector(unittest.TestCase):
def _config_with_service() -> ResourceConfig:
return ResourceConfig(
detection_development=ExperimentalResourceDetection(
detectors=[ExperimentalResourceDetector(service={})]
detectors=[{"service": {}}]
)
)

Expand Down Expand Up @@ -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"}):
Expand All @@ -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"]),
)
)
Expand All @@ -386,7 +386,7 @@ class TestHostResourceDetector(unittest.TestCase):
def _config_with_host() -> ResourceConfig:
return ResourceConfig(
detection_development=ExperimentalResourceDetection(
detectors=[ExperimentalResourceDetector(host={})]
detectors=[{"host": {}}]
)
)

Expand Down Expand Up @@ -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)
Expand All @@ -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"]),
)
)
Expand All @@ -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"]),
)
)
Expand All @@ -457,7 +457,7 @@ class TestContainerResourceDetector(unittest.TestCase):
def _config_with_container() -> ResourceConfig:
return ResourceConfig(
detection_development=ExperimentalResourceDetection(
detectors=[ExperimentalResourceDetector(container={})]
detectors=[{"container": {}}]
)
)

Expand All @@ -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."""
Expand All @@ -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())
Expand All @@ -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)
Expand All @@ -542,7 +533,7 @@ class TestProcessResourceDetector(unittest.TestCase):
def _config_with_process() -> ResourceConfig:
return ResourceConfig(
detection_development=ExperimentalResourceDetection(
detectors=[ExperimentalResourceDetector(process={})]
detectors=[{"process": {}}]
)
)

Expand Down Expand Up @@ -584,7 +575,7 @@ def test_explicit_attributes_override_process_detector(self):
)
],
detection_development=ExperimentalResourceDetection(
detectors=[ExperimentalResourceDetector(process={})]
detectors=[{"process": {}}]
),
)
resource = create_resource(config)
Expand All @@ -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)
Loading