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 @@ -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 `additional_properties` support to generated config models via custom `datamodel-codegen` template, enabling plugin/custom component names to flow through typed dataclasses
([#5131](https://github.com/open-telemetry/opentelemetry-python/pull/5131))
- Fix incorrect code example in `create_tracer()` docstring
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@

from __future__ import annotations

import dataclasses
import fnmatch
import logging
import os
import uuid
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,
Expand All @@ -37,7 +39,6 @@
Resource,
_HostResourceDetector,
)
from opentelemetry.util._importlib_metadata import entry_points

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -146,55 +147,56 @@ def create_resource(config: Optional[ResourceConfig]) -> Resource:
return result.merge(config_resource)


def _detect_service(_config) -> dict[str, object]:
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.

the argument to this function is not used ?

"""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)
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.

nit: use the walrus operator to save a line ?

if service_name := os.environ.get():

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,
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)
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.

Do we just discard value ? It seems we ignore it in all the functions in _RESOURCE_DETECTOR_REGISTRY

)
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 @@ -18,6 +18,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 @@ -478,23 +479,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 +497,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 @@ -529,7 +521,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 @@ -602,3 +594,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