From 5c26f004500a5454097092793a1d11a91bea2a71 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 13 Mar 2026 13:04:09 +0000 Subject: [PATCH 01/14] 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 --- CHANGELOG.md | 2 + .../sdk/_configuration/_exceptions.py | 25 ++ .../sdk/_configuration/_propagator.py | 126 ++++++++ .../sdk/_configuration/_resource.py | 141 +++++++++ .../sdk/_configuration/file/__init__.py | 8 + .../sdk/_configuration/file/_loader.py | 12 +- .../tests/_configuration/test_propagator.py | 254 ++++++++++++++++ .../tests/_configuration/test_resource.py | 282 ++++++++++++++++++ 8 files changed, 841 insertions(+), 9 deletions(-) create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_exceptions.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py create mode 100644 opentelemetry-sdk/tests/_configuration/test_propagator.py create mode 100644 opentelemetry-sdk/tests/_configuration/test_resource.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 540f7b9d347..c4ddccc4ae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `opentelemetry-sdk`: Add `create_resource` and `create_propagator`/`configure_propagator` to declarative file configuration, enabling Resource and propagator instantiation from config files without reading env vars + ([#XXXX](https://github.com/open-telemetry/opentelemetry-python/pull/XXXX)) - `opentelemetry-sdk`: Add file configuration support with YAML/JSON loading, environment variable substitution, and schema validation against the vendored OTel config JSON schema ([#4898](https://github.com/open-telemetry/opentelemetry-python/pull/4898)) - Fix intermittent CI failures in `getting-started` and `tracecontext` jobs caused by GitHub git CDN SHA propagation lag by installing contrib packages from the already-checked-out local copy instead of a second git clone diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_exceptions.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_exceptions.py new file mode 100644 index 00000000000..9b90dbd50a5 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_exceptions.py @@ -0,0 +1,25 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class ConfigurationError(Exception): + """Raised when configuration loading, parsing, validation, or instantiation fails. + + This includes errors from: + - File not found or inaccessible + - Invalid YAML/JSON syntax + - Schema validation failures + - Environment variable substitution errors + - Missing required SDK extensions (e.g., propagator packages not installed) + """ diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py new file mode 100644 index 00000000000..696371ab52f --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py @@ -0,0 +1,126 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import logging +from typing import Optional + +from opentelemetry.baggage.propagation import W3CBaggagePropagator +from opentelemetry.propagate import set_global_textmap +from opentelemetry.propagators.composite import CompositePropagator +from opentelemetry.propagators.textmap import TextMapPropagator +from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + Propagator as PropagatorConfig, + TextMapPropagator as TextMapPropagatorConfig, +) +from opentelemetry.trace.propagation.tracecontext import ( + TraceContextTextMapPropagator, +) +from opentelemetry.util._importlib_metadata import entry_points + +_logger = logging.getLogger(__name__) + + +def _load_entry_point_propagator(name: str) -> TextMapPropagator: + """Load a propagator by name from the opentelemetry_propagator entry point group.""" + try: + eps = list( + entry_points(group="opentelemetry_propagator", name=name) + ) + if not eps: + raise ConfigurationError( + f"Propagator '{name}' not found. " + "It may not be installed or may be misspelled." + ) + return eps[0].load()() + except ConfigurationError: + raise + except Exception as e: + raise ConfigurationError( + f"Failed to load propagator '{name}': {e}" + ) from e + + +def _propagators_from_textmap_config( + config: TextMapPropagatorConfig, +) -> list[TextMapPropagator]: + """Resolve a single TextMapPropagator config entry to a list of propagators.""" + result: list[TextMapPropagator] = [] + if config.tracecontext is not None: + result.append(TraceContextTextMapPropagator()) + if config.baggage is not None: + result.append(W3CBaggagePropagator()) + if config.b3 is not None: + result.append(_load_entry_point_propagator("b3")) + if config.b3multi is not None: + result.append(_load_entry_point_propagator("b3multi")) + return result + + +def create_propagator( + config: Optional[PropagatorConfig], +) -> CompositePropagator: + """Create a CompositePropagator from declarative config. + + If config is None or has no propagators defined, returns an empty + CompositePropagator (no-op), ensuring "what you see is what you get" + semantics — the env-var-based default propagators are not used. + + Args: + config: Propagator config from the parsed config file, or None. + + Returns: + A CompositePropagator wrapping all configured propagators. + """ + if config is None: + return CompositePropagator([]) + + propagators: list[TextMapPropagator] = [] + seen_types: set[type] = set() + + def _add_deduped(p: TextMapPropagator) -> None: + if type(p) not in seen_types: + seen_types.add(type(p)) + propagators.append(p) + + # Process structured composite list + if config.composite: + for entry in config.composite: + for p in _propagators_from_textmap_config(entry): + _add_deduped(p) + + # Process composite_list (comma-separated propagator names via entry_points) + if config.composite_list: + for name in config.composite_list.split(","): + name = name.strip() + if not name or name.lower() == "none": + continue + p = _load_entry_point_propagator(name) + _add_deduped(p) + + return CompositePropagator(propagators) + + +def configure_propagator(config: Optional[PropagatorConfig]) -> None: + """Configure the global text map propagator from declarative config. + + Always calls set_global_textmap to override any defaults (including the + env-var-based tracecontext+baggage default set by the SDK). + + Args: + config: Propagator config from the parsed config file, or None. + """ + set_global_textmap(create_propagator(config)) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py new file mode 100644 index 00000000000..4681fdaded7 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -0,0 +1,141 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import logging +from typing import Optional +from urllib import parse + +from opentelemetry.sdk._configuration.models import ( + AttributeNameValue, + AttributeType, + Resource as ResourceConfig, +) +from opentelemetry.sdk.resources import ( + SERVICE_NAME, + TELEMETRY_SDK_LANGUAGE, + TELEMETRY_SDK_NAME, + TELEMETRY_SDK_VERSION, + Resource, + _OPENTELEMETRY_SDK_VERSION, +) + +_logger = logging.getLogger(__name__) + + +def _coerce_attribute_value(attr: AttributeNameValue) -> object: + """Coerce an attribute value to the correct Python type based on AttributeType.""" + value = attr.value + attr_type = attr.type + + if attr_type is None: + return value + + if attr_type == AttributeType.string: + return str(value) + if attr_type == AttributeType.bool: + if isinstance(value, str): + return value.lower() not in ("false", "0", "") + return bool(value) + if attr_type == AttributeType.int: + return int(value) # type: ignore[arg-type] + if attr_type == AttributeType.double: + return float(value) # type: ignore[arg-type] + if attr_type == AttributeType.string_array: + return [str(v) for v in value] # type: ignore[union-attr] + if attr_type == AttributeType.bool_array: + return [bool(v) for v in value] # type: ignore[union-attr] + if attr_type == AttributeType.int_array: + return [int(v) for v in value] # type: ignore[union-attr,arg-type] + if attr_type == AttributeType.double_array: + return [float(v) for v in value] # type: ignore[union-attr,arg-type] + + return value + + +def _parse_attributes_list(attributes_list: str) -> dict[str, str]: + """Parse a comma-separated key=value string into a dict. + + Format is the same as OTEL_RESOURCE_ATTRIBUTES: key=value,key=value + Values are always strings (no type coercion). + """ + result: dict[str, str] = {} + for item in attributes_list.split(","): + item = item.strip() + if not item: + continue + if "=" not in item: + _logger.warning( + "Invalid resource attribute pair in attributes_list: %s", + item, + ) + continue + key, value = item.split("=", maxsplit=1) + result[key.strip()] = parse.unquote(value.strip()) + return result + + +def _sdk_default_attributes() -> dict[str, object]: + """Return the SDK telemetry attributes (equivalent to Java's Resource.getDefault()).""" + return { + TELEMETRY_SDK_LANGUAGE: "python", + TELEMETRY_SDK_NAME: "opentelemetry", + TELEMETRY_SDK_VERSION: _OPENTELEMETRY_SDK_VERSION, + } + + +def create_resource(config: Optional[ResourceConfig]) -> Resource: + """Create an SDK Resource from declarative config. + + Does NOT read OTEL_RESOURCE_ATTRIBUTES or run any resource detectors. + Starts from SDK telemetry defaults (telemetry.sdk.*) and merges config + attributes on top, matching Java SDK behavior. + + Args: + config: Resource config from the parsed config file, or None. + + Returns: + A Resource with SDK defaults merged with any config-specified attributes. + """ + base = Resource(_sdk_default_attributes()) + + if config is None: + service_resource = Resource({SERVICE_NAME: "unknown_service"}) + return base.merge(service_resource) + + # Build attributes from config.attributes list + config_attrs: dict[str, object] = {} + if config.attributes: + for attr in config.attributes: + config_attrs[attr.name] = _coerce_attribute_value(attr) + + # Parse attributes_list (key=value,key=value string format) + if config.attributes_list: + list_attrs = _parse_attributes_list(config.attributes_list) + # attributes_list entries do not override explicit attributes + for k, v in list_attrs.items(): + if k not in config_attrs: + config_attrs[k] = v + + schema_url = config.schema_url + + config_resource = Resource(config_attrs, schema_url) # type: ignore[arg-type] + result = base.merge(config_resource) + + # Add default service.name if not specified (matches Java SDK behavior) + if not result.attributes.get(SERVICE_NAME): + result = result.merge(Resource({SERVICE_NAME: "unknown_service"})) + + return result diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py index a9955397495..17a6847abb8 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py @@ -24,6 +24,11 @@ '1.0' """ +from opentelemetry.sdk._configuration._propagator import ( + configure_propagator, + create_propagator, +) +from opentelemetry.sdk._configuration._resource import create_resource from opentelemetry.sdk._configuration.file._env_substitution import ( EnvSubstitutionError, substitute_env_vars, @@ -38,4 +43,7 @@ "substitute_env_vars", "ConfigurationError", "EnvSubstitutionError", + "create_resource", + "create_propagator", + "configure_propagator", ] diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py index 60191666690..2649398170f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py @@ -20,6 +20,7 @@ from pathlib import Path from typing import Any +from opentelemetry.sdk._configuration._exceptions import ConfigurationError from opentelemetry.sdk._configuration.file._env_substitution import ( substitute_env_vars, ) @@ -59,15 +60,8 @@ def _get_schema() -> dict: _logger = logging.getLogger(__name__) -class ConfigurationError(Exception): - """Raised when configuration file loading, parsing, or validation fails. - - This includes errors from: - - File not found or inaccessible - - Invalid YAML/JSON syntax - - Schema validation failures - - Environment variable substitution errors - """ +# Re-export for backwards compatibility +__all__ = ["ConfigurationError", "load_config_file"] def load_config_file(file_path: str) -> OpenTelemetryConfiguration: diff --git a/opentelemetry-sdk/tests/_configuration/test_propagator.py b/opentelemetry-sdk/tests/_configuration/test_propagator.py new file mode 100644 index 00000000000..268f7910b64 --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_propagator.py @@ -0,0 +1,254 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from unittest.mock import MagicMock, patch + +from opentelemetry.baggage.propagation import W3CBaggagePropagator +from opentelemetry.propagators.composite import CompositePropagator +from opentelemetry.sdk._configuration._propagator import ( + configure_propagator, + create_propagator, +) +from opentelemetry.sdk._configuration.file._loader import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + Propagator as PropagatorConfig, + TextMapPropagator as TextMapPropagatorConfig, +) +from opentelemetry.trace.propagation.tracecontext import ( + TraceContextTextMapPropagator, +) + + +class TestCreatePropagator(unittest.TestCase): + def test_none_config_returns_empty_composite(self): + result = create_propagator(None) + self.assertIsInstance(result, CompositePropagator) + self.assertEqual(result._propagators, []) # type: ignore[attr-defined] + + def test_empty_config_returns_empty_composite(self): + result = create_propagator(PropagatorConfig()) + self.assertIsInstance(result, CompositePropagator) + self.assertEqual(result._propagators, []) # type: ignore[attr-defined] + + def test_tracecontext_only(self): + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(tracecontext={})] + ) + result = create_propagator(config) + self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] + self.assertIsInstance( + result._propagators[0], TraceContextTextMapPropagator # type: ignore[attr-defined] + ) + + def test_baggage_only(self): + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(baggage={})] + ) + result = create_propagator(config) + self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] + self.assertIsInstance(result._propagators[0], W3CBaggagePropagator) # type: ignore[attr-defined] + + def test_tracecontext_and_baggage(self): + config = PropagatorConfig( + composite=[ + TextMapPropagatorConfig(tracecontext={}), + TextMapPropagatorConfig(baggage={}), + ] + ) + result = create_propagator(config) + self.assertEqual(len(result._propagators), 2) # type: ignore[attr-defined] + self.assertIsInstance( + result._propagators[0], TraceContextTextMapPropagator # type: ignore[attr-defined] + ) + self.assertIsInstance(result._propagators[1], W3CBaggagePropagator) # type: ignore[attr-defined] + + def test_b3_via_entry_point(self): + mock_propagator = MagicMock() + mock_ep = MagicMock() + mock_ep.load.return_value = lambda: mock_propagator + + with patch( + "opentelemetry.sdk._configuration._propagator.entry_points", + return_value=[mock_ep], + ): + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(b3={})] + ) + result = create_propagator(config) + + self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] + self.assertIs(result._propagators[0], mock_propagator) # type: ignore[attr-defined] + + def test_b3multi_via_entry_point(self): + mock_propagator = MagicMock() + mock_ep = MagicMock() + mock_ep.load.return_value = lambda: mock_propagator + + with patch( + "opentelemetry.sdk._configuration._propagator.entry_points", + return_value=[mock_ep], + ): + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(b3multi={})] + ) + result = create_propagator(config) + + self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] + + def test_b3_not_installed_raises_configuration_error(self): + with patch( + "opentelemetry.sdk._configuration._propagator.entry_points", + return_value=[], + ): + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(b3={})] + ) + with self.assertRaises(ConfigurationError) as ctx: + create_propagator(config) + self.assertIn("b3", str(ctx.exception)) + + def test_composite_list_tracecontext(self): + config = PropagatorConfig(composite_list="tracecontext") + mock_tc = TraceContextTextMapPropagator() + mock_ep = MagicMock() + mock_ep.load.return_value = lambda: mock_tc + + with patch( + "opentelemetry.sdk._configuration._propagator.entry_points", + return_value=[mock_ep], + ): + result = create_propagator(config) + + self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] + + def test_composite_list_multiple(self): + mock_tc = TraceContextTextMapPropagator() + mock_baggage = W3CBaggagePropagator() + mock_ep_tc = MagicMock() + mock_ep_tc.load.return_value = lambda: mock_tc + mock_ep_baggage = MagicMock() + mock_ep_baggage.load.return_value = lambda: mock_baggage + + def fake_entry_points(group, name): + if name == "tracecontext": + return [mock_ep_tc] + if name == "baggage": + return [mock_ep_baggage] + return [] + + with patch( + "opentelemetry.sdk._configuration._propagator.entry_points", + side_effect=fake_entry_points, + ): + config = PropagatorConfig( + composite_list="tracecontext,baggage" + ) + result = create_propagator(config) + + self.assertEqual(len(result._propagators), 2) # type: ignore[attr-defined] + + def test_composite_list_none_entry_skipped(self): + config = PropagatorConfig(composite_list="none") + result = create_propagator(config) + self.assertEqual(result._propagators, []) # type: ignore[attr-defined] + + def test_composite_list_empty_entries_skipped(self): + config = PropagatorConfig(composite_list=",, ,") + result = create_propagator(config) + self.assertEqual(result._propagators, []) # type: ignore[attr-defined] + + def test_composite_list_whitespace_around_names(self): + mock_tc = TraceContextTextMapPropagator() + mock_ep = MagicMock() + mock_ep.load.return_value = lambda: mock_tc + + with patch( + "opentelemetry.sdk._configuration._propagator.entry_points", + return_value=[mock_ep], + ): + config = PropagatorConfig(composite_list=" tracecontext ") + result = create_propagator(config) + + self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] + + def test_entry_point_load_exception_raises_configuration_error(self): + mock_ep = MagicMock() + mock_ep.load.side_effect = RuntimeError("package broken") + + with patch( + "opentelemetry.sdk._configuration._propagator.entry_points", + return_value=[mock_ep], + ): + config = PropagatorConfig(composite_list="broken-prop") + with self.assertRaises(ConfigurationError) as ctx: + create_propagator(config) + self.assertIn("broken-prop", str(ctx.exception)) + + def test_deduplication_across_composite_and_composite_list(self): + """Same propagator type from both composite and composite_list is deduplicated.""" + mock_tc = TraceContextTextMapPropagator() + mock_ep = MagicMock() + mock_ep.load.return_value = lambda: mock_tc + + with patch( + "opentelemetry.sdk._configuration._propagator.entry_points", + return_value=[mock_ep], + ): + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(tracecontext={})], + composite_list="tracecontext", + ) + result = create_propagator(config) + + # Only one TraceContextTextMapPropagator despite being in both + tc_count = sum( + 1 + for p in result._propagators # type: ignore[attr-defined] + if isinstance(p, TraceContextTextMapPropagator) + ) + self.assertEqual(tc_count, 1) + + def test_unknown_composite_list_propagator_raises(self): + with patch( + "opentelemetry.sdk._configuration._propagator.entry_points", + return_value=[], + ): + config = PropagatorConfig(composite_list="nonexistent") + with self.assertRaises(ConfigurationError): + create_propagator(config) + + +class TestConfigurePropagator(unittest.TestCase): + def test_configure_propagator_calls_set_global_textmap(self): + with patch( + "opentelemetry.sdk._configuration._propagator.set_global_textmap" + ) as mock_set: + configure_propagator(None) + mock_set.assert_called_once() + arg = mock_set.call_args[0][0] + self.assertIsInstance(arg, CompositePropagator) + + def test_configure_propagator_with_config(self): + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(tracecontext={})] + ) + with patch( + "opentelemetry.sdk._configuration._propagator.set_global_textmap" + ) as mock_set: + configure_propagator(config) + mock_set.assert_called_once() + propagator = mock_set.call_args[0][0] + self.assertIsInstance(propagator, CompositePropagator) + self.assertEqual(len(propagator._propagators), 1) # type: ignore[attr-defined] diff --git a/opentelemetry-sdk/tests/_configuration/test_resource.py b/opentelemetry-sdk/tests/_configuration/test_resource.py new file mode 100644 index 00000000000..1a13a859bb2 --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_resource.py @@ -0,0 +1,282 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +from opentelemetry.sdk._configuration._resource import create_resource +from opentelemetry.sdk._configuration.models import ( + AttributeNameValue, + AttributeType, + Resource as ResourceConfig, +) +from opentelemetry.sdk.resources import ( + SERVICE_NAME, + TELEMETRY_SDK_LANGUAGE, + TELEMETRY_SDK_NAME, + TELEMETRY_SDK_VERSION, + Resource, +) + + +class TestCreateResource(unittest.TestCase): + def test_none_config_returns_sdk_defaults(self): + resource = create_resource(None) + self.assertIsInstance(resource, Resource) + self.assertEqual(resource.attributes[TELEMETRY_SDK_LANGUAGE], "python") + self.assertEqual( + resource.attributes[TELEMETRY_SDK_NAME], "opentelemetry" + ) + self.assertIn(TELEMETRY_SDK_VERSION, resource.attributes) + self.assertEqual( + resource.attributes[SERVICE_NAME], "unknown_service" + ) + + def test_none_config_does_not_read_env_vars(self): + import os + from unittest.mock import patch + + with patch.dict( + os.environ, + {"OTEL_RESOURCE_ATTRIBUTES": "foo=bar", "OTEL_SERVICE_NAME": "my-service"}, + ): + resource = create_resource(None) + self.assertNotIn("foo", resource.attributes) + self.assertEqual(resource.attributes[SERVICE_NAME], "unknown_service") + + def test_empty_resource_config(self): + resource = create_resource(ResourceConfig()) + self.assertEqual(resource.attributes[TELEMETRY_SDK_LANGUAGE], "python") + self.assertEqual(resource.attributes[SERVICE_NAME], "unknown_service") + + def test_attributes_plain(self): + config = ResourceConfig( + attributes=[ + AttributeNameValue(name="service.name", value="my-service"), + AttributeNameValue(name="env", value="production"), + ] + ) + resource = create_resource(config) + self.assertEqual(resource.attributes["service.name"], "my-service") + self.assertEqual(resource.attributes["env"], "production") + # SDK defaults still present + self.assertEqual(resource.attributes[TELEMETRY_SDK_LANGUAGE], "python") + + def test_attribute_type_string(self): + config = ResourceConfig( + attributes=[ + AttributeNameValue( + name="k", value=42, type=AttributeType.string + ) + ] + ) + resource = create_resource(config) + self.assertEqual(resource.attributes["k"], "42") + self.assertIsInstance(resource.attributes["k"], str) + + def test_attribute_type_int(self): + config = ResourceConfig( + attributes=[ + AttributeNameValue( + name="k", value=3.0, type=AttributeType.int + ) + ] + ) + resource = create_resource(config) + self.assertEqual(resource.attributes["k"], 3) + self.assertIsInstance(resource.attributes["k"], int) + + def test_attribute_type_double(self): + config = ResourceConfig( + attributes=[ + AttributeNameValue( + name="k", value="1.5", type=AttributeType.double + ) + ] + ) + resource = create_resource(config) + self.assertAlmostEqual(resource.attributes["k"], 1.5) # type: ignore[arg-type] + self.assertIsInstance(resource.attributes["k"], float) + + def test_attribute_type_bool(self): + config = ResourceConfig( + attributes=[ + AttributeNameValue( + name="k", value="true", type=AttributeType.bool + ) + ] + ) + resource = create_resource(config) + self.assertTrue(resource.attributes["k"]) + + def test_attribute_type_bool_false_string(self): + config = ResourceConfig( + attributes=[ + AttributeNameValue( + name="k", value="false", type=AttributeType.bool + ) + ] + ) + resource = create_resource(config) + self.assertFalse(resource.attributes["k"]) + + def test_attribute_type_string_array(self): + config = ResourceConfig( + attributes=[ + AttributeNameValue( + name="k", + value=["a", "b"], + type=AttributeType.string_array, + ) + ] + ) + resource = create_resource(config) + self.assertEqual(list(resource.attributes["k"]), ["a", "b"]) # type: ignore[arg-type] + + def test_attribute_type_int_array(self): + config = ResourceConfig( + attributes=[ + AttributeNameValue( + name="k", + value=[1.0, 2.0], + type=AttributeType.int_array, + ) + ] + ) + resource = create_resource(config) + self.assertEqual(list(resource.attributes["k"]), [1, 2]) # type: ignore[arg-type] + + def test_attribute_type_double_array(self): + config = ResourceConfig( + attributes=[ + AttributeNameValue( + name="k", + value=[1, 2], + type=AttributeType.double_array, + ) + ] + ) + resource = create_resource(config) + self.assertEqual(list(resource.attributes["k"]), [1.0, 2.0]) # type: ignore[arg-type] + + def test_attribute_type_bool_array(self): + config = ResourceConfig( + attributes=[ + AttributeNameValue( + name="k", + value=[True, False], + type=AttributeType.bool_array, + ) + ] + ) + resource = create_resource(config) + self.assertEqual(list(resource.attributes["k"]), [True, False]) # type: ignore[arg-type] + + def test_schema_url(self): + config = ResourceConfig( + schema_url="https://opentelemetry.io/schemas/1.24.0" + ) + resource = create_resource(config) + self.assertEqual( + resource.schema_url, "https://opentelemetry.io/schemas/1.24.0" + ) + + def test_schema_url_none(self): + resource = create_resource(ResourceConfig()) + self.assertEqual(resource.schema_url, "") + + def test_attributes_list_parsed(self): + config = ResourceConfig( + attributes_list="service.name=my-svc,region=us-east-1" + ) + resource = create_resource(config) + self.assertEqual(resource.attributes["service.name"], "my-svc") + self.assertEqual(resource.attributes["region"], "us-east-1") + + def test_attributes_list_does_not_override_attributes(self): + """Explicit attributes take precedence over attributes_list.""" + config = ResourceConfig( + attributes=[ + AttributeNameValue(name="service.name", value="explicit") + ], + attributes_list="service.name=from-list,extra=val", + ) + resource = create_resource(config) + self.assertEqual(resource.attributes["service.name"], "explicit") + self.assertEqual(resource.attributes["extra"], "val") + + def test_attributes_list_only_includes_sdk_defaults(self): + """attributes_list alone should still include telemetry.sdk.* defaults.""" + config = ResourceConfig(attributes_list="env=prod") + resource = create_resource(config) + self.assertEqual(resource.attributes["env"], "prod") + self.assertEqual(resource.attributes[TELEMETRY_SDK_LANGUAGE], "python") + + def test_attributes_list_value_containing_equals(self): + """Values containing '=' should be preserved intact.""" + config = ResourceConfig(attributes_list="token=abc=def=ghi") + resource = create_resource(config) + self.assertEqual(resource.attributes["token"], "abc=def=ghi") + + def test_attributes_list_empty_pairs_skipped(self): + config = ResourceConfig(attributes_list=",foo=bar,,") + resource = create_resource(config) + self.assertEqual(resource.attributes["foo"], "bar") + + def test_attributes_list_url_decoded(self): + config = ResourceConfig( + attributes_list="service.namespace=my%20namespace,region=us-east-1" + ) + resource = create_resource(config) + self.assertEqual(resource.attributes["service.namespace"], "my namespace") + + def test_attributes_list_invalid_pair_skipped(self): + with self.assertLogs( + "opentelemetry.sdk._configuration._resource", level="WARNING" + ) as cm: + config = ResourceConfig(attributes_list="no-equals,foo=bar") + resource = create_resource(config) + self.assertEqual(resource.attributes["foo"], "bar") + self.assertNotIn("no-equals", resource.attributes) + self.assertTrue(any("no-equals" in msg for msg in cm.output)) + + def test_service_name_default_added_when_missing(self): + config = ResourceConfig( + attributes=[AttributeNameValue(name="env", value="staging")] + ) + resource = create_resource(config) + self.assertEqual(resource.attributes[SERVICE_NAME], "unknown_service") + + def test_service_name_not_overridden_when_set(self): + config = ResourceConfig( + attributes=[ + AttributeNameValue(name="service.name", value="my-app") + ] + ) + resource = create_resource(config) + self.assertEqual(resource.attributes[SERVICE_NAME], "my-app") + + def test_env_vars_not_read(self): + """OTEL_RESOURCE_ATTRIBUTES must not affect declarative config resource.""" + import os + from unittest.mock import patch + + with patch.dict( + os.environ, + {"OTEL_RESOURCE_ATTRIBUTES": "injected=true"}, + ): + config = ResourceConfig( + attributes=[AttributeNameValue(name="k", value="v")] + ) + resource = create_resource(config) + self.assertNotIn("injected", resource.attributes) From 82320121caf2696b4999e219d1cc8acddeaec9d1 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 13 Mar 2026 13:12:36 +0000 Subject: [PATCH 02/14] update changelog with PR number Assisted-by: Claude Sonnet 4.6 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ddccc4ae2..02e51173eb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased - `opentelemetry-sdk`: Add `create_resource` and `create_propagator`/`configure_propagator` to declarative file configuration, enabling Resource and propagator instantiation from config files without reading env vars - ([#XXXX](https://github.com/open-telemetry/opentelemetry-python/pull/XXXX)) + ([#4979](https://github.com/open-telemetry/opentelemetry-python/pull/4979)) - `opentelemetry-sdk`: Add file configuration support with YAML/JSON loading, environment variable substitution, and schema validation against the vendored OTel config JSON schema ([#4898](https://github.com/open-telemetry/opentelemetry-python/pull/4898)) - Fix intermittent CI failures in `getting-started` and `tracecontext` jobs caused by GitHub git CDN SHA propagation lag by installing contrib packages from the already-checked-out local copy instead of a second git clone From 8329ae4bda09fe56ddf06610f1bf6326e1e9c93b Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 13 Mar 2026 13:29:52 +0000 Subject: [PATCH 03/14] 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 --- .../sdk/_configuration/_propagator.py | 27 ++--- .../sdk/_configuration/_resource.py | 59 +++++---- .../tests/_configuration/test_propagator.py | 15 ++- .../tests/_configuration/test_resource.py | 113 +++++++++--------- 4 files changed, 114 insertions(+), 100 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py index 696371ab52f..1c49d05651a 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py @@ -24,6 +24,8 @@ from opentelemetry.sdk._configuration._exceptions import ConfigurationError from opentelemetry.sdk._configuration.models import ( Propagator as PropagatorConfig, +) +from opentelemetry.sdk._configuration.models import ( TextMapPropagator as TextMapPropagatorConfig, ) from opentelemetry.trace.propagation.tracecontext import ( @@ -37,9 +39,7 @@ def _load_entry_point_propagator(name: str) -> TextMapPropagator: """Load a propagator by name from the opentelemetry_propagator entry point group.""" try: - eps = list( - entry_points(group="opentelemetry_propagator", name=name) - ) + eps = list(entry_points(group="opentelemetry_propagator", name=name)) if not eps: raise ConfigurationError( f"Propagator '{name}' not found. " @@ -48,10 +48,10 @@ def _load_entry_point_propagator(name: str) -> TextMapPropagator: return eps[0].load()() except ConfigurationError: raise - except Exception as e: + except Exception as exc: raise ConfigurationError( - f"Failed to load propagator '{name}': {e}" - ) from e + f"Failed to load propagator '{name}': {exc}" + ) from exc def _propagators_from_textmap_config( @@ -91,16 +91,16 @@ def create_propagator( propagators: list[TextMapPropagator] = [] seen_types: set[type] = set() - def _add_deduped(p: TextMapPropagator) -> None: - if type(p) not in seen_types: - seen_types.add(type(p)) - propagators.append(p) + def _add_deduped(propagator: TextMapPropagator) -> None: + if type(propagator) not in seen_types: + seen_types.add(type(propagator)) + propagators.append(propagator) # Process structured composite list if config.composite: for entry in config.composite: - for p in _propagators_from_textmap_config(entry): - _add_deduped(p) + for propagator in _propagators_from_textmap_config(entry): + _add_deduped(propagator) # Process composite_list (comma-separated propagator names via entry_points) if config.composite_list: @@ -108,8 +108,7 @@ def _add_deduped(p: TextMapPropagator) -> None: name = name.strip() if not name or name.lower() == "none": continue - p = _load_entry_point_propagator(name) - _add_deduped(p) + _add_deduped(_load_entry_point_propagator(name)) return CompositePropagator(propagators) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py index 4681fdaded7..7a04d87446d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -21,19 +21,40 @@ from opentelemetry.sdk._configuration.models import ( AttributeNameValue, AttributeType, - Resource as ResourceConfig, ) +from opentelemetry.sdk._configuration.models import Resource as ResourceConfig from opentelemetry.sdk.resources import ( + _OPENTELEMETRY_SDK_VERSION, SERVICE_NAME, TELEMETRY_SDK_LANGUAGE, TELEMETRY_SDK_NAME, TELEMETRY_SDK_VERSION, Resource, - _OPENTELEMETRY_SDK_VERSION, ) _logger = logging.getLogger(__name__) +# Dispatch table for scalar type coercions +_SCALAR_COERCIONS = { + AttributeType.string: str, + AttributeType.int: int, + AttributeType.double: float, +} + +# Dispatch table for array type coercions +_ARRAY_COERCIONS = { + AttributeType.string_array: str, + AttributeType.bool_array: bool, + AttributeType.int_array: int, + AttributeType.double_array: float, +} + + +def _coerce_bool(value: object) -> bool: + if isinstance(value, str): + return value.lower() not in ("false", "0", "") + return bool(value) + def _coerce_attribute_value(attr: AttributeNameValue) -> object: """Coerce an attribute value to the correct Python type based on AttributeType.""" @@ -42,26 +63,14 @@ def _coerce_attribute_value(attr: AttributeNameValue) -> object: if attr_type is None: return value - - if attr_type == AttributeType.string: - return str(value) if attr_type == AttributeType.bool: - if isinstance(value, str): - return value.lower() not in ("false", "0", "") - return bool(value) - if attr_type == AttributeType.int: - return int(value) # type: ignore[arg-type] - if attr_type == AttributeType.double: - return float(value) # type: ignore[arg-type] - if attr_type == AttributeType.string_array: - return [str(v) for v in value] # type: ignore[union-attr] - if attr_type == AttributeType.bool_array: - return [bool(v) for v in value] # type: ignore[union-attr] - if attr_type == AttributeType.int_array: - return [int(v) for v in value] # type: ignore[union-attr,arg-type] - if attr_type == AttributeType.double_array: - return [float(v) for v in value] # type: ignore[union-attr,arg-type] - + return _coerce_bool(value) + scalar_coercer = _SCALAR_COERCIONS.get(attr_type) + if scalar_coercer is not None: + return scalar_coercer(value) # type: ignore[arg-type] + array_coercer = _ARRAY_COERCIONS.get(attr_type) + if array_coercer is not None: + return [array_coercer(item) for item in value] # type: ignore[union-attr,arg-type] return value @@ -87,7 +96,7 @@ def _parse_attributes_list(attributes_list: str) -> dict[str, str]: return result -def _sdk_default_attributes() -> dict[str, object]: +def _sdk_default_attributes() -> dict[str, str]: """Return the SDK telemetry attributes (equivalent to Java's Resource.getDefault()).""" return { TELEMETRY_SDK_LANGUAGE: "python", @@ -125,9 +134,9 @@ def create_resource(config: Optional[ResourceConfig]) -> Resource: if config.attributes_list: list_attrs = _parse_attributes_list(config.attributes_list) # attributes_list entries do not override explicit attributes - for k, v in list_attrs.items(): - if k not in config_attrs: - config_attrs[k] = v + for attr_key, attr_val in list_attrs.items(): + if attr_key not in config_attrs: + config_attrs[attr_key] = attr_val schema_url = config.schema_url diff --git a/opentelemetry-sdk/tests/_configuration/test_propagator.py b/opentelemetry-sdk/tests/_configuration/test_propagator.py index 268f7910b64..e22acfca0f5 100644 --- a/opentelemetry-sdk/tests/_configuration/test_propagator.py +++ b/opentelemetry-sdk/tests/_configuration/test_propagator.py @@ -15,6 +15,9 @@ import unittest from unittest.mock import MagicMock, patch +# CompositePropagator stores its propagators in _propagators (private). +# We access it here to assert composition correctness. +# pylint: disable=protected-access from opentelemetry.baggage.propagation import W3CBaggagePropagator from opentelemetry.propagators.composite import CompositePropagator from opentelemetry.sdk._configuration._propagator import ( @@ -24,6 +27,8 @@ from opentelemetry.sdk._configuration.file._loader import ConfigurationError from opentelemetry.sdk._configuration.models import ( Propagator as PropagatorConfig, +) +from opentelemetry.sdk._configuration.models import ( TextMapPropagator as TextMapPropagatorConfig, ) from opentelemetry.trace.propagation.tracecontext import ( @@ -49,7 +54,8 @@ def test_tracecontext_only(self): result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] self.assertIsInstance( - result._propagators[0], TraceContextTextMapPropagator # type: ignore[attr-defined] + result._propagators[0], + TraceContextTextMapPropagator, # type: ignore[attr-defined] ) def test_baggage_only(self): @@ -70,7 +76,8 @@ def test_tracecontext_and_baggage(self): result = create_propagator(config) self.assertEqual(len(result._propagators), 2) # type: ignore[attr-defined] self.assertIsInstance( - result._propagators[0], TraceContextTextMapPropagator # type: ignore[attr-defined] + result._propagators[0], + TraceContextTextMapPropagator, # type: ignore[attr-defined] ) self.assertIsInstance(result._propagators[1], W3CBaggagePropagator) # type: ignore[attr-defined] @@ -152,9 +159,7 @@ def fake_entry_points(group, name): "opentelemetry.sdk._configuration._propagator.entry_points", side_effect=fake_entry_points, ): - config = PropagatorConfig( - composite_list="tracecontext,baggage" - ) + config = PropagatorConfig(composite_list="tracecontext,baggage") result = create_propagator(config) self.assertEqual(len(result._propagators), 2) # type: ignore[attr-defined] diff --git a/opentelemetry-sdk/tests/_configuration/test_resource.py b/opentelemetry-sdk/tests/_configuration/test_resource.py index 1a13a859bb2..b74c0774f0f 100644 --- a/opentelemetry-sdk/tests/_configuration/test_resource.py +++ b/opentelemetry-sdk/tests/_configuration/test_resource.py @@ -12,14 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os import unittest +from unittest.mock import patch from opentelemetry.sdk._configuration._resource import create_resource from opentelemetry.sdk._configuration.models import ( AttributeNameValue, AttributeType, - Resource as ResourceConfig, ) +from opentelemetry.sdk._configuration.models import Resource as ResourceConfig from opentelemetry.sdk.resources import ( SERVICE_NAME, TELEMETRY_SDK_LANGUAGE, @@ -29,7 +31,7 @@ ) -class TestCreateResource(unittest.TestCase): +class TestCreateResourceDefaults(unittest.TestCase): def test_none_config_returns_sdk_defaults(self): resource = create_resource(None) self.assertIsInstance(resource, Resource) @@ -38,17 +40,15 @@ def test_none_config_returns_sdk_defaults(self): resource.attributes[TELEMETRY_SDK_NAME], "opentelemetry" ) self.assertIn(TELEMETRY_SDK_VERSION, resource.attributes) - self.assertEqual( - resource.attributes[SERVICE_NAME], "unknown_service" - ) + self.assertEqual(resource.attributes[SERVICE_NAME], "unknown_service") def test_none_config_does_not_read_env_vars(self): - import os - from unittest.mock import patch - with patch.dict( os.environ, - {"OTEL_RESOURCE_ATTRIBUTES": "foo=bar", "OTEL_SERVICE_NAME": "my-service"}, + { + "OTEL_RESOURCE_ATTRIBUTES": "foo=bar", + "OTEL_SERVICE_NAME": "my-service", + }, ): resource = create_resource(None) self.assertNotIn("foo", resource.attributes) @@ -59,6 +59,49 @@ def test_empty_resource_config(self): self.assertEqual(resource.attributes[TELEMETRY_SDK_LANGUAGE], "python") self.assertEqual(resource.attributes[SERVICE_NAME], "unknown_service") + def test_service_name_default_added_when_missing(self): + config = ResourceConfig( + attributes=[AttributeNameValue(name="env", value="staging")] + ) + resource = create_resource(config) + self.assertEqual(resource.attributes[SERVICE_NAME], "unknown_service") + + def test_service_name_not_overridden_when_set(self): + config = ResourceConfig( + attributes=[ + AttributeNameValue(name="service.name", value="my-app") + ] + ) + resource = create_resource(config) + self.assertEqual(resource.attributes[SERVICE_NAME], "my-app") + + def test_env_vars_not_read(self): + """OTEL_RESOURCE_ATTRIBUTES must not affect declarative config resource.""" + with patch.dict( + os.environ, + {"OTEL_RESOURCE_ATTRIBUTES": "injected=true"}, + ): + config = ResourceConfig( + attributes=[AttributeNameValue(name="k", value="v")] + ) + resource = create_resource(config) + self.assertNotIn("injected", resource.attributes) + + def test_schema_url(self): + config = ResourceConfig( + schema_url="https://opentelemetry.io/schemas/1.24.0" + ) + resource = create_resource(config) + self.assertEqual( + resource.schema_url, "https://opentelemetry.io/schemas/1.24.0" + ) + + def test_schema_url_none(self): + resource = create_resource(ResourceConfig()) + self.assertEqual(resource.schema_url, "") + + +class TestCreateResourceAttributes(unittest.TestCase): def test_attributes_plain(self): config = ResourceConfig( attributes=[ @@ -87,9 +130,7 @@ def test_attribute_type_string(self): def test_attribute_type_int(self): config = ResourceConfig( attributes=[ - AttributeNameValue( - name="k", value=3.0, type=AttributeType.int - ) + AttributeNameValue(name="k", value=3.0, type=AttributeType.int) ] ) resource = create_resource(config) @@ -182,19 +223,8 @@ def test_attribute_type_bool_array(self): resource = create_resource(config) self.assertEqual(list(resource.attributes["k"]), [True, False]) # type: ignore[arg-type] - def test_schema_url(self): - config = ResourceConfig( - schema_url="https://opentelemetry.io/schemas/1.24.0" - ) - resource = create_resource(config) - self.assertEqual( - resource.schema_url, "https://opentelemetry.io/schemas/1.24.0" - ) - - def test_schema_url_none(self): - resource = create_resource(ResourceConfig()) - self.assertEqual(resource.schema_url, "") +class TestCreateResourceAttributesList(unittest.TestCase): def test_attributes_list_parsed(self): config = ResourceConfig( attributes_list="service.name=my-svc,region=us-east-1" @@ -238,7 +268,9 @@ def test_attributes_list_url_decoded(self): attributes_list="service.namespace=my%20namespace,region=us-east-1" ) resource = create_resource(config) - self.assertEqual(resource.attributes["service.namespace"], "my namespace") + self.assertEqual( + resource.attributes["service.namespace"], "my namespace" + ) def test_attributes_list_invalid_pair_skipped(self): with self.assertLogs( @@ -249,34 +281,3 @@ def test_attributes_list_invalid_pair_skipped(self): self.assertEqual(resource.attributes["foo"], "bar") self.assertNotIn("no-equals", resource.attributes) self.assertTrue(any("no-equals" in msg for msg in cm.output)) - - def test_service_name_default_added_when_missing(self): - config = ResourceConfig( - attributes=[AttributeNameValue(name="env", value="staging")] - ) - resource = create_resource(config) - self.assertEqual(resource.attributes[SERVICE_NAME], "unknown_service") - - def test_service_name_not_overridden_when_set(self): - config = ResourceConfig( - attributes=[ - AttributeNameValue(name="service.name", value="my-app") - ] - ) - resource = create_resource(config) - self.assertEqual(resource.attributes[SERVICE_NAME], "my-app") - - def test_env_vars_not_read(self): - """OTEL_RESOURCE_ATTRIBUTES must not affect declarative config resource.""" - import os - from unittest.mock import patch - - with patch.dict( - os.environ, - {"OTEL_RESOURCE_ATTRIBUTES": "injected=true"}, - ): - config = ResourceConfig( - attributes=[AttributeNameValue(name="k", value="v")] - ) - resource = create_resource(config) - self.assertNotIn("injected", resource.attributes) From 506d8160a78134cd1d97c3086b5ea4df903f0632 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 16 Mar 2026 12:56:33 +0000 Subject: [PATCH 04/14] 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 --- .../sdk/_configuration/_resource.py | 34 ++++++------------- .../tests/_configuration/test_resource.py | 14 ++++++++ 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py index 7a04d87446d..22d96c7b65c 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -24,38 +24,37 @@ ) from opentelemetry.sdk._configuration.models import Resource as ResourceConfig from opentelemetry.sdk.resources import ( - _OPENTELEMETRY_SDK_VERSION, SERVICE_NAME, - TELEMETRY_SDK_LANGUAGE, - TELEMETRY_SDK_NAME, - TELEMETRY_SDK_VERSION, Resource, + _DEFAULT_RESOURCE, ) _logger = logging.getLogger(__name__) + +def _coerce_bool(value: object) -> bool: + if isinstance(value, str): + return value.lower() not in ("false", "0", "") + return bool(value) + + # Dispatch table for scalar type coercions _SCALAR_COERCIONS = { AttributeType.string: str, AttributeType.int: int, AttributeType.double: float, + AttributeType.bool: _coerce_bool, } # Dispatch table for array type coercions _ARRAY_COERCIONS = { AttributeType.string_array: str, - AttributeType.bool_array: bool, + AttributeType.bool_array: _coerce_bool, AttributeType.int_array: int, AttributeType.double_array: float, } -def _coerce_bool(value: object) -> bool: - if isinstance(value, str): - return value.lower() not in ("false", "0", "") - return bool(value) - - def _coerce_attribute_value(attr: AttributeNameValue) -> object: """Coerce an attribute value to the correct Python type based on AttributeType.""" value = attr.value @@ -63,8 +62,6 @@ def _coerce_attribute_value(attr: AttributeNameValue) -> object: if attr_type is None: return value - if attr_type == AttributeType.bool: - return _coerce_bool(value) scalar_coercer = _SCALAR_COERCIONS.get(attr_type) if scalar_coercer is not None: return scalar_coercer(value) # type: ignore[arg-type] @@ -96,15 +93,6 @@ def _parse_attributes_list(attributes_list: str) -> dict[str, str]: return result -def _sdk_default_attributes() -> dict[str, str]: - """Return the SDK telemetry attributes (equivalent to Java's Resource.getDefault()).""" - return { - TELEMETRY_SDK_LANGUAGE: "python", - TELEMETRY_SDK_NAME: "opentelemetry", - TELEMETRY_SDK_VERSION: _OPENTELEMETRY_SDK_VERSION, - } - - def create_resource(config: Optional[ResourceConfig]) -> Resource: """Create an SDK Resource from declarative config. @@ -118,7 +106,7 @@ def create_resource(config: Optional[ResourceConfig]) -> Resource: Returns: A Resource with SDK defaults merged with any config-specified attributes. """ - base = Resource(_sdk_default_attributes()) + base = _DEFAULT_RESOURCE if config is None: service_resource = Resource({SERVICE_NAME: "unknown_service"}) diff --git a/opentelemetry-sdk/tests/_configuration/test_resource.py b/opentelemetry-sdk/tests/_configuration/test_resource.py index b74c0774f0f..b50bc03fff4 100644 --- a/opentelemetry-sdk/tests/_configuration/test_resource.py +++ b/opentelemetry-sdk/tests/_configuration/test_resource.py @@ -223,6 +223,20 @@ def test_attribute_type_bool_array(self): resource = create_resource(config) self.assertEqual(list(resource.attributes["k"]), [True, False]) # type: ignore[arg-type] + def test_attribute_type_bool_array_string_values(self): + """bool_array must use _coerce_bool, not plain bool() — 'false' must be False.""" + config = ResourceConfig( + attributes=[ + AttributeNameValue( + name="k", + value=["true", "false"], + type=AttributeType.bool_array, + ) + ] + ) + resource = create_resource(config) + self.assertEqual(list(resource.attributes["k"]), [True, False]) # type: ignore[arg-type] + class TestCreateResourceAttributesList(unittest.TestCase): def test_attributes_list_parsed(self): From 8232d48d55d8d8e7e2c5a5ae8a34f7fb28218228 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 16 Mar 2026 13:00:19 +0000 Subject: [PATCH 05/14] fix linter --- .../src/opentelemetry/sdk/_configuration/_resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py index 22d96c7b65c..a9298bf91d4 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -24,9 +24,9 @@ ) from opentelemetry.sdk._configuration.models import Resource as ResourceConfig from opentelemetry.sdk.resources import ( + _DEFAULT_RESOURCE, SERVICE_NAME, Resource, - _DEFAULT_RESOURCE, ) _logger = logging.getLogger(__name__) From 99753f90259b1acabf92eb5290360977327f8a23 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 16 Mar 2026 15:15:02 +0000 Subject: [PATCH 06/14] 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 --- .../sdk/_configuration/_resource.py | 48 +++++++------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py index a9298bf91d4..bb76cf77e89 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -38,37 +38,27 @@ def _coerce_bool(value: object) -> bool: return bool(value) -# Dispatch table for scalar type coercions -_SCALAR_COERCIONS = { +def _array(coerce: object) -> object: + return lambda value: [coerce(item) for item in value] # type: ignore[operator] + + +# Unified dispatch table for all attribute type coercions +_COERCIONS = { AttributeType.string: str, AttributeType.int: int, AttributeType.double: float, AttributeType.bool: _coerce_bool, -} - -# Dispatch table for array type coercions -_ARRAY_COERCIONS = { - AttributeType.string_array: str, - AttributeType.bool_array: _coerce_bool, - AttributeType.int_array: int, - AttributeType.double_array: float, + AttributeType.string_array: _array(str), + AttributeType.int_array: _array(int), + AttributeType.double_array: _array(float), + AttributeType.bool_array: _array(_coerce_bool), } def _coerce_attribute_value(attr: AttributeNameValue) -> object: """Coerce an attribute value to the correct Python type based on AttributeType.""" - value = attr.value - attr_type = attr.type - - if attr_type is None: - return value - scalar_coercer = _SCALAR_COERCIONS.get(attr_type) - if scalar_coercer is not None: - return scalar_coercer(value) # type: ignore[arg-type] - array_coercer = _ARRAY_COERCIONS.get(attr_type) - if array_coercer is not None: - return [array_coercer(item) for item in value] # type: ignore[union-attr,arg-type] - return value + coerce = _COERCIONS.get(attr.type) # type: ignore[arg-type] + return coerce(attr.value) if coerce is not None else attr.value # type: ignore[operator] def _parse_attributes_list(attributes_list: str) -> dict[str, str]: @@ -112,20 +102,16 @@ def create_resource(config: Optional[ResourceConfig]) -> Resource: service_resource = Resource({SERVICE_NAME: "unknown_service"}) return base.merge(service_resource) - # Build attributes from config.attributes list + # attributes_list is lower priority; process it first so that explicit + # attributes can simply overwrite any conflicting keys. config_attrs: dict[str, object] = {} + if config.attributes_list: + config_attrs.update(_parse_attributes_list(config.attributes_list)) + if config.attributes: for attr in config.attributes: config_attrs[attr.name] = _coerce_attribute_value(attr) - # Parse attributes_list (key=value,key=value string format) - if config.attributes_list: - list_attrs = _parse_attributes_list(config.attributes_list) - # attributes_list entries do not override explicit attributes - for attr_key, attr_val in list_attrs.items(): - if attr_key not in config_attrs: - config_attrs[attr_key] = attr_val - schema_url = config.schema_url config_resource = Resource(config_attrs, schema_url) # type: ignore[arg-type] From 8ba91d8fcdeba7999d5e8dc646adb26ff0b3fc21 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 17 Mar 2026 16:29:39 +0000 Subject: [PATCH 07/14] use Callable type annotation on _array helper Assisted-by: Claude Sonnet 4.6 --- .../src/opentelemetry/sdk/_configuration/_resource.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py index bb76cf77e89..7d28bc87971 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -15,7 +15,7 @@ from __future__ import annotations import logging -from typing import Optional +from typing import Callable, Optional from urllib import parse from opentelemetry.sdk._configuration.models import ( @@ -38,8 +38,8 @@ def _coerce_bool(value: object) -> bool: return bool(value) -def _array(coerce: object) -> object: - return lambda value: [coerce(item) for item in value] # type: ignore[operator] +def _array(coerce: Callable) -> Callable: + return lambda value: [coerce(item) for item in value] # Unified dispatch table for all attribute type coercions From 9cfdcceab8b5742f417b67aa3656ecbc9381e3b2 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 20 Mar 2026 13:01:40 +0000 Subject: [PATCH 08/14] 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 --- .../sdk/_configuration/_resource.py | 85 ++++++++++++++++--- 1 file changed, 74 insertions(+), 11 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py index 7d28bc87971..97cf883a074 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -14,6 +14,7 @@ from __future__ import annotations +import fnmatch import logging from typing import Callable, Optional from urllib import parse @@ -21,6 +22,8 @@ from opentelemetry.sdk._configuration.models import ( AttributeNameValue, AttributeType, + ExperimentalResourceDetector, + IncludeExclude, ) from opentelemetry.sdk._configuration.models import Resource as ResourceConfig from opentelemetry.sdk.resources import ( @@ -42,7 +45,7 @@ def _array(coerce: Callable) -> Callable: return lambda value: [coerce(item) for item in value] -# Unified dispatch table for all attribute type coercions +# Dispatch table mapping AttributeType to its coercion callable _COERCIONS = { AttributeType.string: str, AttributeType.int: int, @@ -86,15 +89,17 @@ def _parse_attributes_list(attributes_list: str) -> dict[str, str]: def create_resource(config: Optional[ResourceConfig]) -> Resource: """Create an SDK Resource from declarative config. - Does NOT read OTEL_RESOURCE_ATTRIBUTES or run any resource detectors. - Starts from SDK telemetry defaults (telemetry.sdk.*) and merges config - attributes on top, matching Java SDK behavior. + Does NOT read OTEL_RESOURCE_ATTRIBUTES. Resource detectors are only run + when explicitly listed under detection_development.detectors in the config. + Starts from SDK telemetry defaults (telemetry.sdk.*), merges any detected + attributes, then merges explicit config attributes on top (highest priority). Args: config: Resource config from the parsed config file, or None. Returns: - A Resource with SDK defaults merged with any config-specified attributes. + A Resource with SDK defaults, optional detector attributes, and any + config-specified attributes merged in priority order. """ base = _DEFAULT_RESOURCE @@ -102,8 +107,7 @@ def create_resource(config: Optional[ResourceConfig]) -> Resource: service_resource = Resource({SERVICE_NAME: "unknown_service"}) return base.merge(service_resource) - # attributes_list is lower priority; process it first so that explicit - # attributes can simply overwrite any conflicting keys. + # attributes_list is lower priority; explicit attributes overwrite conflicts. config_attrs: dict[str, object] = {} if config.attributes_list: config_attrs.update(_parse_attributes_list(config.attributes_list)) @@ -112,13 +116,72 @@ def create_resource(config: Optional[ResourceConfig]) -> Resource: for attr in config.attributes: config_attrs[attr.name] = _coerce_attribute_value(attr) + # Spec requires service.name to always be present. + if SERVICE_NAME not in config_attrs: + config_attrs[SERVICE_NAME] = "unknown_service" + schema_url = config.schema_url + # Run detectors only if detection_development is configured. Collect all + # detected attributes, apply the include/exclude filter, then merge before + # config attributes so explicit values always win. + result = base + if config.detection_development: + detected_attrs: dict[str, object] = {} + if config.detection_development.detectors: + for detector_config in config.detection_development.detectors: + _run_detectors(detector_config, detected_attrs) + + filtered = _filter_attributes( + detected_attrs, config.detection_development.attributes + ) + if filtered: + result = result.merge(Resource(filtered)) # type: ignore[arg-type] + config_resource = Resource(config_attrs, schema_url) # type: ignore[arg-type] - result = base.merge(config_resource) + return result.merge(config_resource) + + +def _run_detectors( + detector_config: ExperimentalResourceDetector, + detected_attrs: dict[str, object], +) -> None: + """Run any 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. + """ + - # Add default service.name if not specified (matches Java SDK behavior) - if not result.attributes.get(SERVICE_NAME): - result = result.merge(Resource({SERVICE_NAME: "unknown_service"})) +def _filter_attributes( + attrs: dict[str, object], filter_config: Optional[IncludeExclude] +) -> dict[str, object]: + """Filter detected attribute keys using include/exclude glob patterns. + Mirrors other SDK IncludeExcludePredicate.createPatternMatching behaviour: + - No filter config (attributes absent) → include all detected attributes. + - included patterns are checked first; excluded patterns are applied after. + - An empty included list is treated as "include everything". + """ + if filter_config is None: + return attrs + + included = filter_config.included + excluded = filter_config.excluded + + if not included and not excluded: + return attrs + + effective_included = included if included else None # [] → include all + + result: dict[str, object] = {} + for key, value in attrs.items(): + if effective_included is not None and not any( + fnmatch.fnmatch(key, pat) for pat in effective_included + ): + continue + if excluded and any(fnmatch.fnmatch(key, pat) for pat in excluded): + continue + result[key] = value return result From 103ff08a0fe14f6faaf8918aae39f5d3b3244c1f Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 20 Mar 2026 13:23:10 +0000 Subject: [PATCH 09/14] 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 --- .../src/opentelemetry/sdk/_configuration/_resource.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py index 97cf883a074..d58bd4d31de 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -101,11 +101,12 @@ def create_resource(config: Optional[ResourceConfig]) -> Resource: A Resource with SDK defaults, optional detector attributes, and any config-specified attributes merged in priority order. """ - base = _DEFAULT_RESOURCE + # Spec requires service.name to always be present; detectors and explicit + # config attributes can override this default. + base = _DEFAULT_RESOURCE.merge(Resource({SERVICE_NAME: "unknown_service"})) if config is None: - service_resource = Resource({SERVICE_NAME: "unknown_service"}) - return base.merge(service_resource) + return base # attributes_list is lower priority; explicit attributes overwrite conflicts. config_attrs: dict[str, object] = {} @@ -116,10 +117,6 @@ def create_resource(config: Optional[ResourceConfig]) -> Resource: for attr in config.attributes: config_attrs[attr.name] = _coerce_attribute_value(attr) - # Spec requires service.name to always be present. - if SERVICE_NAME not in config_attrs: - config_attrs[SERVICE_NAME] = "unknown_service" - schema_url = config.schema_url # Run detectors only if detection_development is configured. Collect all From 91baf9918eaa69bba42c7a5b56c56790c42d7a6d Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 16 Mar 2026 14:47:11 +0000 Subject: [PATCH 10/14] add TracerProvider creation from declarative config Implements create_tracer_provider() and configure_tracer_provider() for the declarative configuration pipeline (tracking issue #3631 step 5). Key behaviors: - Never reads OTEL_TRACES_SAMPLER or OTEL_SPAN_*_LIMIT env vars; absent config fields use OTel spec defaults (matching Java SDK behavior) - Default sampler is ParentBased(root=ALWAYS_ON) per the OTel spec - SpanLimits absent fields use hardcoded defaults (128) not env vars - configure_tracer_provider(None) is a no-op per spec/Java/JS behavior - OTLP exporter fields pass None through so the exporter reads its own env vars for unspecified values - Lazy imports for optional OTLP packages with ConfigurationError on missing - Supports all 4 ParentBased delegate samplers Assisted-by: Claude Sonnet 4.6 --- .../sdk/_configuration/_tracer_provider.py | 377 ++++++++++++++++ .../sdk/_configuration/file/__init__.py | 6 + .../_configuration/test_tracer_provider.py | 427 ++++++++++++++++++ 3 files changed, 810 insertions(+) create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py create mode 100644 opentelemetry-sdk/tests/_configuration/test_tracer_provider.py diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py new file mode 100644 index 00000000000..015f0fada5b --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py @@ -0,0 +1,377 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Dict, Optional + +from opentelemetry import trace +from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + BatchSpanProcessor as BatchSpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpGrpcExporter as OtlpGrpcExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpHttpExporter as OtlpHttpExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + ParentBasedSampler as ParentBasedSamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + Sampler as SamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + SimpleSpanProcessor as SimpleSpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanExporter as SpanExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanLimits as SpanLimitsConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanProcessor as SpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + TracerProvider as TracerProviderConfig, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import ( + SpanLimits, + TracerProvider, + _DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT, + _DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT, + _DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, + _DEFAULT_OTEL_SPAN_EVENT_COUNT_LIMIT, + _DEFAULT_OTEL_SPAN_LINK_COUNT_LIMIT, +) +from opentelemetry.sdk.trace.sampling import ( + ALWAYS_OFF, + ALWAYS_ON, + ParentBased, + Sampler, + TraceIdRatioBased, +) + +if TYPE_CHECKING: + from opentelemetry.sdk.trace.export import SpanExporter + +_logger = logging.getLogger(__name__) + +# Default sampler per the OTel spec: parent_based with always_on root. +_DEFAULT_SAMPLER = ParentBased(root=ALWAYS_ON) + + +def _parse_headers( + headers: Optional[list], + headers_list: Optional[str], +) -> Optional[Dict[str, str]]: + """Merge headers struct and headers_list into a dict. + + Returns None if neither is set, letting the exporter read env vars. + headers struct takes priority over headers_list for the same key. + """ + if headers is None and headers_list is None: + return None + result: Dict[str, str] = {} + if headers_list: + for item in headers_list.split(","): + item = item.strip() + if "=" in item: + key, value = item.split("=", 1) + result[key.strip()] = value.strip() + elif item: + _logger.warning( + "Invalid header pair in headers_list (missing '='): %s", + item, + ) + if headers: + for pair in headers: + result[pair.name] = pair.value or "" + return result + + +def _create_otlp_http_span_exporter(config: OtlpHttpExporterConfig) -> "SpanExporter": + """Create an OTLP HTTP span exporter from config.""" + try: + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( # type: ignore[import-untyped] + OTLPSpanExporter, + ) + from opentelemetry.exporter.otlp.proto.http import ( # type: ignore[import-untyped] + Compression, + ) + except ImportError as exc: + raise ConfigurationError( + "otlp_http span exporter requires 'opentelemetry-exporter-otlp-proto-http'. " + "Install it with: pip install opentelemetry-exporter-otlp-proto-http" + ) from exc + + compression = _map_compression_http(config.compression, Compression) + headers = _parse_headers(config.headers, config.headers_list) + timeout = (config.timeout / 1000.0) if config.timeout is not None else None + + return OTLPSpanExporter( + endpoint=config.endpoint, + headers=headers, + timeout=timeout, + compression=compression, + ) + + +def _map_compression_http( + value: Optional[str], compression_enum: type +) -> Optional[object]: + """Map a compression string to the HTTP Compression enum value.""" + if value is None or value.lower() == "none": + return None + if value.lower() == "gzip": + return compression_enum.Gzip + raise ConfigurationError( + f"Unsupported compression value '{value}'. Supported values: 'gzip', 'none'." + ) + + +def _create_otlp_grpc_span_exporter(config: OtlpGrpcExporterConfig) -> "SpanExporter": + """Create an OTLP gRPC span exporter from config.""" + try: + import grpc # type: ignore[import-untyped] + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # type: ignore[import-untyped] + OTLPSpanExporter, + ) + except ImportError as exc: + raise ConfigurationError( + "otlp_grpc span exporter requires 'opentelemetry-exporter-otlp-proto-grpc'. " + "Install it with: pip install opentelemetry-exporter-otlp-proto-grpc" + ) from exc + + compression = _map_compression_grpc(config.compression, grpc) + headers = _parse_headers(config.headers, config.headers_list) + timeout = (config.timeout / 1000.0) if config.timeout is not None else None + + return OTLPSpanExporter( + endpoint=config.endpoint, + headers=headers, + timeout=timeout, + compression=compression, + ) + + +def _map_compression_grpc( + value: Optional[str], grpc_module: object +) -> Optional[object]: + """Map a compression string to the gRPC Compression enum value.""" + if value is None or value.lower() == "none": + return None + if value.lower() == "gzip": + return grpc_module.Compression.Gzip # type: ignore[attr-defined] + raise ConfigurationError( + f"Unsupported compression value '{value}'. Supported values: 'gzip', 'none'." + ) + + +def _create_span_exporter(config: SpanExporterConfig) -> "SpanExporter": + """Create a span exporter from config.""" + if config.otlp_http is not None: + return _create_otlp_http_span_exporter(config.otlp_http) + if config.otlp_grpc is not None: + return _create_otlp_grpc_span_exporter(config.otlp_grpc) + if config.console is not None: + from opentelemetry.sdk.trace.export import ConsoleSpanExporter + + return ConsoleSpanExporter() + raise ConfigurationError( + "No exporter type specified in span exporter config. " + "Supported types: otlp_http, otlp_grpc, console." + ) + + +def _create_span_processor(config: SpanProcessorConfig) -> object: + """Create a span processor from config.""" + from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + SimpleSpanProcessor, + ) + + if config.batch is not None: + return _create_batch_span_processor(config.batch, BatchSpanProcessor) + if config.simple is not None: + exporter = _create_span_exporter(config.simple.exporter) + return SimpleSpanProcessor(exporter) + raise ConfigurationError( + "No processor type specified in span processor config. " + "Supported types: batch, simple." + ) + + +def _create_batch_span_processor( + config: BatchSpanProcessorConfig, batch_cls: type +) -> object: + """Build a BatchSpanProcessor from config.""" + exporter = _create_span_exporter(config.exporter) + return batch_cls( + exporter, + max_queue_size=config.max_queue_size, + schedule_delay_millis=config.schedule_delay, + max_export_batch_size=config.max_export_batch_size, + export_timeout_millis=config.export_timeout, + ) + + +def _create_sampler(config: SamplerConfig) -> Sampler: + """Create a sampler from config.""" + if config.always_on is not None: + return ALWAYS_ON + if config.always_off is not None: + return ALWAYS_OFF + if config.trace_id_ratio_based is not None: + ratio = config.trace_id_ratio_based.ratio + return TraceIdRatioBased(ratio if ratio is not None else 1.0) + if config.parent_based is not None: + return _create_parent_based_sampler(config.parent_based) + raise ConfigurationError( + f"Unknown or unsupported sampler type in config: {config!r}. " + "Supported types: always_on, always_off, trace_id_ratio_based, parent_based." + ) + + +def _create_parent_based_sampler(config: ParentBasedSamplerConfig) -> Sampler: + """Create a ParentBased sampler from config, applying SDK defaults for absent delegates.""" + root = _create_sampler(config.root) if config.root is not None else ALWAYS_ON + kwargs = {"root": root} + if config.remote_parent_sampled is not None: + kwargs["remote_parent_sampled"] = _create_sampler( + config.remote_parent_sampled + ) + if config.remote_parent_not_sampled is not None: + kwargs["remote_parent_not_sampled"] = _create_sampler( + config.remote_parent_not_sampled + ) + if config.local_parent_sampled is not None: + kwargs["local_parent_sampled"] = _create_sampler( + config.local_parent_sampled + ) + if config.local_parent_not_sampled is not None: + kwargs["local_parent_not_sampled"] = _create_sampler( + config.local_parent_not_sampled + ) + return ParentBased(**kwargs) # type: ignore[arg-type] + + +def _create_span_limits(config: SpanLimitsConfig) -> SpanLimits: + """Create SpanLimits from config. + + Absent fields use the OTel spec defaults (128 for counts, unlimited for lengths). + Explicit values suppress env-var reading — matching Java SDK behavior. + """ + return SpanLimits( + max_span_attributes=( + config.attribute_count_limit + if config.attribute_count_limit is not None + else _DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT + ), + max_events=( + config.event_count_limit + if config.event_count_limit is not None + else _DEFAULT_OTEL_SPAN_EVENT_COUNT_LIMIT + ), + max_links=( + config.link_count_limit + if config.link_count_limit is not None + else _DEFAULT_OTEL_SPAN_LINK_COUNT_LIMIT + ), + max_event_attributes=( + config.event_attribute_count_limit + if config.event_attribute_count_limit is not None + else _DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT + ), + max_link_attributes=( + config.link_attribute_count_limit + if config.link_attribute_count_limit is not None + else _DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT + ), + max_attribute_length=( + config.attribute_value_length_limit + ), + ) + + +def create_tracer_provider( + config: Optional[TracerProviderConfig], + resource: Optional[Resource] = None, +) -> TracerProvider: + """Create an SDK TracerProvider from declarative config. + + Does NOT read OTEL_TRACES_SAMPLER, OTEL_SPAN_*_LIMIT, or any other env vars + for values that are explicitly controlled by the config. Absent config values + use OTel spec defaults (not env vars), matching Java SDK behavior. + + Args: + config: TracerProvider config from the parsed config file, or None. + resource: Resource to attach to the provider. + + Returns: + A configured TracerProvider. + """ + sampler = ( + _create_sampler(config.sampler) + if config is not None and config.sampler is not None + else _DEFAULT_SAMPLER + ) + span_limits = ( + _create_span_limits(config.limits) + if config is not None and config.limits is not None + else SpanLimits( + max_span_attributes=_DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, + max_events=_DEFAULT_OTEL_SPAN_EVENT_COUNT_LIMIT, + max_links=_DEFAULT_OTEL_SPAN_LINK_COUNT_LIMIT, + max_event_attributes=_DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT, + max_link_attributes=_DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT, + ) + ) + + provider = TracerProvider( + resource=resource, + sampler=sampler, + span_limits=span_limits, + ) + + if config is not None: + for proc_config in config.processors: + provider.add_span_processor( # type: ignore[arg-type] + _create_span_processor(proc_config) + ) + + return provider + + +def configure_tracer_provider( + config: Optional[TracerProviderConfig], + resource: Optional[Resource] = None, +) -> None: + """Configure the global TracerProvider from declarative config. + + When config is None (tracer_provider section absent from config file), + the global is not set — matching Java/JS SDK behavior and the spec's + "a noop tracer provider is used" default. + + Args: + config: TracerProvider config from the parsed config file, or None. + resource: Resource to attach to the provider. + """ + if config is None: + return + trace.set_tracer_provider(create_tracer_provider(config, resource)) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py index 17a6847abb8..2f4f8b955a5 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py @@ -29,6 +29,10 @@ create_propagator, ) from opentelemetry.sdk._configuration._resource import create_resource +from opentelemetry.sdk._configuration._tracer_provider import ( + configure_tracer_provider, + create_tracer_provider, +) from opentelemetry.sdk._configuration.file._env_substitution import ( EnvSubstitutionError, substitute_env_vars, @@ -46,4 +50,6 @@ "create_resource", "create_propagator", "configure_propagator", + "create_tracer_provider", + "configure_tracer_provider", ] diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py new file mode 100644 index 00000000000..08dab90b3dd --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -0,0 +1,427 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from unittest.mock import MagicMock, patch + +from opentelemetry.sdk._configuration._tracer_provider import ( + _DEFAULT_SAMPLER, + configure_tracer_provider, + create_tracer_provider, +) +from opentelemetry.sdk._configuration.file._loader import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + BatchSpanProcessor as BatchSpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpGrpcExporter as OtlpGrpcExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + OtlpHttpExporter as OtlpHttpExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + ParentBasedSampler as ParentBasedSamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + Sampler as SamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + SimpleSpanProcessor as SimpleSpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanExporter as SpanExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanLimits as SpanLimitsConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanProcessor as SpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + TracerProvider as TracerProviderConfig, +) +from opentelemetry.sdk._configuration.models import ( + TraceIdRatioBasedSampler as TraceIdRatioBasedConfig, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import SpanLimits, TracerProvider +from opentelemetry.sdk.trace.sampling import ( + ALWAYS_OFF, + ALWAYS_ON, + ParentBased, + TraceIdRatioBased, +) + + +class TestCreateTracerProviderBasic(unittest.TestCase): + def test_none_config_returns_provider(self): + resource = Resource({"service.name": "test"}) + provider = create_tracer_provider(None, resource) + self.assertIsInstance(provider, TracerProvider) + + def test_none_config_uses_supplied_resource(self): + resource = Resource({"service.name": "svc"}) + provider = create_tracer_provider(None, resource) + self.assertIs(provider._resource, resource) + + def test_none_config_uses_default_sampler(self): + provider = create_tracer_provider(None) + self.assertIsInstance(provider.sampler, ParentBased) + + def test_none_config_no_processors(self): + provider = create_tracer_provider(None) + # SynchronousMultiSpanProcessor with no processors added + self.assertEqual( + len(provider._active_span_processor._span_processors), 0 + ) + + def test_none_config_does_not_read_sampler_env_var(self): + with patch.dict(os.environ, {"OTEL_TRACES_SAMPLER": "always_off"}): + provider = create_tracer_provider(None) + # Should still be ParentBased (default), not ALWAYS_OFF from env var + self.assertIsInstance(provider.sampler, ParentBased) + + def test_none_config_does_not_read_span_limit_env_var(self): + with patch.dict( + os.environ, {"OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT": "1"} + ): + provider = create_tracer_provider(None) + self.assertEqual( + provider._span_limits.max_span_attributes, 128 + ) + + def test_configure_none_does_not_set_global(self): + original = __import__( + "opentelemetry.trace", fromlist=["get_tracer_provider"] + ).get_tracer_provider() + configure_tracer_provider(None) + after = __import__( + "opentelemetry.trace", fromlist=["get_tracer_provider"] + ).get_tracer_provider() + self.assertIs(original, after) + + def test_configure_with_config_sets_global(self): + config = TracerProviderConfig(processors=[]) + with patch( + "opentelemetry.sdk._configuration._tracer_provider.trace.set_tracer_provider" + ) as mock_set: + configure_tracer_provider(config) + mock_set.assert_called_once() + arg = mock_set.call_args[0][0] + self.assertIsInstance(arg, TracerProvider) + + def test_processors_added_in_order(self): + mock_proc_a = MagicMock() + mock_proc_b = MagicMock() + config = TracerProviderConfig(processors=[]) + provider = create_tracer_provider(config) + provider.add_span_processor(mock_proc_a) + provider.add_span_processor(mock_proc_b) + procs = provider._active_span_processor._span_processors + self.assertIs(procs[0], mock_proc_a) + self.assertIs(procs[1], mock_proc_b) + + def test_span_limits_from_config(self): + config = TracerProviderConfig( + processors=[], + limits=SpanLimitsConfig( + attribute_count_limit=5, + event_count_limit=10, + link_count_limit=3, + ), + ) + provider = create_tracer_provider(config) + self.assertEqual(provider._span_limits.max_span_attributes, 5) + self.assertEqual(provider._span_limits.max_events, 10) + self.assertEqual(provider._span_limits.max_links, 3) + + +class TestCreateSampler(unittest.TestCase): + def _make_provider(self, sampler_config): + config = TracerProviderConfig( + processors=[], sampler=sampler_config + ) + return create_tracer_provider(config) + + def test_always_on(self): + provider = self._make_provider(SamplerConfig(always_on={})) + self.assertIs(provider.sampler, ALWAYS_ON) + + def test_always_off(self): + provider = self._make_provider(SamplerConfig(always_off={})) + self.assertIs(provider.sampler, ALWAYS_OFF) + + def test_trace_id_ratio_based(self): + provider = self._make_provider( + SamplerConfig( + trace_id_ratio_based=TraceIdRatioBasedConfig(ratio=0.5) + ) + ) + self.assertIsInstance(provider.sampler, TraceIdRatioBased) + self.assertAlmostEqual(provider.sampler._rate, 0.5) + + def test_trace_id_ratio_based_none_ratio_defaults_to_1(self): + provider = self._make_provider( + SamplerConfig(trace_id_ratio_based=TraceIdRatioBasedConfig()) + ) + self.assertIsInstance(provider.sampler, TraceIdRatioBased) + self.assertAlmostEqual(provider.sampler._rate, 1.0) + + def test_parent_based_with_root(self): + provider = self._make_provider( + SamplerConfig( + parent_based=ParentBasedSamplerConfig( + root=SamplerConfig(always_on={}) + ) + ) + ) + self.assertIsInstance(provider.sampler, ParentBased) + + def test_parent_based_no_root_defaults_to_always_on(self): + provider = self._make_provider( + SamplerConfig(parent_based=ParentBasedSamplerConfig()) + ) + self.assertIsInstance(provider.sampler, ParentBased) + # Root defaults to ALWAYS_ON + self.assertIs(provider.sampler._root, ALWAYS_ON) + + def test_parent_based_with_delegate_samplers(self): + provider = self._make_provider( + SamplerConfig( + parent_based=ParentBasedSamplerConfig( + root=SamplerConfig(always_on={}), + remote_parent_sampled=SamplerConfig(always_on={}), + remote_parent_not_sampled=SamplerConfig(always_off={}), + local_parent_sampled=SamplerConfig(always_on={}), + local_parent_not_sampled=SamplerConfig(always_off={}), + ) + ) + ) + sampler = provider.sampler + self.assertIsInstance(sampler, ParentBased) + self.assertIs(sampler._remote_parent_sampled, ALWAYS_ON) + self.assertIs(sampler._remote_parent_not_sampled, ALWAYS_OFF) + self.assertIs(sampler._local_parent_sampled, ALWAYS_ON) + self.assertIs(sampler._local_parent_not_sampled, ALWAYS_OFF) + + def test_unknown_sampler_raises_configuration_error(self): + with self.assertRaises(ConfigurationError): + create_tracer_provider( + TracerProviderConfig( + processors=[], sampler=SamplerConfig() + ) + ) + + +class TestCreateSpanExporterAndProcessor(unittest.TestCase): + def _make_batch_config(self, exporter_config): + return TracerProviderConfig( + processors=[ + SpanProcessorConfig( + batch=BatchSpanProcessorConfig(exporter=exporter_config) + ) + ] + ) + + def _make_simple_config(self, exporter_config): + return TracerProviderConfig( + processors=[ + SpanProcessorConfig( + simple=SimpleSpanProcessorConfig(exporter=exporter_config) + ) + ] + ) + + def test_console_exporter_batch(self): + from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + ConsoleSpanExporter, + ) + + config = self._make_batch_config(SpanExporterConfig(console={})) + provider = create_tracer_provider(config) + procs = provider._active_span_processor._span_processors + self.assertEqual(len(procs), 1) + self.assertIsInstance(procs[0], BatchSpanProcessor) + self.assertIsInstance(procs[0].span_exporter, ConsoleSpanExporter) + + def test_console_exporter_simple(self): + from opentelemetry.sdk.trace.export import ( + ConsoleSpanExporter, + SimpleSpanProcessor, + ) + + config = self._make_simple_config(SpanExporterConfig(console={})) + provider = create_tracer_provider(config) + procs = provider._active_span_processor._span_processors + self.assertIsInstance(procs[0], SimpleSpanProcessor) + self.assertIsInstance(procs[0].span_exporter, ConsoleSpanExporter) + + def test_otlp_http_missing_package_raises(self): + import sys + + config = self._make_batch_config( + SpanExporterConfig(otlp_http=OtlpHttpExporterConfig()) + ) + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.http.trace_exporter": None, + "opentelemetry.exporter.otlp.proto.http": None, + }, + ): + with self.assertRaises(ConfigurationError) as ctx: + create_tracer_provider(config) + self.assertIn("otlp-proto-http", str(ctx.exception)) + + def test_otlp_http_created_with_endpoint(self): + mock_exporter_cls = MagicMock() + mock_compression_cls = MagicMock() + mock_compression_cls.Gzip = "gzip_val" + import sys + + mock_module = MagicMock() + mock_module.OTLPSpanExporter = mock_exporter_cls + mock_http_module = MagicMock() + mock_http_module.Compression = mock_compression_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.http.trace_exporter": mock_module, + "opentelemetry.exporter.otlp.proto.http": mock_http_module, + }, + ): + config = self._make_batch_config( + SpanExporterConfig( + otlp_http=OtlpHttpExporterConfig( + endpoint="http://localhost:4318" + ) + ) + ) + create_tracer_provider(config) + + mock_exporter_cls.assert_called_once_with( + endpoint="http://localhost:4318", + headers=None, + timeout=None, + compression=None, + ) + + def test_otlp_http_headers_list(self): + mock_exporter_cls = MagicMock() + mock_http_module = MagicMock() + import sys + + mock_module = MagicMock() + mock_module.OTLPSpanExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.http.trace_exporter": mock_module, + "opentelemetry.exporter.otlp.proto.http": mock_http_module, + }, + ): + config = self._make_batch_config( + SpanExporterConfig( + otlp_http=OtlpHttpExporterConfig( + headers_list="x-api-key=secret,env=prod" + ) + ) + ) + create_tracer_provider(config) + + _, kwargs = mock_exporter_cls.call_args + self.assertEqual( + kwargs["headers"], {"x-api-key": "secret", "env": "prod"} + ) + + def test_otlp_grpc_missing_package_raises(self): + import sys + + config = self._make_batch_config( + SpanExporterConfig(otlp_grpc=OtlpGrpcExporterConfig()) + ) + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.grpc.trace_exporter": None, + "grpc": None, + }, + ): + with self.assertRaises(ConfigurationError) as ctx: + create_tracer_provider(config) + self.assertIn("otlp-proto-grpc", str(ctx.exception)) + + def test_no_processor_type_raises(self): + config = TracerProviderConfig( + processors=[SpanProcessorConfig()] + ) + with self.assertRaises(ConfigurationError): + create_tracer_provider(config) + + def test_no_exporter_type_raises(self): + config = self._make_batch_config(SpanExporterConfig()) + with self.assertRaises(ConfigurationError): + create_tracer_provider(config) + + +class TestCreateSpanLimits(unittest.TestCase): + def _create_with_limits(self, limits_config): + config = TracerProviderConfig(processors=[], limits=limits_config) + return create_tracer_provider(config) + + def test_explicit_attribute_count_limit(self): + provider = self._create_with_limits( + SpanLimitsConfig(attribute_count_limit=10) + ) + self.assertEqual(provider._span_limits.max_span_attributes, 10) + + def test_explicit_event_count_limit(self): + provider = self._create_with_limits( + SpanLimitsConfig(event_count_limit=5) + ) + self.assertEqual(provider._span_limits.max_events, 5) + + def test_explicit_link_count_limit(self): + provider = self._create_with_limits( + SpanLimitsConfig(link_count_limit=2) + ) + self.assertEqual(provider._span_limits.max_links, 2) + + def test_explicit_attribute_value_length_limit(self): + provider = self._create_with_limits( + SpanLimitsConfig(attribute_value_length_limit=64) + ) + self.assertEqual(provider._span_limits.max_attribute_length, 64) + + def test_absent_limits_use_spec_defaults(self): + provider = self._create_with_limits(SpanLimitsConfig()) + self.assertEqual(provider._span_limits.max_span_attributes, 128) + self.assertEqual(provider._span_limits.max_events, 128) + self.assertEqual(provider._span_limits.max_links, 128) + + def test_absent_limits_do_not_read_env_vars(self): + with patch.dict( + os.environ, + { + "OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT": "1", + "OTEL_SPAN_EVENT_COUNT_LIMIT": "2", + }, + ): + provider = self._create_with_limits(SpanLimitsConfig()) + self.assertEqual(provider._span_limits.max_span_attributes, 128) + self.assertEqual(provider._span_limits.max_events, 128) From a7edb8f589ebe69d1c95ad08e9a60a2a4c20acc7 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 16 Mar 2026 14:59:46 +0000 Subject: [PATCH 11/14] add changelog entry for PR #4985 Assisted-by: Claude Sonnet 4.6 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02456fe4772..eac3263d895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `opentelemetry-sdk`: Add `create_tracer_provider`/`configure_tracer_provider` to declarative file configuration, enabling TracerProvider instantiation from config files without reading env vars + ([#4985](https://github.com/open-telemetry/opentelemetry-python/pull/4985)) - `opentelemetry-sdk`: Add `create_resource` and `create_propagator`/`configure_propagator` to declarative file configuration, enabling Resource and propagator instantiation from config files without reading env vars ([#4979](https://github.com/open-telemetry/opentelemetry-python/pull/4979)) - `opentelemetry-sdk`: Add file configuration support with YAML/JSON loading, environment variable substitution, and schema validation against the vendored OTel config JSON schema From d09db96f327b9e88e4f03614441f6427ad740f3a Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 16 Mar 2026 15:30:47 +0000 Subject: [PATCH 12/14] fix CI lint/type failures in tracer provider config - add # noqa: PLC0415 to lazy OTLP imports (ruff also enforces this) - move SDK imports to top-level (BatchSpanProcessor, etc.) - convert test helper methods to @staticmethod to satisfy no-self-use - add pylint: disable=protected-access for private member access in tests - fix return type annotation on _create_span_processor Assisted-by: Claude Sonnet 4.6 --- .../sdk/_configuration/_tracer_provider.py | 132 ++++++++---------- .../_configuration/test_tracer_provider.py | 76 ++++------ 2 files changed, 86 insertions(+), 122 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py index 015f0fada5b..86cfc796936 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py @@ -15,13 +15,10 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Dict, Optional +from typing import Dict, Optional from opentelemetry import trace from opentelemetry.sdk._configuration._exceptions import ConfigurationError -from opentelemetry.sdk._configuration.models import ( - BatchSpanProcessor as BatchSpanProcessorConfig, -) from opentelemetry.sdk._configuration.models import ( OtlpGrpcExporter as OtlpGrpcExporterConfig, ) @@ -34,9 +31,6 @@ from opentelemetry.sdk._configuration.models import ( Sampler as SamplerConfig, ) -from opentelemetry.sdk._configuration.models import ( - SimpleSpanProcessor as SimpleSpanProcessorConfig, -) from opentelemetry.sdk._configuration.models import ( SpanExporter as SpanExporterConfig, ) @@ -51,13 +45,19 @@ ) from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import ( - SpanLimits, - TracerProvider, _DEFAULT_OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT, _DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT, _DEFAULT_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, _DEFAULT_OTEL_SPAN_EVENT_COUNT_LIMIT, _DEFAULT_OTEL_SPAN_LINK_COUNT_LIMIT, + SpanLimits, + TracerProvider, +) +from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + ConsoleSpanExporter, + SimpleSpanProcessor, + SpanExporter, ) from opentelemetry.sdk.trace.sampling import ( ALWAYS_OFF, @@ -67,9 +67,6 @@ TraceIdRatioBased, ) -if TYPE_CHECKING: - from opentelemetry.sdk.trace.export import SpanExporter - _logger = logging.getLogger(__name__) # Default sampler per the OTel spec: parent_based with always_on root. @@ -105,51 +102,58 @@ def _parse_headers( return result -def _create_otlp_http_span_exporter(config: OtlpHttpExporterConfig) -> "SpanExporter": +def _create_otlp_http_span_exporter( + config: OtlpHttpExporterConfig, +) -> SpanExporter: """Create an OTLP HTTP span exporter from config.""" try: - from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( # type: ignore[import-untyped] - OTLPSpanExporter, - ) - from opentelemetry.exporter.otlp.proto.http import ( # type: ignore[import-untyped] + # pylint: disable=import-outside-toplevel,no-name-in-module + from opentelemetry.exporter.otlp.proto.http import ( # type: ignore[import-untyped] # noqa: PLC0415 Compression, ) + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415 + OTLPSpanExporter, + ) except ImportError as exc: raise ConfigurationError( "otlp_http span exporter requires 'opentelemetry-exporter-otlp-proto-http'. " "Install it with: pip install opentelemetry-exporter-otlp-proto-http" ) from exc - compression = _map_compression_http(config.compression, Compression) + compression = _map_compression(config.compression, Compression) headers = _parse_headers(config.headers, config.headers_list) timeout = (config.timeout / 1000.0) if config.timeout is not None else None - return OTLPSpanExporter( + return OTLPSpanExporter( # type: ignore[return-value] endpoint=config.endpoint, headers=headers, timeout=timeout, - compression=compression, + compression=compression, # type: ignore[arg-type] ) -def _map_compression_http( +def _map_compression( value: Optional[str], compression_enum: type ) -> Optional[object]: - """Map a compression string to the HTTP Compression enum value.""" + """Map a compression string to the given Compression enum value.""" if value is None or value.lower() == "none": return None if value.lower() == "gzip": - return compression_enum.Gzip + return compression_enum.Gzip # type: ignore[attr-defined] raise ConfigurationError( f"Unsupported compression value '{value}'. Supported values: 'gzip', 'none'." ) -def _create_otlp_grpc_span_exporter(config: OtlpGrpcExporterConfig) -> "SpanExporter": +def _create_otlp_grpc_span_exporter( + config: OtlpGrpcExporterConfig, +) -> SpanExporter: """Create an OTLP gRPC span exporter from config.""" try: - import grpc # type: ignore[import-untyped] - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # type: ignore[import-untyped] + # pylint: disable=import-outside-toplevel,no-name-in-module + import grpc # type: ignore[import-untyped] # noqa: PLC0415 + + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # type: ignore[import-untyped] # noqa: PLC0415 OTLPSpanExporter, ) except ImportError as exc: @@ -158,40 +162,25 @@ def _create_otlp_grpc_span_exporter(config: OtlpGrpcExporterConfig) -> "SpanExpo "Install it with: pip install opentelemetry-exporter-otlp-proto-grpc" ) from exc - compression = _map_compression_grpc(config.compression, grpc) + compression = _map_compression(config.compression, grpc.Compression) headers = _parse_headers(config.headers, config.headers_list) timeout = (config.timeout / 1000.0) if config.timeout is not None else None - return OTLPSpanExporter( + return OTLPSpanExporter( # type: ignore[return-value] endpoint=config.endpoint, headers=headers, timeout=timeout, - compression=compression, - ) - - -def _map_compression_grpc( - value: Optional[str], grpc_module: object -) -> Optional[object]: - """Map a compression string to the gRPC Compression enum value.""" - if value is None or value.lower() == "none": - return None - if value.lower() == "gzip": - return grpc_module.Compression.Gzip # type: ignore[attr-defined] - raise ConfigurationError( - f"Unsupported compression value '{value}'. Supported values: 'gzip', 'none'." + compression=compression, # type: ignore[arg-type] ) -def _create_span_exporter(config: SpanExporterConfig) -> "SpanExporter": +def _create_span_exporter(config: SpanExporterConfig) -> SpanExporter: """Create a span exporter from config.""" if config.otlp_http is not None: return _create_otlp_http_span_exporter(config.otlp_http) if config.otlp_grpc is not None: return _create_otlp_grpc_span_exporter(config.otlp_grpc) if config.console is not None: - from opentelemetry.sdk.trace.export import ConsoleSpanExporter - return ConsoleSpanExporter() raise ConfigurationError( "No exporter type specified in span exporter config. " @@ -199,38 +188,29 @@ def _create_span_exporter(config: SpanExporterConfig) -> "SpanExporter": ) -def _create_span_processor(config: SpanProcessorConfig) -> object: +def _create_span_processor( + config: SpanProcessorConfig, +) -> BatchSpanProcessor | SimpleSpanProcessor: """Create a span processor from config.""" - from opentelemetry.sdk.trace.export import ( - BatchSpanProcessor, - SimpleSpanProcessor, - ) - if config.batch is not None: - return _create_batch_span_processor(config.batch, BatchSpanProcessor) + exporter = _create_span_exporter(config.batch.exporter) + return BatchSpanProcessor( + exporter, + max_queue_size=config.batch.max_queue_size, + schedule_delay_millis=config.batch.schedule_delay, + max_export_batch_size=config.batch.max_export_batch_size, + export_timeout_millis=config.batch.export_timeout, + ) if config.simple is not None: - exporter = _create_span_exporter(config.simple.exporter) - return SimpleSpanProcessor(exporter) + return SimpleSpanProcessor( + _create_span_exporter(config.simple.exporter) + ) raise ConfigurationError( "No processor type specified in span processor config. " "Supported types: batch, simple." ) -def _create_batch_span_processor( - config: BatchSpanProcessorConfig, batch_cls: type -) -> object: - """Build a BatchSpanProcessor from config.""" - exporter = _create_span_exporter(config.exporter) - return batch_cls( - exporter, - max_queue_size=config.max_queue_size, - schedule_delay_millis=config.schedule_delay, - max_export_batch_size=config.max_export_batch_size, - export_timeout_millis=config.export_timeout, - ) - - def _create_sampler(config: SamplerConfig) -> Sampler: """Create a sampler from config.""" if config.always_on is not None: @@ -250,8 +230,10 @@ def _create_sampler(config: SamplerConfig) -> Sampler: def _create_parent_based_sampler(config: ParentBasedSamplerConfig) -> Sampler: """Create a ParentBased sampler from config, applying SDK defaults for absent delegates.""" - root = _create_sampler(config.root) if config.root is not None else ALWAYS_ON - kwargs = {"root": root} + root = ( + _create_sampler(config.root) if config.root is not None else ALWAYS_ON + ) + kwargs: dict = {"root": root} if config.remote_parent_sampled is not None: kwargs["remote_parent_sampled"] = _create_sampler( config.remote_parent_sampled @@ -268,7 +250,7 @@ def _create_parent_based_sampler(config: ParentBasedSamplerConfig) -> Sampler: kwargs["local_parent_not_sampled"] = _create_sampler( config.local_parent_not_sampled ) - return ParentBased(**kwargs) # type: ignore[arg-type] + return ParentBased(**kwargs) def _create_span_limits(config: SpanLimitsConfig) -> SpanLimits: @@ -303,9 +285,7 @@ def _create_span_limits(config: SpanLimitsConfig) -> SpanLimits: if config.link_attribute_count_limit is not None else _DEFAULT_OTEL_LINK_ATTRIBUTE_COUNT_LIMIT ), - max_attribute_length=( - config.attribute_value_length_limit - ), + max_attribute_length=config.attribute_value_length_limit, ) @@ -351,9 +331,7 @@ def create_tracer_provider( if config is not None: for proc_config in config.processors: - provider.add_span_processor( # type: ignore[arg-type] - _create_span_processor(proc_config) - ) + provider.add_span_processor(_create_span_processor(proc_config)) return provider diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index 08dab90b3dd..1e1d93303bd 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -12,12 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +# Tests access private members of SDK classes to assert correct configuration. +# pylint: disable=protected-access + import os +import sys import unittest from unittest.mock import MagicMock, patch from opentelemetry.sdk._configuration._tracer_provider import ( - _DEFAULT_SAMPLER, configure_tracer_provider, create_tracer_provider, ) @@ -50,13 +53,18 @@ SpanProcessor as SpanProcessorConfig, ) from opentelemetry.sdk._configuration.models import ( - TracerProvider as TracerProviderConfig, + TraceIdRatioBasedSampler as TraceIdRatioBasedConfig, ) from opentelemetry.sdk._configuration.models import ( - TraceIdRatioBasedSampler as TraceIdRatioBasedConfig, + TracerProvider as TracerProviderConfig, ) from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import SpanLimits, TracerProvider +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + ConsoleSpanExporter, + SimpleSpanProcessor, +) from opentelemetry.sdk.trace.sampling import ( ALWAYS_OFF, ALWAYS_ON, @@ -82,7 +90,6 @@ def test_none_config_uses_default_sampler(self): def test_none_config_no_processors(self): provider = create_tracer_provider(None) - # SynchronousMultiSpanProcessor with no processors added self.assertEqual( len(provider._active_span_processor._span_processors), 0 ) @@ -90,17 +97,12 @@ def test_none_config_no_processors(self): def test_none_config_does_not_read_sampler_env_var(self): with patch.dict(os.environ, {"OTEL_TRACES_SAMPLER": "always_off"}): provider = create_tracer_provider(None) - # Should still be ParentBased (default), not ALWAYS_OFF from env var self.assertIsInstance(provider.sampler, ParentBased) def test_none_config_does_not_read_span_limit_env_var(self): - with patch.dict( - os.environ, {"OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT": "1"} - ): + with patch.dict(os.environ, {"OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT": "1"}): provider = create_tracer_provider(None) - self.assertEqual( - provider._span_limits.max_span_attributes, 128 - ) + self.assertEqual(provider._span_limits.max_span_attributes, 128) def test_configure_none_does_not_set_global(self): original = __import__( @@ -150,10 +152,9 @@ def test_span_limits_from_config(self): class TestCreateSampler(unittest.TestCase): def _make_provider(self, sampler_config): - config = TracerProviderConfig( - processors=[], sampler=sampler_config + return create_tracer_provider( + TracerProviderConfig(processors=[], sampler=sampler_config) ) - return create_tracer_provider(config) def test_always_on(self): provider = self._make_provider(SamplerConfig(always_on={})) @@ -194,7 +195,6 @@ def test_parent_based_no_root_defaults_to_always_on(self): SamplerConfig(parent_based=ParentBasedSamplerConfig()) ) self.assertIsInstance(provider.sampler, ParentBased) - # Root defaults to ALWAYS_ON self.assertIs(provider.sampler._root, ALWAYS_ON) def test_parent_based_with_delegate_samplers(self): @@ -219,14 +219,15 @@ def test_parent_based_with_delegate_samplers(self): def test_unknown_sampler_raises_configuration_error(self): with self.assertRaises(ConfigurationError): create_tracer_provider( - TracerProviderConfig( - processors=[], sampler=SamplerConfig() - ) + TracerProviderConfig(processors=[], sampler=SamplerConfig()) ) class TestCreateSpanExporterAndProcessor(unittest.TestCase): - def _make_batch_config(self, exporter_config): + # pylint: disable=no-self-use + + @staticmethod + def _make_batch_config(exporter_config): return TracerProviderConfig( processors=[ SpanProcessorConfig( @@ -235,7 +236,8 @@ def _make_batch_config(self, exporter_config): ] ) - def _make_simple_config(self, exporter_config): + @staticmethod + def _make_simple_config(exporter_config): return TracerProviderConfig( processors=[ SpanProcessorConfig( @@ -245,11 +247,6 @@ def _make_simple_config(self, exporter_config): ) def test_console_exporter_batch(self): - from opentelemetry.sdk.trace.export import ( - BatchSpanProcessor, - ConsoleSpanExporter, - ) - config = self._make_batch_config(SpanExporterConfig(console={})) provider = create_tracer_provider(config) procs = provider._active_span_processor._span_processors @@ -258,11 +255,6 @@ def test_console_exporter_batch(self): self.assertIsInstance(procs[0].span_exporter, ConsoleSpanExporter) def test_console_exporter_simple(self): - from opentelemetry.sdk.trace.export import ( - ConsoleSpanExporter, - SimpleSpanProcessor, - ) - config = self._make_simple_config(SpanExporterConfig(console={})) provider = create_tracer_provider(config) procs = provider._active_span_processor._span_processors @@ -270,8 +262,6 @@ def test_console_exporter_simple(self): self.assertIsInstance(procs[0].span_exporter, ConsoleSpanExporter) def test_otlp_http_missing_package_raises(self): - import sys - config = self._make_batch_config( SpanExporterConfig(otlp_http=OtlpHttpExporterConfig()) ) @@ -290,8 +280,6 @@ def test_otlp_http_created_with_endpoint(self): mock_exporter_cls = MagicMock() mock_compression_cls = MagicMock() mock_compression_cls.Gzip = "gzip_val" - import sys - mock_module = MagicMock() mock_module.OTLPSpanExporter = mock_exporter_cls mock_http_module = MagicMock() @@ -323,8 +311,6 @@ def test_otlp_http_created_with_endpoint(self): def test_otlp_http_headers_list(self): mock_exporter_cls = MagicMock() mock_http_module = MagicMock() - import sys - mock_module = MagicMock() mock_module.OTLPSpanExporter = mock_exporter_cls @@ -350,8 +336,6 @@ def test_otlp_http_headers_list(self): ) def test_otlp_grpc_missing_package_raises(self): - import sys - config = self._make_batch_config( SpanExporterConfig(otlp_grpc=OtlpGrpcExporterConfig()) ) @@ -367,9 +351,7 @@ def test_otlp_grpc_missing_package_raises(self): self.assertIn("otlp-proto-grpc", str(ctx.exception)) def test_no_processor_type_raises(self): - config = TracerProviderConfig( - processors=[SpanProcessorConfig()] - ) + config = TracerProviderConfig(processors=[SpanProcessorConfig()]) with self.assertRaises(ConfigurationError): create_tracer_provider(config) @@ -380,9 +362,13 @@ def test_no_exporter_type_raises(self): class TestCreateSpanLimits(unittest.TestCase): - def _create_with_limits(self, limits_config): - config = TracerProviderConfig(processors=[], limits=limits_config) - return create_tracer_provider(config) + # pylint: disable=no-self-use + + @staticmethod + def _create_with_limits(limits_config): + return create_tracer_provider( + TracerProviderConfig(processors=[], limits=limits_config) + ) def test_explicit_attribute_count_limit(self): provider = self._create_with_limits( From 3af6d2fb27fa9b4f141852047eda34eb23bc742a Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 16 Mar 2026 15:59:16 +0000 Subject: [PATCH 13/14] fix pylint no-self-use on TestCreateSampler._make_provider Assisted-by: Claude Sonnet 4.6 --- opentelemetry-sdk/tests/_configuration/test_tracer_provider.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index 1e1d93303bd..5caf077cd5e 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -151,7 +151,8 @@ def test_span_limits_from_config(self): class TestCreateSampler(unittest.TestCase): - def _make_provider(self, sampler_config): + @staticmethod + def _make_provider(sampler_config): return create_tracer_provider( TracerProviderConfig(processors=[], sampler=sampler_config) ) From 9653d678c752425265884dedca49ca5ce3f953c9 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 18 Mar 2026 14:58:48 +0000 Subject: [PATCH 14/14] use allowlist for bool coercion in declarative config resource Assisted-by: Claude Sonnet 4.6 --- .../src/opentelemetry/sdk/_configuration/_resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py index d58bd4d31de..bf2978744d5 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -37,7 +37,7 @@ def _coerce_bool(value: object) -> bool: if isinstance(value, str): - return value.lower() not in ("false", "0", "") + return value.lower() in ("true", "1") return bool(value)