Skip to content

Commit 0eedb27

Browse files
iblancasaxrmxMikeGoldsmith
authored
Add logger exception support for logs API/SDK (#4908)
* Add logger exception support for logs API/SDK Signed-off-by: Israel Blancas <iblancasa@gmail.com> * Apply changes requested in code review Signed-off-by: Israel Blancas <iblancasa@gmail.com> * Fix CI Signed-off-by: Israel Blancas <iblancasa@gmail.com> * Fix ci Signed-off-by: Israel Blancas <iblancasa@gmail.com> * Apply feedback from code review Signed-off-by: Israel Blancas <iblancasa@gmail.com> * Fix lint Signed-off-by: Israel Blancas <iblancasa@gmail.com> * Remove unrelated entry from changelog Signed-off-by: Israel Blancas <iblancasa@gmail.com> * Fix lint Signed-off-by: Israel Blancas <iblancasa@gmail.com> * Fix lint Signed-off-by: Israel Blancas <iblancasa@gmail.com> --------- Signed-off-by: Israel Blancas <iblancasa@gmail.com> Co-authored-by: Riccardo Magliocchetti <riccardo.magliocchetti@gmail.com> Co-authored-by: Mike Goldsmith <goldsmith.mike@gmail.com>
1 parent db59842 commit 0eedb27

File tree

7 files changed

+281
-1
lines changed

7 files changed

+281
-1
lines changed

CHANGELOG.md

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

1515
- `opentelemetry-sdk`: Add `service` resource detector support to declarative file configuration via `detection_development.detectors[].service`
1616
([#5003](https://github.com/open-telemetry/opentelemetry-python/pull/5003))
17+
- logs: add exception support to Logger emit and LogRecord attributes
18+
([#4907](https://github.com/open-telemetry/opentelemetry-python/issues/4907))
1719
- Drop Python 3.9 support
1820
([#5076](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/5076))
1921

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

Lines changed: 10 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
@@ -110,6 +111,7 @@ def __init__(
110111
body: AnyValue = None,
111112
attributes: Optional[_ExtendedAttributes] = None,
112113
event_name: Optional[str] = None,
114+
exception: Optional[BaseException] = None,
113115
) -> None:
114116
if not context:
115117
context = get_current()
@@ -127,6 +129,7 @@ def __init__(
127129
self.body = body
128130
self.attributes = attributes
129131
self.event_name = event_name
132+
self.exception = exception
130133

131134

132135
class Logger(ABC):
@@ -157,6 +160,7 @@ def emit(
157160
body: AnyValue | None = None,
158161
attributes: _ExtendedAttributes | None = None,
159162
event_name: str | None = None,
163+
exception: BaseException | None = None,
160164
) -> None: ...
161165

162166
@overload
@@ -178,6 +182,7 @@ def emit(
178182
body: AnyValue | None = None,
179183
attributes: _ExtendedAttributes | None = None,
180184
event_name: str | None = None,
185+
exception: BaseException | None = None,
181186
) -> None:
182187
"""Emits a :class:`LogRecord` representing a log to the processing pipeline."""
183188

@@ -200,6 +205,7 @@ def emit(
200205
body: AnyValue | None = None,
201206
attributes: _ExtendedAttributes | None = None,
202207
event_name: str | None = None,
208+
exception: BaseException | None = None,
203209
) -> None: ...
204210

205211
@overload
@@ -220,6 +226,7 @@ def emit(
220226
body: AnyValue | None = None,
221227
attributes: _ExtendedAttributes | None = None,
222228
event_name: str | None = None,
229+
exception: BaseException | None = None,
223230
) -> None:
224231
pass
225232

@@ -266,6 +273,7 @@ def emit(
266273
body: AnyValue | None = None,
267274
attributes: _ExtendedAttributes | None = None,
268275
event_name: str | None = None,
276+
exception: BaseException | None = None,
269277
) -> None: ...
270278

271279
@overload
@@ -286,6 +294,7 @@ def emit(
286294
body: AnyValue | None = None,
287295
attributes: _ExtendedAttributes | None = None,
288296
event_name: str | None = None,
297+
exception: BaseException | None = None,
289298
) -> None:
290299
if record:
291300
self._logger.emit(record)
@@ -299,6 +308,7 @@ def emit(
299308
body=body,
300309
attributes=attributes,
301310
event_name=event_name,
311+
exception=exception,
302312
)
303313

304314

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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# pylint: disable=W0212,W0222,W0221
1616
import typing
1717
import unittest
18+
from unittest.mock import Mock
1819

1920
import opentelemetry._logs._internal as _logs_internal
2021
from opentelemetry import _logs
@@ -46,6 +47,7 @@ def emit(
4647
body=None,
4748
attributes=None,
4849
event_name=None,
50+
exception: typing.Optional[BaseException] = None,
4951
) -> None:
5052
pass
5153

@@ -74,3 +76,13 @@ def test_proxy_logger(self):
7476
# references to the old provider still work but return real logger now
7577
real_logger = provider.get_logger("proxy-test")
7678
self.assertIsInstance(real_logger, LoggerTest)
79+
80+
def test_proxy_logger_forwards_record_with_exception(self):
81+
logger = _logs_internal.ProxyLogger("proxy-test")
82+
logger._real_logger = Mock(spec=LoggerTest("proxy-test"))
83+
record = _logs.LogRecord(exception=ValueError("boom"))
84+
85+
self.assertIsNotNone(logger._real_logger)
86+
logger.emit(record)
87+
88+
logger._real_logger.emit.assert_called_once_with(record)

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@
5252
from opentelemetry.context import get_current
5353
from opentelemetry.context.context import Context
5454
from opentelemetry.metrics import MeterProvider, get_meter_provider
55+
from opentelemetry.sdk._logs._internal._exceptions import (
56+
_copy_log_record_with_exception,
57+
_create_log_record_with_exception,
58+
_set_log_record_exception_attributes,
59+
)
5560
from opentelemetry.sdk._logs._internal._logger_metrics import LoggerMetrics
5661
from opentelemetry.sdk.environment_variables import (
5762
OTEL_ATTRIBUTE_COUNT_LIMIT,
@@ -712,6 +717,7 @@ def emit(
712717
body: AnyValue | None = None,
713718
attributes: _ExtendedAttributes | None = None,
714719
event_name: str | None = None,
720+
exception: BaseException | None = None,
715721
) -> None:
716722
"""Emits the :class:`ReadWriteLogRecord` by setting instrumentation scope
717723
and forwarding to the processor.
@@ -721,17 +727,20 @@ def emit(
721727
# If a record is provided, use it directly
722728
if record is not None:
723729
if not isinstance(record, ReadWriteLogRecord):
730+
if record.exception is not None:
731+
record = _copy_log_record_with_exception(record)
724732
# pylint:disable=protected-access
725733
writable_record = ReadWriteLogRecord._from_api_log_record(
726734
record=record,
727735
resource=self._resource,
728736
instrumentation_scope=self._instrumentation_scope,
729737
)
730738
else:
739+
_set_log_record_exception_attributes(record.log_record)
731740
writable_record = record
732741
else:
733742
# Create a record from individual parameters
734-
log_record = LogRecord(
743+
log_record = _create_log_record_with_exception(
735744
timestamp=timestamp,
736745
observed_timestamp=observed_timestamp,
737746
context=context,
@@ -740,6 +749,7 @@ def emit(
740749
body=body,
741750
attributes=attributes,
742751
event_name=event_name,
752+
exception=exception,
743753
)
744754
# pylint:disable=protected-access
745755
writable_record = ReadWriteLogRecord._from_api_log_record(
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from __future__ import annotations
15+
16+
import traceback
17+
18+
from opentelemetry._logs import LogRecord
19+
from opentelemetry.attributes import BoundedAttributes
20+
from opentelemetry.semconv.attributes import exception_attributes
21+
from opentelemetry.util.types import AnyValue, _ExtendedAttributes
22+
23+
24+
def _get_exception_attributes(
25+
exception: BaseException,
26+
) -> dict[str, AnyValue]:
27+
stacktrace = "".join(
28+
traceback.format_exception(
29+
type(exception), value=exception, tb=exception.__traceback__
30+
)
31+
)
32+
module = type(exception).__module__
33+
qualname = type(exception).__qualname__
34+
exception_type = (
35+
f"{module}.{qualname}" if module and module != "builtins" else qualname
36+
)
37+
return {
38+
exception_attributes.EXCEPTION_TYPE: exception_type,
39+
exception_attributes.EXCEPTION_MESSAGE: str(exception),
40+
exception_attributes.EXCEPTION_STACKTRACE: stacktrace,
41+
}
42+
43+
44+
def _get_attributes_with_exception(
45+
attributes: _ExtendedAttributes | None,
46+
exception: BaseException | None,
47+
) -> _ExtendedAttributes | None:
48+
if exception is None:
49+
return attributes
50+
51+
exception_attributes_map = _get_exception_attributes(exception)
52+
if attributes is None:
53+
attributes_map: _ExtendedAttributes = {}
54+
else:
55+
attributes_map = attributes
56+
57+
if isinstance(attributes_map, BoundedAttributes):
58+
bounded_attributes = attributes_map
59+
merged = BoundedAttributes(
60+
maxlen=bounded_attributes.maxlen,
61+
attributes=bounded_attributes,
62+
immutable=False,
63+
max_value_len=bounded_attributes.max_value_len,
64+
extended_attributes=bounded_attributes._extended_attributes, # pylint: disable=protected-access
65+
)
66+
merged.dropped = bounded_attributes.dropped
67+
for key, value in exception_attributes_map.items():
68+
if key not in merged:
69+
merged[key] = value
70+
return merged
71+
72+
return exception_attributes_map | dict(attributes_map.items())
73+
74+
75+
def _copy_log_record(
76+
record: LogRecord,
77+
attributes: _ExtendedAttributes | None,
78+
) -> LogRecord:
79+
copied_record = LogRecord(
80+
timestamp=record.timestamp,
81+
observed_timestamp=record.observed_timestamp,
82+
context=record.context,
83+
severity_text=record.severity_text,
84+
severity_number=record.severity_number,
85+
body=record.body,
86+
attributes=attributes,
87+
event_name=record.event_name,
88+
exception=getattr(record, "exception", None),
89+
)
90+
copied_record.trace_id = record.trace_id
91+
copied_record.span_id = record.span_id
92+
copied_record.trace_flags = record.trace_flags
93+
return copied_record
94+
95+
96+
def _copy_log_record_with_exception(record: LogRecord) -> LogRecord:
97+
return _copy_log_record(
98+
record,
99+
_get_attributes_with_exception(record.attributes, record.exception),
100+
)
101+
102+
103+
def _set_log_record_exception_attributes(record: LogRecord) -> None:
104+
record.attributes = _get_attributes_with_exception(
105+
record.attributes,
106+
record.exception,
107+
)
108+
109+
110+
def _create_log_record_with_exception(
111+
*,
112+
timestamp: int | None = None,
113+
observed_timestamp: int | None = None,
114+
context=None,
115+
severity_number=None,
116+
severity_text: str | None = None,
117+
body: AnyValue | None = None,
118+
attributes: _ExtendedAttributes | None = None,
119+
event_name: str | None = None,
120+
exception: BaseException | None = None,
121+
) -> LogRecord:
122+
return LogRecord(
123+
timestamp=timestamp,
124+
observed_timestamp=observed_timestamp,
125+
context=context,
126+
severity_number=severity_number,
127+
severity_text=severity_text,
128+
body=body,
129+
attributes=_get_attributes_with_exception(attributes, exception),
130+
event_name=event_name,
131+
exception=exception,
132+
)

0 commit comments

Comments
 (0)