diff --git a/.changelog/5271.added b/.changelog/5271.added new file mode 100644 index 00000000000..f69fb5c17c7 --- /dev/null +++ b/.changelog/5271.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: the SDK configurator now honors the `OTEL_CONFIG_FILE` environment variable. When set, the SDK loads and applies the referenced declarative configuration file (YAML or JSON) in place of the env-var-based init path. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py index 432346c6e17..46d1ca7ddfc 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py @@ -37,6 +37,7 @@ ) from opentelemetry.sdk.environment_variables import ( _OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED, + OTEL_CONFIG_FILE, OTEL_EXPORTER_OTLP_LOGS_PROTOCOL, OTEL_EXPORTER_OTLP_METRICS_PROTOCOL, OTEL_EXPORTER_OTLP_PROTOCOL, @@ -720,4 +721,24 @@ class _OTelSDKConfigurator(_BaseConfigurator): """ def _configure(self, **kwargs): + if config_file := environ.get(OTEL_CONFIG_FILE): + # Imported lazily so that the SDK does not require the optional + # file-configuration extras (pyyaml, jsonschema) unless a config + # file is actually requested. + # pylint: disable=import-outside-toplevel + from opentelemetry.sdk._configuration._sdk import ( # noqa: PLC0415 + configure_sdk, + ) + from opentelemetry.sdk._configuration.file._loader import ( # noqa: PLC0415 + load_config_file, + ) + + if kwargs: + _logger.warning( + "%s is set; ignoring configurator kwargs: %s", + OTEL_CONFIG_FILE, + sorted(kwargs), + ) + configure_sdk(load_config_file(config_file)) + return _initialize_components(**kwargs) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index 94738ca4697..e7d04c4eb02 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -9,6 +9,18 @@ Default: "false" """ +OTEL_CONFIG_FILE = "OTEL_CONFIG_FILE" +""" +.. envvar:: OTEL_CONFIG_FILE + +The :envvar:`OTEL_CONFIG_FILE` environment variable points the SDK at a +declarative configuration file (YAML or JSON). When set, the file is the +sole source of SDK configuration; other ``OTEL_*`` environment variables +are ignored except where referenced via ``${env:VAR}`` substitution inside +the file. See the OpenTelemetry declarative configuration specification +for details. +""" + OTEL_RESOURCE_ATTRIBUTES = "OTEL_RESOURCE_ATTRIBUTES" """ .. envvar:: OTEL_RESOURCE_ATTRIBUTES diff --git a/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py b/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py new file mode 100644 index 00000000000..89b2098217b --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py @@ -0,0 +1,89 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# Tests access private members of SDK classes to assert correct configuration. +# pylint: disable=protected-access,no-self-use + +import unittest +from unittest.mock import patch + +from opentelemetry.sdk._configuration import _OTelSDKConfigurator +from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk.environment_variables import OTEL_CONFIG_FILE + + +class TestConfiguratorFileRouting(unittest.TestCase): + def tearDown(self): + # _BaseConfigurator caches instances via a singleton; reset so sibling + # tests (e.g. test_configurator.py's CustomConfigurator subclass) are + # not affected by this class's singleton state. + _OTelSDKConfigurator._instance = None + + @patch.dict("os.environ", {}, clear=True) + @patch("opentelemetry.sdk._configuration._initialize_components") + def test_env_var_unset_runs_env_var_path(self, mock_init_components): + _OTelSDKConfigurator()._configure(auto_instrumentation_version="X") + mock_init_components.assert_called_once_with( + auto_instrumentation_version="X" + ) + + @patch.dict("os.environ", {OTEL_CONFIG_FILE: "/tmp/otel.yaml"}) + @patch("opentelemetry.sdk._configuration._sdk.configure_sdk") + @patch("opentelemetry.sdk._configuration.file._loader.load_config_file") + @patch("opentelemetry.sdk._configuration._initialize_components") + def test_env_var_set_routes_to_declarative_path( + self, mock_init_components, mock_load, mock_configure_sdk + ): + sentinel_config = object() + mock_load.return_value = sentinel_config + + _OTelSDKConfigurator()._configure() + + mock_load.assert_called_once_with("/tmp/otel.yaml") + mock_configure_sdk.assert_called_once_with(sentinel_config) + mock_init_components.assert_not_called() + + @patch.dict("os.environ", {OTEL_CONFIG_FILE: "/does/not/exist.yaml"}) + @patch("opentelemetry.sdk._configuration._initialize_components") + def test_env_var_set_missing_file_propagates(self, mock_init_components): + with self.assertRaises(ConfigurationError): + _OTelSDKConfigurator()._configure() + mock_init_components.assert_not_called() + + @patch.dict("os.environ", {OTEL_CONFIG_FILE: "/tmp/otel.yaml"}) + @patch("opentelemetry.sdk._configuration._sdk.configure_sdk") + @patch("opentelemetry.sdk._configuration.file._loader.load_config_file") + def test_env_var_set_with_kwargs_warns_and_ignores( + self, mock_load, mock_configure_sdk + ): + mock_load.return_value = object() + + with self.assertLogs( + "opentelemetry.sdk._configuration", level="WARNING" + ) as captured: + _OTelSDKConfigurator()._configure( + sampler="X", auto_instrumentation_version="Y" + ) + + self.assertTrue( + any( + "OTEL_CONFIG_FILE" in msg and "sampler" in msg + for msg in captured.output + ), + f"Expected warning about ignored kwargs, got: {captured.output}", + ) + mock_configure_sdk.assert_called_once() + + @patch.dict("os.environ", {}, clear=True) + @patch("opentelemetry.sdk._configuration._initialize_components") + def test_distro_override_pattern_still_works(self, mock_init_components): + class CustomConfigurator(_OTelSDKConfigurator): + def _configure(self, **kwargs): + kwargs["sampler"] = "TEST_SAMPLER" + super()._configure(**kwargs) + + CustomConfigurator()._configure(auto_instrumentation_version="V") + + mock_init_components.assert_called_once_with( + auto_instrumentation_version="V", sampler="TEST_SAMPLER" + )