diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a10cc3543..d6ff166ec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix incorrect installation of some exporter “convenience” packages into “site-packages/src” ([#2525](https://github.com/open-telemetry/opentelemetry-python/pull/2525)) +- Capture exception information as part of log attributes + ([#2531](https://github.com/open-telemetry/opentelemetry-python/pull/2531)) - Change OTLPHandler to LoggingHandler ([#2528](https://github.com/open-telemetry/opentelemetry-python/pull/2528)) - Fix delta histogram sum not being reset on collection diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/__init__.py index d13c8c59ea..c278635c6b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/__init__.py @@ -19,6 +19,7 @@ import logging import os import threading +import traceback from typing import Any, Callable, Optional, Tuple, Union, cast from opentelemetry.sdk._logs.severity import SeverityNumber, std_to_otlp @@ -28,6 +29,7 @@ from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.util import ns_to_iso_str from opentelemetry.sdk.util.instrumentation import InstrumentationInfo +from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.trace import ( format_span_id, format_trace_id, @@ -319,9 +321,27 @@ def __init__( @staticmethod def _get_attributes(record: logging.LogRecord) -> Attributes: - return { + attributes = { k: v for k, v in vars(record).items() if k not in _RESERVED_ATTRS } + if record.exc_info is not None: + exc_type = "" + message = "" + stack_trace = "" + exctype, value, tb = record.exc_info + if exctype is not None: + exc_type = exctype.__name__ + if value is not None and value.args: + message = value.args[0] + if tb is not None: + # https://github.com/open-telemetry/opentelemetry-specification/blob/9fa7c656b26647b27e485a6af7e38dc716eba98a/specification/trace/semantic_conventions/exceptions.md#stacktrace-representation + stack_trace = "".join( + traceback.format_exception(*record.exc_info) + ) + attributes[SpanAttributes.EXCEPTION_TYPE] = exc_type + attributes[SpanAttributes.EXCEPTION_MESSAGE] = message + attributes[SpanAttributes.EXCEPTION_STACKTRACE] = stack_trace + return attributes def _translate(self, record: logging.LogRecord) -> LogRecord: timestamp = int(record.created * 1e9) diff --git a/opentelemetry-sdk/tests/logs/test_handler.py b/opentelemetry-sdk/tests/logs/test_handler.py index d80356c454..7ea478e844 100644 --- a/opentelemetry-sdk/tests/logs/test_handler.py +++ b/opentelemetry-sdk/tests/logs/test_handler.py @@ -11,7 +11,6 @@ # 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. - import logging import unittest from unittest.mock import Mock @@ -19,6 +18,7 @@ from opentelemetry.sdk import trace from opentelemetry.sdk._logs import LogEmitter, LoggingHandler from opentelemetry.sdk._logs.severity import SeverityNumber +from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.trace import INVALID_SPAN_CONTEXT @@ -77,6 +77,36 @@ def test_log_record_user_attributes(self): self.assertIsNotNone(log_record) self.assertEqual(log_record.attributes, {"http.status_code": 200}) + def test_log_record_exception(self): + """Exception information will be included in attributes""" + emitter_mock = Mock(spec=LogEmitter) + logger = get_logger(log_emitter=emitter_mock) + try: + raise ZeroDivisionError("division by zero") + except ZeroDivisionError: + logger.exception("Zero Division Error") + args, _ = emitter_mock.emit.call_args_list[0] + log_record = args[0] + + self.assertIsNotNone(log_record) + self.assertEqual(log_record.body, "Zero Division Error") + self.assertEqual( + log_record.attributes[SpanAttributes.EXCEPTION_TYPE], + ZeroDivisionError.__name__, + ) + self.assertEqual( + log_record.attributes[SpanAttributes.EXCEPTION_MESSAGE], + "division by zero", + ) + stack_trace = log_record.attributes[ + SpanAttributes.EXCEPTION_STACKTRACE + ] + self.assertIsInstance(stack_trace, str) + self.assertTrue("Traceback" in stack_trace) + self.assertTrue("ZeroDivisionError" in stack_trace) + self.assertTrue("division by zero" in stack_trace) + self.assertTrue(__file__ in stack_trace) + def test_log_record_trace_correlation(self): emitter_mock = Mock(spec=LogEmitter) logger = get_logger(log_emitter=emitter_mock)