diff --git a/CHANGELOG.md b/CHANGELOG.md index bac47c6bc01..fa72e74c8cc 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 - Refactor `BatchLogRecordProcessor` to simplify code and make the control flow more clear ([#4562](https://github.com/open-telemetry/opentelemetry-python/pull/4562/) and [#4535](https://github.com/open-telemetry/opentelemetry-python/pull/4535)). +- Enable configuration of logging format and level in auto-instrumentation + ([#4203](https://github.com/open-telemetry/opentelemetry-python/pull/4203)) ## Version 1.33.0/0.54b0 (2025-05-09) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py index 745a83385f9..51262ba2fec 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py @@ -45,6 +45,8 @@ OTEL_EXPORTER_OTLP_METRICS_PROTOCOL, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, + OTEL_PYTHON_LOG_FORMAT, + OTEL_PYTHON_LOG_LEVEL, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, ) @@ -89,6 +91,15 @@ _OTEL_SAMPLER_ENTRY_POINT_GROUP = "opentelemetry_traces_sampler" +_OTEL_PYTHON_LOG_LEVEL_BY_NAME = { + "notset": logging.NOTSET, + "debug": logging.DEBUG, + "info": logging.INFO, + "warn": logging.WARNING, + "warning": logging.WARNING, + "error": logging.ERROR, +} + _logger = logging.getLogger(__name__) @@ -133,6 +144,13 @@ def _get_id_generator() -> str: return environ.get(OTEL_PYTHON_ID_GENERATOR, _DEFAULT_ID_GENERATOR) +def _get_log_level() -> int: + return _OTEL_PYTHON_LOG_LEVEL_BY_NAME.get( + environ.get(OTEL_PYTHON_LOG_LEVEL, "notset").lower().strip(), + logging.NOTSET, + ) + + def _get_exporter_entry_point( exporter_name: str, signal_type: Literal["traces", "metrics", "logs"] ): @@ -255,11 +273,19 @@ def _init_logging( if setup_logging_handler: _patch_basic_config() - # Add OTel handler - handler = LoggingHandler( - level=logging.NOTSET, logger_provider=provider - ) - logging.getLogger().addHandler(handler) + # Log Handler + root_logger = logging.getLogger() + handler = LoggingHandler(logger_provider=provider) + # Log level + if OTEL_PYTHON_LOG_LEVEL in environ: + handler.setLevel(_get_log_level()) + # Log format + if OTEL_PYTHON_LOG_FORMAT in environ: + log_format = environ.get( + OTEL_PYTHON_LOG_FORMAT, logging.BASIC_FORMAT + ) + handler.setFormatter(logging.Formatter(log_format)) + root_logger.addHandler(handler) def _patch_basic_config(): diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index 23b634fcd85..ab88b8d1bf8 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -54,6 +54,22 @@ Default: "info" """ +OTEL_PYTHON_LOG_FORMAT = "OTEL_PYTHON_LOG_FORMAT" +""" +.. envvar:: OTEL_PYTHON_LOG_FORMAT + +The :envvar:`OTEL_PYTHON_LOG_FORMAT` environment variable sets the log format for the OpenTelemetry LoggingHandler's Formatter +Default: "logging.BASIC_FORMAT" +""" + +OTEL_PYTHON_LOG_LEVEL = "OTEL_PYTHON_LOG_LEVEL" +""" +.. envvar:: OTEL_PYTHON_LOG_LEVEL + +The :envvar:`OTEL_PYTHON_LOG_LEVEL` environment variable sets the log level for the OpenTelemetry LoggingHandler +Default: "logging.NOTSET" +""" + OTEL_TRACES_SAMPLER = "OTEL_TRACES_SAMPLER" """ .. envvar:: OTEL_TRACES_SAMPLER diff --git a/opentelemetry-sdk/tests/test_configurator.py b/opentelemetry-sdk/tests/test_configurator.py index 9fda75b66f0..05e033c2d97 100644 --- a/opentelemetry-sdk/tests/test_configurator.py +++ b/opentelemetry-sdk/tests/test_configurator.py @@ -16,7 +16,14 @@ from __future__ import annotations import logging -from logging import WARNING, getLogger +from logging import ( + DEBUG, + ERROR, + INFO, + NOTSET, + WARNING, + getLogger, +) from os import environ from typing import Iterable, Optional, Sequence from unittest import TestCase, mock @@ -33,6 +40,7 @@ _EXPORTER_OTLP_PROTO_HTTP, _get_exporter_names, _get_id_generator, + _get_log_level, _get_sampler, _import_config_components, _import_exporters, @@ -75,6 +83,8 @@ from opentelemetry.trace.span import TraceState from opentelemetry.util.types import Attributes +CUSTOM_LOG_FORMAT = "CUSTOM FORMAT %(levelname)s:%(name)s:%(message)s" + class Provider: def __init__(self, resource=None, sampler=None, id_generator=None): @@ -610,6 +620,8 @@ def setUp(self): self.set_event_logger_provider_patch.start() ) + getLogger().handlers.clear() + def tearDown(self): self.processor_patch.stop() self.set_provider_patch.stop() @@ -692,6 +704,151 @@ def test_logging_init_exporter_without_handler_setup(self): ) getLogger(__name__).error("hello") self.assertFalse(provider.processor.exporter.export_called) + root_logger = getLogger() + for handler in root_logger.handlers: + if isinstance(handler, LoggingHandler): + self.fail() + + @patch.dict( + environ, + { + "OTEL_RESOURCE_ATTRIBUTES": "service.name=otlp-service", + "OTEL_PYTHON_LOG_LEVEL": "CUSTOM_LOG_LEVEL", + }, + clear=True, + ) + @patch("opentelemetry.sdk._configuration._get_log_level", return_value=39) + def test_logging_init_exporter_level_under(self, log_level_mock): + resource = Resource.create({}) + _init_logging( + {"otlp": DummyOTLPLogExporter}, + resource=resource, + ) + self.assertEqual(self.set_provider_mock.call_count, 1) + provider = self.set_provider_mock.call_args[0][0] + self.assertIsInstance(provider, DummyLoggerProvider) + self.assertIsInstance(provider.resource, Resource) + self.assertEqual( + provider.resource.attributes.get("service.name"), + "otlp-service", + ) + self.assertIsInstance(provider.processor, DummyLogRecordProcessor) + self.assertIsInstance( + provider.processor.exporter, DummyOTLPLogExporter + ) + getLogger(__name__).error("hello") + self.assertTrue(provider.processor.exporter.export_called) + root_logger = getLogger() + handler_present = False + for handler in root_logger.handlers: + if isinstance(handler, LoggingHandler): + handler_present = True + self.assertEqual(handler.level, 39) + self.assertTrue(handler_present) + + @patch.dict( + environ, + { + "OTEL_RESOURCE_ATTRIBUTES": "service.name=otlp-service", + "OTEL_PYTHON_LOG_LEVEL": "CUSTOM_LOG_LEVEL", + }, + clear=True, + ) + @patch("opentelemetry.sdk._configuration._get_log_level", return_value=41) + def test_logging_init_exporter_level_over(self, log_level_mock): + resource = Resource.create({}) + _init_logging( + {"otlp": DummyOTLPLogExporter}, + resource=resource, + ) + self.assertEqual(self.set_provider_mock.call_count, 1) + provider = self.set_provider_mock.call_args[0][0] + self.assertIsInstance(provider, DummyLoggerProvider) + self.assertIsInstance(provider.resource, Resource) + self.assertEqual( + provider.resource.attributes.get("service.name"), + "otlp-service", + ) + self.assertIsInstance(provider.processor, DummyLogRecordProcessor) + self.assertIsInstance( + provider.processor.exporter, DummyOTLPLogExporter + ) + getLogger(__name__).error("hello") + self.assertFalse(provider.processor.exporter.export_called) + root_logger = getLogger() + handler_present = False + for handler in root_logger.handlers: + if isinstance(handler, LoggingHandler): + handler_present = True + self.assertEqual(handler.level, 41) + self.assertTrue(handler_present) + + @patch.dict( + environ, + { + "OTEL_RESOURCE_ATTRIBUTES": "service.name=otlp-service", + "OTEL_PYTHON_LOG_FORMAT": CUSTOM_LOG_FORMAT, + }, + ) + def test_logging_init_exporter_format(self): + resource = Resource.create({}) + _init_logging( + {"otlp": DummyOTLPLogExporter}, + resource=resource, + ) + self.assertEqual(self.set_provider_mock.call_count, 1) + provider = self.set_provider_mock.call_args[0][0] + self.assertIsInstance(provider, DummyLoggerProvider) + self.assertIsInstance(provider.resource, Resource) + self.assertEqual( + provider.resource.attributes.get("service.name"), + "otlp-service", + ) + self.assertIsInstance(provider.processor, DummyLogRecordProcessor) + self.assertIsInstance( + provider.processor.exporter, DummyOTLPLogExporter + ) + getLogger(__name__).error("hello") + self.assertTrue(provider.processor.exporter.export_called) + root_logger = getLogger() + handler_present = False + for handler in root_logger.handlers: + if isinstance(handler, LoggingHandler): + self.assertEqual(handler.formatter._fmt, CUSTOM_LOG_FORMAT) + handler_present = True + self.assertTrue(handler_present) + + @patch.dict(environ, {}, clear=True) + def test_otel_log_level_by_name_default(self): + self.assertEqual(_get_log_level(), NOTSET) + + @patch.dict(environ, {"OTEL_PYTHON_LOG_LEVEL": "NOTSET "}, clear=True) + def test_otel_log_level_by_name_notset(self): + self.assertEqual(_get_log_level(), NOTSET) + + @patch.dict(environ, {"OTEL_PYTHON_LOG_LEVEL": " DeBug "}, clear=True) + def test_otel_log_level_by_name_debug(self): + self.assertEqual(_get_log_level(), DEBUG) + + @patch.dict(environ, {"OTEL_PYTHON_LOG_LEVEL": " info "}, clear=True) + def test_otel_log_level_by_name_info(self): + self.assertEqual(_get_log_level(), INFO) + + @patch.dict(environ, {"OTEL_PYTHON_LOG_LEVEL": " warn"}, clear=True) + def test_otel_log_level_by_name_warn(self): + self.assertEqual(_get_log_level(), WARNING) + + @patch.dict(environ, {"OTEL_PYTHON_LOG_LEVEL": " warnING "}, clear=True) + def test_otel_log_level_by_name_warning(self): + self.assertEqual(_get_log_level(), WARNING) + + @patch.dict(environ, {"OTEL_PYTHON_LOG_LEVEL": " eRroR"}, clear=True) + def test_otel_log_level_by_name_error(self): + self.assertEqual(_get_log_level(), ERROR) + + @patch.dict(environ, {"OTEL_PYTHON_LOG_LEVEL": "foobar"}, clear=True) + def test_otel_log_level_by_name_invalid(self): + self.assertEqual(_get_log_level(), NOTSET) @patch.dict( environ, @@ -843,6 +1000,101 @@ def test_initialize_components_kwargs( True, ) + @patch.dict( + environ, + { + "OTEL_TRACES_EXPORTER": _EXPORTER_OTLP, + "OTEL_METRICS_EXPORTER": _EXPORTER_OTLP_PROTO_GRPC, + "OTEL_LOGS_EXPORTER": _EXPORTER_OTLP_PROTO_HTTP, + }, + ) + @patch.dict( + environ, + { + "OTEL_RESOURCE_ATTRIBUTES": "service.name=otlp-service, custom.key.1=env-value", + "OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED": "False", + }, + ) + @patch("opentelemetry.sdk._configuration.Resource") + @patch("opentelemetry.sdk._configuration._import_exporters") + @patch("opentelemetry.sdk._configuration._get_exporter_names") + @patch("opentelemetry.sdk._configuration._init_tracing") + @patch("opentelemetry.sdk._configuration._init_logging") + @patch("opentelemetry.sdk._configuration._init_metrics") + def test_initialize_components_kwargs_disable_logging_handler( + self, + metrics_mock, + logging_mock, + tracing_mock, + exporter_names_mock, + import_exporters_mock, + resource_mock, + ): + exporter_names_mock.return_value = [ + "env_var_exporter_1", + "env_var_exporter_2", + ] + import_exporters_mock.return_value = ( + "TEST_SPAN_EXPORTERS_DICT", + "TEST_METRICS_EXPORTERS_DICT", + "TEST_LOG_EXPORTERS_DICT", + ) + resource_mock.create.return_value = "TEST_RESOURCE" + kwargs = { + "auto_instrumentation_version": "auto-version", + "trace_exporter_names": ["custom_span_exporter"], + "metric_exporter_names": ["custom_metric_exporter"], + "log_exporter_names": ["custom_log_exporter"], + "sampler": "TEST_SAMPLER", + "resource_attributes": { + "custom.key.1": "pass-in-value-1", + "custom.key.2": "pass-in-value-2", + }, + "id_generator": "TEST_GENERATOR", + } + _initialize_components(**kwargs) + + import_exporters_mock.assert_called_once_with( + [ + "custom_span_exporter", + "env_var_exporter_1", + "env_var_exporter_2", + ], + [ + "custom_metric_exporter", + "env_var_exporter_1", + "env_var_exporter_2", + ], + [ + "custom_log_exporter", + "env_var_exporter_1", + "env_var_exporter_2", + ], + ) + resource_mock.create.assert_called_once_with( + { + "telemetry.auto.version": "auto-version", + "custom.key.1": "pass-in-value-1", + "custom.key.2": "pass-in-value-2", + } + ) + # Resource is checked separates + tracing_mock.assert_called_once_with( + exporters="TEST_SPAN_EXPORTERS_DICT", + id_generator="TEST_GENERATOR", + sampler="TEST_SAMPLER", + resource="TEST_RESOURCE", + ) + metrics_mock.assert_called_once_with( + "TEST_METRICS_EXPORTERS_DICT", + "TEST_RESOURCE", + ) + logging_mock.assert_called_once_with( + "TEST_LOG_EXPORTERS_DICT", + "TEST_RESOURCE", + False, + ) + def test_basicConfig_works_with_otel_handler(self): with ClearLoggingHandlers(): _init_logging(