Skip to content

Commit 22d1fd1

Browse files
authored
Fix API LogRecord serialization (#4741)
* opentelemetry-sdk: handle none timestamp in LogRecord serialization * opentelemetry-exporter-otlp-proto-common: fix exporting of empty log severity number * opentelemetry-sdk: convert an API LogRecord into a LogRecord in Logger.emit So that the layers below would not care about any difference. Fixes: Exception while exporting Log. Traceback (most recent call last): File "/home/rm/src/opentelemetry-python/opentelemetry-sdk/src/opentelemetry/sdk/_shared_internal/__init__.py", line 179, in _export self._exporter.export( File "/home/rm/src/opentelemetry-python/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/export/__init__.py", line 98, in export self.out.write(self.formatter(data.log_record)) File "/home/rm/src/opentelemetry-python/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/export/__init__.py", line 90, in <lambda> formatter: Callable[[LogRecord], str] = lambda record: record.to_json() AttributeError: 'LogRecord' object has no attribute 'to_json' * Add changelog * Fix forwarding of span and trace ids * Feedback * More feedback * Add the logger resource to the log record * Please pylint
1 parent d6c0441 commit 22d1fd1

File tree

6 files changed

+168
-5
lines changed

6 files changed

+168
-5
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ inject a `requests.Session` or `grpc.ChannelCredentials` object into OTLP export
2626
([#4731](https://github.com/open-telemetry/opentelemetry-python/pull/4731))
2727
- Performance: Cache `importlib_metadata.entry_points`
2828
([#4735](https://github.com/open-telemetry/opentelemetry-python/pull/4735))
29+
- opentelemetry-sdk: fix calling Logger.emit with an API LogRecord instance
30+
([#4741](https://github.com/open-telemetry/opentelemetry-python/pull/4741))
2931

3032
## Version 1.36.0/0.57b0 (2025-07-29)
3133

exporter/opentelemetry-exporter-otlp-proto-common/src/opentelemetry/exporter/otlp/proto/common/_internal/_log_encoder/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ def _encode_log(log_data: LogData) -> PB2LogRecord:
6161
log_data.log_record.attributes, allow_null=True
6262
),
6363
dropped_attributes_count=log_data.log_record.dropped_attributes,
64-
severity_number=log_data.log_record.severity_number.value,
64+
severity_number=getattr(
65+
log_data.log_record.severity_number, "value", None
66+
),
6567
event_name=log_data.log_record.event_name,
6668
)
6769

exporter/opentelemetry-exporter-otlp-proto-common/tests/test_log_encoder.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def test_dropped_attributes_count(self):
8989

9090
@staticmethod
9191
def _get_sdk_log_data() -> List[LogData]:
92+
# pylint:disable=too-many-locals
9293
ctx_log1 = set_span_in_context(
9394
NonRecordingSpan(
9495
SpanContext(
@@ -304,7 +305,29 @@ def _get_sdk_log_data() -> List[LogData]:
304305
"extended_name", "extended_version"
305306
),
306307
)
307-
return [log1, log2, log3, log4, log5, log6, log7, log8]
308+
309+
ctx_log9 = set_span_in_context(
310+
NonRecordingSpan(
311+
SpanContext(
312+
212592107417388365804938480559624925566,
313+
6077757853989569466,
314+
False,
315+
TraceFlags(0x01),
316+
)
317+
)
318+
)
319+
log9 = LogData(
320+
log_record=SDKLogRecord(
321+
# these are otherwise set by default
322+
observed_timestamp=1644650584292683045,
323+
context=ctx_log9,
324+
resource=SDKResource({}),
325+
),
326+
instrumentation_scope=InstrumentationScope(
327+
"empty_log_record_name", "empty_log_record_version"
328+
),
329+
)
330+
return [log1, log2, log3, log4, log5, log6, log7, log8, log9]
308331

309332
def get_test_logs(
310333
self,
@@ -593,6 +616,29 @@ def get_test_logs(
593616
),
594617
],
595618
),
619+
PB2ScopeLogs(
620+
scope=PB2InstrumentationScope(
621+
name="empty_log_record_name",
622+
version="empty_log_record_version",
623+
),
624+
log_records=[
625+
PB2LogRecord(
626+
time_unix_nano=None,
627+
observed_time_unix_nano=1644650584292683045,
628+
trace_id=_encode_trace_id(
629+
212592107417388365804938480559624925566
630+
),
631+
span_id=_encode_span_id(
632+
6077757853989569466,
633+
),
634+
flags=int(TraceFlags(0x01)),
635+
severity_text=None,
636+
severity_number=None,
637+
body=None,
638+
attributes=None,
639+
),
640+
],
641+
),
596642
],
597643
),
598644
]

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

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,9 @@ def to_json(self, indent: int | None = 4) -> str:
306306
dict(self.attributes) if bool(self.attributes) else None
307307
),
308308
"dropped_attributes": self.dropped_attributes,
309-
"timestamp": ns_to_iso_str(self.timestamp),
309+
"timestamp": ns_to_iso_str(self.timestamp)
310+
if self.timestamp is not None
311+
else None,
310312
"observed_timestamp": ns_to_iso_str(self.observed_timestamp),
311313
"trace_id": (
312314
f"0x{format_trace_id(self.trace_id)}"
@@ -335,6 +337,25 @@ def dropped_attributes(self) -> int:
335337
return attributes.dropped
336338
return 0
337339

340+
@classmethod
341+
def _from_api_log_record(
342+
cls, *, record: APILogRecord, resource: Resource
343+
) -> LogRecord:
344+
return cls(
345+
timestamp=record.timestamp,
346+
observed_timestamp=record.observed_timestamp,
347+
context=record.context,
348+
trace_id=record.trace_id,
349+
span_id=record.span_id,
350+
trace_flags=record.trace_flags,
351+
severity_text=record.severity_text,
352+
severity_number=record.severity_number,
353+
body=record.body,
354+
attributes=record.attributes,
355+
event_name=record.event_name,
356+
resource=resource,
357+
)
358+
338359

339360
class LogData:
340361
"""Readable LogRecord data plus associated InstrumentationLibrary."""
@@ -678,10 +699,15 @@ def __init__(
678699
def resource(self):
679700
return self._resource
680701

681-
def emit(self, record: LogRecord):
702+
def emit(self, record: APILogRecord):
682703
"""Emits the :class:`LogData` by associating :class:`LogRecord`
683704
and instrumentation info.
684705
"""
706+
if not isinstance(record, LogRecord):
707+
# pylint:disable=protected-access
708+
record = LogRecord._from_api_log_record(
709+
record=record, resource=self._resource
710+
)
685711
log_data = LogData(record, self._instrumentation_scope)
686712
self._multi_log_record_processor.on_emit(log_data)
687713

opentelemetry-sdk/tests/logs/test_log_record.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import unittest
1717
import warnings
1818

19+
from opentelemetry._logs import LogRecord as APILogRecord
1920
from opentelemetry._logs.severity import SeverityNumber
2021
from opentelemetry.attributes import BoundedAttributes
2122
from opentelemetry.context import get_current
@@ -62,6 +63,16 @@ def test_log_record_to_json_serializes_severity_number_as_int(self):
6263
decoded = json.loads(actual.to_json())
6364
self.assertEqual(SeverityNumber.WARN.value, decoded["severity_number"])
6465

66+
def test_log_record_to_json_serializes_null_severity_number(self):
67+
actual = LogRecord(
68+
observed_timestamp=0,
69+
body="a log line",
70+
resource=Resource({"service.name": "foo"}),
71+
)
72+
73+
decoded = json.loads(actual.to_json())
74+
self.assertEqual(None, decoded["timestamp"])
75+
6576
def test_log_record_bounded_attributes(self):
6677
attr = {"key": "value"}
6778

@@ -172,3 +183,37 @@ def test_log_record_deprecated_init_warning(self):
172183
for _ in range(10):
173184
LogRecord(context=get_current())
174185
self.assertEqual(len(cw), 0)
186+
187+
# pylint:disable=protected-access
188+
def test_log_record_from_api_log_record(self):
189+
api_log_record = APILogRecord(
190+
timestamp=1,
191+
observed_timestamp=2,
192+
context=get_current(),
193+
trace_id=123,
194+
span_id=456,
195+
trace_flags=TraceFlags(0x01),
196+
severity_text="WARN",
197+
severity_number=SeverityNumber.WARN,
198+
body="a log line",
199+
attributes={"a": "b"},
200+
event_name="an.event",
201+
)
202+
203+
resource = Resource.create({})
204+
record = LogRecord._from_api_log_record(
205+
record=api_log_record, resource=resource
206+
)
207+
208+
self.assertEqual(record.timestamp, 1)
209+
self.assertEqual(record.observed_timestamp, 2)
210+
self.assertEqual(record.context, get_current())
211+
self.assertEqual(record.trace_id, 123)
212+
self.assertEqual(record.span_id, 456)
213+
self.assertEqual(record.trace_flags, TraceFlags(0x01))
214+
self.assertEqual(record.severity_text, "WARN")
215+
self.assertEqual(record.severity_number, SeverityNumber.WARN)
216+
self.assertEqual(record.body, "a log line")
217+
self.assertEqual(record.attributes, {"a": "b"})
218+
self.assertEqual(record.event_name, "an.event")
219+
self.assertEqual(record.resource, resource)

opentelemetry-sdk/tests/logs/test_logs.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@
1717
import unittest
1818
from unittest.mock import Mock, patch
1919

20-
from opentelemetry.sdk._logs import LoggerProvider
20+
from opentelemetry._logs import LogRecord as APILogRecord
21+
from opentelemetry.sdk._logs import Logger, LoggerProvider, LogRecord
2122
from opentelemetry.sdk._logs._internal import (
2223
NoOpLogger,
2324
SynchronousMultiLogRecordProcessor,
2425
)
2526
from opentelemetry.sdk.environment_variables import OTEL_SDK_DISABLED
2627
from opentelemetry.sdk.resources import Resource
28+
from opentelemetry.sdk.util.instrumentation import InstrumentationScope
2729

2830

2931
class TestLoggerProvider(unittest.TestCase):
@@ -85,3 +87,43 @@ def test_logger_provider_init(self, resource_patch):
8587
)
8688
)
8789
self.assertIsNotNone(logger_provider._at_exit_handler)
90+
91+
92+
class TestLogger(unittest.TestCase):
93+
@staticmethod
94+
def _get_logger():
95+
log_record_processor_mock = Mock()
96+
logger = Logger(
97+
resource=Resource.create({}),
98+
multi_log_record_processor=log_record_processor_mock,
99+
instrumentation_scope=InstrumentationScope(
100+
"name",
101+
"version",
102+
"schema_url",
103+
{"an": "attribute"},
104+
),
105+
)
106+
return logger, log_record_processor_mock
107+
108+
def test_can_emit_logrecord(self):
109+
logger, log_record_processor_mock = self._get_logger()
110+
log_record = LogRecord(
111+
observed_timestamp=0,
112+
body="a log line",
113+
)
114+
115+
logger.emit(log_record)
116+
log_record_processor_mock.on_emit.assert_called_once()
117+
log_data = log_record_processor_mock.on_emit.call_args.args[0]
118+
self.assertTrue(isinstance(log_data.log_record, LogRecord))
119+
120+
def test_can_emit_api_logrecord(self):
121+
logger, log_record_processor_mock = self._get_logger()
122+
api_log_record = APILogRecord(
123+
observed_timestamp=0,
124+
body="a log line",
125+
)
126+
logger.emit(api_log_record)
127+
log_record_processor_mock.on_emit.assert_called_once()
128+
log_data = log_record_processor_mock.on_emit.call_args.args[0]
129+
self.assertTrue(isinstance(log_data.log_record, LogRecord))

0 commit comments

Comments
 (0)