Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9c21864
Add logger exception support for logs API/SDK
iblancasa Feb 11, 2026
26ac99b
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
iblancasa Feb 17, 2026
d226158
Merge branch 'main' into 4907
iblancasa Feb 18, 2026
6581718
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
iblancasa Feb 23, 2026
0f2bcf0
Merge branch '4907' of github.com:iblancasa/opentelemetry-python into…
iblancasa Feb 23, 2026
a42e569
Merge branch 'main' into 4907
iblancasa Feb 24, 2026
b02a455
Merge branch 'main' into 4907
xrmx Mar 5, 2026
4003ef3
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
iblancasa Mar 10, 2026
bcafcc1
Merge branch '4907' of github.com:iblancasa/opentelemetry-python into…
iblancasa Mar 10, 2026
9ca005d
Apply changes requested in code review
iblancasa Mar 12, 2026
a4c169c
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
iblancasa Mar 12, 2026
bf10796
Fix CI
iblancasa Mar 13, 2026
9689d30
Merge branch 'main' into 4907
iblancasa Mar 13, 2026
b1d7f0d
Fix ci
iblancasa Mar 13, 2026
bd6ba38
Merge branch '4907' of github.com:iblancasa/opentelemetry-python into…
iblancasa Mar 13, 2026
819d015
Merge branch 'main' into 4907
iblancasa Mar 16, 2026
065460a
Merge branch 'main' into 4907
iblancasa Mar 17, 2026
c6ef81a
Merge branch 'main' into 4907
iblancasa Mar 18, 2026
c6b76ba
Merge branch 'main' into 4907
iblancasa Mar 23, 2026
3b45085
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
iblancasa Mar 30, 2026
3fe27bf
Merge branch '4907' of github.com:iblancasa/opentelemetry-python into…
iblancasa Mar 30, 2026
79b1406
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
iblancasa Mar 31, 2026
f961c23
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
iblancasa Apr 6, 2026
2b7556a
Merge branch 'main' into 4907
MikeGoldsmith Apr 8, 2026
6184425
Merge branch 'main' into 4907
xrmx Apr 9, 2026
d0b1933
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
iblancasa Apr 10, 2026
0467ae7
Apply feedback from code review
iblancasa Apr 10, 2026
f5df25f
Merge branch 'main' of github.com:open-telemetry/opentelemetry-python…
iblancasa Apr 13, 2026
f58262f
Fix lint
iblancasa Apr 13, 2026
fb48cca
Merge branch '4907' of github.com:iblancasa/opentelemetry-python into…
iblancasa Apr 13, 2026
29571ac
Remove unrelated entry from changelog
iblancasa Apr 13, 2026
8850d1e
Fix lint
iblancasa Apr 13, 2026
ec9e842
Fix lint
iblancasa Apr 13, 2026
3e6e7ec
Merge branch 'main' into 4907
xrmx Apr 13, 2026
35ab882
Merge branch 'main' into 4907
xrmx Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
- Fix intermittent CI failures in `getting-started` and `tracecontext` jobs caused by GitHub git CDN SHA propagation lag by installing contrib packages from the already-checked-out local copy instead of a second git clone
([#4958](https://github.com/open-telemetry/opentelemetry-python/pull/4958))
- `opentelemetry-sdk`: fix type annotations on `MetricReader` and related types
Expand Down
11 changes: 11 additions & 0 deletions opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def __init__(
body: AnyValue = None,
attributes: Optional[_ExtendedAttributes] = None,
event_name: Optional[str] = None,
exception: Optional[BaseException] = None,
) -> None: ...

@overload
Expand All @@ -94,6 +95,7 @@ def __init__(
severity_number: Optional[SeverityNumber] = None,
body: AnyValue = None,
attributes: Optional[_ExtendedAttributes] = None,
exception: Optional[BaseException] = None,
Comment thread
iblancasa marked this conversation as resolved.
Outdated
) -> None: ...

def __init__(
Expand All @@ -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()
Expand All @@ -127,6 +130,7 @@ def __init__(
self.body = body
self.attributes = attributes
self.event_name = event_name
self.exception = exception


class Logger(ABC):
Expand Down Expand Up @@ -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
Expand All @@ -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."""

Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -286,6 +295,7 @@ def emit(
body: AnyValue | None = None,
attributes: _ExtendedAttributes | None = None,
event_name: str | None = None,
exception: BaseException | None = None,
Comment thread
iblancasa marked this conversation as resolved.
) -> None:
if record:
self._logger.emit(record)
Expand All @@ -299,6 +309,7 @@ def emit(
body=body,
attributes=attributes,
event_name=event_name,
exception=exception,
)


Expand Down
5 changes: 5 additions & 0 deletions opentelemetry-api/tests/logs/test_log_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
1 change: 1 addition & 0 deletions opentelemetry-api/tests/logs/test_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def emit(
body=None,
attributes=None,
event_name=None,
exception: typing.Optional[BaseException] = None,
) -> None:
pass

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,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
Comment thread
iblancasa marked this conversation as resolved.
Outdated
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
Comment thread
iblancasa marked this conversation as resolved.
Outdated


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.
Expand Down Expand Up @@ -666,20 +710,32 @@ 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)
Comment thread
iblancasa marked this conversation as resolved.
Outdated
if record_exception is None and isinstance(
record, ReadWriteLogRecord
):
record_exception = getattr(
record.log_record, "exception", None
)
if not isinstance(record, ReadWriteLogRecord):
Comment thread
iblancasa marked this conversation as resolved.
_apply_exception_attributes(record, record_exception)
# pylint:disable=protected-access
writable_record = ReadWriteLogRecord._from_api_log_record(
record=record,
resource=self._resource,
instrumentation_scope=self._instrumentation_scope,
)
else:
_apply_exception_attributes(
record.log_record, record_exception
)
writable_record = record
else:
# Create a record from individual parameters
Expand All @@ -692,7 +748,9 @@ def emit(
body=body,
attributes=attributes,
event_name=event_name,
exception=exception,
)
_apply_exception_attributes(log_record, exception)
Comment thread
iblancasa marked this conversation as resolved.
Outdated
# pylint:disable=protected-access
writable_record = ReadWriteLogRecord._from_api_log_record(
record=log_record,
Expand Down
64 changes: 64 additions & 0 deletions opentelemetry-sdk/tests/logs/test_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
Logger,
LoggerProvider,
ReadableLogRecord,
ReadWriteLogRecord,
)
from opentelemetry.sdk._logs._internal import (
NoOpLogger,
Expand All @@ -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):
Expand Down Expand Up @@ -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],
Comment thread
iblancasa marked this conversation as resolved.
)

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"
)
Loading