Skip to content

Commit 82993a3

Browse files
a-recknagelArne Caratti
authored and
Arne Caratti
committed
changed the default otel-LogHandler to format the original logrecord before emitting it as the body, and retain all meaningful attributes of the original logrecord.
1 parent d270d41 commit 82993a3

File tree

4 files changed

+90
-80
lines changed

4 files changed

+90
-80
lines changed

README.md

+1-2
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,7 @@ this repository and perform an [editable
8080
install](https://pip.pypa.io/en/stable/reference/pip_install/#editable-installs):
8181

8282
```sh
83-
pip install -e ./opentelemetry-api
84-
pip install -e ./opentelemetry-sdk
83+
pip install -e ./opentelemetry-sdk/ -e ./opentelemetry-api/ -e ./opentelemetry-semantic-conventions/
8584
pip install -e ./instrumentation/opentelemetry-instrumentation-{instrumentation}
8685
```
8786

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

+12-73
Original file line numberDiff line numberDiff line change
@@ -402,37 +402,6 @@ def force_flush(self, timeout_millis: int = 30000) -> bool:
402402
return True
403403

404404

405-
# skip natural LogRecord attributes
406-
# http://docs.python.org/library/logging.html#logrecord-attributes
407-
_RESERVED_ATTRS = frozenset(
408-
(
409-
"asctime",
410-
"args",
411-
"created",
412-
"exc_info",
413-
"exc_text",
414-
"filename",
415-
"funcName",
416-
"message",
417-
"levelname",
418-
"levelno",
419-
"lineno",
420-
"module",
421-
"msecs",
422-
"msg",
423-
"name",
424-
"pathname",
425-
"process",
426-
"processName",
427-
"relativeCreated",
428-
"stack_info",
429-
"thread",
430-
"threadName",
431-
"taskName",
432-
)
433-
)
434-
435-
436405
class LoggingHandler(logging.Handler):
437406
"""A handler class which writes logging records, in OTLP format, to
438407
a network destination or file. Supports signals from the `logging` module.
@@ -452,8 +421,17 @@ def __init__(
452421

453422
@staticmethod
454423
def _get_attributes(record: logging.LogRecord) -> Attributes:
424+
private_record_attrs = (
425+
"args",
426+
"msg",
427+
"stack_info",
428+
"exc_info",
429+
"exc_text",
430+
)
455431
attributes = {
456-
k: v for k, v in vars(record).items() if k not in _RESERVED_ATTRS
432+
k: v
433+
for k, v in vars(record).items()
434+
if k not in private_record_attrs
457435
}
458436
if record.exc_info:
459437
exc_type = ""
@@ -478,48 +456,9 @@ def _translate(self, record: logging.LogRecord) -> LogRecord:
478456
timestamp = int(record.created * 1e9)
479457
span_context = get_current_span().get_span_context()
480458
attributes = self._get_attributes(record)
481-
# This comment is taken from GanyedeNil's PR #3343, I have redacted it
482-
# slightly for clarity:
483-
# According to the definition of the Body field type in the
484-
# OTel 1.22.0 Logs Data Model article, the Body field should be of
485-
# type 'any' and should not use the str method to directly translate
486-
# the msg. This is because str only converts non-text types into a
487-
# human-readable form, rather than a standard format, which leads to
488-
# the need for additional operations when collected through a log
489-
# collector.
490-
# Considering that he Body field should be of type 'any' and should not
491-
# use the str method but record.msg is also a string type, then the
492-
# difference is just the self.args formatting?
493-
# The primary consideration depends on the ultimate purpose of the log.
494-
# Converting the default log directly into a string is acceptable as it
495-
# will be required to be presented in a more readable format. However,
496-
# this approach might not be as "standard" when hoping to aggregate
497-
# logs and perform subsequent data analysis. In the context of log
498-
# extraction, it would be more appropriate for the msg to be
499-
# converted into JSON format or remain unchanged, as it will eventually
500-
# be transformed into JSON. If the final output JSON data contains a
501-
# structure that appears similar to JSON but is not, it may confuse
502-
# users. This is particularly true for operation and maintenance
503-
# personnel who need to deal with log data in various languages.
504-
# Where is the JSON converting occur? and what about when the msg
505-
# represents something else but JSON, the expected behavior change?
506-
# For the ConsoleLogExporter, it performs the to_json operation in
507-
# opentelemetry.sdk._logs._internal.export.ConsoleLogExporter.__init__,
508-
# so it can handle any type of input without problems. As for the
509-
# OTLPLogExporter, it also handles any type of input encoding in
510-
# _encode_log located in
511-
# opentelemetry.exporter.otlp.proto.common._internal._log_encoder.
512-
# Therefore, no extra operation is needed to support this change.
513-
# The only thing to consider is the users who have already been using
514-
# this SDK. If they upgrade the SDK after this change, they will need
515-
# to readjust their logging collection rules to adapt to the latest
516-
# output format. Therefore, this change is considered a breaking
517-
# change and needs to be upgraded at an appropriate time.
518459
severity_number = std_to_otel(record.levelno)
519-
if isinstance(record.msg, str) and record.args:
520-
body = record.msg % record.args
521-
else:
522-
body = record.msg
460+
body = self.format(record)
461+
523462
return LogRecord(
524463
timestamp=timestamp,
525464
trace_id=span_context.trace_id,

opentelemetry-sdk/tests/logs/test_export.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,8 @@ def test_simple_log_record_processor_different_msg_types(self):
198198
"Temperature hits high 420 C in Hyderabad",
199199
"CRITICAL",
200200
),
201-
(["list", "of", "strings"], "WARNING"),
202-
({"key": "value"}, "ERROR"),
201+
("['list', 'of', 'strings']", "WARNING"),
202+
("{'key': 'value'}", "ERROR"),
203203
]
204204
emitted = [
205205
(item.log_record.body, item.log_record.severity_text)

opentelemetry-sdk/tests/logs/test_handler.py

+75-3
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@
2424
from opentelemetry.trace import INVALID_SPAN_CONTEXT
2525

2626

27-
def get_logger(level=logging.NOTSET, logger_provider=None):
27+
def get_logger(level=logging.NOTSET, logger_provider=None, formatter=None):
2828
logger = logging.getLogger(__name__)
2929
handler = LoggingHandler(level=level, logger_provider=logger_provider)
30+
if formatter:
31+
handler.setFormatter(formatter)
3032
logger.addHandler(handler)
3133
return logger
3234

@@ -112,7 +114,10 @@ def test_log_record_user_attributes(self):
112114
log_record = args[0]
113115

114116
self.assertIsNotNone(log_record)
115-
self.assertEqual(log_record.attributes, {"http.status_code": 200})
117+
self.assertEqual(
118+
log_record.attributes,
119+
{**log_record.attributes, **{"http.status_code": 200}},
120+
)
116121
self.assertTrue(isinstance(log_record.attributes, BoundedAttributes))
117122

118123
def test_log_record_exception(self):
@@ -131,7 +136,7 @@ def test_log_record_exception(self):
131136
log_record = args[0]
132137

133138
self.assertIsNotNone(log_record)
134-
self.assertEqual(log_record.body, "Zero Division Error")
139+
self.assertIn("Zero Division Error", log_record.body)
135140
self.assertEqual(
136141
log_record.attributes[SpanAttributes.EXCEPTION_TYPE],
137142
ZeroDivisionError.__name__,
@@ -195,3 +200,70 @@ def test_log_record_trace_correlation(self):
195200
self.assertEqual(log_record.trace_id, span_context.trace_id)
196201
self.assertEqual(log_record.span_id, span_context.span_id)
197202
self.assertEqual(log_record.trace_flags, span_context.trace_flags)
203+
204+
def test_original_record_args_are_retained(self):
205+
emitter_provider_mock = Mock(spec=LoggerProvider)
206+
emitter_mock = APIGetLogger(
207+
__name__, logger_provider=emitter_provider_mock
208+
)
209+
logger = get_logger(logger_provider=emitter_provider_mock)
210+
211+
with self.assertLogs(level=logging.INFO):
212+
logger.info("Test message")
213+
args, _ = emitter_mock.emit.call_args_list[0]
214+
log_record = args[0]
215+
216+
self.assertEqual(
217+
set(log_record.attributes),
218+
{
219+
"message",
220+
"created",
221+
"filename",
222+
"funcName",
223+
"levelname",
224+
"levelno",
225+
"lineno",
226+
"module",
227+
"msecs",
228+
"name",
229+
"pathname",
230+
"process",
231+
"processName",
232+
"relativeCreated",
233+
"thread",
234+
"threadName",
235+
},
236+
)
237+
238+
def test_format_is_called(self):
239+
emitter_provider_mock = Mock(spec=LoggerProvider)
240+
emitter_mock = APIGetLogger(
241+
__name__, logger_provider=emitter_provider_mock
242+
)
243+
formatter = logging.Formatter("%(name)s - %(levelname)s - %(message)s")
244+
logger = get_logger(
245+
logger_provider=emitter_provider_mock, formatter=formatter
246+
)
247+
248+
with self.assertLogs(level=logging.INFO):
249+
logger.info("Test message")
250+
args, _ = emitter_mock.emit.call_args_list[0]
251+
log_record = args[0]
252+
253+
self.assertEqual(
254+
log_record.body, "tests.logs.test_handler - INFO - Test message"
255+
)
256+
257+
def test_log_body_is_always_string(self):
258+
emitter_provider_mock = Mock(spec=LoggerProvider)
259+
emitter_mock = APIGetLogger(
260+
__name__, logger_provider=emitter_provider_mock
261+
)
262+
logger = get_logger(logger_provider=emitter_provider_mock)
263+
264+
with self.assertLogs(level=logging.INFO):
265+
logger.info(["something", "of", "note"])
266+
args, _ = emitter_mock.emit.call_args_list[0]
267+
log_record = args[0]
268+
269+
self.assertIsInstance(log_record.body, str)

0 commit comments

Comments
 (0)