Skip to content

Commit 6fa1773

Browse files
committed
feat!: support string-encoded json (#339)
1 parent c632503 commit 6fa1773

File tree

3 files changed

+256
-7
lines changed

3 files changed

+256
-7
lines changed

google/cloud/logging_v2/handlers/handlers.py

+31-7
Original file line numberDiff line numberDiff line change
@@ -192,13 +192,8 @@ def emit(self, record):
192192
"""
193193
resource = record._resource or self.resource
194194
labels = record._labels
195-
message = None
196-
if isinstance(record.msg, collections.abc.Mapping):
197-
# if input is a dictionary, pass as-is for structured logging
198-
message = record.msg
199-
elif record.msg:
200-
# otherwise, format message string based on superclass
201-
message = super(CloudLoggingHandler, self).format(record)
195+
message = _format_and_parse_message(record, self)
196+
202197
if resource.type == _GAE_RESOURCE_TYPE and record._trace is not None:
203198
# add GAE-specific label
204199
labels = {_GAE_TRACE_ID_LABEL: record._trace, **(labels or {})}
@@ -215,6 +210,35 @@ def emit(self, record):
215210
)
216211

217212

213+
def _format_and_parse_message(record, formatter_handler):
214+
"""
215+
Helper function to apply formatting to a LogRecord message,
216+
and attempt to parse encoded JSON into a dictionary object.
217+
218+
Resulting output will be of type (str | dict | None)
219+
220+
Args:
221+
record (logging.LogRecord): The record object representing the log
222+
formatter_handler (logging.Handler): The handler used to format the log
223+
"""
224+
# if message is a dictionary, return as-is
225+
if isinstance(record.msg, collections.abc.Mapping):
226+
return record.msg
227+
# format message string based on superclass
228+
message = formatter_handler.format(record)
229+
try:
230+
# attempt to parse encoded json into dictionary
231+
if message[0] == "{":
232+
json_message = json.loads(message)
233+
if isinstance(json_message, collections.abc.Mapping):
234+
message = json_message
235+
except (json.decoder.JSONDecodeError, IndexError):
236+
# log string is not valid json
237+
pass
238+
# if formatted message contains no content, return None
239+
return message if message != "None" else None
240+
241+
218242
def setup_logging(
219243
handler, *, excluded_loggers=EXCLUDED_LOGGER_DEFAULTS, log_level=logging.INFO
220244
):

tests/unit/handlers/test_handlers.py

+184
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,20 @@ def test_emit(self):
311311
),
312312
)
313313

314+
def test_emit_minimal(self):
315+
from google.cloud.logging_v2.logger import _GLOBAL_RESOURCE
316+
317+
client = _Client(self.PROJECT)
318+
handler = self._make_one(
319+
client, transport=_Transport, resource=_GLOBAL_RESOURCE
320+
)
321+
record = logging.LogRecord(None, logging.INFO, None, None, None, None, None)
322+
handler.handle(record)
323+
self.assertEqual(
324+
handler.transport.send_called_with,
325+
(record, None, _GLOBAL_RESOURCE, None, None, None, None, None,),
326+
)
327+
314328
def test_emit_manual_field_override(self):
315329
from google.cloud.logging_v2.logger import _GLOBAL_RESOURCE
316330
from google.cloud.logging_v2.resource import Resource
@@ -401,6 +415,70 @@ def test_emit_with_custom_formatter(self):
401415
),
402416
)
403417

418+
def test_emit_dict(self):
419+
"""
420+
Handler should support logging dictionaries
421+
"""
422+
from google.cloud.logging_v2.logger import _GLOBAL_RESOURCE
423+
424+
client = _Client(self.PROJECT)
425+
handler = self._make_one(
426+
client, transport=_Transport, resource=_GLOBAL_RESOURCE,
427+
)
428+
message = {"x": "test"}
429+
logname = "logname"
430+
expected_label = {"python_logger": logname}
431+
record = logging.LogRecord(
432+
logname, logging.INFO, None, None, message, None, None
433+
)
434+
handler.handle(record)
435+
436+
self.assertEqual(
437+
handler.transport.send_called_with,
438+
(
439+
record,
440+
message,
441+
_GLOBAL_RESOURCE,
442+
expected_label,
443+
None,
444+
None,
445+
None,
446+
None,
447+
),
448+
)
449+
450+
def test_emit_with_encoded_json(self):
451+
"""
452+
Handler should parse json encoded as a string
453+
"""
454+
from google.cloud.logging_v2.logger import _GLOBAL_RESOURCE
455+
456+
client = _Client(self.PROJECT)
457+
handler = self._make_one(
458+
client, transport=_Transport, resource=_GLOBAL_RESOURCE,
459+
)
460+
logFormatter = logging.Formatter(fmt='{ "x" : "%(name)s" }')
461+
handler.setFormatter(logFormatter)
462+
logname = "logname"
463+
expected_result = {"x": logname}
464+
expected_label = {"python_logger": logname}
465+
record = logging.LogRecord(logname, logging.INFO, None, None, None, None, None)
466+
handler.handle(record)
467+
468+
self.assertEqual(
469+
handler.transport.send_called_with,
470+
(
471+
record,
472+
expected_result,
473+
_GLOBAL_RESOURCE,
474+
expected_label,
475+
None,
476+
None,
477+
None,
478+
None,
479+
),
480+
)
481+
404482
def test_format_with_arguments(self):
405483
"""
406484
Handler should support format string arguments
@@ -425,6 +503,112 @@ def test_format_with_arguments(self):
425503
)
426504

427505

506+
class TestFormatAndParseMessage(unittest.TestCase):
507+
def test_none(self):
508+
"""
509+
None messages with no special formatting should return
510+
None after formatting
511+
"""
512+
from google.cloud.logging_v2.handlers.handlers import _format_and_parse_message
513+
514+
message = None
515+
record = logging.LogRecord(None, None, None, None, message, None, None)
516+
handler = logging.StreamHandler()
517+
result = _format_and_parse_message(record, handler)
518+
self.assertEqual(result, None)
519+
520+
def test_none_formatted(self):
521+
"""
522+
None messages with formatting rules should return formatted string
523+
"""
524+
from google.cloud.logging_v2.handlers.handlers import _format_and_parse_message
525+
526+
message = None
527+
record = logging.LogRecord("logname", None, None, None, message, None, None)
528+
handler = logging.StreamHandler()
529+
formatter = logging.Formatter("name: %(name)s")
530+
handler.setFormatter(formatter)
531+
result = _format_and_parse_message(record, handler)
532+
self.assertEqual(result, "name: logname")
533+
534+
def test_unformatted_string(self):
535+
"""
536+
Unformated strings should be returned unchanged
537+
"""
538+
from google.cloud.logging_v2.handlers.handlers import _format_and_parse_message
539+
540+
message = '"test"'
541+
record = logging.LogRecord("logname", None, None, None, message, None, None)
542+
handler = logging.StreamHandler()
543+
result = _format_and_parse_message(record, handler)
544+
self.assertEqual(result, message)
545+
546+
def test_empty_string(self):
547+
"""
548+
Empty strings should be returned unchanged
549+
"""
550+
from google.cloud.logging_v2.handlers.handlers import _format_and_parse_message
551+
552+
message = ""
553+
record = logging.LogRecord("logname", None, None, None, message, None, None)
554+
handler = logging.StreamHandler()
555+
result = _format_and_parse_message(record, handler)
556+
self.assertEqual(result, message)
557+
558+
def test_string_formatted_with_args(self):
559+
"""
560+
string messages should properly apply formatting and arguments
561+
"""
562+
from google.cloud.logging_v2.handlers.handlers import _format_and_parse_message
563+
564+
message = "argument: %s"
565+
arg = "test"
566+
record = logging.LogRecord("logname", None, None, None, message, arg, None)
567+
handler = logging.StreamHandler()
568+
formatter = logging.Formatter("name: %(name)s :: message: %(message)s")
569+
handler.setFormatter(formatter)
570+
result = _format_and_parse_message(record, handler)
571+
self.assertEqual(result, "name: logname :: message: argument: test")
572+
573+
def test_dict(self):
574+
"""
575+
dict messages should be unchanged
576+
"""
577+
from google.cloud.logging_v2.handlers.handlers import _format_and_parse_message
578+
579+
message = {"a": "b"}
580+
record = logging.LogRecord("logname", None, None, None, message, None, None)
581+
handler = logging.StreamHandler()
582+
formatter = logging.Formatter("name: %(name)s")
583+
handler.setFormatter(formatter)
584+
result = _format_and_parse_message(record, handler)
585+
self.assertEqual(result, message)
586+
587+
def test_string_encoded_dict(self):
588+
"""
589+
dicts should be extracted from string messages
590+
"""
591+
from google.cloud.logging_v2.handlers.handlers import _format_and_parse_message
592+
593+
message = '{ "x": { "y" : "z" } }'
594+
record = logging.LogRecord("logname", None, None, None, message, None, None)
595+
handler = logging.StreamHandler()
596+
result = _format_and_parse_message(record, handler)
597+
self.assertEqual(result, {"x": {"y": "z"}})
598+
599+
def test_broken_encoded_dict(self):
600+
"""
601+
unparseable encoded dicts should be kept as strings
602+
"""
603+
from google.cloud.logging_v2.handlers.handlers import _format_and_parse_message
604+
605+
message = '{ "x": { "y" : '
606+
record = logging.LogRecord("logname", None, None, None, message, None, None)
607+
handler = logging.StreamHandler()
608+
result = _format_and_parse_message(record, handler)
609+
self.assertEqual(result, message)
610+
611+
428612
class TestSetupLogging(unittest.TestCase):
429613
def _call_fut(self, handler, excludes=None):
430614
from google.cloud.logging.handlers import setup_logging

tests/unit/handlers/test_structured_log.py

+41
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,16 @@ def test_format_minimal(self):
9292
record = logging.LogRecord(None, logging.INFO, None, None, None, None, None,)
9393
record.created = None
9494
expected_payload = {
95+
"severity": "INFO",
9596
"logging.googleapis.com/trace": "",
97+
"logging.googleapis.com/spanId": "",
9698
"logging.googleapis.com/sourceLocation": {},
9799
"httpRequest": {},
98100
"logging.googleapis.com/labels": {},
99101
}
100102
handler.filter(record)
101103
result = json.loads(handler.format(record))
104+
self.assertEqual(set(expected_payload.keys()), set(result.keys()))
102105
for (key, value) in expected_payload.items():
103106
self.assertEqual(
104107
value, result[key], f"expected_payload[{key}] != result[{key}]"
@@ -170,6 +173,44 @@ def test_format_with_custom_formatter(self):
170173
handler.filter(record)
171174
result = handler.format(record)
172175
self.assertIn(expected_result, result)
176+
self.assertIn("message", result)
177+
178+
def test_dict(self):
179+
"""
180+
Handler should parse json encoded as a string
181+
"""
182+
import logging
183+
184+
handler = self._make_one()
185+
message = {"x": "test"}
186+
expected_result = '"x": "test"'
187+
record = logging.LogRecord(
188+
"logname", logging.INFO, None, None, message, None, None,
189+
)
190+
record.created = None
191+
handler.filter(record)
192+
result = handler.format(record)
193+
self.assertIn(expected_result, result)
194+
self.assertNotIn("message", result)
195+
196+
def test_encoded_json(self):
197+
"""
198+
Handler should parse json encoded as a string
199+
"""
200+
import logging
201+
202+
handler = self._make_one()
203+
logFormatter = logging.Formatter(fmt='{ "name" : "%(name)s" }')
204+
handler.setFormatter(logFormatter)
205+
expected_result = '"name": "logname"'
206+
record = logging.LogRecord(
207+
"logname", logging.INFO, None, None, None, None, None,
208+
)
209+
record.created = None
210+
handler.filter(record)
211+
result = handler.format(record)
212+
self.assertIn(expected_result, result)
213+
self.assertNotIn("message", result)
173214

174215
def test_format_with_arguments(self):
175216
"""

0 commit comments

Comments
 (0)