diff --git a/.changelog/5351.added b/.changelog/5351.added new file mode 100644 index 0000000000..a1dc9d12dc --- /dev/null +++ b/.changelog/5351.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: wire the top-level `log_level` field in declarative configuration — when set, maps the OTel `SeverityNumber` value to a Python logging level and applies it to the `opentelemetry` logger so SDK internal diagnostics respect the configured severity. diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py index f7d8c9f16a..80934a72bc 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py @@ -10,7 +10,7 @@ from __future__ import annotations -from logging import getLogger +from logging import CRITICAL, DEBUG, ERROR, INFO, WARNING, getLogger from opentelemetry.configuration._logger_provider import ( configure_logger_provider, @@ -26,10 +26,43 @@ from opentelemetry.configuration.instrumentation import ( configure_instrumentation, ) -from opentelemetry.configuration.models import OpenTelemetryConfiguration +from opentelemetry.configuration.models import ( + OpenTelemetryConfiguration, + SeverityNumber, +) _logger = getLogger(__name__) +# Maps OTel SeverityNumber groups to Python logging levels. +# The numbered variants (debug2, info3, …) are sub-levels within the same +# Python tier, so they collapse to the same level constant. +_SEVERITY_TO_LOGGING_LEVEL: dict[SeverityNumber, int] = { + SeverityNumber.trace: DEBUG, + SeverityNumber.trace2: DEBUG, + SeverityNumber.trace3: DEBUG, + SeverityNumber.trace4: DEBUG, + SeverityNumber.debug: DEBUG, + SeverityNumber.debug2: DEBUG, + SeverityNumber.debug3: DEBUG, + SeverityNumber.debug4: DEBUG, + SeverityNumber.info: INFO, + SeverityNumber.info2: INFO, + SeverityNumber.info3: INFO, + SeverityNumber.info4: INFO, + SeverityNumber.warn: WARNING, + SeverityNumber.warn2: WARNING, + SeverityNumber.warn3: WARNING, + SeverityNumber.warn4: WARNING, + SeverityNumber.error: ERROR, + SeverityNumber.error2: ERROR, + SeverityNumber.error3: ERROR, + SeverityNumber.error4: ERROR, + SeverityNumber.fatal: CRITICAL, + SeverityNumber.fatal2: CRITICAL, + SeverityNumber.fatal3: CRITICAL, + SeverityNumber.fatal4: CRITICAL, +} + def configure_sdk(config: OpenTelemetryConfiguration) -> None: """Configure the global SDK from a parsed declarative configuration. @@ -42,6 +75,9 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None: behavior. Honors the top-level ``disabled`` flag: when true, no globals are set. + The ``log_level`` field, when present, is applied to the internal + ``opentelemetry`` logger regardless of ``disabled`` — it configures + SDK self-diagnostics, not telemetry emission. Args: config: Parsed ``OpenTelemetryConfiguration`` (typically from @@ -54,6 +90,10 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None: >>> config = load_config_file("otel-config.yaml") >>> configure_sdk(config) """ + if config.log_level is not None: + level = _SEVERITY_TO_LOGGING_LEVEL.get(config.log_level, INFO) + getLogger("opentelemetry").setLevel(level) + if config.disabled: _logger.warning( "Declarative configuration has disabled=true; skipping SDK setup." diff --git a/opentelemetry-configuration/tests/test_sdk.py b/opentelemetry-configuration/tests/test_sdk.py index 1637eba8d7..58ac1b1f2b 100644 --- a/opentelemetry-configuration/tests/test_sdk.py +++ b/opentelemetry-configuration/tests/test_sdk.py @@ -4,12 +4,14 @@ # Tests access private members of SDK classes to assert correct configuration. # pylint: disable=protected-access +import logging import unittest from unittest.mock import patch from opentelemetry.configuration._sdk import configure_sdk from opentelemetry.configuration.models import ( OpenTelemetryConfiguration, + SeverityNumber, ) from opentelemetry.configuration.models import ( Propagator as PropagatorConfig, @@ -122,6 +124,86 @@ def test_absent_sections_pass_none( self.assertEqual(mock_propagator.call_args.args[0], None) +class TestConfigureSdkLogLevel(unittest.TestCase): + def setUp(self): + # Preserve whatever level was set before this test so we can + # restore it in tearDown, keeping tests isolated from each other + # and from the ambient logging configuration. + self._original_level = logging.getLogger("opentelemetry").level + + def tearDown(self): + logging.getLogger("opentelemetry").setLevel(self._original_level) + + @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_sets_opentelemetry_logger_level(self, *_mocks): + configure_sdk(_config(log_level=SeverityNumber.warn)) + self.assertEqual( + logging.getLogger("opentelemetry").level, logging.WARNING + ) + + @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_log_level_leaves_logger_unchanged(self, *_mocks): + logging.getLogger("opentelemetry").setLevel(logging.ERROR) + configure_sdk(_config()) + self.assertEqual( + logging.getLogger("opentelemetry").level, logging.ERROR + ) + + @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_severity_number_variants_map_correctly(self, *_mocks): + cases = [ + (SeverityNumber.trace, logging.DEBUG), + (SeverityNumber.trace2, logging.DEBUG), + (SeverityNumber.trace3, logging.DEBUG), + (SeverityNumber.trace4, logging.DEBUG), + (SeverityNumber.debug, logging.DEBUG), + (SeverityNumber.debug2, logging.DEBUG), + (SeverityNumber.debug3, logging.DEBUG), + (SeverityNumber.debug4, logging.DEBUG), + (SeverityNumber.info, logging.INFO), + (SeverityNumber.info2, logging.INFO), + (SeverityNumber.info3, logging.INFO), + (SeverityNumber.info4, logging.INFO), + (SeverityNumber.warn, logging.WARNING), + (SeverityNumber.warn2, logging.WARNING), + (SeverityNumber.warn3, logging.WARNING), + (SeverityNumber.warn4, logging.WARNING), + (SeverityNumber.error, logging.ERROR), + (SeverityNumber.error2, logging.ERROR), + (SeverityNumber.error3, logging.ERROR), + (SeverityNumber.error4, logging.ERROR), + (SeverityNumber.fatal, logging.CRITICAL), + (SeverityNumber.fatal2, logging.CRITICAL), + (SeverityNumber.fatal3, logging.CRITICAL), + (SeverityNumber.fatal4, logging.CRITICAL), + ] + for severity, expected_level in cases: + with self.subTest(severity=severity): + configure_sdk(_config(log_level=severity)) + self.assertEqual( + logging.getLogger("opentelemetry").level, + expected_level, + ) + + def test_log_level_applies_even_when_disabled(self): + configure_sdk(_config(disabled=True, log_level=SeverityNumber.error)) + self.assertEqual( + logging.getLogger("opentelemetry").level, logging.ERROR + ) + + class TestConfigureSdkIntegration(unittest.TestCase): """End-to-end: build a real OpenTelemetryConfiguration and apply it."""