diff --git a/.changelog/5270.added b/.changelog/5270.added new file mode 100644 index 0000000000..7f79fad4c2 --- /dev/null +++ b/.changelog/5270.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: add `configure_sdk(config)` to the declarative configuration API. Single entry point that takes a parsed `OpenTelemetryConfiguration`, builds the resource, and applies the tracer/meter/logger providers and propagator globally. Honors the top-level `disabled` flag. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py index 53a67429e0..eb89c747a4 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py @@ -16,7 +16,7 @@ import types import typing from collections.abc import Mapping -from typing import Any, TypeVar, Union, get_args, get_origin +from typing import Any, TypeVar, get_args, get_origin _T = TypeVar("_T") @@ -24,10 +24,10 @@ def _unwrap_optional(type_hint: Any) -> Any: """Strip ``None`` from a ``X | None`` / ``Optional[X]`` annotation. - Returns the unwrapped type, or the original hint if not a Union with None. + Returns the unwrapped type, or the original hint if not a union with None. """ origin = get_origin(type_hint) - if origin is Union or origin is types.UnionType: + if origin is types.UnionType or origin is typing.Union: non_none = [t for t in get_args(type_hint) if t is not type(None)] if len(non_none) == 1: return non_none[0] diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_sdk.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_sdk.py new file mode 100644 index 0000000000..efa26aa936 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_sdk.py @@ -0,0 +1,64 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""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. +""" + +from __future__ import annotations + +import logging + +from opentelemetry.sdk._configuration._logger_provider import ( + configure_logger_provider, +) +from opentelemetry.sdk._configuration._meter_provider import ( + configure_meter_provider, +) +from opentelemetry.sdk._configuration._propagator import configure_propagator +from opentelemetry.sdk._configuration._resource import create_resource +from opentelemetry.sdk._configuration._tracer_provider import ( + configure_tracer_provider, +) +from opentelemetry.sdk._configuration.models import OpenTelemetryConfiguration + +_logger = logging.getLogger(__name__) + + +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. + + Honors the top-level ``disabled`` flag: when true, no globals are set. + + Args: + config: Parsed ``OpenTelemetryConfiguration`` (typically from + ``load_config_file``). + + Example: + >>> from opentelemetry.sdk._configuration.file import ( + ... load_config_file, configure_sdk, + ... ) + >>> config = load_config_file("otel-config.yaml") + >>> configure_sdk(config) + """ + if config.disabled: + _logger.warning( + "Declarative configuration has disabled=true; skipping SDK setup." + ) + 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) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py index cb1e9ec904..49b09ba46b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py @@ -27,6 +27,7 @@ create_propagator, ) from opentelemetry.sdk._configuration._resource import create_resource +from opentelemetry.sdk._configuration._sdk import configure_sdk from opentelemetry.sdk._configuration._tracer_provider import ( configure_tracer_provider, create_tracer_provider, @@ -39,6 +40,7 @@ __all__ = [ "load_config_file", + "configure_sdk", "substitute_env_vars", "ConfigurationError", "EnvSubstitutionError", diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py index c96866ef29..e35a25f16e 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py @@ -6,6 +6,7 @@ import importlib.resources import json import logging +import os from pathlib import Path from typing import Any @@ -50,7 +51,9 @@ def _get_schema() -> dict: _logger = logging.getLogger(__name__) -def load_config_file(file_path: str) -> OpenTelemetryConfiguration: +def load_config_file( + file_path: str | os.PathLike[str], +) -> OpenTelemetryConfiguration: """Load and parse an OpenTelemetry configuration file. Supports YAML and JSON formats. Performs environment variable substitution diff --git a/opentelemetry-sdk/tests/_configuration/test_sdk.py b/opentelemetry-sdk/tests/_configuration/test_sdk.py new file mode 100644 index 0000000000..91a4b61421 --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_sdk.py @@ -0,0 +1,149 @@ +# 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 + +import unittest +from unittest.mock import patch + +from opentelemetry.sdk._configuration._sdk import configure_sdk +from opentelemetry.sdk._configuration.models import ( + OpenTelemetryConfiguration, +) +from opentelemetry.sdk._configuration.models import ( + Propagator as PropagatorConfig, +) +from opentelemetry.sdk._configuration.models import ( + Resource as ResourceConfig, +) +from opentelemetry.sdk._configuration.models import ( + SimpleSpanProcessor as SimpleSpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanExporter as SpanExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanProcessor as SpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + TracerProvider as TracerProviderConfig, +) +from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider + +_MIN_CONFIG_KWARGS = {"file_format": "1.0-rc.1"} + + +def _config(**kwargs) -> OpenTelemetryConfiguration: + return OpenTelemetryConfiguration(**{**_MIN_CONFIG_KWARGS, **kwargs}) + + +class TestConfigureSdk(unittest.TestCase): + @patch("opentelemetry.sdk._configuration._sdk.configure_propagator") + @patch("opentelemetry.sdk._configuration._sdk.configure_logger_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_meter_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_tracer_provider") + @patch("opentelemetry.sdk._configuration._sdk.create_resource") + # pylint: disable=no-self-use + def test_calls_each_signal_with_resource( + self, + mock_create_resource, + mock_tracer, + mock_meter, + mock_logger, + mock_propagator, + ): + sentinel_resource = object() + mock_create_resource.return_value = sentinel_resource + + resource_cfg = ResourceConfig() + tracer_cfg = TracerProviderConfig(processors=[]) + propagator_cfg = PropagatorConfig() + config = _config( + resource=resource_cfg, + tracer_provider=tracer_cfg, + propagator=propagator_cfg, + ) + + 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.sdk._configuration._sdk.configure_propagator") + @patch("opentelemetry.sdk._configuration._sdk.configure_logger_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_meter_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_tracer_provider") + @patch("opentelemetry.sdk._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, + ): + config = _config( + disabled=True, + tracer_provider=TracerProviderConfig(processors=[]), + ) + + 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.sdk._configuration._sdk.configure_propagator") + @patch("opentelemetry.sdk._configuration._sdk.configure_logger_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_meter_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_tracer_provider") + @patch("opentelemetry.sdk._configuration._sdk.create_resource") + def test_absent_sections_pass_none( + self, + mock_create_resource, # noqa: ARG002 + mock_tracer, + mock_meter, + mock_logger, + mock_propagator, + ): + 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) + + +class TestConfigureSdkIntegration(unittest.TestCase): + """End-to-end: build a real OpenTelemetryConfiguration and apply it.""" + + @patch( + "opentelemetry.sdk._configuration._tracer_provider.trace.set_tracer_provider" + ) + def test_applies_tracer_provider_globally(self, mock_set_tracer): + config = _config( + tracer_provider=TracerProviderConfig( + processors=[ + SpanProcessorConfig( + simple=SimpleSpanProcessorConfig( + exporter=SpanExporterConfig(console={}) + ) + ) + ] + ) + ) + + configure_sdk(config) + + mock_set_tracer.assert_called_once() + self.assertIsInstance( + mock_set_tracer.call_args[0][0], SdkTracerProvider + )