diff --git a/.changelog/5486.added b/.changelog/5486.added new file mode 100644 index 0000000000..c7b0f704fb --- /dev/null +++ b/.changelog/5486.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, 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 934987fb7b..fc979dc2b7 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py @@ -29,14 +29,28 @@ behaviour may change between minor versions. """ +from opentelemetry.configuration._config_provider import ( + ConfigProperties, + ConfigProvider, + NoOpConfigProvider, + ProxyConfigProvider, + 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", + "NoOpConfigProvider", "OpenTelemetryConfiguration", + "ProxyConfigProvider", "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 0000000000..d801f63b2e --- /dev/null +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py @@ -0,0 +1,308 @@ +# 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 + +from opentelemetry.util._once import Once + +_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 {} + ) + + @staticmethod + def _log_type_mismatch(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``. + + Logs a warning if ``name`` is present with an incompatible type. + """ + value = self._properties.get(name) + 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``. + + Logs a warning if ``name`` is present with an incompatible type. + """ + value = self._properties.get(name) + 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). Logs + a warning if ``name`` is present with an incompatible type. + """ + value = self._properties.get(name) + 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``. Logs + a warning if ``name`` is present with an incompatible type. + """ + value = self._properties.get(name) + 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. Logs a warning if ``name`` is present with an + incompatible type. + """ + 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)) + 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. 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_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``. + + ``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: + coerced = coerce(item) + if coerced is not None: + result.append(coerced) + return result + + 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 + + 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 + + +class NoOpConfigProvider(ConfigProvider): + """A :class:`ConfigProvider` exposing empty instrumentation config. + + 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 +_PROXY_CONFIG_PROVIDER = ProxyConfigProvider() + + +def set_config_provider(config_provider: ConfigProvider) -> None: + """Set the global :class:`ConfigProvider`. + + 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`). + """ + + 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: + """Return the global :class:`ConfigProvider`. + + 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 _PROXY_CONFIG_PROVIDER + return _CONFIG_PROVIDER diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py index 71295c9d80..2c90673c25 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 0000000000..4564b11660 --- /dev/null +++ b/opentelemetry-configuration/tests/test_config_provider.py @@ -0,0 +1,237 @@ +# Copyright The OpenTelemetry Authors +# 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, + ProxyConfigProvider, + _node_to_mapping, + get_config_provider, + set_config_provider, +) +from opentelemetry.configuration.models import ( + ExperimentalGeneralInstrumentation, + ExperimentalInstrumentation, +) +from opentelemetry.util._once import Once + + +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( + 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}}) + 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_string_list_drops_non_matching(self): + props = ConfigProperties({"names": ["a", "b", 3]}) + # Non-matching element (3) dropped. + self.assertEqual(props.get_string_list("names"), ["a", "b"]) + + def test_get_int_list_drops_bool(self): + props = ConfigProperties({"nums": [1, 2, True]}) + self.assertEqual(props.get_int_list("nums"), [1, 2]) + + 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): + 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 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() + + def test_get_returns_proxy_when_unset(self): + provider = get_config_provider() + 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) + self.assertIs(get_config_provider(), provider) + self.assertEqual( + 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" + )