From 9c21864fb01bc2519d8de2711e12fd4d82fd7ebe Mon Sep 17 00:00:00 2001 From: Israel Blancas Date: Wed, 11 Feb 2026 18:40:43 +0100 Subject: [PATCH 1/9] Add logger exception support for logs API/SDK Signed-off-by: Israel Blancas --- CHANGELOG.md | 2 + .../opentelemetry/_logs/_internal/__init__.py | 11 ++++ .../tests/logs/test_log_record.py | 5 ++ opentelemetry-api/tests/logs/test_proxy.py | 1 + .../sdk/_logs/_internal/__init__.py | 58 +++++++++++++++++ opentelemetry-sdk/tests/logs/test_logs.py | 64 +++++++++++++++++++ 6 files changed, 141 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f125b47e997..970a2a59ec4 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 bbcfcddc846..dc3e351ac80 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 a06ed8dabfc..da0f41e3b6b 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 d72ccc7c6b2..120908e7ff0 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 d775dd44555..dc8bbf40daa 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 70811260ae4..edf8e97e49f 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" + ) From 9ca005dfab02fca447dfa0282e0d3c4fb36019e6 Mon Sep 17 00:00:00 2001 From: Israel Blancas Date: Thu, 12 Mar 2026 16:26:16 +0100 Subject: [PATCH 2/9] Apply changes requested in code review Signed-off-by: Israel Blancas --- .../opentelemetry/_logs/_internal/__init__.py | 3 +- opentelemetry-api/tests/logs/test_proxy.py | 13 ++++ .../sdk/_logs/_internal/__init__.py | 74 +++++++++++++------ opentelemetry-sdk/tests/logs/test_logs.py | 45 +++++++++++ 4 files changed, 111 insertions(+), 24 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py b/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py index dc3e351ac80..b437c1755cb 100644 --- a/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py +++ b/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py @@ -95,7 +95,6 @@ def __init__( severity_number: Optional[SeverityNumber] = None, body: AnyValue = None, attributes: Optional[_ExtendedAttributes] = None, - exception: Optional[BaseException] = None, ) -> None: ... def __init__( @@ -298,7 +297,7 @@ def emit( exception: BaseException | None = None, ) -> None: if record: - self._logger.emit(record) + self._logger.emit(record, exception=exception) else: self._logger.emit( timestamp=timestamp, diff --git a/opentelemetry-api/tests/logs/test_proxy.py b/opentelemetry-api/tests/logs/test_proxy.py index 120908e7ff0..3f6a723cbaa 100644 --- a/opentelemetry-api/tests/logs/test_proxy.py +++ b/opentelemetry-api/tests/logs/test_proxy.py @@ -15,6 +15,7 @@ # pylint: disable=W0212,W0222,W0221 import typing import unittest +from unittest.mock import Mock import opentelemetry._logs._internal as _logs_internal from opentelemetry import _logs @@ -75,3 +76,15 @@ def test_proxy_logger(self): # references to the old provider still work but return real logger now real_logger = provider.get_logger("proxy-test") self.assertIsInstance(real_logger, LoggerTest) + + def test_proxy_logger_forwards_exception_with_record(self): + logger = _logs_internal.ProxyLogger("proxy-test") + logger._real_logger = Mock(spec=LoggerTest("proxy-test")) + record = _logs.LogRecord() + exception = ValueError("boom") + + logger.emit(record, exception=exception) + + logger._real_logger.emit.assert_called_once_with( + record, exception=exception + ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 71581a58cfa..6794a435c80 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -534,28 +534,50 @@ def _get_exception_attributes( } -def _apply_exception_attributes( - log_record: LogRecord, +def _get_attributes_with_exception( + attributes: _ExtendedAttributes | None, exception: BaseException | None, -) -> None: +) -> _ExtendedAttributes | None: if exception is None: - return + return attributes 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) + attributes = attributes or {} + if isinstance(attributes, BoundedAttributes): + merged = BoundedAttributes( + maxlen=attributes.maxlen, + attributes=attributes, + immutable=False, + max_value_len=attributes.max_value_len, + extended_attributes=attributes._extended_attributes, # pylint: disable=protected-access + ) + merged.dropped = attributes.dropped for key, value in exception_attributes_map.items(): - merged.setdefault(key, value) - log_record.attributes = merged - return - - log_record.attributes = exception_attributes_map + if key not in merged: + merged[key] = value + return merged + + return exception_attributes_map | dict(attributes) + + +def _copy_log_record( + record: LogRecord, + attributes: _ExtendedAttributes | None, +) -> LogRecord: + return LogRecord( + timestamp=record.timestamp, + observed_timestamp=record.observed_timestamp, + context=record.context, + trace_id=record.trace_id, + span_id=record.span_id, + trace_flags=record.trace_flags, + severity_text=record.severity_text, + severity_number=record.severity_number, + body=record.body, + attributes=attributes, + event_name=record.event_name, + exception=getattr(record, "exception", None), + ) class LoggingHandler(logging.Handler): @@ -725,7 +747,13 @@ def emit( record.log_record, "exception", None ) if not isinstance(record, ReadWriteLogRecord): - _apply_exception_attributes(record, record_exception) + if record_exception is not None: + record = _copy_log_record( + record, + _get_attributes_with_exception( + record.attributes, record_exception + ), + ) # pylint:disable=protected-access writable_record = ReadWriteLogRecord._from_api_log_record( record=record, @@ -733,12 +761,15 @@ def emit( instrumentation_scope=self._instrumentation_scope, ) else: - _apply_exception_attributes( - record.log_record, record_exception + record.log_record.attributes = _get_attributes_with_exception( + record.log_record.attributes, record_exception ) writable_record = record else: # Create a record from individual parameters + log_record_attributes = _get_attributes_with_exception( + attributes, exception + ) log_record = LogRecord( timestamp=timestamp, observed_timestamp=observed_timestamp, @@ -746,11 +777,10 @@ def emit( severity_number=severity_number, severity_text=severity_text, body=body, - attributes=attributes, + attributes=log_record_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 edf8e97e49f..957ba989109 100644 --- a/opentelemetry-sdk/tests/logs/test_logs.py +++ b/opentelemetry-sdk/tests/logs/test_logs.py @@ -18,6 +18,7 @@ from unittest.mock import Mock, patch from opentelemetry._logs import LogRecord, SeverityNumber +from opentelemetry.attributes import BoundedAttributes from opentelemetry.context import get_current from opentelemetry.sdk._logs import ( Logger, @@ -236,6 +237,22 @@ def test_emit_with_exception_adds_attributes(self): attributes[exception_attributes.EXCEPTION_STACKTRACE], ) + def test_emit_with_raised_exception_has_stacktrace(self): + logger, log_record_processor_mock = self._get_logger() + + try: + raise ValueError("boom") + except ValueError as exc: + logger.emit(body="error", exception=exc) + + log_record_processor_mock.on_emit.assert_called_once() + log_data = log_record_processor_mock.on_emit.call_args.args[0] + stacktrace = dict(log_data.log_record.attributes)[ + exception_attributes.EXCEPTION_STACKTRACE + ] + self.assertIn("Traceback (most recent call last)", stacktrace) + self.assertIn("raise ValueError", stacktrace) + def test_emit_logrecord_exception_preserves_user_attributes(self): logger, log_record_processor_mock = self._get_logger() exc = ValueError("boom") @@ -257,6 +274,34 @@ def test_emit_logrecord_exception_preserves_user_attributes(self): attributes[exception_attributes.EXCEPTION_MESSAGE], "boom" ) + def test_emit_logrecord_exception_with_immutable_attributes(self): + logger, log_record_processor_mock = self._get_logger() + exc = ValueError("boom") + original_attributes = BoundedAttributes( + attributes={"custom": "value"}, + immutable=True, + extended_attributes=True, + ) + log_record = LogRecord( + observed_timestamp=0, + body="a log line", + attributes=original_attributes, + exception=exc, + ) + + logger.emit(log_record) + + self.assertNotIn( + exception_attributes.EXCEPTION_TYPE, log_record.attributes + ) + 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["custom"], "value") + self.assertEqual( + attributes[exception_attributes.EXCEPTION_TYPE], "ValueError" + ) + def test_emit_readwrite_logrecord_uses_exception(self): logger, log_record_processor_mock = self._get_logger() exc = RuntimeError("kaput") From bf107968a355ae17abe2946a2f06f20a17fd7bdf Mon Sep 17 00:00:00 2001 From: Israel Blancas Date: Fri, 13 Mar 2026 11:15:44 +0100 Subject: [PATCH 3/9] Fix CI Signed-off-by: Israel Blancas --- .../opentelemetry/_logs/_internal/__init__.py | 6 ++++++ .../sdk/_logs/_internal/__init__.py | 21 ++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py b/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py index b437c1755cb..b7ee546508d 100644 --- a/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py +++ b/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py @@ -167,6 +167,8 @@ def emit( def emit( self, record: LogRecord, + *, + exception: BaseException | None = None, ) -> None: ... @abstractmethod @@ -212,6 +214,8 @@ def emit( def emit( # pylint:disable=arguments-differ self, record: LogRecord, + *, + exception: BaseException | None = None, ) -> None: ... def emit( @@ -280,6 +284,8 @@ def emit( def emit( # pylint:disable=arguments-differ self, record: LogRecord, + *, + exception: BaseException | None = None, ) -> None: ... def emit( diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 72625b8c349..54e94a933ca 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -544,22 +544,27 @@ def _get_attributes_with_exception( return attributes exception_attributes_map = _get_exception_attributes(exception) - attributes = attributes or {} - if isinstance(attributes, BoundedAttributes): + if attributes is None: + attributes_map: _ExtendedAttributes = {} + else: + attributes_map = attributes + + if isinstance(attributes_map, BoundedAttributes): + bounded_attributes = attributes_map merged = BoundedAttributes( - maxlen=attributes.maxlen, - attributes=attributes, + maxlen=bounded_attributes.maxlen, + attributes=bounded_attributes, immutable=False, - max_value_len=attributes.max_value_len, - extended_attributes=attributes._extended_attributes, # pylint: disable=protected-access + max_value_len=bounded_attributes.max_value_len, + extended_attributes=bounded_attributes._extended_attributes, # pylint: disable=protected-access ) - merged.dropped = attributes.dropped + merged.dropped = bounded_attributes.dropped for key, value in exception_attributes_map.items(): if key not in merged: merged[key] = value return merged - return exception_attributes_map | dict(attributes) + return exception_attributes_map | dict(attributes_map.items()) def _copy_log_record( From b1d7f0dd5a778b706ee039432c5ed44ca3ad5a55 Mon Sep 17 00:00:00 2001 From: Israel Blancas Date: Fri, 13 Mar 2026 11:27:40 +0100 Subject: [PATCH 4/9] Fix ci Signed-off-by: Israel Blancas --- .../src/opentelemetry/sdk/_logs/_internal/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 54e94a933ca..f48a6a93d02 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -571,13 +571,10 @@ def _copy_log_record( record: LogRecord, attributes: _ExtendedAttributes | None, ) -> LogRecord: - return LogRecord( + copied_record = LogRecord( timestamp=record.timestamp, observed_timestamp=record.observed_timestamp, context=record.context, - trace_id=record.trace_id, - span_id=record.span_id, - trace_flags=record.trace_flags, severity_text=record.severity_text, severity_number=record.severity_number, body=record.body, @@ -585,6 +582,10 @@ def _copy_log_record( event_name=record.event_name, exception=getattr(record, "exception", None), ) + copied_record.trace_id = record.trace_id + copied_record.span_id = record.span_id + copied_record.trace_flags = record.trace_flags + return copied_record class LoggingHandler(logging.Handler): From 0467ae7224c263ac5daa4e7a5e7e0ab2ef047444 Mon Sep 17 00:00:00 2001 From: Israel Blancas Date: Fri, 10 Apr 2026 18:20:08 +0200 Subject: [PATCH 5/9] Apply feedback from code review Signed-off-by: Israel Blancas --- .../src/opentelemetry/_logs/_internal/__init__.py | 8 +------- opentelemetry-api/tests/logs/test_proxy.py | 11 ++++------- .../opentelemetry/sdk/_logs/_internal/__init__.py | 13 +++---------- 3 files changed, 8 insertions(+), 24 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py b/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py index 6264b6eca4a..12a8cc08f88 100644 --- a/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py +++ b/opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py @@ -167,8 +167,6 @@ def emit( def emit( self, record: LogRecord, - *, - exception: BaseException | None = None, ) -> None: ... @abstractmethod @@ -214,8 +212,6 @@ def emit( def emit( # pylint:disable=arguments-differ self, record: LogRecord, - *, - exception: BaseException | None = None, ) -> None: ... def emit( @@ -284,8 +280,6 @@ def emit( def emit( # pylint:disable=arguments-differ self, record: LogRecord, - *, - exception: BaseException | None = None, ) -> None: ... def emit( @@ -303,7 +297,7 @@ def emit( exception: BaseException | None = None, ) -> None: if record: - self._logger.emit(record, exception=exception) + self._logger.emit(record) else: self._logger.emit( timestamp=timestamp, diff --git a/opentelemetry-api/tests/logs/test_proxy.py b/opentelemetry-api/tests/logs/test_proxy.py index f7744e5f368..1b5d0c22ce1 100644 --- a/opentelemetry-api/tests/logs/test_proxy.py +++ b/opentelemetry-api/tests/logs/test_proxy.py @@ -77,15 +77,12 @@ def test_proxy_logger(self): real_logger = provider.get_logger("proxy-test") self.assertIsInstance(real_logger, LoggerTest) - def test_proxy_logger_forwards_exception_with_record(self): + def test_proxy_logger_forwards_record_with_exception(self): logger = _logs_internal.ProxyLogger("proxy-test") logger._real_logger = Mock(spec=LoggerTest("proxy-test")) - record = _logs.LogRecord() - exception = ValueError("boom") + record = _logs.LogRecord(exception=ValueError("boom")) self.assertIsNotNone(logger._real_logger) - logger.emit(record, exception=exception) + logger.emit(record) - logger._real_logger.emit.assert_called_once_with( - record, exception=exception - ) + logger._real_logger.emit.assert_called_once_with(record) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 3633a308c2e..4ed043e6776 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -793,19 +793,12 @@ def emit( return # 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): - if record_exception is not None: + if record.exception is not None: record = _copy_log_record( record, _get_attributes_with_exception( - record.attributes, record_exception + record.attributes, record.exception ), ) # pylint:disable=protected-access @@ -816,7 +809,7 @@ def emit( ) else: record.log_record.attributes = _get_attributes_with_exception( - record.log_record.attributes, record_exception + record.log_record.attributes, record.log_record.exception ) writable_record = record else: From f58262f2ffae554cef256992b7d12f46137281e6 Mon Sep 17 00:00:00 2001 From: Israel Blancas Date: Mon, 13 Apr 2026 16:12:48 +0200 Subject: [PATCH 6/9] Fix lint Signed-off-by: Israel Blancas --- .../sdk/_logs/_internal/__init__.py | 93 ++------------- .../sdk/_logs/_internal/_exceptions.py | 107 ++++++++++++++++++ 2 files changed, 115 insertions(+), 85 deletions(-) create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_exceptions.py diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 4ed043e6776..720384c9279 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -52,6 +52,11 @@ from opentelemetry.context import get_current from opentelemetry.context.context import Context from opentelemetry.metrics import MeterProvider, get_meter_provider +from opentelemetry.sdk._logs._internal._exceptions import ( + _copy_log_record_with_exception, + _get_attributes_with_exception, + _set_log_record_exception_attributes, +) from opentelemetry.sdk._logs._internal._logger_metrics import LoggerMetrics from opentelemetry.sdk.environment_variables import ( OTEL_ATTRIBUTE_COUNT_LIMIT, @@ -529,79 +534,6 @@ 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 _get_attributes_with_exception( - attributes: _ExtendedAttributes | None, - exception: BaseException | None, -) -> _ExtendedAttributes | None: - if exception is None: - return attributes - - exception_attributes_map = _get_exception_attributes(exception) - if attributes is None: - attributes_map: _ExtendedAttributes = {} - else: - attributes_map = attributes - - if isinstance(attributes_map, BoundedAttributes): - bounded_attributes = attributes_map - merged = BoundedAttributes( - maxlen=bounded_attributes.maxlen, - attributes=bounded_attributes, - immutable=False, - max_value_len=bounded_attributes.max_value_len, - extended_attributes=bounded_attributes._extended_attributes, # pylint: disable=protected-access - ) - merged.dropped = bounded_attributes.dropped - for key, value in exception_attributes_map.items(): - if key not in merged: - merged[key] = value - return merged - - return exception_attributes_map | dict(attributes_map.items()) - - -def _copy_log_record( - record: LogRecord, - attributes: _ExtendedAttributes | None, -) -> LogRecord: - copied_record = LogRecord( - timestamp=record.timestamp, - observed_timestamp=record.observed_timestamp, - context=record.context, - severity_text=record.severity_text, - severity_number=record.severity_number, - body=record.body, - attributes=attributes, - event_name=record.event_name, - exception=getattr(record, "exception", None), - ) - copied_record.trace_id = record.trace_id - copied_record.span_id = record.span_id - copied_record.trace_flags = record.trace_flags - return copied_record - - 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. @@ -795,12 +727,7 @@ def emit( if record is not None: if not isinstance(record, ReadWriteLogRecord): if record.exception is not None: - record = _copy_log_record( - record, - _get_attributes_with_exception( - record.attributes, record.exception - ), - ) + record = _copy_log_record_with_exception(record) # pylint:disable=protected-access writable_record = ReadWriteLogRecord._from_api_log_record( record=record, @@ -808,15 +735,11 @@ def emit( instrumentation_scope=self._instrumentation_scope, ) else: - record.log_record.attributes = _get_attributes_with_exception( - record.log_record.attributes, record.log_record.exception - ) + _set_log_record_exception_attributes(record.log_record) writable_record = record else: # Create a record from individual parameters - log_record_attributes = _get_attributes_with_exception( - attributes, exception - ) + log_record_attributes = _get_attributes_with_exception(attributes, exception) log_record = LogRecord( timestamp=timestamp, observed_timestamp=observed_timestamp, diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_exceptions.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_exceptions.py new file mode 100644 index 00000000000..c4a0c62068c --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_exceptions.py @@ -0,0 +1,107 @@ +# 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. +from __future__ import annotations + +import traceback + +from opentelemetry._logs import LogRecord +from opentelemetry.attributes import BoundedAttributes +from opentelemetry.semconv.attributes import exception_attributes +from opentelemetry.util.types import AnyValue, _ExtendedAttributes + + +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 _get_attributes_with_exception( + attributes: _ExtendedAttributes | None, + exception: BaseException | None, +) -> _ExtendedAttributes | None: + if exception is None: + return attributes + + exception_attributes_map = _get_exception_attributes(exception) + if attributes is None: + attributes_map: _ExtendedAttributes = {} + else: + attributes_map = attributes + + if isinstance(attributes_map, BoundedAttributes): + bounded_attributes = attributes_map + merged = BoundedAttributes( + maxlen=bounded_attributes.maxlen, + attributes=bounded_attributes, + immutable=False, + max_value_len=bounded_attributes.max_value_len, + extended_attributes=bounded_attributes._extended_attributes, # pylint: disable=protected-access + ) + merged.dropped = bounded_attributes.dropped + for key, value in exception_attributes_map.items(): + if key not in merged: + merged[key] = value + return merged + + return exception_attributes_map | dict(attributes_map.items()) + + +def _copy_log_record( + record: LogRecord, + attributes: _ExtendedAttributes | None, +) -> LogRecord: + copied_record = LogRecord( + timestamp=record.timestamp, + observed_timestamp=record.observed_timestamp, + context=record.context, + severity_text=record.severity_text, + severity_number=record.severity_number, + body=record.body, + attributes=attributes, + event_name=record.event_name, + exception=getattr(record, "exception", None), + ) + copied_record.trace_id = record.trace_id + copied_record.span_id = record.span_id + copied_record.trace_flags = record.trace_flags + return copied_record + + +def _copy_log_record_with_exception(record: LogRecord) -> LogRecord: + return _copy_log_record( + record, + _get_attributes_with_exception(record.attributes, record.exception), + ) + + +def _set_log_record_exception_attributes(record: LogRecord) -> None: + record.attributes = _get_attributes_with_exception( + record.attributes, + record.exception, + ) From 29571ac373e2caf5d7589043d0c214b061e86daa Mon Sep 17 00:00:00 2001 From: Israel Blancas Date: Mon, 13 Apr 2026 16:15:14 +0200 Subject: [PATCH 7/9] Remove unrelated entry from changelog Signed-off-by: Israel Blancas --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e0cc88696..a76d3fab305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Version 1.41.0/0.62b0 (2026-04-09) -- Enabled the flake8-tidy-import plugins rules for the ruff linter. These rules throw warnings for relative imports in the modules. - `opentelemetry-sdk`: Add `host` resource detector support to declarative file configuration via `detection_development.detectors[].host` ([#5002](https://github.com/open-telemetry/opentelemetry-python/pull/5002)) - `opentelemetry-sdk`: Add `container` resource detector support to declarative file configuration via `detection_development.detectors[].container`, using entry point loading of the `opentelemetry-resource-detector-containerid` contrib package From 8850d1e717bb0701eaba6e4ff2e9f7a07360280e Mon Sep 17 00:00:00 2001 From: Israel Blancas Date: Mon, 13 Apr 2026 16:33:31 +0200 Subject: [PATCH 8/9] Fix lint Signed-off-by: Israel Blancas --- .../sdk/_logs/_internal/__init__.py | 8 +++--- .../sdk/_logs/_internal/_exceptions.py | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 720384c9279..9978164cc2b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -53,8 +53,8 @@ from opentelemetry.context.context import Context from opentelemetry.metrics import MeterProvider, get_meter_provider from opentelemetry.sdk._logs._internal._exceptions import ( + _create_log_record_with_exception, _copy_log_record_with_exception, - _get_attributes_with_exception, _set_log_record_exception_attributes, ) from opentelemetry.sdk._logs._internal._logger_metrics import LoggerMetrics @@ -534,6 +534,7 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: ) ) + 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. @@ -739,15 +740,14 @@ def emit( writable_record = record else: # Create a record from individual parameters - log_record_attributes = _get_attributes_with_exception(attributes, exception) - log_record = LogRecord( + log_record = _create_log_record_with_exception( timestamp=timestamp, observed_timestamp=observed_timestamp, context=context, severity_number=severity_number, severity_text=severity_text, body=body, - attributes=log_record_attributes, + attributes=attributes, event_name=event_name, exception=exception, ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_exceptions.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_exceptions.py index c4a0c62068c..4f3fdabec41 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_exceptions.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/_exceptions.py @@ -105,3 +105,28 @@ def _set_log_record_exception_attributes(record: LogRecord) -> None: record.attributes, record.exception, ) + + +def _create_log_record_with_exception( + *, + timestamp: int | None = None, + observed_timestamp: int | None = None, + context=None, + severity_number=None, + severity_text: str | None = None, + body: AnyValue | None = None, + attributes: _ExtendedAttributes | None = None, + event_name: str | None = None, + exception: BaseException | None = None, +) -> LogRecord: + return LogRecord( + timestamp=timestamp, + observed_timestamp=observed_timestamp, + context=context, + severity_number=severity_number, + severity_text=severity_text, + body=body, + attributes=_get_attributes_with_exception(attributes, exception), + event_name=event_name, + exception=exception, + ) From ec9e84259198cb65593de9032fa7f283122bb1bf Mon Sep 17 00:00:00 2001 From: Israel Blancas Date: Mon, 13 Apr 2026 16:39:06 +0200 Subject: [PATCH 9/9] Fix lint Signed-off-by: Israel Blancas --- .../src/opentelemetry/sdk/_logs/_internal/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 9978164cc2b..956d9f28bd7 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -53,8 +53,8 @@ from opentelemetry.context.context import Context from opentelemetry.metrics import MeterProvider, get_meter_provider from opentelemetry.sdk._logs._internal._exceptions import ( - _create_log_record_with_exception, _copy_log_record_with_exception, + _create_log_record_with_exception, _set_log_record_exception_attributes, ) from opentelemetry.sdk._logs._internal._logger_metrics import LoggerMetrics