Skip to content

Commit 9c270da

Browse files
feat(config): add service resource detector support for declarative config (#5003)
* config: add resource and propagator creation from declarative config Implements create_resource() and create_propagator()/configure_propagator() for the declarative file configuration. Resource creation does not read OTEL_RESOURCE_ATTRIBUTES or run any detectors (matches Java/JS SDK behavior). Propagator configuration always calls set_global_textmap to override Python's default tracecontext+baggage, setting a noop CompositePropagator when no propagator is configured. Assisted-by: Claude Sonnet 4.6 * update changelog with PR number Assisted-by: Claude Sonnet 4.6 * fix pylint, pyright and ruff errors in resource/propagator config - _resource.py: refactor _coerce_attribute_value to dispatch table to avoid too-many-return-statements; fix short variable names k/v -> attr_key/attr_val; fix return type of _sdk_default_attributes to dict[str, str] to satisfy pyright - _propagator.py: rename short variable names e -> exc, p -> propagator - test_resource.py: move imports to top level; split TestCreateResource (25 methods) into three focused classes to satisfy too-many-public-methods - test_propagator.py: add pylint disable for protected-access Assisted-by: Claude Sonnet 4.6 * address review feedback: use _DEFAULT_RESOURCE, fix bool_array coercion - replace _sdk_default_attributes() with _DEFAULT_RESOURCE from resources module - move _coerce_bool into dispatch tables for both scalar and array bool types, fixing a bug where bool_array with string values like "false" would coerce incorrectly via plain bool() (non-empty string -> True) - add test for bool_array with string values to cover the bug Assisted-by: Claude Sonnet 4.6 * fix linter * address review feedback: single coercion table, simplify attributes merge - collapse _SCALAR_COERCIONS and _ARRAY_COERCIONS into a single _COERCIONS dict using an _array() factory, reducing _coerce_attribute_value to two lines - process attributes_list before attributes so explicit attributes naturally overwrite list entries without needing an explicit guard Assisted-by: Claude Sonnet 4.6 * use Callable type annotation on _array helper Assisted-by: Claude Sonnet 4.6 * add detection infrastructure foundations for resource detectors Adds _run_detectors() stub and _filter_attributes() to create_resource(), providing the shared scaffolding for detector PRs to build on. Detectors are opt-in: nothing runs unless explicitly listed under detection_development.detectors in the config. The include/exclude attribute filter mirrors other SDK behaviour. Assisted-by: Claude Sonnet 4.6 * move service.name default into base resource Merges service.name=unknown_service into base before running detectors, so detectors (e.g. service) can override it. Previously it was added to config_attrs and merged last, which would have silently overridden any detector-provided service.name. Assisted-by: Claude Sonnet 4.6 * remove unused logging import from _propagator.py Assisted-by: Claude Sonnet 4.6 * wire service resource detector in declarative config Adds service detector support to _run_detectors(): sets a random UUID for service.instance.id and reads OTEL_SERVICE_NAME for service.name if set. Explicit config attributes still take priority (merged last). Assisted-by: Claude Sonnet 4.6 * add changelog entry for service resource detector (#5003) Assisted-by: Claude Sonnet 4.6 --------- Co-authored-by: Aaron Abbott <aaronabbott@google.com>
1 parent 01a552f commit 9c270da

File tree

3 files changed

+90
-0
lines changed

3 files changed

+90
-0
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212

1313
## Unreleased
1414

15+
- `opentelemetry-sdk`: Add `service` resource detector support to declarative file configuration via `detection_development.detectors[].service`
16+
([#5003](https://github.com/open-telemetry/opentelemetry-python/pull/5003))
17+
1518
## Version 1.41.0/0.62b0 (2026-04-09)
1619

1720
- `opentelemetry-sdk`: Add `host` resource detector support to declarative file configuration via `detection_development.detectors[].host`

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
import fnmatch
1818
import logging
19+
import os
20+
import uuid
1921
from typing import Callable, Optional
2022
from urllib import parse
2123

@@ -28,6 +30,8 @@
2830
from opentelemetry.sdk._configuration.models import Resource as ResourceConfig
2931
from opentelemetry.sdk.resources import (
3032
_DEFAULT_RESOURCE,
33+
OTEL_SERVICE_NAME,
34+
SERVICE_INSTANCE_ID,
3135
SERVICE_NAME,
3236
ProcessResourceDetector,
3337
Resource,
@@ -152,6 +156,15 @@ def _run_detectors(
152156
is updated in-place; later detectors overwrite earlier ones for the
153157
same key.
154158
"""
159+
if detector_config.service is not None:
160+
attrs: dict[str, object] = {
161+
SERVICE_INSTANCE_ID: str(uuid.uuid4()),
162+
}
163+
service_name = os.environ.get(OTEL_SERVICE_NAME)
164+
if service_name:
165+
attrs[SERVICE_NAME] = service_name
166+
detected_attrs.update(attrs)
167+
155168
if detector_config.host is not None:
156169
detected_attrs.update(_HostResourceDetector().detect().attributes)
157170

opentelemetry-sdk/tests/_configuration/test_resource.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
HOST_NAME,
3434
PROCESS_PID,
3535
PROCESS_RUNTIME_NAME,
36+
SERVICE_INSTANCE_ID,
3637
SERVICE_NAME,
3738
TELEMETRY_SDK_LANGUAGE,
3839
TELEMETRY_SDK_NAME,
@@ -307,6 +308,79 @@ def test_attributes_list_invalid_pair_skipped(self):
307308
self.assertTrue(any("no-equals" in msg for msg in cm.output))
308309

309310

311+
class TestServiceResourceDetector(unittest.TestCase):
312+
@staticmethod
313+
def _config_with_service() -> ResourceConfig:
314+
return ResourceConfig(
315+
detection_development=ExperimentalResourceDetection(
316+
detectors=[ExperimentalResourceDetector(service={})]
317+
)
318+
)
319+
320+
def test_service_detector_adds_instance_id(self):
321+
resource = create_resource(self._config_with_service())
322+
self.assertIn(SERVICE_INSTANCE_ID, resource.attributes)
323+
324+
def test_service_instance_id_is_unique_per_call(self):
325+
r1 = create_resource(self._config_with_service())
326+
r2 = create_resource(self._config_with_service())
327+
self.assertNotEqual(
328+
r1.attributes[SERVICE_INSTANCE_ID],
329+
r2.attributes[SERVICE_INSTANCE_ID],
330+
)
331+
332+
def test_service_detector_reads_otel_service_name_env_var(self):
333+
with patch.dict(os.environ, {"OTEL_SERVICE_NAME": "my-service"}):
334+
resource = create_resource(self._config_with_service())
335+
self.assertEqual(resource.attributes[SERVICE_NAME], "my-service")
336+
337+
def test_service_detector_no_env_var_leaves_default_service_name(self):
338+
with patch.dict(os.environ, {}, clear=True):
339+
resource = create_resource(self._config_with_service())
340+
self.assertEqual(resource.attributes[SERVICE_NAME], "unknown_service")
341+
342+
def test_explicit_service_name_overrides_env_var(self):
343+
"""Config attributes win over the service detector's env-var value."""
344+
config = ResourceConfig(
345+
attributes=[
346+
AttributeNameValue(name="service.name", value="explicit-svc")
347+
],
348+
detection_development=ExperimentalResourceDetection(
349+
detectors=[ExperimentalResourceDetector(service={})]
350+
),
351+
)
352+
with patch.dict(os.environ, {"OTEL_SERVICE_NAME": "env-svc"}):
353+
resource = create_resource(config)
354+
self.assertEqual(resource.attributes[SERVICE_NAME], "explicit-svc")
355+
356+
def test_service_detector_not_run_when_absent(self):
357+
resource = create_resource(ResourceConfig())
358+
self.assertNotIn(SERVICE_INSTANCE_ID, resource.attributes)
359+
360+
def test_service_detector_not_run_when_detection_development_is_none(self):
361+
resource = create_resource(ResourceConfig(detection_development=None))
362+
self.assertNotIn(SERVICE_INSTANCE_ID, resource.attributes)
363+
364+
def test_service_detector_also_includes_sdk_defaults(self):
365+
resource = create_resource(self._config_with_service())
366+
self.assertEqual(resource.attributes[TELEMETRY_SDK_LANGUAGE], "python")
367+
self.assertIn(TELEMETRY_SDK_VERSION, resource.attributes)
368+
369+
def test_included_filter_limits_service_attributes(self):
370+
config = ResourceConfig(
371+
detection_development=ExperimentalResourceDetection(
372+
detectors=[ExperimentalResourceDetector(service={})],
373+
attributes=IncludeExclude(included=["service.instance.id"]),
374+
)
375+
)
376+
with patch.dict(os.environ, {"OTEL_SERVICE_NAME": "my-service"}):
377+
resource = create_resource(config)
378+
self.assertIn(SERVICE_INSTANCE_ID, resource.attributes)
379+
# service.name comes from the filter-excluded detector output, but the
380+
# default "unknown_service" is still added by create_resource directly
381+
self.assertEqual(resource.attributes[SERVICE_NAME], "unknown_service")
382+
383+
310384
class TestHostResourceDetector(unittest.TestCase):
311385
@staticmethod
312386
def _config_with_host() -> ResourceConfig:

0 commit comments

Comments
 (0)