Skip to content

Commit e19d346

Browse files
feat(config): generic resource detector plugin loading for declarative config (#5129)
* add generic resource detector plugin loading to declarative config ExperimentalResourceDetector is changed from @DataClass to TypeAlias = dict[str, Any] in models.py, preserving unknown detector names as dict keys through the config pipeline. _run_detectors() now iterates the dict's key-value pairs directly via _RESOURCE_DETECTOR_REGISTRY. Known names (service, host, process) are bootstrapped directly from the SDK. 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 container detector behavior changes from warning-when-missing to raising ConfigurationError, consistent with the fail-fast approach agreed on in the issue discussion. Assisted-by: Claude Opus 4.6 * update CHANGELOG with PR number #5129 * add generic resource detector plugin loading to declarative config _run_detectors() now iterates detector config dicts directly via _RESOURCE_DETECTOR_REGISTRY. Known names (service, host, process) are bootstrapped directly from the SDK. 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 generated models are unchanged. Python dataclasses don't enforce field types at runtime, so the detectors list naturally accepts raw dicts (from the YAML loader) alongside typed ExperimentalResourceDetector instances. This preserves unknown plugin names as dict keys without diverging from codegen. The container detector behavior changes from warning-when-missing to raising ConfigurationError, consistent with the fail-fast approach agreed on in the issue discussion. Assisted-by: Claude Opus 4.6 * update resource detector plugin loading to use additional_properties Use typed ExperimentalResourceDetector with additional_properties from the @_additional_properties decorator instead of raw dict iteration. Known schema fields (service, host, process) are checked via _RESOURCE_DETECTOR_REGISTRY. Known fields not in the registry (e.g. container) and unknown plugin names from additional_properties are loaded via entry points. Assisted-by: Claude Opus 4.6 * address review feedback on resource detector PR - use walrus operator for OTEL_SERVICE_NAME check - use AttributeValue type hint from opentelemetry.util.types - type _RESOURCE_DETECTOR_REGISTRY with Callable signature - _config param typed as Any for forward compatibility The overlap with OTELResourceDetector is minimal (3 lines for OTEL_SERVICE_NAME reading) — not worth extracting a shared utility. Assisted-by: Claude Opus 4.6 * merge upstream/main, fix unused import * pass plugin config args to entry point resource detectors Plugin detectors now receive their config as kwargs: cls(**(plugin_config or {})).detect().attributes Previously config values were discarded. Assisted-by: Claude Opus 4.6
1 parent 1d69bd2 commit e19d346

3 files changed

Lines changed: 92 additions & 57 deletions

File tree

.changelog/5129.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`opentelemetry-sdk`: add generic resource detector plugin loading to declarative file configuration via the `opentelemetry_resource_detector` entry point group, matching the spec's PluginComponentProvider mechanism

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py

Lines changed: 46 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33

44
from __future__ import annotations
55

6+
import dataclasses
67
import fnmatch
78
import logging
89
import os
910
import uuid
1011
from collections.abc import Callable
12+
from typing import Any
1113
from urllib import parse
1214

15+
from opentelemetry.sdk._configuration._common import load_entry_point
1316
from opentelemetry.sdk._configuration.models import (
1417
AttributeNameValue,
1518
AttributeType,
@@ -26,7 +29,7 @@
2629
Resource,
2730
_HostResourceDetector,
2831
)
29-
from opentelemetry.util._importlib_metadata import entry_points
32+
from opentelemetry.util.types import AttributeValue
3033

3134
_logger = logging.getLogger(__name__)
3235

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

137140

141+
def _detect_service(_config: Any) -> dict[str, AttributeValue]:
142+
"""Service detector: generates instance ID and reads OTEL_SERVICE_NAME."""
143+
attrs: dict[str, AttributeValue] = {
144+
SERVICE_INSTANCE_ID: str(uuid.uuid4()),
145+
}
146+
if service_name := os.environ.get(OTEL_SERVICE_NAME):
147+
attrs[SERVICE_NAME] = service_name
148+
return attrs
149+
150+
151+
_RESOURCE_DETECTOR_REGISTRY: dict[
152+
str, Callable[[Any], dict[str, AttributeValue]]
153+
] = {
154+
"service": _detect_service,
155+
"host": lambda _: dict(_HostResourceDetector().detect().attributes),
156+
"process": lambda _: dict(ProcessResourceDetector().detect().attributes),
157+
}
158+
159+
138160
def _run_detectors(
139161
detector_config: ExperimentalResourceDetector,
140162
detected_attrs: dict[str, object],
141163
) -> None:
142-
"""Run any detectors present in a single detector config entry.
164+
"""Run detectors present in a single detector config entry.
143165
144-
Each detector PR adds its own branch here. The detected_attrs dict
145-
is updated in-place; later detectors overwrite earlier ones for the
146-
same key.
166+
Known detectors (service, host, process) are handled directly via
167+
_RESOURCE_DETECTOR_REGISTRY. All other detectors — including known
168+
schema fields like container that require contrib packages, and
169+
unknown plugin detectors captured in additional_properties — are
170+
loaded via the ``opentelemetry_resource_detector`` entry point group.
171+
172+
The detected_attrs dict is updated in-place; later detectors overwrite
173+
earlier ones for the same key.
147174
"""
148-
if detector_config.service is not None:
149-
attrs: dict[str, object] = {
150-
SERVICE_INSTANCE_ID: str(uuid.uuid4()),
151-
}
152-
service_name = os.environ.get(OTEL_SERVICE_NAME)
153-
if service_name:
154-
attrs[SERVICE_NAME] = service_name
155-
detected_attrs.update(attrs)
156-
157-
if detector_config.host is not None:
158-
detected_attrs.update(_HostResourceDetector().detect().attributes)
159-
160-
if detector_config.container is not None:
161-
# The container detector is not part of the core SDK. It is provided
162-
# by the opentelemetry-resource-detector-containerid contrib package,
163-
# which registers itself under the opentelemetry_resource_detector
164-
# entry point group as "container". Loading via entry point matches
165-
# the env-var config counterpart (OTEL_EXPERIMENTAL_RESOURCE_DETECTORS)
166-
# and avoids a hard import dependency on contrib. See also:
167-
# https://github.com/open-telemetry/opentelemetry-configuration/issues/570
168-
ep = next(
169-
iter(
170-
entry_points(
171-
group="opentelemetry_resource_detector", name="container"
172-
)
173-
),
174-
None,
175-
)
176-
if ep is None:
177-
_logger.warning(
178-
"container resource detector requested but "
179-
"'opentelemetry-resource-detector-containerid' is not "
180-
"installed; install it to enable container detection"
175+
for name in dataclasses.fields(detector_config):
176+
value = getattr(detector_config, name.name, None)
177+
if value is None:
178+
continue
179+
if name.name in _RESOURCE_DETECTOR_REGISTRY:
180+
detected_attrs.update(
181+
_RESOURCE_DETECTOR_REGISTRY[name.name](value)
181182
)
182183
else:
183-
detected_attrs.update(ep.load()().detect().attributes)
184+
cls = load_entry_point(
185+
"opentelemetry_resource_detector", name.name
186+
)
187+
detected_attrs.update(cls(**(value or {})).detect().attributes)
184188

185-
if detector_config.process is not None:
186-
detected_attrs.update(ProcessResourceDetector().detect().attributes)
189+
for name, plugin_config in detector_config.additional_properties.items():
190+
cls = load_entry_point("opentelemetry_resource_detector", name)
191+
detected_attrs.update(cls(**(plugin_config or {})).detect().attributes)
187192

188193

189194
def _filter_attributes(

opentelemetry-sdk/tests/_configuration/test_resource.py

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import unittest
88
from unittest.mock import MagicMock, patch
99

10+
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
1011
from opentelemetry.sdk._configuration._resource import create_resource
1112
from opentelemetry.sdk._configuration.models import (
1213
AttributeNameValue,
@@ -467,23 +468,14 @@ def test_container_detector_not_run_when_detectors_list_empty(self):
467468
resource = create_resource(config)
468469
self.assertNotIn(CONTAINER_ID, resource.attributes)
469470

470-
def test_container_detector_warns_when_package_missing(self):
471-
"""A warning is logged when the contrib entry point is not found."""
471+
def test_container_detector_raises_when_package_missing(self):
472+
"""ConfigurationError is raised when the contrib entry point is not found."""
472473
with patch(
473-
"opentelemetry.sdk._configuration._resource.entry_points",
474+
"opentelemetry.sdk._configuration._common.entry_points",
474475
return_value=[],
475476
):
476-
with self.assertLogs(
477-
"opentelemetry.sdk._configuration._resource", level="WARNING"
478-
) as cm:
479-
resource = create_resource(self._config_with_container())
480-
self.assertNotIn(CONTAINER_ID, resource.attributes)
481-
self.assertTrue(
482-
any(
483-
"opentelemetry-resource-detector-containerid" in msg
484-
for msg in cm.output
485-
)
486-
)
477+
with self.assertRaises(ConfigurationError):
478+
create_resource(self._config_with_container())
487479

488480
def test_container_detector_uses_contrib_when_available(self):
489481
"""When the contrib entry point is registered, container.id is detected."""
@@ -494,7 +486,7 @@ def test_container_detector_uses_contrib_when_available(self):
494486
mock_ep.load.return_value = mock_detector
495487

496488
with patch(
497-
"opentelemetry.sdk._configuration._resource.entry_points",
489+
"opentelemetry.sdk._configuration._common.entry_points",
498490
return_value=[mock_ep],
499491
):
500492
resource = create_resource(self._config_with_container())
@@ -518,7 +510,7 @@ def test_explicit_attributes_override_container_detector(self):
518510
),
519511
)
520512
with patch(
521-
"opentelemetry.sdk._configuration._resource.entry_points",
513+
"opentelemetry.sdk._configuration._common.entry_points",
522514
return_value=[mock_ep],
523515
):
524516
resource = create_resource(config)
@@ -591,3 +583,40 @@ def test_multiple_detector_entries_run_process_once(self):
591583
)
592584
resource = create_resource(config)
593585
self.assertEqual(resource.attributes[PROCESS_PID], os.getpid())
586+
587+
588+
class TestPluginResourceDetector(unittest.TestCase):
589+
def test_plugin_detector_loaded_via_entry_point(self):
590+
mock_resource = Resource({"custom.attr": "value"})
591+
mock_detector = MagicMock()
592+
mock_detector.return_value.detect.return_value = mock_resource
593+
mock_ep = MagicMock()
594+
mock_ep.load.return_value = mock_detector
595+
596+
config = ResourceConfig(
597+
detection_development=ExperimentalResourceDetection(
598+
# pylint: disable=unexpected-keyword-arg
599+
detectors=[ExperimentalResourceDetector(my_custom_detector={})]
600+
)
601+
)
602+
with patch(
603+
"opentelemetry.sdk._configuration._common.entry_points",
604+
return_value=[mock_ep],
605+
):
606+
resource = create_resource(config)
607+
608+
self.assertEqual(resource.attributes["custom.attr"], "value")
609+
610+
def test_unknown_detector_raises_configuration_error(self):
611+
config = ResourceConfig(
612+
detection_development=ExperimentalResourceDetection(
613+
# pylint: disable=unexpected-keyword-arg
614+
detectors=[ExperimentalResourceDetector(no_such_detector={})]
615+
)
616+
)
617+
with patch(
618+
"opentelemetry.sdk._configuration._common.entry_points",
619+
return_value=[],
620+
):
621+
with self.assertRaises(ConfigurationError):
622+
create_resource(config)

0 commit comments

Comments
 (0)