diff --git a/CHANGELOG.md b/CHANGELOG.md index 06469336811..aa54d05abb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ inject a `requests.Session` or `grpc.ChannelCredentials` object into OTLP export ([#4731](https://github.com/open-telemetry/opentelemetry-python/pull/4731)) - Performance: Cache `importlib_metadata.entry_points` ([#4735](https://github.com/open-telemetry/opentelemetry-python/pull/4735)) +- opentelemetry-sdk: fix calling Logger.emit with an API LogRecord instance + ([#4741](https://github.com/open-telemetry/opentelemetry-python/pull/4741)) ## Version 1.36.0/0.57b0 (2025-07-29) diff --git a/exporter/opentelemetry-exporter-otlp-proto-common/src/opentelemetry/exporter/otlp/proto/common/_internal/_log_encoder/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-common/src/opentelemetry/exporter/otlp/proto/common/_internal/_log_encoder/__init__.py index 000e56ed8bf..52cdd71aaed 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-common/src/opentelemetry/exporter/otlp/proto/common/_internal/_log_encoder/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-common/src/opentelemetry/exporter/otlp/proto/common/_internal/_log_encoder/__init__.py @@ -61,7 +61,9 @@ def _encode_log(log_data: LogData) -> PB2LogRecord: log_data.log_record.attributes, allow_null=True ), dropped_attributes_count=log_data.log_record.dropped_attributes, - severity_number=log_data.log_record.severity_number.value, + severity_number=getattr( + log_data.log_record.severity_number, "value", None + ), event_name=log_data.log_record.event_name, ) diff --git a/exporter/opentelemetry-exporter-otlp-proto-common/tests/test_log_encoder.py b/exporter/opentelemetry-exporter-otlp-proto-common/tests/test_log_encoder.py index 5407d9f1bca..44a859ba7b5 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-common/tests/test_log_encoder.py +++ b/exporter/opentelemetry-exporter-otlp-proto-common/tests/test_log_encoder.py @@ -89,6 +89,7 @@ def test_dropped_attributes_count(self): @staticmethod def _get_sdk_log_data() -> List[LogData]: + # pylint:disable=too-many-locals ctx_log1 = set_span_in_context( NonRecordingSpan( SpanContext( @@ -304,7 +305,29 @@ def _get_sdk_log_data() -> List[LogData]: "extended_name", "extended_version" ), ) - return [log1, log2, log3, log4, log5, log6, log7, log8] + + ctx_log9 = set_span_in_context( + NonRecordingSpan( + SpanContext( + 212592107417388365804938480559624925566, + 6077757853989569466, + False, + TraceFlags(0x01), + ) + ) + ) + log9 = LogData( + log_record=SDKLogRecord( + # these are otherwise set by default + observed_timestamp=1644650584292683045, + context=ctx_log9, + resource=SDKResource({}), + ), + instrumentation_scope=InstrumentationScope( + "empty_log_record_name", "empty_log_record_version" + ), + ) + return [log1, log2, log3, log4, log5, log6, log7, log8, log9] def get_test_logs( self, @@ -593,6 +616,29 @@ def get_test_logs( ), ], ), + PB2ScopeLogs( + scope=PB2InstrumentationScope( + name="empty_log_record_name", + version="empty_log_record_version", + ), + log_records=[ + PB2LogRecord( + time_unix_nano=None, + observed_time_unix_nano=1644650584292683045, + trace_id=_encode_trace_id( + 212592107417388365804938480559624925566 + ), + span_id=_encode_span_id( + 6077757853989569466, + ), + flags=int(TraceFlags(0x01)), + severity_text=None, + severity_number=None, + body=None, + attributes=None, + ), + ], + ), ], ), ] diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 505904839b8..8b17de1c348 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -306,7 +306,9 @@ def to_json(self, indent: int | None = 4) -> str: dict(self.attributes) if bool(self.attributes) else None ), "dropped_attributes": self.dropped_attributes, - "timestamp": ns_to_iso_str(self.timestamp), + "timestamp": ns_to_iso_str(self.timestamp) + if self.timestamp is not None + else None, "observed_timestamp": ns_to_iso_str(self.observed_timestamp), "trace_id": ( f"0x{format_trace_id(self.trace_id)}" @@ -335,6 +337,25 @@ def dropped_attributes(self) -> int: return attributes.dropped return 0 + @classmethod + def _from_api_log_record( + cls, *, record: APILogRecord, resource: Resource + ) -> LogRecord: + return cls( + 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=record.attributes, + event_name=record.event_name, + resource=resource, + ) + class LogData: """Readable LogRecord data plus associated InstrumentationLibrary.""" @@ -678,10 +699,15 @@ def __init__( def resource(self): return self._resource - def emit(self, record: LogRecord): + def emit(self, record: APILogRecord): """Emits the :class:`LogData` by associating :class:`LogRecord` and instrumentation info. """ + if not isinstance(record, LogRecord): + # pylint:disable=protected-access + record = LogRecord._from_api_log_record( + record=record, resource=self._resource + ) log_data = LogData(record, self._instrumentation_scope) self._multi_log_record_processor.on_emit(log_data) diff --git a/opentelemetry-sdk/tests/logs/test_log_record.py b/opentelemetry-sdk/tests/logs/test_log_record.py index dc9c0aab103..d5f2745d248 100644 --- a/opentelemetry-sdk/tests/logs/test_log_record.py +++ b/opentelemetry-sdk/tests/logs/test_log_record.py @@ -16,6 +16,7 @@ import unittest import warnings +from opentelemetry._logs import LogRecord as APILogRecord from opentelemetry._logs.severity import SeverityNumber from opentelemetry.attributes import BoundedAttributes from opentelemetry.context import get_current @@ -62,6 +63,16 @@ def test_log_record_to_json_serializes_severity_number_as_int(self): decoded = json.loads(actual.to_json()) self.assertEqual(SeverityNumber.WARN.value, decoded["severity_number"]) + def test_log_record_to_json_serializes_null_severity_number(self): + actual = LogRecord( + observed_timestamp=0, + body="a log line", + resource=Resource({"service.name": "foo"}), + ) + + decoded = json.loads(actual.to_json()) + self.assertEqual(None, decoded["timestamp"]) + def test_log_record_bounded_attributes(self): attr = {"key": "value"} @@ -172,3 +183,37 @@ def test_log_record_deprecated_init_warning(self): for _ in range(10): LogRecord(context=get_current()) self.assertEqual(len(cw), 0) + + # pylint:disable=protected-access + def test_log_record_from_api_log_record(self): + api_log_record = APILogRecord( + timestamp=1, + observed_timestamp=2, + context=get_current(), + trace_id=123, + span_id=456, + trace_flags=TraceFlags(0x01), + severity_text="WARN", + severity_number=SeverityNumber.WARN, + body="a log line", + attributes={"a": "b"}, + event_name="an.event", + ) + + resource = Resource.create({}) + record = LogRecord._from_api_log_record( + record=api_log_record, resource=resource + ) + + self.assertEqual(record.timestamp, 1) + self.assertEqual(record.observed_timestamp, 2) + self.assertEqual(record.context, get_current()) + self.assertEqual(record.trace_id, 123) + self.assertEqual(record.span_id, 456) + self.assertEqual(record.trace_flags, TraceFlags(0x01)) + self.assertEqual(record.severity_text, "WARN") + self.assertEqual(record.severity_number, SeverityNumber.WARN) + self.assertEqual(record.body, "a log line") + self.assertEqual(record.attributes, {"a": "b"}) + self.assertEqual(record.event_name, "an.event") + self.assertEqual(record.resource, resource) diff --git a/opentelemetry-sdk/tests/logs/test_logs.py b/opentelemetry-sdk/tests/logs/test_logs.py index 92daf4d40b3..33983c4f737 100644 --- a/opentelemetry-sdk/tests/logs/test_logs.py +++ b/opentelemetry-sdk/tests/logs/test_logs.py @@ -17,13 +17,15 @@ import unittest from unittest.mock import Mock, patch -from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry._logs import LogRecord as APILogRecord +from opentelemetry.sdk._logs import Logger, LoggerProvider, LogRecord from opentelemetry.sdk._logs._internal import ( NoOpLogger, SynchronousMultiLogRecordProcessor, ) from opentelemetry.sdk.environment_variables import OTEL_SDK_DISABLED from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope class TestLoggerProvider(unittest.TestCase): @@ -85,3 +87,43 @@ def test_logger_provider_init(self, resource_patch): ) ) self.assertIsNotNone(logger_provider._at_exit_handler) + + +class TestLogger(unittest.TestCase): + @staticmethod + def _get_logger(): + log_record_processor_mock = Mock() + logger = Logger( + resource=Resource.create({}), + multi_log_record_processor=log_record_processor_mock, + instrumentation_scope=InstrumentationScope( + "name", + "version", + "schema_url", + {"an": "attribute"}, + ), + ) + return logger, log_record_processor_mock + + def test_can_emit_logrecord(self): + logger, log_record_processor_mock = self._get_logger() + log_record = LogRecord( + observed_timestamp=0, + body="a log line", + ) + + 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] + self.assertTrue(isinstance(log_data.log_record, LogRecord)) + + def test_can_emit_api_logrecord(self): + logger, log_record_processor_mock = self._get_logger() + api_log_record = APILogRecord( + observed_timestamp=0, + body="a log line", + ) + logger.emit(api_log_record) + log_record_processor_mock.on_emit.assert_called_once() + log_data = log_record_processor_mock.on_emit.call_args.args[0] + self.assertTrue(isinstance(log_data.log_record, LogRecord))