diff --git a/.changelog/45.added b/.changelog/45.added new file mode 100644 index 0000000000..6d15614071 --- /dev/null +++ b/.changelog/45.added @@ -0,0 +1 @@ +Declarative configuration: add a pure `create(model)` that builds providers without mutating global state, and a `ConfigProvider`/`ConfigProperties` read view over the instrumentation config diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py b/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py index 934987fb7b..b0fcdf93b7 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py @@ -29,14 +29,26 @@ behaviour may change between minor versions. """ +from opentelemetry.configuration._config_provider import ( + ConfigProperties, + ConfigProvider, + get_config_provider, + set_config_provider, +) from opentelemetry.configuration._exceptions import ConfigurationError -from opentelemetry.configuration._sdk import configure_sdk +from opentelemetry.configuration._sdk import Providers, configure_sdk, create from opentelemetry.configuration.file._loader import load_config_file from opentelemetry.configuration.models import OpenTelemetryConfiguration __all__ = [ + "ConfigProperties", + "ConfigProvider", "ConfigurationError", "OpenTelemetryConfiguration", + "Providers", "configure_sdk", + "create", + "get_config_provider", "load_config_file", + "set_config_provider", ] diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py new file mode 100644 index 0000000000..10cf8599c0 --- /dev/null +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py @@ -0,0 +1,189 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Read view over declarative instrumentation configuration. + +Implements the spec's ``ConfigProvider`` / ``ConfigProperties`` API +(``configuration/api.md``): a stateless, typed read view over the parsed +``instrumentation`` node of a declarative configuration, plus a global +``ConfigProvider`` that makes it retrievable by instrumentation code. + +``ConfigProperties`` wraps a mapping (a parsed sub-tree of the config) and +exposes typed getters that return ``None`` when a key is absent or cannot +be coerced to the requested type, matching the spec's "return null" and +Java's ``DeclarativeConfigProperties`` semantics. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import asdict, is_dataclass +from logging import getLogger +from typing import Any + +_logger = getLogger(__name__) + + +def _node_to_mapping(node: Any) -> dict[str, Any]: + """Normalize a config node into a plain ``dict`` for typed access. + + Dataclass nodes (the parsed model tree) are converted recursively via + ``asdict``; mappings are copied as-is. Anything else yields an empty + mapping so the getters uniformly return ``None``. + """ + if node is None: + return {} + if is_dataclass(node) and not isinstance(node, type): + return asdict(node) + if isinstance(node, Mapping): + return dict(node) + return {} + + +class ConfigProperties: + """A typed read view over a parsed configuration sub-tree. + + Wraps a mapping of configuration keys to values. Typed getters coerce + the stored value to the requested type and return ``None`` when the key + is missing or the value has an incompatible type. ``get_config`` returns + a nested :class:`ConfigProperties` for a sub-mapping, enabling traversal + of the full instrumentation tree. + """ + + def __init__(self, properties: Mapping[str, Any] | None = None) -> None: + self._properties: dict[str, Any] = ( + dict(properties) if properties is not None else {} + ) + + def get_string(self, name: str) -> str | None: + """Return the value of ``name`` as a ``str``, or ``None``.""" + value = self._properties.get(name) + return value if isinstance(value, str) else None + + def get_bool(self, name: str) -> bool | None: + """Return the value of ``name`` as a ``bool``, or ``None``.""" + value = self._properties.get(name) + return value if isinstance(value, bool) else None + + def get_int(self, name: str) -> int | None: + """Return the value of ``name`` as an ``int``, or ``None``. + + ``bool`` values are rejected (they are not treated as integers). + """ + value = self._properties.get(name) + if isinstance(value, bool): + return None + return value if isinstance(value, int) else None + + def get_float(self, name: str) -> float | None: + """Return the value of ``name`` as a ``float``, or ``None``. + + Accepts ``int`` values (widened to ``float``); rejects ``bool``. + """ + value = self._properties.get(name) + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + return None + + def get_config(self, name: str) -> ConfigProperties | None: + """Return the sub-mapping at ``name`` as :class:`ConfigProperties`. + + Returns ``None`` when ``name`` is absent or its value is not a + mapping / dataclass node. + """ + value = self._properties.get(name) + if value is None: + return None + if is_dataclass(value) and not isinstance(value, type): + return ConfigProperties(_node_to_mapping(value)) + if isinstance(value, Mapping): + return ConfigProperties(dict(value)) + return None + + def get_config_list(self, name: str) -> list[ConfigProperties] | None: + """Return the list at ``name`` as a list of :class:`ConfigProperties`. + + Each element must be a mapping / dataclass node; returns ``None`` + when ``name`` is absent or is not a list of mappings. + """ + value = self._properties.get(name) + if not isinstance(value, list): + return None + result: list[ConfigProperties] = [] + for item in value: + mapping = _node_to_mapping(item) + if not mapping and item is not None: + return None + result.append(ConfigProperties(mapping)) + return result + + def get_scalar_list(self, name: str, scalar_type: type) -> list | None: + """Return the sequence at ``name`` as a list of ``scalar_type``. + + Elements whose type does not match ``scalar_type`` are dropped + (matching Java's ``getScalarList``). ``bool`` is never treated as an + ``int``. Returns ``None`` when ``name`` is absent or is not a list. + """ + value = self._properties.get(name) + if not isinstance(value, list): + return None + result: list = [] + for item in value: + if scalar_type is int and isinstance(item, bool): + continue + if ( + scalar_type is float + and isinstance(item, int) + and not isinstance(item, bool) + ): + result.append(float(item)) + continue + if isinstance(item, scalar_type): + result.append(item) + return result + + def keys(self) -> list[str]: + """Return the property keys present in this view.""" + return list(self._properties.keys()) + + def __contains__(self, name: str) -> bool: + return name in self._properties + + def __repr__(self) -> str: + return f"ConfigProperties(keys={self.keys()!r})" + + +class ConfigProvider: + """Holds the instrumentation :class:`ConfigProperties` for global access.""" + + def __init__(self, instrumentation_config: ConfigProperties) -> None: + self._instrumentation_config = instrumentation_config + + def get_instrumentation_config(self) -> ConfigProperties: + """Return the read view over the ``instrumentation`` config node.""" + return self._instrumentation_config + + +_CONFIG_PROVIDER: ConfigProvider | None = None + + +def set_config_provider(config_provider: ConfigProvider) -> None: + """Set the global :class:`ConfigProvider`. + + A warning is logged (and the value overwritten) if one is already set, + matching the "set once" behavior of the other declarative globals. + """ + global _CONFIG_PROVIDER # pylint: disable=global-statement + if _CONFIG_PROVIDER is not None: + _logger.warning( + "Overriding of current ConfigProvider is not allowed once set; " + "overwriting the existing instance." + ) + _CONFIG_PROVIDER = config_provider + + +def get_config_provider() -> ConfigProvider | None: + """Return the global :class:`ConfigProvider`, or ``None`` if unset.""" + return _CONFIG_PROVIDER diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py index f7d8c9f16a..3cf3b27fae 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py @@ -3,43 +3,125 @@ """Top-level orchestrator for declarative SDK configuration. -Takes a parsed ``OpenTelemetryConfiguration`` and applies it by calling -each per-signal ``configure_*`` factory in order. This is the single -entry point for "apply this config" on the declarative path. +Provides two entry points on the declarative path: + +* :func:`create` builds the SDK providers from a parsed + ``OpenTelemetryConfiguration`` and returns them **without** touching any + process-global state. It mirrors Java's ``DeclarativeConfiguration.create``. +* :func:`configure_sdk` applies a parsed configuration by delegating to + :func:`create` and then installing the built providers as the process + globals (tracer, meter, logger, propagator, ConfigProvider) and running + instrumentation. This is the "apply this config" entry point. """ from __future__ import annotations +from dataclasses import dataclass from logging import getLogger +from opentelemetry import metrics, trace +from opentelemetry._logs import set_logger_provider +from opentelemetry.configuration._config_provider import ( + ConfigProperties, + ConfigProvider, + _node_to_mapping, + set_config_provider, +) from opentelemetry.configuration._logger_provider import ( - configure_logger_provider, + create_logger_provider, ) from opentelemetry.configuration._meter_provider import ( - configure_meter_provider, + create_meter_provider, ) -from opentelemetry.configuration._propagator import configure_propagator +from opentelemetry.configuration._propagator import create_propagator from opentelemetry.configuration._resource import create_resource from opentelemetry.configuration._tracer_provider import ( - configure_tracer_provider, + create_tracer_provider, ) from opentelemetry.configuration.instrumentation import ( configure_instrumentation, ) from opentelemetry.configuration.models import OpenTelemetryConfiguration +from opentelemetry.propagate import set_global_textmap +from opentelemetry.propagators.textmap import TextMapPropagator +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.trace import TracerProvider _logger = getLogger(__name__) +@dataclass +class Providers: + """The SDK objects built from a declarative configuration. + + A provider is ``None`` when its config section was absent (matching the + spec's "noop default" behavior). The propagator is always built (an empty + :class:`CompositePropagator` when no propagator section is present). + ``config_provider`` exposes the instrumentation config as a read view. + """ + + tracer_provider: TracerProvider | None + meter_provider: MeterProvider | None + logger_provider: LoggerProvider | None + propagator: TextMapPropagator + config_provider: ConfigProvider + + +def create(config: OpenTelemetryConfiguration) -> Providers: + """Build SDK providers from a parsed declarative configuration. + + This is a pure builder: it constructs and returns the providers without + mutating any process-global state (no ``set_tracer_provider``, etc.) and + without running instrumentation. Use :func:`configure_sdk` to also install + the result as the process globals. + + Sections absent from the config (``None``) yield a ``None`` provider, + matching the spec's "noop default" behavior. The instrumentation config + is always exposed via ``config_provider`` (empty when absent). + + Args: + config: Parsed ``OpenTelemetryConfiguration`` (typically from + ``load_config_file``). + + Returns: + A :class:`Providers` bundle of the built objects. + """ + resource = create_resource(config.resource) + return Providers( + tracer_provider=( + create_tracer_provider(config.tracer_provider, resource) + if config.tracer_provider is not None + else None + ), + meter_provider=( + create_meter_provider(config.meter_provider, resource) + if config.meter_provider is not None + else None + ), + logger_provider=( + create_logger_provider(config.logger_provider, resource) + if config.logger_provider is not None + else None + ), + propagator=create_propagator(config.propagator), + config_provider=ConfigProvider( + ConfigProperties( + _node_to_mapping(config.instrumentation_development) + ) + ), + ) + + def configure_sdk(config: OpenTelemetryConfiguration) -> None: """Configure the global SDK from a parsed declarative configuration. - Builds a :class:`Resource` from ``config.resource`` and applies it to - each signal provider. Sets the global tracer provider, meter provider, - logger provider, and text map propagator from their respective config - sections. Sections absent from the config (``None``) leave the - corresponding global untouched — matching the spec's "noop default" - behavior. + Delegates to :func:`create` to build the providers, then installs them as + the process globals: sets the global tracer provider, meter provider, + logger provider, text map propagator, and :class:`ConfigProvider` from the + built objects. Sections absent from the config leave the corresponding + global untouched — matching the spec's "noop default" behavior. Finally + runs instrumentation from the ``instrumentation`` section. Honors the top-level ``disabled`` flag: when true, no globals are set. @@ -48,7 +130,7 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None: ``load_config_file``). Example: - >>> from opentelemetry.configuration.file import ( + >>> from opentelemetry.configuration import ( ... load_config_file, configure_sdk, ... ) >>> config = load_config_file("otel-config.yaml") @@ -60,9 +142,15 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None: ) return - resource = create_resource(config.resource) - configure_tracer_provider(config.tracer_provider, resource) - configure_meter_provider(config.meter_provider, resource) - configure_logger_provider(config.logger_provider, resource) - configure_propagator(config.propagator) + providers = create(config) + + if providers.tracer_provider is not None: + trace.set_tracer_provider(providers.tracer_provider) + if providers.meter_provider is not None: + metrics.set_meter_provider(providers.meter_provider) + if providers.logger_provider is not None: + set_logger_provider(providers.logger_provider) + set_global_textmap(providers.propagator) + set_config_provider(providers.config_provider) + configure_instrumentation(config.instrumentation_development) diff --git a/opentelemetry-configuration/tests/test_config_provider.py b/opentelemetry-configuration/tests/test_config_provider.py new file mode 100644 index 0000000000..435e8e9b5e --- /dev/null +++ b/opentelemetry-configuration/tests/test_config_provider.py @@ -0,0 +1,155 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import unittest + +import opentelemetry.configuration._config_provider as config_provider_module +from opentelemetry.configuration._config_provider import ( + ConfigProperties, + ConfigProvider, + _node_to_mapping, + get_config_provider, + set_config_provider, +) +from opentelemetry.configuration.models import ( + ExperimentalGeneralInstrumentation, + ExperimentalInstrumentation, +) + + +class TestConfigPropertiesScalars(unittest.TestCase): + def setUp(self): + self.props = ConfigProperties( + { + "name": "service", + "flag": True, + "count": 5, + "ratio": 0.25, + "whole": 3, + } + ) + + def test_get_string(self): + self.assertEqual(self.props.get_string("name"), "service") + + def test_get_string_missing_returns_none(self): + self.assertIsNone(self.props.get_string("nope")) + + def test_get_string_wrong_type_returns_none(self): + self.assertIsNone(self.props.get_string("count")) + + def test_get_bool(self): + self.assertIs(self.props.get_bool("flag"), True) + + def test_get_bool_wrong_type_returns_none(self): + self.assertIsNone(self.props.get_bool("count")) + + def test_get_int(self): + self.assertEqual(self.props.get_int("count"), 5) + + def test_get_int_rejects_bool(self): + self.assertIsNone(self.props.get_int("flag")) + + def test_get_int_wrong_type_returns_none(self): + self.assertIsNone(self.props.get_int("name")) + + def test_get_float(self): + self.assertEqual(self.props.get_float("ratio"), 0.25) + + def test_get_float_widens_int(self): + result = self.props.get_float("whole") + self.assertIsInstance(result, float) + self.assertEqual(result, 3.0) + + def test_get_float_rejects_bool(self): + self.assertIsNone(self.props.get_float("flag")) + + def test_keys(self): + self.assertEqual( + set(self.props.keys()), + {"name", "flag", "count", "ratio", "whole"}, + ) + + def test_contains(self): + self.assertIn("name", self.props) + self.assertNotIn("nope", self.props) + + +class TestConfigPropertiesStructured(unittest.TestCase): + def test_get_config_returns_sub_view(self): + props = ConfigProperties({"peer": {"host": "localhost", "port": 8080}}) + sub = props.get_config("peer") + self.assertIsInstance(sub, ConfigProperties) + self.assertEqual(sub.get_string("host"), "localhost") + self.assertEqual(sub.get_int("port"), 8080) + + def test_get_config_missing_returns_none(self): + self.assertIsNone(ConfigProperties({}).get_config("peer")) + + def test_get_config_non_mapping_returns_none(self): + self.assertIsNone(ConfigProperties({"peer": 5}).get_config("peer")) + + def test_get_config_list(self): + props = ConfigProperties({"servers": [{"host": "a"}, {"host": "b"}]}) + result = props.get_config_list("servers") + self.assertEqual(len(result), 2) + self.assertEqual(result[0].get_string("host"), "a") + self.assertEqual(result[1].get_string("host"), "b") + + def test_get_config_list_missing_returns_none(self): + self.assertIsNone(ConfigProperties({}).get_config_list("servers")) + + def test_get_scalar_list_strings(self): + props = ConfigProperties({"names": ["a", "b", 3]}) + # Non-matching element (3) dropped. + self.assertEqual(props.get_scalar_list("names", str), ["a", "b"]) + + def test_get_scalar_list_ints_drops_bool(self): + props = ConfigProperties({"nums": [1, 2, True]}) + self.assertEqual(props.get_scalar_list("nums", int), [1, 2]) + + def test_get_scalar_list_missing_returns_none(self): + self.assertIsNone(ConfigProperties({}).get_scalar_list("x", str)) + + +class TestNodeToMapping(unittest.TestCase): + def test_dataclass_node_converted_recursively(self): + node = ExperimentalInstrumentation( + general=ExperimentalGeneralInstrumentation( + stability_opt_in_list="http" + ) + ) + mapping = _node_to_mapping(node) + self.assertEqual(mapping["general"]["stability_opt_in_list"], "http") + + def test_none_yields_empty_mapping(self): + self.assertEqual(_node_to_mapping(None), {}) + + def test_config_properties_over_instrumentation_node(self): + node = ExperimentalInstrumentation( + general=ExperimentalGeneralInstrumentation( + stability_opt_in_list="http" + ) + ) + props = ConfigProperties(_node_to_mapping(node)) + general = props.get_config("general") + self.assertIsInstance(general, ConfigProperties) + self.assertEqual(general.get_string("stability_opt_in_list"), "http") + + +class TestGlobalConfigProvider(unittest.TestCase): + def setUp(self): + # Reset the module global before each test. + config_provider_module._CONFIG_PROVIDER = None + + def test_get_returns_none_when_unset(self): + self.assertIsNone(get_config_provider()) + + def test_set_and_get(self): + provider = ConfigProvider(ConfigProperties({"k": "v"})) + set_config_provider(provider) + self.assertIs(get_config_provider(), provider) + self.assertEqual( + get_config_provider().get_instrumentation_config().get_string("k"), + "v", + ) diff --git a/opentelemetry-configuration/tests/test_sdk.py b/opentelemetry-configuration/tests/test_sdk.py index 1637eba8d7..77502f5740 100644 --- a/opentelemetry-configuration/tests/test_sdk.py +++ b/opentelemetry-configuration/tests/test_sdk.py @@ -7,8 +7,12 @@ import unittest from unittest.mock import patch -from opentelemetry.configuration._sdk import configure_sdk +import opentelemetry.configuration._config_provider as config_provider_module +from opentelemetry.configuration._config_provider import ConfigProperties +from opentelemetry.configuration._sdk import Providers, configure_sdk, create from opentelemetry.configuration.models import ( + ExperimentalGeneralInstrumentation, + ExperimentalInstrumentation, OpenTelemetryConfiguration, ) from opentelemetry.configuration.models import ( @@ -39,22 +43,32 @@ def _config(**kwargs) -> OpenTelemetryConfiguration: class TestConfigureSdk(unittest.TestCase): - @patch("opentelemetry.configuration._sdk.configure_propagator") - @patch("opentelemetry.configuration._sdk.configure_logger_provider") - @patch("opentelemetry.configuration._sdk.configure_meter_provider") - @patch("opentelemetry.configuration._sdk.configure_tracer_provider") + @patch("opentelemetry.configuration._sdk.set_config_provider") + @patch("opentelemetry.configuration._sdk.set_global_textmap") + @patch("opentelemetry.configuration._sdk.set_logger_provider") + @patch("opentelemetry.configuration._sdk.metrics.set_meter_provider") + @patch("opentelemetry.configuration._sdk.trace.set_tracer_provider") + @patch("opentelemetry.configuration._sdk.create_logger_provider") + @patch("opentelemetry.configuration._sdk.create_meter_provider") + @patch("opentelemetry.configuration._sdk.create_tracer_provider") @patch("opentelemetry.configuration._sdk.create_resource") - # pylint: disable=no-self-use - def test_calls_each_signal_with_resource( + # pylint: disable=no-self-use,too-many-arguments,too-many-positional-arguments + def test_installs_present_sections_as_globals( self, mock_create_resource, - mock_tracer, - mock_meter, - mock_logger, - mock_propagator, + mock_create_tracer, + mock_create_meter, + mock_create_logger, + mock_set_tracer, + mock_set_meter, + mock_set_logger, + mock_set_textmap, + mock_set_config, ): sentinel_resource = object() mock_create_resource.return_value = sentinel_resource + sentinel_tracer = object() + mock_create_tracer.return_value = sentinel_tracer resource_cfg = ResourceConfig() tracer_cfg = TracerProviderConfig(processors=[]) @@ -68,24 +82,30 @@ def test_calls_each_signal_with_resource( configure_sdk(config) mock_create_resource.assert_called_once_with(resource_cfg) - mock_tracer.assert_called_once_with(tracer_cfg, sentinel_resource) - mock_meter.assert_called_once_with(None, sentinel_resource) - mock_logger.assert_called_once_with(None, sentinel_resource) - mock_propagator.assert_called_once_with(propagator_cfg) - - @patch("opentelemetry.configuration._sdk.configure_propagator") - @patch("opentelemetry.configuration._sdk.configure_logger_provider") - @patch("opentelemetry.configuration._sdk.configure_meter_provider") - @patch("opentelemetry.configuration._sdk.configure_tracer_provider") + mock_create_tracer.assert_called_once_with( + tracer_cfg, sentinel_resource + ) + mock_set_tracer.assert_called_once_with(sentinel_tracer) + # Absent meter/logger sections are not built or installed. + mock_create_meter.assert_not_called() + mock_create_logger.assert_not_called() + mock_set_meter.assert_not_called() + mock_set_logger.assert_not_called() + # Propagator and ConfigProvider are always installed. + mock_set_textmap.assert_called_once() + mock_set_config.assert_called_once() + + @patch("opentelemetry.configuration._sdk.set_config_provider") + @patch("opentelemetry.configuration._sdk.set_global_textmap") + @patch("opentelemetry.configuration._sdk.trace.set_tracer_provider") @patch("opentelemetry.configuration._sdk.create_resource") # pylint: disable=no-self-use def test_disabled_skips_everything( self, mock_create_resource, - mock_tracer, - mock_meter, - mock_logger, - mock_propagator, + mock_set_tracer, + mock_set_textmap, + mock_set_config, ): config = _config( disabled=True, @@ -95,31 +115,127 @@ def test_disabled_skips_everything( configure_sdk(config) mock_create_resource.assert_not_called() - mock_tracer.assert_not_called() - mock_meter.assert_not_called() - mock_logger.assert_not_called() - mock_propagator.assert_not_called() - - @patch("opentelemetry.configuration._sdk.configure_propagator") - @patch("opentelemetry.configuration._sdk.configure_logger_provider") - @patch("opentelemetry.configuration._sdk.configure_meter_provider") - @patch("opentelemetry.configuration._sdk.configure_tracer_provider") - @patch("opentelemetry.configuration._sdk.create_resource") - def test_absent_sections_pass_none( + mock_set_tracer.assert_not_called() + mock_set_textmap.assert_not_called() + mock_set_config.assert_not_called() + + @patch("opentelemetry.configuration._sdk.set_config_provider") + @patch("opentelemetry.configuration._sdk.set_global_textmap") + @patch("opentelemetry.configuration._sdk.set_logger_provider") + @patch("opentelemetry.configuration._sdk.metrics.set_meter_provider") + @patch("opentelemetry.configuration._sdk.trace.set_tracer_provider") + def test_absent_sections_do_not_set_provider_globals( self, - mock_create_resource, # noqa: ARG002 - mock_tracer, - mock_meter, - mock_logger, - mock_propagator, + mock_set_tracer, + mock_set_meter, + mock_set_logger, + mock_set_textmap, + mock_set_config, ): configure_sdk(_config()) - # Each configure_* is called exactly once, with config=None. - self.assertEqual(mock_tracer.call_args.args[0], None) - self.assertEqual(mock_meter.call_args.args[0], None) - self.assertEqual(mock_logger.call_args.args[0], None) - self.assertEqual(mock_propagator.call_args.args[0], None) + # No provider globals set when their sections are absent. + mock_set_tracer.assert_not_called() + mock_set_meter.assert_not_called() + mock_set_logger.assert_not_called() + # Propagator and ConfigProvider are always installed. + mock_set_textmap.assert_called_once() + mock_set_config.assert_called_once() + + +class TestCreate(unittest.TestCase): + """The pure ``create`` builder returns providers without mutating globals.""" + + def test_returns_providers_without_mutating_globals(self): + config = _config( + tracer_provider=TracerProviderConfig( + processors=[ + SpanProcessorConfig( + simple=SimpleSpanProcessorConfig( + exporter=SpanExporterConfig(console={}) + ) + ) + ] + ), + propagator=PropagatorConfig(), + ) + + with ( + patch( + "opentelemetry.configuration._sdk.trace.set_tracer_provider" + ) as set_tracer, + patch( + "opentelemetry.configuration._sdk.metrics.set_meter_provider" + ) as set_meter, + patch( + "opentelemetry.configuration._sdk.set_logger_provider" + ) as set_logger, + patch( + "opentelemetry.configuration._sdk.set_global_textmap" + ) as set_textmap, + patch( + "opentelemetry.configuration._sdk.set_config_provider" + ) as set_config, + ): + providers = create(config) + + set_tracer.assert_not_called() + set_meter.assert_not_called() + set_logger.assert_not_called() + set_textmap.assert_not_called() + set_config.assert_not_called() + + self.assertIsInstance(providers, Providers) + self.assertIsInstance(providers.tracer_provider, SdkTracerProvider) + # Absent sections yield None providers. + self.assertIsNone(providers.meter_provider) + self.assertIsNone(providers.logger_provider) + # Propagator is always built. + self.assertIsNotNone(providers.propagator) + # ConfigProvider is always present with a read view. + self.assertIsInstance( + providers.config_provider.get_instrumentation_config(), + ConfigProperties, + ) + + def test_exposes_instrumentation_config(self): + config = _config( + instrumentation_development=ExperimentalInstrumentation( + general=ExperimentalGeneralInstrumentation( + stability_opt_in_list="http" + ) + ) + ) + + providers = create(config) + + instrumentation = ( + providers.config_provider.get_instrumentation_config() + ) + general = instrumentation.get_config("general") + self.assertIsNotNone(general) + self.assertEqual(general.get_string("stability_opt_in_list"), "http") + + def test_configure_sdk_sets_config_provider_from_create(self): + config_provider_module._CONFIG_PROVIDER = None + config = _config( + instrumentation_development=ExperimentalInstrumentation( + general=ExperimentalGeneralInstrumentation( + stability_opt_in_list="db" + ) + ) + ) + + configure_sdk(config) + + provider = config_provider_module.get_config_provider() + self.assertIsNotNone(provider) + self.assertEqual( + provider.get_instrumentation_config() + .get_config("general") + .get_string("stability_opt_in_list"), + "db", + ) class TestConfigureSdkIntegration(unittest.TestCase):