From b36d05324e03496a0ad43002b9be7188e076bac0 Mon Sep 17 00:00:00 2001 From: Gaurav Mishra Date: Wed, 15 Apr 2026 21:58:57 +0000 Subject: [PATCH 1/5] feat(logs): add OTEL_LOG_LEVEL support --- .../sdk/_logs/_internal/__init__.py | 28 ++++++ opentelemetry-sdk/tests/logs/test_logs.py | 90 ++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 956d9f28bd..3f52826558 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -61,6 +61,7 @@ from opentelemetry.sdk.environment_variables import ( OTEL_ATTRIBUTE_COUNT_LIMIT, OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT, + OTEL_LOG_LEVEL, OTEL_SDK_DISABLED, ) from opentelemetry.sdk.resources import Resource @@ -80,8 +81,34 @@ _DEFAULT_OTEL_ATTRIBUTE_COUNT_LIMIT = 128 _ENV_VALUE_UNSET = "" +# "warn" is included alongside "warning" because the OTel spec default is +# "info" (lowercase OTel style) and OTel canonical short names use "WARN", +# so users following OTel documentation will naturally try "warn". +_OTEL_LOG_LEVEL_TO_PYTHON = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warn": logging.WARNING, + "warning": logging.WARNING, + "error": logging.ERROR, + "critical": logging.CRITICAL, +} + _logger = logging.getLogger(__name__) +# Target opentelemetry.sdk (not the module-level _logger) so the level +# propagates to all SDK sub-modules: trace, metrics, logs, exporters. +_otel_log_level_raw = environ.get(OTEL_LOG_LEVEL) +_otel_log_level = (_otel_log_level_raw or "info").lower() +if _otel_log_level_raw and _otel_log_level not in _OTEL_LOG_LEVEL_TO_PYTHON: + _logger.warning( + "Invalid value for OTEL_LOG_LEVEL: %r. " + "Valid values: debug, info, warn, warning, error, critical. " + "Defaulting to INFO.", + _otel_log_level_raw, + ) +_python_level = _OTEL_LOG_LEVEL_TO_PYTHON.get(_otel_log_level, logging.INFO) +logging.getLogger("opentelemetry.sdk").setLevel(_python_level) + class BytesEncoder(json.JSONEncoder): def default(self, o): @@ -724,6 +751,7 @@ def emit( """ if not self._is_enabled(): return + # If a record is provided, use it directly if record is not None: if not isinstance(record, ReadWriteLogRecord): diff --git a/opentelemetry-sdk/tests/logs/test_logs.py b/opentelemetry-sdk/tests/logs/test_logs.py index deb242f4a1..77bd302217 100644 --- a/opentelemetry-sdk/tests/logs/test_logs.py +++ b/opentelemetry-sdk/tests/logs/test_logs.py @@ -14,9 +14,12 @@ # pylint: disable=protected-access +import importlib +import logging import unittest from unittest.mock import Mock, patch +import opentelemetry.sdk._logs._internal as _logs_internal from opentelemetry._logs import LogRecord, SeverityNumber from opentelemetry.attributes import BoundedAttributes from opentelemetry.context import get_current @@ -28,6 +31,7 @@ ReadWriteLogRecord, ) from opentelemetry.sdk._logs._internal import ( + _OTEL_LOG_LEVEL_TO_PYTHON, LoggerMetrics, NoOpLogger, SynchronousMultiLogRecordProcessor, @@ -35,7 +39,10 @@ _LoggerConfig, _RuleBasedLoggerConfigurator, ) -from opentelemetry.sdk.environment_variables import OTEL_SDK_DISABLED +from opentelemetry.sdk.environment_variables import ( + OTEL_LOG_LEVEL, + OTEL_SDK_DISABLED, +) from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.util.instrumentation import ( InstrumentationScope, @@ -463,3 +470,84 @@ def test_emit_readwrite_logrecord_uses_exception(self): self.assertEqual( attributes[exception_attributes.EXCEPTION_TYPE], "RuntimeError" ) + + +class TestOtelLogLevelEnvVar(unittest.TestCase): + """Tests for OTEL_LOG_LEVEL → SDK internal logger level.""" + + def setUp(self): + self._sdk_logger = logging.getLogger("opentelemetry.sdk") + + def tearDown(self): + importlib.reload(_logs_internal) + + def test_otel_log_level_to_python_mapping_accepted_values(self): + expected_keys = { + "debug", + "info", + "warn", + "warning", + "error", + "critical", + } + self.assertEqual(set(_OTEL_LOG_LEVEL_TO_PYTHON.keys()), expected_keys) + + @patch.dict("os.environ", {OTEL_LOG_LEVEL: ""}) + def test_default_level_is_info(self): + importlib.reload(_logs_internal) + self.assertEqual(self._sdk_logger.level, logging.INFO) + + def test_invalid_value_warns_and_defaults_to_info(self): + # "trace", "verbose", "none" are valid in other SDKs but not accepted here + for invalid in ("INVALID", "trace", "verbose", "none", "0"): + with self.subTest(invalid=invalid): + with patch.dict("os.environ", {OTEL_LOG_LEVEL: invalid}): + with self.assertLogs( + "opentelemetry.sdk._logs._internal", + level=logging.WARNING, + ): + importlib.reload(_logs_internal) + self.assertEqual(self._sdk_logger.level, logging.INFO) + + def test_case_insensitive(self): + for env_value, expected_level in ( + ("DEBUG", logging.DEBUG), + ("WARN", logging.WARNING), + ("Warning", logging.WARNING), + ("cRiTiCaL", logging.CRITICAL), + ): + with self.subTest(env_value=env_value): + with patch.dict("os.environ", {OTEL_LOG_LEVEL: env_value}): + importlib.reload(_logs_internal) + self.assertEqual(self._sdk_logger.level, expected_level) + + @patch.dict("os.environ", {OTEL_LOG_LEVEL: "critical"}) + def test_level_propagates_to_child_loggers(self): + importlib.reload(_logs_internal) + self.assertEqual( + self._sdk_logger.getChild("trace").getEffectiveLevel(), + logging.CRITICAL, + ) + self.assertEqual( + self._sdk_logger.getChild("metrics").getEffectiveLevel(), + logging.CRITICAL, + ) + self.assertEqual( + self._sdk_logger.getChild("logs").getEffectiveLevel(), + logging.CRITICAL, + ) + + def test_all_valid_values_map_to_correct_level(self): + cases = [ + ("debug", logging.DEBUG), + ("info", logging.INFO), + ("warn", logging.WARNING), + ("warning", logging.WARNING), + ("error", logging.ERROR), + ("critical", logging.CRITICAL), + ] + for env_value, expected_level in cases: + with self.subTest(env_value=env_value): + with patch.dict("os.environ", {OTEL_LOG_LEVEL: env_value}): + importlib.reload(_logs_internal) + self.assertEqual(self._sdk_logger.level, expected_level) From 7c01660b44956c87a638ccf6db073f603aa2947c Mon Sep 17 00:00:00 2001 From: Gaurav Mishra Date: Thu, 16 Apr 2026 14:31:25 +0000 Subject: [PATCH 2/5] update CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index afec89b73f..656ae99c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `opentelemetry-sdk`: Honour `OTEL_LOG_LEVEL` environment variable to configure the SDK's internal diagnostic logger; accepted values: `debug`, `info`, `warn`, `warning`, `error`, `critical`; invalid values emit a warning and default to `info` + ([#1059](https://github.com/open-telemetry/opentelemetry-python/issues/1059)) - `opentelemetry-sdk`: fix YAML structure injection via environment variable substitution in declarative file configuration; values containing newlines are now emitted as quoted YAML scalars per spec requirement ([#5091](https://github.com/open-telemetry/opentelemetry-python/pull/5091)) - `opentelemetry-sdk`: Add `create_logger_provider`/`configure_logger_provider` to declarative file configuration, enabling LoggerProvider instantiation from config files without reading env vars From 93fcffde1d5ea17606731c449d1b9d3d03f2e738 Mon Sep 17 00:00:00 2001 From: Gaurav Mishra Date: Wed, 22 Apr 2026 15:20:41 +0000 Subject: [PATCH 3/5] improve log level handling --- .../sdk/_logs/_internal/__init__.py | 27 ++++++++++--------- opentelemetry-sdk/tests/logs/test_logs.py | 11 ++++---- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 3f52826558..8d17e3805b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -95,19 +95,22 @@ _logger = logging.getLogger(__name__) -# Target opentelemetry.sdk (not the module-level _logger) so the level -# propagates to all SDK sub-modules: trace, metrics, logs, exporters. +# Apply OTEL_LOG_LEVEL to opentelemetry.sdk (not the module-level _logger) +# so the level propagates to all SDK sub-modules: trace, metrics, logs, exporters. _otel_log_level_raw = environ.get(OTEL_LOG_LEVEL) -_otel_log_level = (_otel_log_level_raw or "info").lower() -if _otel_log_level_raw and _otel_log_level not in _OTEL_LOG_LEVEL_TO_PYTHON: - _logger.warning( - "Invalid value for OTEL_LOG_LEVEL: %r. " - "Valid values: debug, info, warn, warning, error, critical. " - "Defaulting to INFO.", - _otel_log_level_raw, - ) -_python_level = _OTEL_LOG_LEVEL_TO_PYTHON.get(_otel_log_level, logging.INFO) -logging.getLogger("opentelemetry.sdk").setLevel(_python_level) +if _otel_log_level_raw: + _otel_log_level = _otel_log_level_raw.lower() + if _otel_log_level in _OTEL_LOG_LEVEL_TO_PYTHON: + logging.getLogger("opentelemetry.sdk").setLevel( + _OTEL_LOG_LEVEL_TO_PYTHON[_otel_log_level] + ) + else: + _logger.warning( + "Invalid value for OTEL_LOG_LEVEL: %r. " + "Valid values: debug, info, warn, warning, error, critical. " + "Logger level unchanged.", + _otel_log_level_raw, + ) class BytesEncoder(json.JSONEncoder): diff --git a/opentelemetry-sdk/tests/logs/test_logs.py b/opentelemetry-sdk/tests/logs/test_logs.py index 77bd302217..39171f7af3 100644 --- a/opentelemetry-sdk/tests/logs/test_logs.py +++ b/opentelemetry-sdk/tests/logs/test_logs.py @@ -479,9 +479,10 @@ def setUp(self): self._sdk_logger = logging.getLogger("opentelemetry.sdk") def tearDown(self): + self._sdk_logger.setLevel(logging.NOTSET) importlib.reload(_logs_internal) - def test_otel_log_level_to_python_mapping_accepted_values(self): + def test_otel_log_level_to_python_mapping_accepted_keys(self): expected_keys = { "debug", "info", @@ -493,11 +494,11 @@ def test_otel_log_level_to_python_mapping_accepted_values(self): self.assertEqual(set(_OTEL_LOG_LEVEL_TO_PYTHON.keys()), expected_keys) @patch.dict("os.environ", {OTEL_LOG_LEVEL: ""}) - def test_default_level_is_info(self): + def test_unset_env_var_does_not_modify_logger_level(self): importlib.reload(_logs_internal) - self.assertEqual(self._sdk_logger.level, logging.INFO) + self.assertEqual(self._sdk_logger.level, logging.NOTSET) - def test_invalid_value_warns_and_defaults_to_info(self): + def test_invalid_value_warns_and_leaves_level_unchanged(self): # "trace", "verbose", "none" are valid in other SDKs but not accepted here for invalid in ("INVALID", "trace", "verbose", "none", "0"): with self.subTest(invalid=invalid): @@ -507,7 +508,7 @@ def test_invalid_value_warns_and_defaults_to_info(self): level=logging.WARNING, ): importlib.reload(_logs_internal) - self.assertEqual(self._sdk_logger.level, logging.INFO) + self.assertEqual(self._sdk_logger.level, logging.NOTSET) def test_case_insensitive(self): for env_value, expected_level in ( From 5283977ff4287a7a1a6820e62d3a945137506741 Mon Sep 17 00:00:00 2001 From: Gaurav Mishra Date: Thu, 23 Apr 2026 20:58:31 +0000 Subject: [PATCH 4/5] add OTEL SDK init --- .../src/opentelemetry/sdk/__init__.py | 47 +++++++++++++++++++ .../sdk/_logs/_internal/__init__.py | 30 ------------ opentelemetry-sdk/tests/logs/test_logs.py | 18 +++---- 3 files changed, 56 insertions(+), 39 deletions(-) create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/__init__.py diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/__init__.py new file mode 100644 index 0000000000..49da35917f --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/__init__.py @@ -0,0 +1,47 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +The OpenTelemetry SDK package is an implementation of the OpenTelemetry API. +""" + +import logging +from os import environ + +from opentelemetry.sdk.environment_variables import OTEL_LOG_LEVEL + +# "warn" is accepted alongside "warning" because OTel canonical short names +# use "WARN", so users following OTel documentation will naturally try "warn". +_OTEL_LOG_LEVEL_TO_PYTHON = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warn": logging.WARNING, + "warning": logging.WARNING, + "error": logging.ERROR, + "critical": logging.CRITICAL, +} + +_otel_log_level_raw = environ.get(OTEL_LOG_LEVEL) +if _otel_log_level_raw: + _logger = logging.getLogger(__name__) + _otel_log_level = _otel_log_level_raw.lower() + if _otel_log_level in _OTEL_LOG_LEVEL_TO_PYTHON: + _logger.setLevel(_OTEL_LOG_LEVEL_TO_PYTHON[_otel_log_level]) + else: + _logger.warning( + "Invalid value for OTEL_LOG_LEVEL: %r. " + "Valid values: debug, info, warn, warning, error, critical. " + "Logger level unchanged.", + _otel_log_level_raw, + ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 8d17e3805b..639e0371d1 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -61,7 +61,6 @@ from opentelemetry.sdk.environment_variables import ( OTEL_ATTRIBUTE_COUNT_LIMIT, OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT, - OTEL_LOG_LEVEL, OTEL_SDK_DISABLED, ) from opentelemetry.sdk.resources import Resource @@ -81,37 +80,8 @@ _DEFAULT_OTEL_ATTRIBUTE_COUNT_LIMIT = 128 _ENV_VALUE_UNSET = "" -# "warn" is included alongside "warning" because the OTel spec default is -# "info" (lowercase OTel style) and OTel canonical short names use "WARN", -# so users following OTel documentation will naturally try "warn". -_OTEL_LOG_LEVEL_TO_PYTHON = { - "debug": logging.DEBUG, - "info": logging.INFO, - "warn": logging.WARNING, - "warning": logging.WARNING, - "error": logging.ERROR, - "critical": logging.CRITICAL, -} - _logger = logging.getLogger(__name__) -# Apply OTEL_LOG_LEVEL to opentelemetry.sdk (not the module-level _logger) -# so the level propagates to all SDK sub-modules: trace, metrics, logs, exporters. -_otel_log_level_raw = environ.get(OTEL_LOG_LEVEL) -if _otel_log_level_raw: - _otel_log_level = _otel_log_level_raw.lower() - if _otel_log_level in _OTEL_LOG_LEVEL_TO_PYTHON: - logging.getLogger("opentelemetry.sdk").setLevel( - _OTEL_LOG_LEVEL_TO_PYTHON[_otel_log_level] - ) - else: - _logger.warning( - "Invalid value for OTEL_LOG_LEVEL: %r. " - "Valid values: debug, info, warn, warning, error, critical. " - "Logger level unchanged.", - _otel_log_level_raw, - ) - class BytesEncoder(json.JSONEncoder): def default(self, o): diff --git a/opentelemetry-sdk/tests/logs/test_logs.py b/opentelemetry-sdk/tests/logs/test_logs.py index 39171f7af3..223591810a 100644 --- a/opentelemetry-sdk/tests/logs/test_logs.py +++ b/opentelemetry-sdk/tests/logs/test_logs.py @@ -19,11 +19,12 @@ import unittest from unittest.mock import Mock, patch -import opentelemetry.sdk._logs._internal as _logs_internal +import opentelemetry.sdk as _sdk from opentelemetry._logs import LogRecord, SeverityNumber from opentelemetry.attributes import BoundedAttributes from opentelemetry.context import get_current from opentelemetry.metrics import NoOpMeterProvider +from opentelemetry.sdk import _OTEL_LOG_LEVEL_TO_PYTHON from opentelemetry.sdk._logs import ( Logger, LoggerProvider, @@ -31,7 +32,6 @@ ReadWriteLogRecord, ) from opentelemetry.sdk._logs._internal import ( - _OTEL_LOG_LEVEL_TO_PYTHON, LoggerMetrics, NoOpLogger, SynchronousMultiLogRecordProcessor, @@ -480,7 +480,7 @@ def setUp(self): def tearDown(self): self._sdk_logger.setLevel(logging.NOTSET) - importlib.reload(_logs_internal) + importlib.reload(_sdk) def test_otel_log_level_to_python_mapping_accepted_keys(self): expected_keys = { @@ -495,7 +495,7 @@ def test_otel_log_level_to_python_mapping_accepted_keys(self): @patch.dict("os.environ", {OTEL_LOG_LEVEL: ""}) def test_unset_env_var_does_not_modify_logger_level(self): - importlib.reload(_logs_internal) + importlib.reload(_sdk) self.assertEqual(self._sdk_logger.level, logging.NOTSET) def test_invalid_value_warns_and_leaves_level_unchanged(self): @@ -504,10 +504,10 @@ def test_invalid_value_warns_and_leaves_level_unchanged(self): with self.subTest(invalid=invalid): with patch.dict("os.environ", {OTEL_LOG_LEVEL: invalid}): with self.assertLogs( - "opentelemetry.sdk._logs._internal", + "opentelemetry.sdk", level=logging.WARNING, ): - importlib.reload(_logs_internal) + importlib.reload(_sdk) self.assertEqual(self._sdk_logger.level, logging.NOTSET) def test_case_insensitive(self): @@ -519,12 +519,12 @@ def test_case_insensitive(self): ): with self.subTest(env_value=env_value): with patch.dict("os.environ", {OTEL_LOG_LEVEL: env_value}): - importlib.reload(_logs_internal) + importlib.reload(_sdk) self.assertEqual(self._sdk_logger.level, expected_level) @patch.dict("os.environ", {OTEL_LOG_LEVEL: "critical"}) def test_level_propagates_to_child_loggers(self): - importlib.reload(_logs_internal) + importlib.reload(_sdk) self.assertEqual( self._sdk_logger.getChild("trace").getEffectiveLevel(), logging.CRITICAL, @@ -550,5 +550,5 @@ def test_all_valid_values_map_to_correct_level(self): for env_value, expected_level in cases: with self.subTest(env_value=env_value): with patch.dict("os.environ", {OTEL_LOG_LEVEL: env_value}): - importlib.reload(_logs_internal) + importlib.reload(_sdk) self.assertEqual(self._sdk_logger.level, expected_level) From a8cf853471606540f4e9f45e6a46021c0d11b805 Mon Sep 17 00:00:00 2001 From: Gaurav Mishra Date: Mon, 27 Apr 2026 14:44:31 +0000 Subject: [PATCH 5/5] update changelog --- CHANGELOG.md | 4 ++-- .../src/opentelemetry/sdk/_logs/_internal/__init__.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 047565825c..1dbf6d69c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- `opentelemetry-sdk`: Honour `OTEL_LOG_LEVEL` environment variable to configure the SDK's internal diagnostic logger; accepted values: `debug`, `info`, `warn`, `warning`, `error`, `critical`; invalid values emit a warning and default to `info` - ([#5115](https://github.com/open-telemetry/opentelemetry-python/issues/5115)) +- `opentelemetry-sdk`: Honour `OTEL_LOG_LEVEL` environment variable to configure the SDK's internal diagnostic logger + ([#5115](https://github.com/open-telemetry/opentelemetry-python/pull/5115)) - `opentelemetry-sdk`: add `additional_properties` support to generated config models via custom `datamodel-codegen` template, enabling plugin/custom component names to flow through typed dataclasses ([#5131](https://github.com/open-telemetry/opentelemetry-python/pull/5131)) - Fix incorrect code example in `create_tracer()` docstring diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 639e0371d1..956d9f28bd 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -724,7 +724,6 @@ def emit( """ if not self._is_enabled(): return - # If a record is provided, use it directly if record is not None: if not isinstance(record, ReadWriteLogRecord):