Skip to content

Commit eeac2e9

Browse files
committed
Add FlatJSONFormatter to flatten complex objects
1 parent 1b20bcb commit eeac2e9

File tree

3 files changed

+149
-1
lines changed

3 files changed

+149
-1
lines changed

README.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,28 @@ with ``VerboseJSONFormatter``.
7373
"time": "2021-07-04T21:05:42.767726"
7474
}
7575
76+
If you need to flatten complex objects as strings, use ``FlatJSONFormatter``.
77+
78+
.. code-block:: python
79+
80+
json_handler.setFormatter(json_log_formatter.FlatJSONFormatter())
81+
logger.error('An error has occured')
82+
83+
logger.info('Sign up', extra={'request': WSGIRequest({
84+
'PATH_INFO': 'bogus',
85+
'REQUEST_METHOD': 'bogus',
86+
'CONTENT_TYPE': 'text/html; charset=utf8',
87+
'wsgi.input': BytesIO(b''),
88+
})})
89+
90+
.. code-block:: json
91+
92+
{
93+
"message": "Sign up",
94+
"time": "2024-10-01T00:59:29.332888+00:00",
95+
"request": "<WSGIRequest: BOGUS '/bogus'>"
96+
}
97+
7698
JSON libraries
7799
--------------
78100

json_log_formatter/__init__.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from decimal import Decimal
23
from datetime import datetime, timezone
34

45
import json
@@ -204,3 +205,35 @@ def json_record(self, message, extra, record):
204205
extra['thread'] = record.thread
205206
extra['threadName'] = record.threadName
206207
return super(VerboseJSONFormatter, self).json_record(message, extra, record)
208+
209+
210+
class FlatJSONFormatter(JSONFormatter):
211+
"""Flat JSON log formatter ensures that complex objects are stored as strings.
212+
213+
Usage example::
214+
215+
logger.info('Sign up', extra={'request': WSGIRequest({
216+
'PATH_INFO': 'bogus',
217+
'REQUEST_METHOD': 'bogus',
218+
'CONTENT_TYPE': 'text/html; charset=utf8',
219+
'wsgi.input': BytesIO(b''),
220+
})})
221+
222+
The log file will contain the following log record (inline)::
223+
224+
{
225+
"message": "Sign up",
226+
"time": "2024-10-01T00:59:29.332888+00:00",
227+
"request": "<WSGIRequest: BOGUS '/bogus'>"
228+
}
229+
230+
"""
231+
232+
keep = (bool, int, float, Decimal, complex, str, datetime)
233+
234+
def json_record(self, message, extra, record):
235+
extra = super(FlatJSONFormatter, self).json_record(message, extra, record)
236+
return {
237+
k: v if v is None or isinstance(v, self.keep) else str(v)
238+
for k, v in extra.items()
239+
}

tests.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
except ImportError:
1818
from io import StringIO
1919

20-
from json_log_formatter import JSONFormatter, VerboseJSONFormatter
20+
from json_log_formatter import JSONFormatter, VerboseJSONFormatter, FlatJSONFormatter
2121

2222
log_buffer = StringIO()
2323
json_handler = logging.StreamHandler(log_buffer)
@@ -336,3 +336,96 @@ def test_stack_info_is_none(self):
336336
logger.error('An error has occured')
337337
json_record = json.loads(log_buffer.getvalue())
338338
self.assertIsNone(json_record['stack_info'])
339+
340+
341+
class FlatJSONFormatterTest(TestCase):
342+
def setUp(self):
343+
json_handler.setFormatter(FlatJSONFormatter())
344+
345+
def test_given_time_is_used_in_log_record(self):
346+
logger.info('Sign up', extra={'time': DATETIME})
347+
expected_time = '"time": "2015-09-01T06:09:42.797203"'
348+
self.assertIn(expected_time, log_buffer.getvalue())
349+
350+
def test_current_time_is_used_by_default_in_log_record(self):
351+
logger.info('Sign up', extra={'fizz': 'bazz'})
352+
self.assertNotIn(DATETIME_ISO, log_buffer.getvalue())
353+
354+
def test_message_and_time_are_in_json_record_when_extra_is_blank(self):
355+
logger.info('Sign up')
356+
json_record = json.loads(log_buffer.getvalue())
357+
expected_fields = set([
358+
'message',
359+
'time',
360+
])
361+
self.assertTrue(expected_fields.issubset(json_record))
362+
363+
def test_message_and_time_and_extra_are_in_json_record_when_extra_is_provided(self):
364+
logger.info('Sign up', extra={'fizz': 'bazz'})
365+
json_record = json.loads(log_buffer.getvalue())
366+
expected_fields = set([
367+
'message',
368+
'time',
369+
'fizz',
370+
])
371+
self.assertTrue(expected_fields.issubset(json_record))
372+
373+
def test_exc_info_is_logged(self):
374+
try:
375+
raise ValueError('something wrong')
376+
except ValueError:
377+
logger.error('Request failed', exc_info=True)
378+
json_record = json.loads(log_buffer.getvalue())
379+
self.assertIn(
380+
'Traceback (most recent call last)',
381+
json_record['exc_info']
382+
)
383+
384+
def test_builtin_types_are_serialized(self):
385+
logger.log(level=logging.ERROR, msg='Payment was sent', extra={
386+
'first_name': 'bob',
387+
'amount': 0.00497265,
388+
'context': {
389+
'tags': ['fizz', 'bazz'],
390+
},
391+
'things': ('a', 'b'),
392+
'ok': True,
393+
'none': None,
394+
})
395+
396+
json_record = json.loads(log_buffer.getvalue())
397+
self.assertEqual(json_record['first_name'], 'bob')
398+
self.assertEqual(json_record['amount'], 0.00497265)
399+
self.assertEqual(json_record['context'], "{'tags': ['fizz', 'bazz']}")
400+
self.assertEqual(json_record['things'], "('a', 'b')")
401+
self.assertEqual(json_record['ok'], True)
402+
self.assertEqual(json_record['none'], None)
403+
404+
def test_decimal_is_serialized_as_string(self):
405+
logger.log(level=logging.ERROR, msg='Payment was sent', extra={
406+
'amount': Decimal('0.00497265')
407+
})
408+
expected_amount = '"amount": "0.00497265"'
409+
self.assertIn(expected_amount, log_buffer.getvalue())
410+
411+
def test_django_wsgi_request_is_serialized_as_dict(self):
412+
request = WSGIRequest({
413+
'PATH_INFO': 'bogus',
414+
'REQUEST_METHOD': 'bogus',
415+
'CONTENT_TYPE': 'text/html; charset=utf8',
416+
'wsgi.input': BytesIO(b''),
417+
})
418+
419+
logger.log(level=logging.ERROR, msg='Django response error', extra={
420+
'status_code': 500,
421+
'request': request,
422+
'dict': {
423+
'request': request,
424+
},
425+
'list': [request],
426+
})
427+
json_record = json.loads(log_buffer.getvalue())
428+
self.assertEqual(json_record['status_code'], 500)
429+
self.assertEqual(json_record['request'], "<WSGIRequest: BOGUS '/bogus'>")
430+
self.assertEqual(json_record['dict'], "{'request': <WSGIRequest: BOGUS '/bogus'>}")
431+
self.assertEqual(json_record['list'], "[<WSGIRequest: BOGUS '/bogus'>]")

0 commit comments

Comments
 (0)