From e8deda1dcb15377b8341d9beaa43917e4817df9e Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Wed, 29 Jul 2026 17:19:00 -0500 Subject: [PATCH 1/4] Implement declarative config ConfigProvider / ConfigProperties API Adds the Instrumentation Configuration API from the declarative configuration spec (configuration/api.md), which was previously unimplemented in Python: - ConfigProperties: a typed, schemaless read view over a parsed configuration mapping node, with get_string/get_bool/get_int/ get_float, get_config, get_config_list, get_scalar_list, and key introspection. Getters return None on a missing key or type mismatch, matching the spec and Java's DeclarativeConfigProperties semantics. - ConfigProvider: holds the ConfigProperties for the instrumentation config node and exposes get_instrumentation_config(). - A global default ConfigProvider with get_config_provider() / set_config_provider(), so instrumentation libraries can access it. - configure_sdk now builds a ConfigProvider from the .instrumentation node and sets it as the global. Fixes #5485. --- .changelog/5485.added | 1 + .../opentelemetry/configuration/__init__.py | 10 + .../configuration/_config_provider.py | 189 ++++++++++++++++++ .../src/opentelemetry/configuration/_sdk.py | 17 +- .../tests/test_config_provider.py | 155 ++++++++++++++ 5 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 .changelog/5485.added create mode 100644 opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py create mode 100644 opentelemetry-configuration/tests/test_config_provider.py diff --git a/.changelog/5485.added b/.changelog/5485.added new file mode 100644 index 00000000000..1a10e3ae205 --- /dev/null +++ b/.changelog/5485.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: implement the declarative configuration Instrumentation Configuration API — add `ConfigProvider` and `ConfigProperties` (a typed read view over the `instrumentation` config node), a global `ConfigProvider` with `get_config_provider`/`set_config_provider`, and wire `configure_sdk` to set the global `ConfigProvider` so instrumentation libraries can consume declarative configuration. diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py b/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py index 934987fb7bd..fa2b0d4387d 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py @@ -29,14 +29,24 @@ 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.file._loader import load_config_file from opentelemetry.configuration.models import OpenTelemetryConfiguration __all__ = [ + "ConfigProperties", + "ConfigProvider", "ConfigurationError", "OpenTelemetryConfiguration", "configure_sdk", + "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 00000000000..10cf8599c03 --- /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 71295c9d80e..2c90673c250 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py @@ -12,6 +12,12 @@ from logging import CRITICAL, DEBUG, ERROR, INFO, WARNING, getLogger +from opentelemetry.configuration._config_provider import ( + ConfigProperties, + ConfigProvider, + _node_to_mapping, + set_config_provider, +) from opentelemetry.configuration._logger_provider import ( configure_logger_provider, ) @@ -72,7 +78,9 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None: 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. + behavior. The global :class:`ConfigProvider` is always set, exposing the + ``instrumentation`` config node as a read view (empty when absent) for + instrumentation libraries to consume. Honors the top-level ``disabled`` flag: when true, the function returns early without setting any globals. The ``log_level`` field, when present @@ -105,4 +113,11 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None: configure_meter_provider(config.meter_provider, resource) configure_logger_provider(config.logger_provider, resource) configure_propagator(config.propagator) + set_config_provider( + ConfigProvider( + ConfigProperties( + _node_to_mapping(config.instrumentation_development) + ) + ) + ) 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 00000000000..435e8e9b5ef --- /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", + ) From e1f8a7ba10f2fd786db294ad0069a46fd86837cf Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Wed, 29 Jul 2026 18:32:49 -0500 Subject: [PATCH 2/4] Align global ConfigProvider with set_tracer_provider semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the global ConfigProvider consistent with the other OpenTelemetry globals (tracer/meter/logger): - set_config_provider is now set-once, guarded by a thread-safe Once. A second call logs a warning and keeps the existing provider instead of overwriting it (the previous version warned but overwrote, and was not thread-safe). - get_config_provider returns a NoOpConfigProvider (empty instrumentation config) when none is set, rather than None, so callers can traverse the config tree without None checks — mirroring ProxyTracerProvider and Java's ConfigProvider.noop(). Its return type is now non-optional. --- .changelog/5485.added | 2 +- .../opentelemetry/configuration/__init__.py | 2 + .../configuration/_config_provider.py | 47 ++++++++++++++----- .../tests/test_config_provider.py | 33 +++++++++++-- 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/.changelog/5485.added b/.changelog/5485.added index 1a10e3ae205..21d3e2af510 100644 --- a/.changelog/5485.added +++ b/.changelog/5485.added @@ -1 +1 @@ -`opentelemetry-sdk`: implement the declarative configuration Instrumentation Configuration API — add `ConfigProvider` and `ConfigProperties` (a typed read view over the `instrumentation` config node), a global `ConfigProvider` with `get_config_provider`/`set_config_provider`, and wire `configure_sdk` to set the global `ConfigProvider` so instrumentation libraries can consume declarative configuration. +`opentelemetry-sdk`: implement the declarative configuration Instrumentation Configuration API — add `ConfigProvider` and `ConfigProperties` (a typed read view over the `instrumentation` config node), a global `ConfigProvider` with `get_config_provider`/`set_config_provider` that follows the set-once, no-op-default semantics of `set_tracer_provider`/`get_tracer_provider` (`get_config_provider` returns a `NoOpConfigProvider` when unset), and wire `configure_sdk` to set the global `ConfigProvider` so instrumentation libraries can consume declarative configuration. diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py b/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py index fa2b0d4387d..539fe707ff4 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py @@ -32,6 +32,7 @@ from opentelemetry.configuration._config_provider import ( ConfigProperties, ConfigProvider, + NoOpConfigProvider, get_config_provider, set_config_provider, ) @@ -44,6 +45,7 @@ "ConfigProperties", "ConfigProvider", "ConfigurationError", + "NoOpConfigProvider", "OpenTelemetryConfiguration", "configure_sdk", "get_config_provider", diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py index 10cf8599c03..e4c9e4b6fdf 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py @@ -21,6 +21,8 @@ from logging import getLogger from typing import Any +from opentelemetry.util._once import Once + _logger = getLogger(__name__) @@ -166,24 +168,47 @@ def get_instrumentation_config(self) -> ConfigProperties: return self._instrumentation_config +class NoOpConfigProvider(ConfigProvider): + """A :class:`ConfigProvider` exposing empty instrumentation config. + + Returned by :func:`get_config_provider` when no global provider has been + set, so callers can traverse the config tree without ``None`` checks — + mirroring ``ProxyTracerProvider`` and Java's ``ConfigProvider.noop()``. + """ + + def __init__(self) -> None: + super().__init__(ConfigProperties()) + + +_CONFIG_PROVIDER_SET_ONCE = Once() _CONFIG_PROVIDER: ConfigProvider | None = None +_NOOP_CONFIG_PROVIDER = NoOpConfigProvider() 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. + This can only be done once; a warning is logged on any further attempt and + the existing provider is kept, matching the set-once behavior of the other + OpenTelemetry globals (e.g. :func:`opentelemetry.trace.set_tracer_provider`). """ - 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 set_cp() -> None: + global _CONFIG_PROVIDER # pylint: disable=global-statement + _CONFIG_PROVIDER = config_provider + + did_set = _CONFIG_PROVIDER_SET_ONCE.do_once(set_cp) + + if not did_set: + _logger.warning("Overriding of current ConfigProvider is not allowed") -def get_config_provider() -> ConfigProvider | None: - """Return the global :class:`ConfigProvider`, or ``None`` if unset.""" + +def get_config_provider() -> ConfigProvider: + """Return the global :class:`ConfigProvider`. + + Returns a :class:`NoOpConfigProvider` (exposing empty instrumentation + config) when none has been set, so callers never receive ``None``. + """ + if _CONFIG_PROVIDER is None: + return _NOOP_CONFIG_PROVIDER return _CONFIG_PROVIDER diff --git a/opentelemetry-configuration/tests/test_config_provider.py b/opentelemetry-configuration/tests/test_config_provider.py index 435e8e9b5ef..85530be0041 100644 --- a/opentelemetry-configuration/tests/test_config_provider.py +++ b/opentelemetry-configuration/tests/test_config_provider.py @@ -2,15 +2,18 @@ # SPDX-License-Identifier: Apache-2.0 import unittest +from unittest.mock import patch import opentelemetry.configuration._config_provider as config_provider_module from opentelemetry.configuration._config_provider import ( ConfigProperties, ConfigProvider, + NoOpConfigProvider, _node_to_mapping, get_config_provider, set_config_provider, ) +from opentelemetry.util._once import Once from opentelemetry.configuration.models import ( ExperimentalGeneralInstrumentation, ExperimentalInstrumentation, @@ -139,11 +142,22 @@ def test_config_properties_over_instrumentation_node(self): class TestGlobalConfigProvider(unittest.TestCase): def setUp(self): - # Reset the module global before each test. + # Reset the module global and its set-once guard before each test. config_provider_module._CONFIG_PROVIDER = None + config_provider_module._CONFIG_PROVIDER_SET_ONCE = Once() - def test_get_returns_none_when_unset(self): - self.assertIsNone(get_config_provider()) + def test_get_returns_noop_when_unset(self): + provider = get_config_provider() + self.assertIsInstance(provider, NoOpConfigProvider) + # The no-op provider exposes empty instrumentation config, so callers + # can traverse it without None checks. + self.assertEqual( + provider.get_instrumentation_config().keys(), + [], + ) + self.assertIsNone( + provider.get_instrumentation_config().get_string("anything") + ) def test_set_and_get(self): provider = ConfigProvider(ConfigProperties({"k": "v"})) @@ -153,3 +167,16 @@ def test_set_and_get(self): get_config_provider().get_instrumentation_config().get_string("k"), "v", ) + + @patch("opentelemetry.configuration._config_provider._logger") + def test_set_is_once_only(self, mock_logger): + first = ConfigProvider(ConfigProperties({"k": "first"})) + second = ConfigProvider(ConfigProperties({"k": "second"})) + set_config_provider(first) + set_config_provider(second) + # The second set is ignored and a warning is logged, matching + # set_tracer_provider semantics. + self.assertIs(get_config_provider(), first) + mock_logger.warning.assert_called_once_with( + "Overriding of current ConfigProvider is not allowed" + ) From daa4f8937367674d134a23bb58f1df0705722f3f Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Wed, 29 Jul 2026 18:48:15 -0500 Subject: [PATCH 3/4] Improve ConfigProperties consistency: proxy default, warnings, typed list getters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address further consistency gaps with existing opentelemetry-python APIs: - get_config_provider now returns a forwarding ProxyConfigProvider (reads the global lazily) instead of a frozen no-op, so a provider obtained before set_config_provider still resolves to the one installed later — matching ProxyTracerProvider / ProxyLoggerProvider. NoOpConfigProvider is kept as an explicit no-op (mirroring NoOpTracerProvider). - ConfigProperties getters now log a warning when a key is present with an incompatible type, instead of silently returning None (matching the package's own logging habits and Java's DeclarativeConfigProperties). - Replaced get_scalar_list(name, type) with typed getters get_string_list, get_bool_list, get_int_list, get_float_list, consistent with the scalar getters and avoiding a runtime type argument. - keys() now returns a set (matching the spec's "set of property keys"). --- .changelog/5485.added | 2 +- .../opentelemetry/configuration/__init__.py | 2 + .../configuration/_config_provider.py | 177 +++++++++++++----- .../tests/test_config_provider.py | 78 ++++++-- 4 files changed, 204 insertions(+), 55 deletions(-) diff --git a/.changelog/5485.added b/.changelog/5485.added index 21d3e2af510..c7b0f704fbb 100644 --- a/.changelog/5485.added +++ b/.changelog/5485.added @@ -1 +1 @@ -`opentelemetry-sdk`: implement the declarative configuration Instrumentation Configuration API — add `ConfigProvider` and `ConfigProperties` (a typed read view over the `instrumentation` config node), a global `ConfigProvider` with `get_config_provider`/`set_config_provider` that follows the set-once, no-op-default semantics of `set_tracer_provider`/`get_tracer_provider` (`get_config_provider` returns a `NoOpConfigProvider` when unset), and wire `configure_sdk` to set the global `ConfigProvider` so instrumentation libraries can consume declarative configuration. +`opentelemetry-sdk`: implement the declarative configuration Instrumentation Configuration API — add `ConfigProvider` and `ConfigProperties` (a typed read view over the `instrumentation` config node, with `get_string`/`get_bool`/`get_int`/`get_float`, `get_config`/`get_config_list`, typed sequence getters `get_string_list`/`get_bool_list`/`get_int_list`/`get_float_list`, and `keys`), a global `ConfigProvider` with `get_config_provider`/`set_config_provider` that follows the set-once, proxy-default semantics of `set_tracer_provider`/`get_tracer_provider` (`get_config_provider` returns a forwarding `ProxyConfigProvider` when unset), and wire `configure_sdk` to set the global `ConfigProvider` so instrumentation libraries can consume declarative configuration. diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py b/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py index 539fe707ff4..fc979dc2b74 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py @@ -33,6 +33,7 @@ ConfigProperties, ConfigProvider, NoOpConfigProvider, + ProxyConfigProvider, get_config_provider, set_config_provider, ) @@ -47,6 +48,7 @@ "ConfigurationError", "NoOpConfigProvider", "OpenTelemetryConfiguration", + "ProxyConfigProvider", "configure_sdk", "get_config_provider", "load_config_file", diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py index e4c9e4b6fdf..4e2c9527880 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py @@ -57,43 +57,90 @@ def __init__(self, properties: Mapping[str, Any] | None = None) -> None: dict(properties) if properties is not None else {} ) + def _log_type_mismatch(self, name: str, value: Any, expected: str) -> None: + _logger.warning( + "Config property %r has type %s, expected %s; ignoring.", + name, + type(value).__name__, + expected, + ) + + @staticmethod + def _as_string(value: Any) -> str | None: + return value if isinstance(value, str) else None + + @staticmethod + def _as_bool(value: Any) -> bool | None: + return value if isinstance(value, bool) else None + + @staticmethod + def _as_int(value: Any) -> int | None: + # ``bool`` is a subclass of ``int`` in Python but is not an integer + # value for config purposes, so it is rejected. + if isinstance(value, bool): + return None + return value if isinstance(value, int) else None + + @staticmethod + def _as_float(value: Any) -> float | None: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + return None + def get_string(self, name: str) -> str | None: - """Return the value of ``name`` as a ``str``, or ``None``.""" + """Return the value of ``name`` as a ``str``, or ``None``. + + Logs a warning if ``name`` is present with an incompatible type. + """ value = self._properties.get(name) - return value if isinstance(value, str) else None + result = self._as_string(value) + if result is None and value is not None: + self._log_type_mismatch(name, value, "string") + return result def get_bool(self, name: str) -> bool | None: - """Return the value of ``name`` as a ``bool``, or ``None``.""" + """Return the value of ``name`` as a ``bool``, or ``None``. + + Logs a warning if ``name`` is present with an incompatible type. + """ value = self._properties.get(name) - return value if isinstance(value, bool) else None + result = self._as_bool(value) + if result is None and value is not None: + self._log_type_mismatch(name, value, "bool") + return result 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). + ``bool`` values are rejected (they are not treated as integers). Logs + a warning if ``name`` is present with an incompatible type. """ value = self._properties.get(name) - if isinstance(value, bool): - return None - return value if isinstance(value, int) else None + result = self._as_int(value) + if result is None and value is not None: + self._log_type_mismatch(name, value, "int") + return result 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``. + Accepts ``int`` values (widened to ``float``); rejects ``bool``. Logs + a warning if ``name`` is present with an incompatible type. """ value = self._properties.get(name) - if isinstance(value, bool): - return None - if isinstance(value, (int, float)): - return float(value) - return None + result = self._as_float(value) + if result is None and value is not None: + self._log_type_mismatch(name, value, "float") + return result 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. + mapping / dataclass node. Logs a warning if ``name`` is present with an + incompatible type. """ value = self._properties.get(name) if value is None: @@ -102,53 +149,80 @@ def get_config(self, name: str) -> ConfigProperties | None: return ConfigProperties(_node_to_mapping(value)) if isinstance(value, Mapping): return ConfigProperties(dict(value)) + self._log_type_mismatch(name, value, "mapping") 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. + when ``name`` is absent or is not a list of mappings. Logs a warning if + ``name`` is present with an incompatible type. """ value = self._properties.get(name) + if value is None: + return None if not isinstance(value, list): + self._log_type_mismatch(name, value, "list of mappings") return None result: list[ConfigProperties] = [] for item in value: mapping = _node_to_mapping(item) if not mapping and item is not None: + self._log_type_mismatch(name, value, "list of mappings") 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``. + def get_string_list(self, name: str) -> list[str] | None: + """Return the sequence at ``name`` as a list of ``str``. + + Elements with an incompatible type are dropped. Returns ``None`` when + ``name`` is absent or is not a sequence. + """ + return self._scalar_list(name, self._as_string) + + def get_bool_list(self, name: str) -> list[bool] | None: + """Return the sequence at ``name`` as a list of ``bool``. + + Elements with an incompatible type are dropped. Returns ``None`` when + ``name`` is absent or is not a sequence. + """ + return self._scalar_list(name, self._as_bool) + + def get_int_list(self, name: str) -> list[int] | None: + """Return the sequence at ``name`` as a list of ``int``. + + Elements with an incompatible type (including ``bool``) are dropped. + Returns ``None`` when ``name`` is absent or is not a sequence. + """ + return self._scalar_list(name, self._as_int) + + def get_float_list(self, name: str) -> list[float] | None: + """Return the sequence at ``name`` as a list of ``float``. - 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. + ``int`` elements are widened to ``float``; incompatible elements are + dropped. Returns ``None`` when ``name`` is absent or is not a sequence. """ + return self._scalar_list(name, self._as_float) + + def _scalar_list(self, name: str, coerce) -> list | None: value = self._properties.get(name) + if value is None: + return None if not isinstance(value, list): + self._log_type_mismatch(name, value, "list of scalars") 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) + coerced = coerce(item) + if coerced is not None: + result.append(coerced) return result - def keys(self) -> list[str]: - """Return the property keys present in this view.""" - return list(self._properties.keys()) + def keys(self) -> set[str]: + """Return the set of property keys present in this view.""" + return set(self._properties.keys()) def __contains__(self, name: str) -> bool: return name in self._properties @@ -171,18 +245,36 @@ def get_instrumentation_config(self) -> ConfigProperties: class NoOpConfigProvider(ConfigProvider): """A :class:`ConfigProvider` exposing empty instrumentation config. - Returned by :func:`get_config_provider` when no global provider has been - set, so callers can traverse the config tree without ``None`` checks — - mirroring ``ProxyTracerProvider`` and Java's ``ConfigProvider.noop()``. + Mirrors ``NoOpTracerProvider`` and Java's ``ConfigProvider.noop()`` — an + explicit no-op for callers that want an empty provider. + """ + + def __init__(self) -> None: + super().__init__(ConfigProperties()) + + +class ProxyConfigProvider(ConfigProvider): + """A :class:`ConfigProvider` that defers to the global provider. + + Returned by :func:`get_config_provider` before a real provider has been + set. It reads the global provider lazily on each call, so a caller that + obtains the provider early still sees configuration installed later — + mirroring ``ProxyTracerProvider`` / ``ProxyLoggerProvider``. Until a real + provider is set, it exposes empty instrumentation config. """ def __init__(self) -> None: super().__init__(ConfigProperties()) + def get_instrumentation_config(self) -> ConfigProperties: + if _CONFIG_PROVIDER is not None: + return _CONFIG_PROVIDER.get_instrumentation_config() + return super().get_instrumentation_config() + _CONFIG_PROVIDER_SET_ONCE = Once() _CONFIG_PROVIDER: ConfigProvider | None = None -_NOOP_CONFIG_PROVIDER = NoOpConfigProvider() +_PROXY_CONFIG_PROVIDER = ProxyConfigProvider() def set_config_provider(config_provider: ConfigProvider) -> None: @@ -206,9 +298,10 @@ def set_cp() -> None: def get_config_provider() -> ConfigProvider: """Return the global :class:`ConfigProvider`. - Returns a :class:`NoOpConfigProvider` (exposing empty instrumentation - config) when none has been set, so callers never receive ``None``. + Returns a :class:`ProxyConfigProvider` when none has been set, so callers + never receive ``None`` and a provider obtained before + :func:`set_config_provider` still resolves to the one installed later. """ if _CONFIG_PROVIDER is None: - return _NOOP_CONFIG_PROVIDER + return _PROXY_CONFIG_PROVIDER return _CONFIG_PROVIDER diff --git a/opentelemetry-configuration/tests/test_config_provider.py b/opentelemetry-configuration/tests/test_config_provider.py index 85530be0041..31a4097124a 100644 --- a/opentelemetry-configuration/tests/test_config_provider.py +++ b/opentelemetry-configuration/tests/test_config_provider.py @@ -9,6 +9,7 @@ ConfigProperties, ConfigProvider, NoOpConfigProvider, + ProxyConfigProvider, _node_to_mapping, get_config_provider, set_config_provider, @@ -69,15 +70,38 @@ def test_get_float_rejects_bool(self): def test_keys(self): self.assertEqual( - set(self.props.keys()), + self.props.keys(), {"name", "flag", "count", "ratio", "whole"}, ) + def test_keys_returns_set(self): + self.assertIsInstance(self.props.keys(), set) + def test_contains(self): self.assertIn("name", self.props) self.assertNotIn("nope", self.props) +class TestConfigPropertiesTypeMismatch(unittest.TestCase): + @patch("opentelemetry.configuration._config_provider._logger") + def test_present_wrong_type_logs_warning(self, mock_logger): + props = ConfigProperties({"count": "not-a-number"}) + self.assertIsNone(props.get_int("count")) + mock_logger.warning.assert_called_once() + + @patch("opentelemetry.configuration._config_provider._logger") + def test_missing_key_does_not_log(self, mock_logger): + props = ConfigProperties({}) + self.assertIsNone(props.get_int("count")) + mock_logger.warning.assert_not_called() + + @patch("opentelemetry.configuration._config_provider._logger") + def test_get_int_rejecting_bool_logs(self, mock_logger): + props = ConfigProperties({"count": True}) + self.assertIsNone(props.get_int("count")) + mock_logger.warning.assert_called_once() + + class TestConfigPropertiesStructured(unittest.TestCase): def test_get_config_returns_sub_view(self): props = ConfigProperties({"peer": {"host": "localhost", "port": 8080}}) @@ -102,17 +126,33 @@ def test_get_config_list(self): def test_get_config_list_missing_returns_none(self): self.assertIsNone(ConfigProperties({}).get_config_list("servers")) - def test_get_scalar_list_strings(self): + def test_get_string_list_drops_non_matching(self): props = ConfigProperties({"names": ["a", "b", 3]}) # Non-matching element (3) dropped. - self.assertEqual(props.get_scalar_list("names", str), ["a", "b"]) + self.assertEqual(props.get_string_list("names"), ["a", "b"]) - def test_get_scalar_list_ints_drops_bool(self): + def test_get_int_list_drops_bool(self): props = ConfigProperties({"nums": [1, 2, True]}) - self.assertEqual(props.get_scalar_list("nums", int), [1, 2]) + self.assertEqual(props.get_int_list("nums"), [1, 2]) - def test_get_scalar_list_missing_returns_none(self): - self.assertIsNone(ConfigProperties({}).get_scalar_list("x", str)) + def test_get_float_list_widens_int(self): + props = ConfigProperties({"nums": [1, 2.5]}) + self.assertEqual(props.get_float_list("nums"), [1.0, 2.5]) + + def test_get_bool_list(self): + props = ConfigProperties({"flags": [True, False, "x"]}) + self.assertEqual(props.get_bool_list("flags"), [True, False]) + + def test_get_string_list_missing_returns_none(self): + self.assertIsNone(ConfigProperties({}).get_string_list("x")) + + @patch("opentelemetry.configuration._config_provider._logger") + def test_get_string_list_non_sequence_logs_and_returns_none( + self, mock_logger + ): + props = ConfigProperties({"names": "not-a-list"}) + self.assertIsNone(props.get_string_list("names")) + mock_logger.warning.assert_called_once() class TestNodeToMapping(unittest.TestCase): @@ -146,19 +186,33 @@ def setUp(self): config_provider_module._CONFIG_PROVIDER = None config_provider_module._CONFIG_PROVIDER_SET_ONCE = Once() - def test_get_returns_noop_when_unset(self): + def test_get_returns_proxy_when_unset(self): provider = get_config_provider() - self.assertIsInstance(provider, NoOpConfigProvider) - # The no-op provider exposes empty instrumentation config, so callers - # can traverse it without None checks. + self.assertIsInstance(provider, ProxyConfigProvider) + # The proxy exposes empty instrumentation config until one is set, so + # callers can traverse it without None checks. self.assertEqual( provider.get_instrumentation_config().keys(), - [], + set(), ) self.assertIsNone( provider.get_instrumentation_config().get_string("anything") ) + def test_proxy_forwards_to_later_set_provider(self): + # A caller that grabs the provider before it is set still sees the + # config installed later, mirroring ProxyTracerProvider. + proxy = get_config_provider() + self.assertIsInstance(proxy, ProxyConfigProvider) + set_config_provider(ConfigProvider(ConfigProperties({"k": "v"}))) + self.assertEqual( + proxy.get_instrumentation_config().get_string("k"), "v" + ) + + def test_noop_provider_is_empty(self): + provider = NoOpConfigProvider() + self.assertEqual(provider.get_instrumentation_config().keys(), set()) + def test_set_and_get(self): provider = ConfigProvider(ConfigProperties({"k": "v"})) set_config_provider(provider) From 3d2622150761837423749e5179ba786d3f1b101f Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Wed, 29 Jul 2026 19:54:51 -0500 Subject: [PATCH 4/4] Fix CI: changelog PR-number, pylint no-self-use/protected-access, ruff import order - Rename changelog fragment 5485.added -> 5486.added to match the PR number (the changelog check requires .). - Make ConfigProperties._log_type_mismatch a staticmethod (it does not use self) to satisfy pylint R6301 (no-self-use). - Disable protected-access in the test setUp that resets the module globals (pylint W0212). - Sort imports in the test module (ruff I001). --- .changelog/{5485.added => 5486.added} | 0 .../src/opentelemetry/configuration/_config_provider.py | 3 ++- opentelemetry-configuration/tests/test_config_provider.py | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) rename .changelog/{5485.added => 5486.added} (100%) diff --git a/.changelog/5485.added b/.changelog/5486.added similarity index 100% rename from .changelog/5485.added rename to .changelog/5486.added diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py index 4e2c9527880..d801f63b2e7 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py @@ -57,7 +57,8 @@ def __init__(self, properties: Mapping[str, Any] | None = None) -> None: dict(properties) if properties is not None else {} ) - def _log_type_mismatch(self, name: str, value: Any, expected: str) -> None: + @staticmethod + def _log_type_mismatch(name: str, value: Any, expected: str) -> None: _logger.warning( "Config property %r has type %s, expected %s; ignoring.", name, diff --git a/opentelemetry-configuration/tests/test_config_provider.py b/opentelemetry-configuration/tests/test_config_provider.py index 31a4097124a..4564b116600 100644 --- a/opentelemetry-configuration/tests/test_config_provider.py +++ b/opentelemetry-configuration/tests/test_config_provider.py @@ -14,11 +14,11 @@ get_config_provider, set_config_provider, ) -from opentelemetry.util._once import Once from opentelemetry.configuration.models import ( ExperimentalGeneralInstrumentation, ExperimentalInstrumentation, ) +from opentelemetry.util._once import Once class TestConfigPropertiesScalars(unittest.TestCase): @@ -183,6 +183,7 @@ def test_config_properties_over_instrumentation_node(self): class TestGlobalConfigProvider(unittest.TestCase): def setUp(self): # Reset the module global and its set-once guard before each test. + # pylint: disable=protected-access config_provider_module._CONFIG_PROVIDER = None config_provider_module._CONFIG_PROVIDER_SET_ONCE = Once()