Skip to content

Commit 9c21864

Browse files
committed
Add logger exception support for logs API/SDK
Signed-off-by: Israel Blancas <iblancasa@gmail.com>
1 parent 253e1c7 commit 9c21864

6 files changed

Lines changed: 141 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212

1313
## Unreleased
1414

15+
- logs: add exception support to Logger emit and LogRecord attributes
16+
([#4907](https://github.com/open-telemetry/opentelemetry-python/issues/4907))
1517
- `opentelemetry-exporter-otlp-proto-grpc`: Fix re-initialization of gRPC channel on UNAVAILABLE error
1618
([#4825](https://github.com/open-telemetry/opentelemetry-python/pull/4825))
1719
- `opentelemetry-exporter-prometheus`: Fix duplicate HELP/TYPE declarations for metrics with different label sets

opentelemetry-api/src/opentelemetry/_logs/_internal/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def __init__(
7676
body: AnyValue = None,
7777
attributes: Optional[_ExtendedAttributes] = None,
7878
event_name: Optional[str] = None,
79+
exception: Optional[BaseException] = None,
7980
) -> None: ...
8081

8182
@overload
@@ -94,6 +95,7 @@ def __init__(
9495
severity_number: Optional[SeverityNumber] = None,
9596
body: AnyValue = None,
9697
attributes: Optional[_ExtendedAttributes] = None,
98+
exception: Optional[BaseException] = None,
9799
) -> None: ...
98100

99101
def __init__(
@@ -110,6 +112,7 @@ def __init__(
110112
body: AnyValue = None,
111113
attributes: Optional[_ExtendedAttributes] = None,
112114
event_name: Optional[str] = None,
115+
exception: Optional[BaseException] = None,
113116
) -> None:
114117
if not context:
115118
context = get_current()
@@ -127,6 +130,7 @@ def __init__(
127130
self.body = body
128131
self.attributes = attributes
129132
self.event_name = event_name
133+
self.exception = exception
130134

131135

132136
class Logger(ABC):
@@ -157,6 +161,7 @@ def emit(
157161
body: AnyValue | None = None,
158162
attributes: _ExtendedAttributes | None = None,
159163
event_name: str | None = None,
164+
exception: BaseException | None = None,
160165
) -> None: ...
161166

162167
@overload
@@ -178,6 +183,7 @@ def emit(
178183
body: AnyValue | None = None,
179184
attributes: _ExtendedAttributes | None = None,
180185
event_name: str | None = None,
186+
exception: BaseException | None = None,
181187
) -> None:
182188
"""Emits a :class:`LogRecord` representing a log to the processing pipeline."""
183189

@@ -200,6 +206,7 @@ def emit(
200206
body: AnyValue | None = None,
201207
attributes: _ExtendedAttributes | None = None,
202208
event_name: str | None = None,
209+
exception: BaseException | None = None,
203210
) -> None: ...
204211

205212
@overload
@@ -220,6 +227,7 @@ def emit(
220227
body: AnyValue | None = None,
221228
attributes: _ExtendedAttributes | None = None,
222229
event_name: str | None = None,
230+
exception: BaseException | None = None,
223231
) -> None:
224232
pass
225233

@@ -266,6 +274,7 @@ def emit(
266274
body: AnyValue | None = None,
267275
attributes: _ExtendedAttributes | None = None,
268276
event_name: str | None = None,
277+
exception: BaseException | None = None,
269278
) -> None: ...
270279

271280
@overload
@@ -286,6 +295,7 @@ def emit(
286295
body: AnyValue | None = None,
287296
attributes: _ExtendedAttributes | None = None,
288297
event_name: str | None = None,
298+
exception: BaseException | None = None,
289299
) -> None:
290300
if record:
291301
self._logger.emit(record)
@@ -299,6 +309,7 @@ def emit(
299309
body=body,
300310
attributes=attributes,
301311
event_name=event_name,
312+
exception=exception,
302313
)
303314

304315

opentelemetry-api/tests/logs/test_log_record.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,8 @@ class TestLogRecord(unittest.TestCase):
2525
def test_log_record_observed_timestamp_default(self, time_ns_mock): # type: ignore
2626
time_ns_mock.return_value = OBSERVED_TIMESTAMP
2727
self.assertEqual(LogRecord().observed_timestamp, OBSERVED_TIMESTAMP)
28+
29+
def test_log_record_exception(self):
30+
exc = ValueError("boom")
31+
log_record = LogRecord(exception=exc)
32+
self.assertIs(log_record.exception, exc)

opentelemetry-api/tests/logs/test_proxy.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def emit(
4646
body=None,
4747
attributes=None,
4848
event_name=None,
49+
exception: typing.Optional[BaseException] = None,
4950
) -> None:
5051
pass
5152

opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,50 @@ def force_flush(self, timeout_millis: int = 30000) -> bool:
482482
)
483483

484484

485+
def _get_exception_attributes(
486+
exception: BaseException,
487+
) -> dict[str, AnyValue]:
488+
stacktrace = "".join(
489+
traceback.format_exception(
490+
type(exception), value=exception, tb=exception.__traceback__
491+
)
492+
)
493+
module = type(exception).__module__
494+
qualname = type(exception).__qualname__
495+
exception_type = (
496+
f"{module}.{qualname}" if module and module != "builtins" else qualname
497+
)
498+
return {
499+
exception_attributes.EXCEPTION_TYPE: exception_type,
500+
exception_attributes.EXCEPTION_MESSAGE: str(exception),
501+
exception_attributes.EXCEPTION_STACKTRACE: stacktrace,
502+
}
503+
504+
505+
def _apply_exception_attributes(
506+
log_record: LogRecord,
507+
exception: BaseException | None,
508+
) -> None:
509+
if exception is None:
510+
return
511+
512+
exception_attributes_map = _get_exception_attributes(exception)
513+
attributes = log_record.attributes
514+
if attributes:
515+
if isinstance(attributes, BoundedAttributes):
516+
for key, value in exception_attributes_map.items():
517+
if key not in attributes:
518+
attributes[key] = value
519+
return
520+
merged = dict(attributes)
521+
for key, value in exception_attributes_map.items():
522+
merged.setdefault(key, value)
523+
log_record.attributes = merged
524+
return
525+
526+
log_record.attributes = exception_attributes_map
527+
528+
485529
class LoggingHandler(logging.Handler):
486530
"""A handler class which writes logging records, in OTLP format, to
487531
a network destination or file. Supports signals from the `logging` module.
@@ -628,20 +672,32 @@ def emit(
628672
body: AnyValue | None = None,
629673
attributes: _ExtendedAttributes | None = None,
630674
event_name: str | None = None,
675+
exception: BaseException | None = None,
631676
) -> None:
632677
"""Emits the :class:`ReadWriteLogRecord` by setting instrumentation scope
633678
and forwarding to the processor.
634679
"""
635680
# If a record is provided, use it directly
636681
if record is not None:
682+
record_exception = exception or getattr(record, "exception", None)
683+
if record_exception is None and isinstance(
684+
record, ReadWriteLogRecord
685+
):
686+
record_exception = getattr(
687+
record.log_record, "exception", None
688+
)
637689
if not isinstance(record, ReadWriteLogRecord):
690+
_apply_exception_attributes(record, record_exception)
638691
# pylint:disable=protected-access
639692
writable_record = ReadWriteLogRecord._from_api_log_record(
640693
record=record,
641694
resource=self._resource,
642695
instrumentation_scope=self._instrumentation_scope,
643696
)
644697
else:
698+
_apply_exception_attributes(
699+
record.log_record, record_exception
700+
)
645701
writable_record = record
646702
else:
647703
# Create a record from individual parameters
@@ -654,7 +710,9 @@ def emit(
654710
body=body,
655711
attributes=attributes,
656712
event_name=event_name,
713+
exception=exception,
657714
)
715+
_apply_exception_attributes(log_record, exception)
658716
# pylint:disable=protected-access
659717
writable_record = ReadWriteLogRecord._from_api_log_record(
660718
record=log_record,

opentelemetry-sdk/tests/logs/test_logs.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
Logger,
2424
LoggerProvider,
2525
ReadableLogRecord,
26+
ReadWriteLogRecord,
2627
)
2728
from opentelemetry.sdk._logs._internal import (
2829
NoOpLogger,
@@ -31,6 +32,7 @@
3132
from opentelemetry.sdk.environment_variables import OTEL_SDK_DISABLED
3233
from opentelemetry.sdk.resources import Resource
3334
from opentelemetry.sdk.util.instrumentation import InstrumentationScope
35+
from opentelemetry.semconv.attributes import exception_attributes
3436

3537

3638
class TestLoggerProvider(unittest.TestCase):
@@ -214,3 +216,65 @@ def test_can_emit_with_keywords_arguments(self):
214216
self.assertEqual(result_log_record.attributes, {"some": "attributes"})
215217
self.assertEqual(result_log_record.event_name, "event_name")
216218
self.assertEqual(log_data.resource, logger.resource)
219+
220+
def test_emit_with_exception_adds_attributes(self):
221+
logger, log_record_processor_mock = self._get_logger()
222+
exc = ValueError("boom")
223+
224+
logger.emit(body="a log line", exception=exc)
225+
log_record_processor_mock.on_emit.assert_called_once()
226+
log_data = log_record_processor_mock.on_emit.call_args.args[0]
227+
attributes = dict(log_data.log_record.attributes)
228+
self.assertEqual(
229+
attributes[exception_attributes.EXCEPTION_TYPE], "ValueError"
230+
)
231+
self.assertEqual(
232+
attributes[exception_attributes.EXCEPTION_MESSAGE], "boom"
233+
)
234+
self.assertIn(
235+
"ValueError: boom",
236+
attributes[exception_attributes.EXCEPTION_STACKTRACE],
237+
)
238+
239+
def test_emit_logrecord_exception_preserves_user_attributes(self):
240+
logger, log_record_processor_mock = self._get_logger()
241+
exc = ValueError("boom")
242+
log_record = LogRecord(
243+
observed_timestamp=0,
244+
body="a log line",
245+
attributes={exception_attributes.EXCEPTION_TYPE: "custom"},
246+
exception=exc,
247+
)
248+
249+
logger.emit(log_record)
250+
log_record_processor_mock.on_emit.assert_called_once()
251+
log_data = log_record_processor_mock.on_emit.call_args.args[0]
252+
attributes = dict(log_data.log_record.attributes)
253+
self.assertEqual(
254+
attributes[exception_attributes.EXCEPTION_TYPE], "custom"
255+
)
256+
self.assertEqual(
257+
attributes[exception_attributes.EXCEPTION_MESSAGE], "boom"
258+
)
259+
260+
def test_emit_readwrite_logrecord_uses_exception(self):
261+
logger, log_record_processor_mock = self._get_logger()
262+
exc = RuntimeError("kaput")
263+
log_record = LogRecord(
264+
observed_timestamp=0,
265+
body="a log line",
266+
exception=exc,
267+
)
268+
readwrite = ReadWriteLogRecord(
269+
log_record=log_record,
270+
resource=Resource.create({}),
271+
instrumentation_scope=logger._instrumentation_scope,
272+
)
273+
274+
logger.emit(readwrite)
275+
log_record_processor_mock.on_emit.assert_called_once()
276+
log_data = log_record_processor_mock.on_emit.call_args.args[0]
277+
attributes = dict(log_data.log_record.attributes)
278+
self.assertEqual(
279+
attributes[exception_attributes.EXCEPTION_TYPE], "RuntimeError"
280+
)

0 commit comments

Comments
 (0)