diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bd0a53c3c..0914929343 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` + ([#5115](https://github.com/open-telemetry/opentelemetry-python/issues/5115)) - Fix incorrect code example in `create_tracer()` docstring ([#5072](https://github.com/open-telemetry/opentelemetry-python/issues/5072)) - `opentelemetry-sdk`: add `load_entry_point` shared utility to declarative file configuration for loading plugins via entry points; refactor propagator loading to use it 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 956d9f28bd..639e0371d1 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -724,6 +724,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..223591810a 100644 --- a/opentelemetry-sdk/tests/logs/test_logs.py +++ b/opentelemetry-sdk/tests/logs/test_logs.py @@ -14,13 +14,17 @@ # pylint: disable=protected-access +import importlib +import logging import unittest from unittest.mock import Mock, patch +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, @@ -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,85 @@ 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): + self._sdk_logger.setLevel(logging.NOTSET) + importlib.reload(_sdk) + + def test_otel_log_level_to_python_mapping_accepted_keys(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_unset_env_var_does_not_modify_logger_level(self): + importlib.reload(_sdk) + self.assertEqual(self._sdk_logger.level, logging.NOTSET) + + 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): + with patch.dict("os.environ", {OTEL_LOG_LEVEL: invalid}): + with self.assertLogs( + "opentelemetry.sdk", + level=logging.WARNING, + ): + importlib.reload(_sdk) + self.assertEqual(self._sdk_logger.level, logging.NOTSET) + + 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(_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(_sdk) + 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(_sdk) + self.assertEqual(self._sdk_logger.level, expected_level)