diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc9179da34..46368c58107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Fix logic to deal with `LoggingHandler.exc_info` occasionally being a string ([#4699](https://github.com/open-telemetry/opentelemetry-python/pull/4699)) + ## Version 1.36.0/0.57b0 (2025-07-29) - Add missing Prometheus exporter documentation diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 505904839b8..3cee79f4388 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -19,6 +19,7 @@ import concurrent.futures import json import logging +import sys import threading import traceback import warnings @@ -568,6 +569,9 @@ def _get_attributes(record: logging.LogRecord) -> _ExtendedAttributes: attributes[code_attributes.CODE_FUNCTION_NAME] = record.funcName attributes[code_attributes.CODE_LINE_NUMBER] = record.lineno + if isinstance(record.exc_info, str): + record.exc_info = sys.exc_info() + if record.exc_info: exctype, value, tb = record.exc_info if exctype is not None: diff --git a/opentelemetry-sdk/tests/logs/test_handler.py b/opentelemetry-sdk/tests/logs/test_handler.py index 55526dc2b6a..f2838d8964a 100644 --- a/opentelemetry-sdk/tests/logs/test_handler.py +++ b/opentelemetry-sdk/tests/logs/test_handler.py @@ -14,6 +14,8 @@ import logging import os +import sys +import traceback import unittest from unittest.mock import Mock, patch @@ -48,6 +50,39 @@ def test_handler_default_log_level(self): logger.warning("Warning message") self.assertEqual(processor.emit_count(), 1) + def test_handler_error_exc_info(self): + processor, logger = set_up_test_logging(logging.NOTSET) + + class CustomException(Exception): + pass + + try: + raise CustomException("Custom exception") + except CustomException as exception: + exc_info = (type(exception), exception, exception.__traceback__) + else: + exc_info = "Second stringified exception" + + exc_info_values = [ + # Don't know what caused it in my context, so I'm relying on mocks to replicate the behavior. + # First the `record.exc_info` becomes a string somehow, then `sys.exc_info` brings the tuple. + "First stringified exception", + exc_info, + ] + + with patch.object(sys, "exc_info", side_effect=exc_info_values): + logger.exception("Exception message") # Should not raise exception + + assert processor.emit_count() == 1 + + attributes = processor.log_data_emitted[0].log_record.attributes._dict + assert attributes["exception.type"] == "CustomException" + assert attributes["exception.message"] == str(exc_info[1]) + assert isinstance(exc_info, tuple) + assert attributes["exception.stacktrace"] == "".join( + traceback.format_exception(*exc_info) + ) + def test_handler_custom_log_level(self): processor, logger = set_up_test_logging(logging.ERROR)