diff --git a/CHANGELOG.md b/CHANGELOG.md index 540f7b9d34..02e51173eb 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 + ([#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 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 0000000000..9b90dbd50a --- /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 0000000000..1c49d05651 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py @@ -0,0 +1,125 @@ +# 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, +) +from opentelemetry.sdk._configuration.models import ( + 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 exc: + raise ConfigurationError( + f"Failed to load propagator '{name}': {exc}" + ) from exc + + +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(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 propagator in _propagators_from_textmap_config(entry): + _add_deduped(propagator) + + # 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 + _add_deduped(_load_entry_point_propagator(name)) + + 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 0000000000..7a04d87446 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_resource.py @@ -0,0 +1,150 @@ +# 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, +) +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, +) + +_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.""" + value = attr.value + attr_type = attr.type + + 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] + 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 + + +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, 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. + + 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 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] + 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 a995539749..17a6847abb 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 6019166669..2649398170 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 0000000000..e22acfca0f --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_propagator.py @@ -0,0 +1,259 @@ +# 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 + +# 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 ( + configure_propagator, + create_propagator, +) +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 ( + 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 0000000000..b74c0774f0 --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_resource.py @@ -0,0 +1,283 @@ +# 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 patch + +from opentelemetry.sdk._configuration._resource import create_resource +from opentelemetry.sdk._configuration.models import ( + AttributeNameValue, + AttributeType, +) +from opentelemetry.sdk._configuration.models import Resource as ResourceConfig +from opentelemetry.sdk.resources import ( + SERVICE_NAME, + TELEMETRY_SDK_LANGUAGE, + TELEMETRY_SDK_NAME, + TELEMETRY_SDK_VERSION, + Resource, +) + + +class TestCreateResourceDefaults(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): + 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_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=[ + 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] + + +class TestCreateResourceAttributesList(unittest.TestCase): + 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))