diff --git a/CHANGELOG.md b/CHANGELOG.md index f125b47e99..970a2a59ec 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 +- logs: add exception support to Logger emit and LogRecord attributes + ([#4907](https://github.com/open-telemetry/opentelemetry-python/issues/4907)) - `opentelemetry-exporter-otlp-proto-grpc`: Fix re-initialization of gRPC channel on UNAVAILABLE error ([#4825](https://github.com/open-telemetry/opentelemetry-python/pull/4825)) - `opentelemetry-exporter-prometheus`: Fix duplicate HELP/TYPE declarations for metrics with different label sets diff --git a/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py b/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py index bbcfcddc84..dc3e351ac8 100644 --- a/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py +++ b/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py @@ -76,6 +76,7 @@ def __init__( body: AnyValue = None, attributes: Optional[_ExtendedAttributes] = None, event_name: Optional[str] = None, + exception: Optional[BaseException] = None, ) -> None: ... @overload @@ -94,6 +95,7 @@ def __init__( severity_number: Optional[SeverityNumber] = None, body: AnyValue = None, attributes: Optional[_ExtendedAttributes] = None, + exception: Optional[BaseException] = None, ) -> None: ... def __init__( @@ -110,6 +112,7 @@ def __init__( body: AnyValue = None, attributes: Optional[_ExtendedAttributes] = None, event_name: Optional[str] = None, + exception: Optional[BaseException] = None, ) -> None: if not context: context = get_current() @@ -127,6 +130,7 @@ def __init__( self.body = body self.attributes = attributes self.event_name = event_name + self.exception = exception class Logger(ABC): @@ -157,6 +161,7 @@ def emit( body: AnyValue | None = None, attributes: _ExtendedAttributes | None = None, event_name: str | None = None, + exception: BaseException | None = None, ) -> None: ... @overload @@ -178,6 +183,7 @@ def emit( body: AnyValue | None = None, attributes: _ExtendedAttributes | None = None, event_name: str | None = None, + exception: BaseException | None = None, ) -> None: """Emits a :class:`LogRecord` representing a log to the processing pipeline.""" @@ -200,6 +206,7 @@ def emit( body: AnyValue | None = None, attributes: _ExtendedAttributes | None = None, event_name: str | None = None, + exception: BaseException | None = None, ) -> None: ... @overload @@ -220,6 +227,7 @@ def emit( body: AnyValue | None = None, attributes: _ExtendedAttributes | None = None, event_name: str | None = None, + exception: BaseException | None = None, ) -> None: pass @@ -266,6 +274,7 @@ def emit( body: AnyValue | None = None, attributes: _ExtendedAttributes | None = None, event_name: str | None = None, + exception: BaseException | None = None, ) -> None: ... @overload @@ -286,6 +295,7 @@ def emit( body: AnyValue | None = None, attributes: _ExtendedAttributes | None = None, event_name: str | None = None, + exception: BaseException | None = None, ) -> None: if record: self._logger.emit(record) @@ -299,6 +309,7 @@ def emit( body=body, attributes=attributes, event_name=event_name, + exception=exception, ) diff --git a/opentelemetry-api/tests/logs/test_log_record.py b/opentelemetry-api/tests/logs/test_log_record.py index a06ed8dabf..da0f41e3b6 100644 --- a/opentelemetry-api/tests/logs/test_log_record.py +++ b/opentelemetry-api/tests/logs/test_log_record.py @@ -25,3 +25,8 @@ class TestLogRecord(unittest.TestCase): def test_log_record_observed_timestamp_default(self, time_ns_mock): # type: ignore time_ns_mock.return_value = OBSERVED_TIMESTAMP self.assertEqual(LogRecord().observed_timestamp, OBSERVED_TIMESTAMP) + + def test_log_record_exception(self): + exc = ValueError("boom") + log_record = LogRecord(exception=exc) + self.assertIs(log_record.exception, exc) diff --git a/opentelemetry-api/tests/logs/test_proxy.py b/opentelemetry-api/tests/logs/test_proxy.py index d72ccc7c6b..120908e7ff 100644 --- a/opentelemetry-api/tests/logs/test_proxy.py +++ b/opentelemetry-api/tests/logs/test_proxy.py @@ -46,6 +46,7 @@ def emit( body=None, attributes=None, event_name=None, + exception: typing.Optional[BaseException] = None, ) -> None: pass diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index d775dd4455..dc8bbf40da 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -482,6 +482,50 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: ) +def _get_exception_attributes( + exception: BaseException, +) -> dict[str, AnyValue]: + stacktrace = "".join( + traceback.format_exception( + type(exception), value=exception, tb=exception.__traceback__ + ) + ) + module = type(exception).__module__ + qualname = type(exception).__qualname__ + exception_type = ( + f"{module}.{qualname}" if module and module != "builtins" else qualname + ) + return { + exception_attributes.EXCEPTION_TYPE: exception_type, + exception_attributes.EXCEPTION_MESSAGE: str(exception), + exception_attributes.EXCEPTION_STACKTRACE: stacktrace, + } + + +def _apply_exception_attributes( + log_record: LogRecord, + exception: BaseException | None, +) -> None: + if exception is None: + return + + exception_attributes_map = _get_exception_attributes(exception) + attributes = log_record.attributes + if attributes: + if isinstance(attributes, BoundedAttributes): + for key, value in exception_attributes_map.items(): + if key not in attributes: + attributes[key] = value + return + merged = dict(attributes) + for key, value in exception_attributes_map.items(): + merged.setdefault(key, value) + log_record.attributes = merged + return + + log_record.attributes = exception_attributes_map + + class LoggingHandler(logging.Handler): """A handler class which writes logging records, in OTLP format, to a network destination or file. Supports signals from the `logging` module. @@ -628,13 +672,22 @@ def emit( body: AnyValue | None = None, attributes: _ExtendedAttributes | None = None, event_name: str | None = None, + exception: BaseException | None = None, ) -> None: """Emits the :class:`ReadWriteLogRecord` by setting instrumentation scope and forwarding to the processor. """ # If a record is provided, use it directly if record is not None: + record_exception = exception or getattr(record, "exception", None) + if record_exception is None and isinstance( + record, ReadWriteLogRecord + ): + record_exception = getattr( + record.log_record, "exception", None + ) if not isinstance(record, ReadWriteLogRecord): + _apply_exception_attributes(record, record_exception) # pylint:disable=protected-access writable_record = ReadWriteLogRecord._from_api_log_record( record=record, @@ -642,6 +695,9 @@ def emit( instrumentation_scope=self._instrumentation_scope, ) else: + _apply_exception_attributes( + record.log_record, record_exception + ) writable_record = record else: # Create a record from individual parameters @@ -654,7 +710,9 @@ def emit( body=body, attributes=attributes, event_name=event_name, + exception=exception, ) + _apply_exception_attributes(log_record, exception) # pylint:disable=protected-access writable_record = ReadWriteLogRecord._from_api_log_record( record=log_record, diff --git a/opentelemetry-sdk/tests/logs/test_logs.py b/opentelemetry-sdk/tests/logs/test_logs.py index 70811260ae..edf8e97e49 100644 --- a/opentelemetry-sdk/tests/logs/test_logs.py +++ b/opentelemetry-sdk/tests/logs/test_logs.py @@ -23,6 +23,7 @@ Logger, LoggerProvider, ReadableLogRecord, + ReadWriteLogRecord, ) from opentelemetry.sdk._logs._internal import ( NoOpLogger, @@ -31,6 +32,7 @@ from opentelemetry.sdk.environment_variables import OTEL_SDK_DISABLED from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.semconv.attributes import exception_attributes class TestLoggerProvider(unittest.TestCase): @@ -214,3 +216,65 @@ def test_can_emit_with_keywords_arguments(self): self.assertEqual(result_log_record.attributes, {"some": "attributes"}) self.assertEqual(result_log_record.event_name, "event_name") self.assertEqual(log_data.resource, logger.resource) + + def test_emit_with_exception_adds_attributes(self): + logger, log_record_processor_mock = self._get_logger() + exc = ValueError("boom") + + logger.emit(body="a log line", exception=exc) + log_record_processor_mock.on_emit.assert_called_once() + log_data = log_record_processor_mock.on_emit.call_args.args[0] + attributes = dict(log_data.log_record.attributes) + self.assertEqual( + attributes[exception_attributes.EXCEPTION_TYPE], "ValueError" + ) + self.assertEqual( + attributes[exception_attributes.EXCEPTION_MESSAGE], "boom" + ) + self.assertIn( + "ValueError: boom", + attributes[exception_attributes.EXCEPTION_STACKTRACE], + ) + + def test_emit_logrecord_exception_preserves_user_attributes(self): + logger, log_record_processor_mock = self._get_logger() + exc = ValueError("boom") + log_record = LogRecord( + observed_timestamp=0, + body="a log line", + attributes={exception_attributes.EXCEPTION_TYPE: "custom"}, + exception=exc, + ) + + logger.emit(log_record) + log_record_processor_mock.on_emit.assert_called_once() + log_data = log_record_processor_mock.on_emit.call_args.args[0] + attributes = dict(log_data.log_record.attributes) + self.assertEqual( + attributes[exception_attributes.EXCEPTION_TYPE], "custom" + ) + self.assertEqual( + attributes[exception_attributes.EXCEPTION_MESSAGE], "boom" + ) + + def test_emit_readwrite_logrecord_uses_exception(self): + logger, log_record_processor_mock = self._get_logger() + exc = RuntimeError("kaput") + log_record = LogRecord( + observed_timestamp=0, + body="a log line", + exception=exc, + ) + readwrite = ReadWriteLogRecord( + log_record=log_record, + resource=Resource.create({}), + instrumentation_scope=logger._instrumentation_scope, + ) + + logger.emit(readwrite) + log_record_processor_mock.on_emit.assert_called_once() + log_data = log_record_processor_mock.on_emit.call_args.args[0] + attributes = dict(log_data.log_record.attributes) + self.assertEqual( + attributes[exception_attributes.EXCEPTION_TYPE], "RuntimeError" + )