Skip to content

Commit eb29875

Browse files
authored
Merge branch 'main' into patch-2
2 parents 8c5e5c7 + 6812da2 commit eb29875

File tree

4 files changed

+85
-43
lines changed

4 files changed

+85
-43
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
([#4299](https://github.com/open-telemetry/opentelemetry-python/pull/4299))
2020
- sdk: fix setting of process owner in ProcessResourceDetector
2121
([#4311](https://github.com/open-telemetry/opentelemetry-python/pull/4311))
22+
- sdk: fix serialization of logs severity_number field to int
23+
([#4324](https://github.com/open-telemetry/opentelemetry-python/pull/4324))
2224

2325
## Version 1.28.0/0.49b0 (2024-11-05)
2426

@@ -46,6 +48,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4648
([#4224](https://github.com/open-telemetry/opentelemetry-python/pull/4224))
4749
- Drop `OTEL_PYTHON_EXPERIMENTAL_DISABLE_PROMETHEUS_UNIT_NORMALIZATION` environment variable
4850
([#4217](https://github.com/open-telemetry/opentelemetry-python/pull/4217))
51+
- Improve compatibility with other logging libraries that override
52+
`LogRecord.getMessage()` in order to customize message formatting
53+
([#4216](https://github.com/open-telemetry/opentelemetry-python/pull/4216))
4954

5055
## Version 1.27.0/0.48b0 (2024-08-28)
5156

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

Lines changed: 19 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,9 @@ def to_json(self, indent=4) -> str:
221221
return json.dumps(
222222
{
223223
"body": self.body,
224-
"severity_number": repr(self.severity_number),
224+
"severity_number": self.severity_number.value
225+
if self.severity_number is not None
226+
else None,
225227
"severity_text": self.severity_text,
226228
"attributes": (
227229
dict(self.attributes) if bool(self.attributes) else None
@@ -438,6 +440,7 @@ def force_flush(self, timeout_millis: int = 30000) -> bool:
438440
"exc_text",
439441
"filename",
440442
"funcName",
443+
"getMessage",
441444
"message",
442445
"levelname",
443446
"levelno",
@@ -503,51 +506,26 @@ def _translate(self, record: logging.LogRecord) -> LogRecord:
503506
observered_timestamp = time_ns()
504507
span_context = get_current_span().get_span_context()
505508
attributes = self._get_attributes(record)
506-
# This comment is taken from GanyedeNil's PR #3343, I have redacted it
507-
# slightly for clarity:
508-
# According to the definition of the Body field type in the
509-
# OTel 1.22.0 Logs Data Model article, the Body field should be of
510-
# type 'any' and should not use the str method to directly translate
511-
# the msg. This is because str only converts non-text types into a
512-
# human-readable form, rather than a standard format, which leads to
513-
# the need for additional operations when collected through a log
514-
# collector.
515-
# Considering that he Body field should be of type 'any' and should not
516-
# use the str method but record.msg is also a string type, then the
517-
# difference is just the self.args formatting?
518-
# The primary consideration depends on the ultimate purpose of the log.
519-
# Converting the default log directly into a string is acceptable as it
520-
# will be required to be presented in a more readable format. However,
521-
# this approach might not be as "standard" when hoping to aggregate
522-
# logs and perform subsequent data analysis. In the context of log
523-
# extraction, it would be more appropriate for the msg to be
524-
# converted into JSON format or remain unchanged, as it will eventually
525-
# be transformed into JSON. If the final output JSON data contains a
526-
# structure that appears similar to JSON but is not, it may confuse
527-
# users. This is particularly true for operation and maintenance
528-
# personnel who need to deal with log data in various languages.
529-
# Where is the JSON converting occur? and what about when the msg
530-
# represents something else but JSON, the expected behavior change?
531-
# For the ConsoleLogExporter, it performs the to_json operation in
532-
# opentelemetry.sdk._logs._internal.export.ConsoleLogExporter.__init__,
533-
# so it can handle any type of input without problems. As for the
534-
# OTLPLogExporter, it also handles any type of input encoding in
535-
# _encode_log located in
536-
# opentelemetry.exporter.otlp.proto.common._internal._log_encoder.
537-
# Therefore, no extra operation is needed to support this change.
538-
# The only thing to consider is the users who have already been using
539-
# this SDK. If they upgrade the SDK after this change, they will need
540-
# to readjust their logging collection rules to adapt to the latest
541-
# output format. Therefore, this change is considered a breaking
542-
# change and needs to be upgraded at an appropriate time.
543509
severity_number = std_to_otel(record.levelno)
544510
if self.formatter:
545511
body = self.format(record)
546512
else:
547-
if isinstance(record.msg, str) and record.args:
548-
body = record.msg % record.args
549-
else:
513+
# `record.getMessage()` uses `record.msg` as a template to format
514+
# `record.args` into. There is a special case in `record.getMessage()`
515+
# where it will only attempt formatting if args are provided,
516+
# otherwise, it just stringifies `record.msg`.
517+
#
518+
# Since the OTLP body field has a type of 'any' and the logging module
519+
# is sometimes used in such a way that objects incorrectly end up
520+
# set as record.msg, in those cases we would like to bypass
521+
# `record.getMessage()` completely and set the body to the object
522+
# itself instead of its string representation.
523+
# For more background, see: https://github.com/open-telemetry/opentelemetry-python/pull/4216
524+
if not record.args and not isinstance(record.msg, str):
525+
# no args are provided so it's *mostly* safe to use the message template as the body
550526
body = record.msg
527+
else:
528+
body = record.getMessage()
551529

552530
# related to https://github.com/open-telemetry/opentelemetry-python/issues/3548
553531
# Severity Text = WARN as defined in https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#displaying-severity.

opentelemetry-sdk/tests/logs/test_export.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,52 @@ def test_simple_log_record_processor_different_msg_types(self):
230230
item.instrumentation_scope.name, "different_msg_types"
231231
)
232232

233+
def test_simple_log_record_processor_custom_single_obj(self):
234+
"""
235+
Tests that special-case handling for logging a single non-string object
236+
is correctly applied.
237+
"""
238+
exporter = InMemoryLogExporter()
239+
log_record_processor = BatchLogRecordProcessor(exporter)
240+
241+
provider = LoggerProvider()
242+
provider.add_log_record_processor(log_record_processor)
243+
244+
logger = logging.getLogger("single_obj")
245+
logger.addHandler(LoggingHandler(logger_provider=provider))
246+
247+
# NOTE: the behaviour of `record.getMessage` is detailed in the
248+
# `logging.Logger.debug` documentation:
249+
# > The msg is the message format string, and the args are the arguments
250+
# > which are merged into msg using the string formatting operator. [...]
251+
# > No % formatting operation is performed on msg when no args are supplied.
252+
253+
# This test uses the presence of '%s' in the first arg to determine if
254+
# formatting was applied
255+
256+
# string msg with no args - getMessage bypasses formatting and sets the string directly
257+
logger.warning("a string with a percent-s: %s")
258+
# string msg with args - getMessage formats args into the msg
259+
logger.warning("a string with a percent-s: %s", "and arg")
260+
# non-string msg with args - getMessage stringifies msg and formats args into it
261+
logger.warning(["a non-string with a percent-s", "%s"], "and arg")
262+
# non-string msg with no args:
263+
# - normally getMessage would stringify the object and bypass formatting
264+
# - SPECIAL CASE: bypass stringification as well to keep the raw object
265+
logger.warning(["a non-string with a percent-s", "%s"])
266+
log_record_processor.shutdown()
267+
268+
finished_logs = exporter.get_finished_logs()
269+
expected = [
270+
("a string with a percent-s: %s"),
271+
("a string with a percent-s: and arg"),
272+
("['a non-string with a percent-s', 'and arg']"),
273+
(["a non-string with a percent-s", "%s"]),
274+
]
275+
for emitted, expected in zip(finished_logs, expected):
276+
self.assertEqual(emitted.log_record.body, expected)
277+
self.assertEqual(emitted.instrumentation_scope.name, "single_obj")
278+
233279
def test_simple_log_record_processor_different_msg_types_with_formatter(
234280
self,
235281
):

opentelemetry-sdk/tests/logs/test_log_record.py

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

19+
from opentelemetry._logs.severity import SeverityNumber
1920
from opentelemetry.attributes import BoundedAttributes
2021
from opentelemetry.sdk._logs import (
2122
LogDroppedAttributesWarning,
@@ -30,7 +31,7 @@ def test_log_record_to_json(self):
3031
expected = json.dumps(
3132
{
3233
"body": "a log line",
33-
"severity_number": "None",
34+
"severity_number": None,
3435
"severity_text": None,
3536
"attributes": None,
3637
"dropped_attributes": 0,
@@ -56,9 +57,21 @@ def test_log_record_to_json(self):
5657
self.assertEqual(expected, actual.to_json(indent=4))
5758
self.assertEqual(
5859
actual.to_json(indent=None),
59-
'{"body": "a log line", "severity_number": "None", "severity_text": null, "attributes": null, "dropped_attributes": 0, "timestamp": "1970-01-01T00:00:00.000000Z", "observed_timestamp": "1970-01-01T00:00:00.000000Z", "trace_id": "", "span_id": "", "trace_flags": null, "resource": {"attributes": {"service.name": "foo"}, "schema_url": ""}}',
60+
'{"body": "a log line", "severity_number": null, "severity_text": null, "attributes": null, "dropped_attributes": 0, "timestamp": "1970-01-01T00:00:00.000000Z", "observed_timestamp": "1970-01-01T00:00:00.000000Z", "trace_id": "", "span_id": "", "trace_flags": null, "resource": {"attributes": {"service.name": "foo"}, "schema_url": ""}}',
6061
)
6162

63+
def test_log_record_to_json_serializes_severity_number_as_int(self):
64+
actual = LogRecord(
65+
timestamp=0,
66+
severity_number=SeverityNumber.WARN,
67+
observed_timestamp=0,
68+
body="a log line",
69+
resource=Resource({"service.name": "foo"}),
70+
)
71+
72+
decoded = json.loads(actual.to_json())
73+
self.assertEqual(SeverityNumber.WARN.value, decoded["severity_number"])
74+
6275
def test_log_record_bounded_attributes(self):
6376
attr = {"key": "value"}
6477

0 commit comments

Comments
 (0)