Skip to content
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))
- Apply fixes for `UP` ruff rule
([#5133](https://github.com/open-telemetry/opentelemetry-python/pull/5133))
- Switch to SPDX license headers and add CI enforcement
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@

from __future__ import annotations

import dataclasses
import fnmatch
import logging
import os
import uuid
from collections.abc import Callable
from typing import Any, Optional
from urllib import parse

from opentelemetry.sdk._configuration._common import load_entry_point
from opentelemetry.sdk._configuration.models import (
AttributeNameValue,
AttributeType,
Expand All @@ -26,7 +29,7 @@
Resource,
_HostResourceDetector,
)
from opentelemetry.util._importlib_metadata import entry_points
from opentelemetry.util.types import AttributeValue

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -135,55 +138,57 @@ def create_resource(config: ResourceConfig | None) -> Resource:
return result.merge(config_resource)


def _detect_service(_config: Any) -> dict[str, AttributeValue]:
"""Service detector: generates instance ID and reads OTEL_SERVICE_NAME."""
attrs: dict[str, AttributeValue] = {
SERVICE_INSTANCE_ID: str(uuid.uuid4()),
}
if service_name := os.environ.get(OTEL_SERVICE_NAME):
attrs[SERVICE_NAME] = service_name
return attrs


_RESOURCE_DETECTOR_REGISTRY: dict[
str, Callable[[Any], dict[str, AttributeValue]]
] = {
"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)
Comment thread
MikeGoldsmith marked this conversation as resolved.
)
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(
Expand Down
61 changes: 45 additions & 16 deletions opentelemetry-sdk/tests/_configuration/test_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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,
Expand Down Expand Up @@ -467,23 +468,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 @@ -494,7 +486,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 @@ -518,7 +510,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)
Expand Down Expand Up @@ -591,3 +583,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)
Loading