diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ee4488d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest ujson simplejson django + - name: Lint with flake8 + run: | + flake8 . --count --show-source --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest -s tests.py diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7f5c18c..0000000 --- a/.travis.yml +++ /dev/null @@ -1,18 +0,0 @@ -language: python -python: - - 2.7 - - 3.4 - - 3.5 - - 3.6 - - 3.7 - - 3.8 -install: - - pip install ujson simplejson django - - python setup.py install -script: nosetests tests -notifications: - email: - recipients: - - marselester@ya.ru - on_success: change - on_failure: change diff --git a/LICENSE b/LICENSE index 01be757..d23ebf0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015 Marsel Mavletkulov +Copyright (c) 2025 Marsel Mavletkulov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 5ac4438..4226fb8 100644 --- a/Makefile +++ b/Makefile @@ -2,4 +2,5 @@ test: tox pypi: - python setup.py sdist upload + python -m build + python -m twine upload dist/* diff --git a/README.rst b/README.rst index e1f9404..e4968aa 100644 --- a/README.rst +++ b/README.rst @@ -1,9 +1,6 @@ -================== -JSON log formatter -================== - -.. image:: https://travis-ci.org/marselester/json-log-formatter.png - :target: https://travis-ci.org/marselester/json-log-formatter +===================== +JSON log formatter 🪵 +===================== The library helps you to store logs in JSON format. Why is it important? Well, it facilitates integration with **Logstash**. @@ -76,6 +73,28 @@ with ``VerboseJSONFormatter``. "time": "2021-07-04T21:05:42.767726" } +If you need to flatten complex objects as strings, use ``FlatJSONFormatter``. + +.. code-block:: python + + json_handler.setFormatter(json_log_formatter.FlatJSONFormatter()) + logger.error('An error has occured') + + logger.info('Sign up', extra={'request': WSGIRequest({ + 'PATH_INFO': 'bogus', + 'REQUEST_METHOD': 'bogus', + 'CONTENT_TYPE': 'text/html; charset=utf8', + 'wsgi.input': BytesIO(b''), + })}) + +.. code-block:: json + + { + "message": "Sign up", + "time": "2024-10-01T00:59:29.332888+00:00", + "request": "" + } + JSON libraries -------------- diff --git a/json_log_formatter/__init__.py b/json_log_formatter/__init__.py index ac1dc4b..6e0be3d 100644 --- a/json_log_formatter/__init__.py +++ b/json_log_formatter/__init__.py @@ -1,5 +1,6 @@ import logging -from datetime import datetime +from decimal import Decimal +from datetime import datetime, timezone import json @@ -24,6 +25,7 @@ 'processName', 'relativeCreated', 'stack_info', + 'taskName', 'thread', 'threadName', } @@ -120,7 +122,7 @@ def json_record(self, message, extra, record): """ extra['message'] = message if 'time' not in extra: - extra['time'] = datetime.utcnow() + extra['time'] = datetime.now(timezone.utc) if record.exc_info: extra['exc_info'] = self.formatException(record.exc_info) @@ -204,3 +206,35 @@ def json_record(self, message, extra, record): extra['thread'] = record.thread extra['threadName'] = record.threadName return super(VerboseJSONFormatter, self).json_record(message, extra, record) + + +class FlatJSONFormatter(JSONFormatter): + """Flat JSON log formatter ensures that complex objects are stored as strings. + + Usage example:: + + logger.info('Sign up', extra={'request': WSGIRequest({ + 'PATH_INFO': 'bogus', + 'REQUEST_METHOD': 'bogus', + 'CONTENT_TYPE': 'text/html; charset=utf8', + 'wsgi.input': BytesIO(b''), + })}) + + The log file will contain the following log record (inline):: + + { + "message": "Sign up", + "time": "2024-10-01T00:59:29.332888+00:00", + "request": "" + } + + """ + + keep = (bool, int, float, Decimal, complex, str, datetime) + + def json_record(self, message, extra, record): + extra = super(FlatJSONFormatter, self).json_record(message, extra, record) + return { + k: v if v is None or isinstance(v, self.keep) else str(v) + for k, v in extra.items() + } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..436d913 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["json_log_formatter"] + +[project] +name = "JSON-log-formatter" +version = "1.2" +description = "JSON log formatter" +readme = "README.rst" +requires-python = ">=3.9" +license = {text = "MIT"} +authors = [ + {name = "Marsel Mavletkulov"}, +] +classifiers=[ + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Libraries :: Python Modules" +] + +[project.urls] +repository = "https://github.com/marselester/json-log-formatter" diff --git a/requirements.txt b/requirements.txt index 1b32173..e78704f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -e . -tox==2.9.1 +tox==4.11.4 diff --git a/setup.py b/setup.py index bb73bf3..63e922c 100644 --- a/setup.py +++ b/setup.py @@ -2,11 +2,10 @@ setup( name='JSON-log-formatter', - version='0.5.1', + version='1.2', license='MIT', packages=['json_log_formatter'], author='Marsel Mavletkulov', - author_email='marselester@ya.ru', url='https://github.com/marselester/json-log-formatter', description='JSON log formatter', long_description=open('README.rst').read(), diff --git a/tests.py b/tests.py index 5823156..b2012d7 100644 --- a/tests.py +++ b/tests.py @@ -4,6 +4,7 @@ import unittest import logging import json +import os.path from django.core.handlers.wsgi import WSGIRequest @@ -16,7 +17,7 @@ except ImportError: from io import StringIO -from json_log_formatter import JSONFormatter, VerboseJSONFormatter +from json_log_formatter import JSONFormatter, VerboseJSONFormatter, FlatJSONFormatter log_buffer = StringIO() json_handler = logging.StreamHandler(log_buffer) @@ -58,7 +59,7 @@ def test_message_and_time_are_in_json_record_when_extra_is_blank(self): 'message', 'time', ]) - self.assertEqual(set(json_record), expected_fields) + self.assertTrue(expected_fields.issubset(json_record)) def test_message_and_time_and_extra_are_in_json_record_when_extra_is_provided(self): logger.info('Sign up', extra={'fizz': 'bazz'}) @@ -68,7 +69,7 @@ def test_message_and_time_and_extra_are_in_json_record_when_extra_is_provided(se 'time', 'fizz', ]) - self.assertEqual(set(json_record), expected_fields) + self.assertTrue(expected_fields.issubset(json_record)) def test_exc_info_is_logged(self): try: @@ -213,7 +214,8 @@ def test_django_wsgi_request_is_serialized_as_empty_list(self): if 'status_code' in json_record: self.assertEqual(json_record['status_code'], 500) if 'request' in json_record: - self.assertEqual(json_record['request'], []) + self.assertEqual(json_record['request']['path'], '/bogus') + self.assertEqual(json_record['request']['method'], 'BOGUS') def test_json_circular_reference_is_handled(self): d = {} @@ -318,7 +320,7 @@ def test_logger_name_is_test(self): def test_path_name_is_test(self): logger.error('An error has occured') json_record = json.loads(log_buffer.getvalue()) - self.assertIn('json-log-formatter/tests.py', json_record['pathname']) + self.assertIn(os.path.basename(os.path.abspath('.')) + '/tests.py', json_record['pathname']) def test_process_name_is_MainProcess(self): logger.error('An error has occured') @@ -334,3 +336,96 @@ def test_stack_info_is_none(self): logger.error('An error has occured') json_record = json.loads(log_buffer.getvalue()) self.assertIsNone(json_record['stack_info']) + + +class FlatJSONFormatterTest(TestCase): + def setUp(self): + json_handler.setFormatter(FlatJSONFormatter()) + + def test_given_time_is_used_in_log_record(self): + logger.info('Sign up', extra={'time': DATETIME}) + expected_time = '"time": "2015-09-01T06:09:42.797203"' + self.assertIn(expected_time, log_buffer.getvalue()) + + def test_current_time_is_used_by_default_in_log_record(self): + logger.info('Sign up', extra={'fizz': 'bazz'}) + self.assertNotIn(DATETIME_ISO, log_buffer.getvalue()) + + def test_message_and_time_are_in_json_record_when_extra_is_blank(self): + logger.info('Sign up') + json_record = json.loads(log_buffer.getvalue()) + expected_fields = set([ + 'message', + 'time', + ]) + self.assertTrue(expected_fields.issubset(json_record)) + + def test_message_and_time_and_extra_are_in_json_record_when_extra_is_provided(self): + logger.info('Sign up', extra={'fizz': 'bazz'}) + json_record = json.loads(log_buffer.getvalue()) + expected_fields = set([ + 'message', + 'time', + 'fizz', + ]) + self.assertTrue(expected_fields.issubset(json_record)) + + def test_exc_info_is_logged(self): + try: + raise ValueError('something wrong') + except ValueError: + logger.error('Request failed', exc_info=True) + json_record = json.loads(log_buffer.getvalue()) + self.assertIn( + 'Traceback (most recent call last)', + json_record['exc_info'] + ) + + def test_builtin_types_are_serialized(self): + logger.log(level=logging.ERROR, msg='Payment was sent', extra={ + 'first_name': 'bob', + 'amount': 0.00497265, + 'context': { + 'tags': ['fizz', 'bazz'], + }, + 'things': ('a', 'b'), + 'ok': True, + 'none': None, + }) + + json_record = json.loads(log_buffer.getvalue()) + self.assertEqual(json_record['first_name'], 'bob') + self.assertEqual(json_record['amount'], 0.00497265) + self.assertEqual(json_record['context'], "{'tags': ['fizz', 'bazz']}") + self.assertEqual(json_record['things'], "('a', 'b')") + self.assertEqual(json_record['ok'], True) + self.assertEqual(json_record['none'], None) + + def test_decimal_is_serialized_as_string(self): + logger.log(level=logging.ERROR, msg='Payment was sent', extra={ + 'amount': Decimal('0.00497265') + }) + expected_amount = '"amount": "0.00497265"' + self.assertIn(expected_amount, log_buffer.getvalue()) + + def test_django_wsgi_request_is_serialized_as_dict(self): + request = WSGIRequest({ + 'PATH_INFO': 'bogus', + 'REQUEST_METHOD': 'bogus', + 'CONTENT_TYPE': 'text/html; charset=utf8', + 'wsgi.input': BytesIO(b''), + }) + + logger.log(level=logging.ERROR, msg='Django response error', extra={ + 'status_code': 500, + 'request': request, + 'dict': { + 'request': request, + }, + 'list': [request], + }) + json_record = json.loads(log_buffer.getvalue()) + self.assertEqual(json_record['status_code'], 500) + self.assertEqual(json_record['request'], "") + self.assertEqual(json_record['dict'], "{'request': }") + self.assertEqual(json_record['list'], "[]") diff --git a/tox.ini b/tox.ini index aae28ab..64abe5f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist=py27,py35,py36,py37,py38,py39,py310 +envlist=py39,py310,py311,py312,py313 [testenv] deps=