├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── _config.yml ├── logging_json ├── __init__.py ├── _formatter.py ├── py.typed └── version.py ├── pyproject.toml └── tests ├── __init__.py └── test_formatter.py /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: '3.13' 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install .[testing] 23 | - name: Test with pytest 24 | run: | 25 | pytest --cov=logging_json --cov-fail-under=100 --cov-report=term-missing 26 | - name: Create packages 27 | run: | 28 | python -m pip install build 29 | python -m build . 30 | - name: Publish packages 31 | run: | 32 | python -m pip install twine 33 | python -m twine upload dist/* --skip-existing --username __token__ --password ${{ secrets.pypi_password }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install .[testing] 23 | - name: Test with pytest 24 | run: | 25 | pytest --cov=logging_json --cov-fail-under=100 --cov-report=term-missing 26 | - name: Create packages 27 | run: | 28 | python -m pip install build 29 | python -m build . -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /logging_json.egg-info/ 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 25.1.0 4 | hooks: 5 | - id: black 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.6.0] - 2025-02-04 10 | ### Fixed 11 | - Using [`logging.exception`](https://docs.python.org/3/library/logging.html#logging.exception) outside an exception context will not prevent log from being formatted anymore. 12 | 13 | ### Removed 14 | - Drop support for python `3.7`. 15 | - Drop support for python `3.8`. 16 | 17 | ### Added 18 | - Explicit support for python `3.13`. 19 | 20 | ## [0.5.0] - 2024-01-17 21 | ### Added 22 | - Explicit support for python `3.12`. Meaning `taskName` is now considered a reserved keyword where value is supposed to be contained in the record itself (otherwise value will be `taskName` for python < 3.12 when specified). 23 | - New parameters `default_time_format` (default to `%Y-%m-%d %H:%M:%S`) and `default_msec_format` (default to `%s,%03d`) allowing to change the formatting of `asctime`. 24 | - More details can be found in the documentation on what the impact is when changing those values. 25 | 26 | ### Fixed 27 | - non-ASCII but valid UTF-8 values and field names will now be output as provided (they will not be escaped anymore). 28 | 29 | ## [0.4.0] - 2023-01-09 30 | ### Changed 31 | - Default message key is now `message` instead of `msg` to stay in line with python default. If you still want previous behavior, set `message_field_name` to `msg` at formatter creation. 32 | 33 | ### Removed 34 | - Drop support for python `3.6`. 35 | 36 | ## [0.3.0] - 2022-12-02 37 | ### Added 38 | - Added `exception_field_name` parameter. 39 | 40 | ## [0.2.1] - 2022-01-26 41 | ### Fixed 42 | - `datetime`, `time` and `date` instances are now represented following [ISO-8601](https://www.iso.org/iso-8601-date-and-time-format.html) format instead of raising a `TypeError`. 43 | - Default to the `str` representation of value instead of raising a `TypeError` for non-standard python types. 44 | 45 | ## [0.2.0] - 2021-11-24 46 | ### Added 47 | - Added `message_field_name` parameter. 48 | 49 | ## [0.1.0] - 2021-10-04 50 | ### Fixed 51 | - Handle `extra` logging parameter. 52 | 53 | ## [0.0.1] - 2020-10-15 54 | ### Added 55 | - Public release. 56 | 57 | [Unreleased]: https://github.com/Colin-b/logging_json/compare/v0.6.0...HEAD 58 | [0.6.0]: https://github.com/Colin-b/logging_json/compare/v0.5.0...v0.6.0 59 | [0.5.0]: https://github.com/Colin-b/logging_json/compare/v0.4.0...v0.5.0 60 | [0.4.0]: https://github.com/Colin-b/logging_json/compare/v0.3.0...v0.4.0 61 | [0.3.0]: https://github.com/Colin-b/logging_json/compare/v0.2.1...v0.3.0 62 | [0.2.1]: https://github.com/Colin-b/logging_json/compare/v0.2.0...v0.2.1 63 | [0.2.0]: https://github.com/Colin-b/logging_json/compare/v0.1.0...v0.2.0 64 | [0.1.0]: https://github.com/Colin-b/logging_json/compare/v0.0.1...v0.1.0 65 | [0.0.1]: https://github.com/Colin-b/logging_json/releases/tag/v0.0.1 66 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Everyone is free to contribute on this project. 4 | 5 | There are 2 ways to contribute: 6 | 7 | - [Submit an issue.](#submitting-an-issue) 8 | - [Submit a pull request.](#submitting-a-pull-request) 9 | 10 | ## Submitting an issue 11 | 12 | Before creating an issue please make sure that it was not already reported. 13 | 14 | ### When? 15 | 16 | - You encountered an issue. 17 | - You have a change proposal. 18 | - You have a feature request. 19 | 20 | ### How? 21 | 22 | 1) Go to the *Issues* tab and click on the *New issue* button. 23 | 2) Title should be a small sentence describing the request. 24 | 3) The comment should contains as much information as possible 25 | * Actual behavior (including the version you used) 26 | * Expected behavior 27 | * Steps to reproduce 28 | 29 | ## Submitting a pull request 30 | 31 | ### When? 32 | 33 | - You fixed an issue. 34 | - You changed something. 35 | - You added a new feature. 36 | 37 | ### How? 38 | 39 | #### Code 40 | 41 | 1) Create a new branch based on `develop` branch. 42 | 2) Fetch all dev dependencies. 43 | * Install required python modules using [`pip`](https://pypi.org/project/pip/): **python -m pip install .[testing]** 44 | 3) Ensure tests are ok by running them using [`pytest`](http://doc.pytest.org/en/latest/index.html). 45 | 4) Follow [Black](https://black.readthedocs.io/en/stable/) code formatting. 46 | * Install [pre-commit](https://pre-commit.com) python module using pip: **python -m pip install pre-commit** 47 | * To add the [pre-commit](https://pre-commit.com) hook, after the installation run: **pre-commit install** 48 | 5) Add your changes. 49 | * The commit should only contain small changes and should be atomic. 50 | * The commit message should follow [those rules](https://chris.beams.io/posts/git-commit/). 51 | 6) Add or update at least one [`pytest`](http://doc.pytest.org/en/latest/index.html) test case. 52 | * Unless it is an internal refactoring request or a documentation update. 53 | * Each line of code should be covered by the test cases. 54 | 7) Add related [changelog entry](https://keepachangelog.com/en/1.1.0/) in the Unreleased section. 55 | * Unless it is a documentation update. 56 | 57 | #### Enter pull request 58 | 59 | 1) Go to the *Pull requests* tab and click on the *New pull request* button. 60 | 2) *base* should always be set to `develop` and it should be compared to your branch. 61 | 3) Title should be a small sentence describing the request. 62 | 4) The comment should contains as much information as possible 63 | * Actual behavior (before the new code) 64 | * Expected behavior (with the new code) 65 | 5) A pull request can contain more than one commit, but the entire content should still be [atomic](#what-is-an-atomic-pull-request). 66 | 67 | ##### What is an atomic pull request 68 | 69 | It is important for a Pull Request to be atomic. But with a Pull Request, we measure the "succeed" as the ability to deliver the smallest possible piece of functionality, it can either be composed by one or many atomic commits. 70 | 71 | One of the bad practices of a Pull Request is changing things that are not concerned with the functionality that is being addressed, like whitespace changes, typo fixes, variable renaming, etc. If those things are not related to the concern of the Pull Request, it should probably be done in a different one. 72 | 73 | One might argue that this practice of not mixing different concerns and small fixes in the same Pull Request violates the Boy Scout Rule because it doesn't allow frequent cleanup. However, cleanup doesn't need to be done in the same Pull Request, the important thing is not leaving the codebase in a bad state after finishing the functionality. If you must, refactor the code in a separate Pull Request, and preferably before the actually concerned functionality is developed, because then if there is a need in the near future to revert the Pull Request, the likelihood of code conflict will be lower. [source](https://medium.com/@fagnerbrack/one-pull-request-one-concern-e84a27dfe9f1) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Colin Bounouar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

JSON formatter for logging

2 | 3 |

4 | pypi version 5 | Build status 6 | Coverage 7 | Code style: black 8 | Number of tests 9 | Number of downloads 10 |

11 | 12 | This module provides a JSON formatter for the python [`logging`](https://docs.python.org/3/library/logging.html) module that will format to JSON formatted string. 13 | 14 | Using this formatter allows to have the proper format for logging to `Splunk` or `ElasticSearch`, but it can also be used for logging to stdout as a string is issued. 15 | 16 | - [Features](#features) 17 | - [Custom fields](#adding-additional-fields-and-values) 18 | - [dict logging](#logging-with-a-dictionary) 19 | - [str logging](#logging-with-anything-else-such-as-a-string) 20 | - [Configuration](#configuration) 21 | - [Using dictConfig](#using-loggingconfigdictconfig) 22 | 23 | ## Features 24 | 25 | ### Adding additional fields and values 26 | 27 | You can add fields to every message that is being logged. 28 | To do so, specify the `fields` parameter to the `logging_json.JSONFormatter` instance. 29 | 30 | It must be a dictionary where keys are the keys to be appended to the resulting JSON dictionary (if not already present) and the values can be one of the following: 31 | * An attribute of the logging record (non-exhaustive list can be found on [the python logging documentation](https://docs.python.org/3/library/logging.html#logrecord-attributes)). 32 | * If not found on the record, the value will be linked to the key. 33 | 34 | #### Logging exceptions, a specific case 35 | 36 | If [an exception is logged](https://docs.python.org/3/library/logging.html#logging.exception), the `exception` key will be appended to the resulting JSON dictionary. 37 | 38 | This dictionary will contain 3 keys: 39 | * `type`: The name of the exception class (useful when the message is blank). 40 | * `message`: The str representation of the exception (usually the provided error message). 41 | * `stack`: The stack trace, formatted as a string. 42 | 43 | You can rename the exception field key by setting the `exception_field_name` parameter with a new name for the key. 44 | It is also possible to disable this behaviour by setting the `exception_field_name` parameter to `None` or an empty string 45 | 46 | ### Logging with a dictionary 47 | 48 | This formatter allows you to log dictionary as in the following: 49 | 50 | ```python 51 | import logging 52 | 53 | logging.info({"key": "value", "other key": "other value"}) 54 | ``` 55 | 56 | The resulting JSON dictionary will be the one you provided (with the [additional fields](#adding-additional-fields-and-values)). 57 | 58 | ### Logging with anything else (such as a string) 59 | 60 | Anything not logged using a dictionary will be handled by the standard formatter, and it can result in one of the 2 output: 61 | * A JSON dictionary, if [additional fields](#adding-additional-fields-and-values) are set or if `extra` parameter is used while logging, with the message available in the `message` key of the resulting JSON dictionary. 62 | Default `message` key name can be changed by `message_field_name` parameter of the `logging_json.JSONFormatter` instance. 63 | * The formatted record, if no [additional fields](#adding-additional-fields-and-values) are set. 64 | 65 | This handles the usual string logging as in the following: 66 | 67 | ```python 68 | import logging 69 | 70 | logging.info("This is my message") 71 | ``` 72 | 73 | ### Changing asctime representation 74 | 75 | You can override the default representation of asctime (`2003-07-08 16:49:45,896`) based on two different scenarii: 76 | 77 | #### Without milliseconds 78 | 79 | Set `datefmt` parameter. 80 | 81 | Setting `datefmt` to `%Y-%m-%dT%H:%M:%S` would result in `2003-07-08T16:49:45`. 82 | 83 | #### With milliseconds 84 | 85 | Set `default_time_format` to something else than `%Y-%m-%d %H:%M:%S` to change the representation part without milliseconds. 86 | Set `default_msec_format` to something else than `%s,%03d` to change the representation milliseconds. 87 | Note that `%s` in `default_msec_format` is going to be replaced by the representation without milliseconds. 88 | 89 | Setting `default_time_format` to `%Y-%m-%dT%H:%M:%S` and `default_msec_format` to `%s.%03d` would result in `2003-07-08T16:49:45.896`. 90 | 91 | ## Configuration 92 | 93 | You can create a formatter instance yourself as in the following, or you can use a logging configuration. 94 | 95 | ```python 96 | import logging_json 97 | 98 | formatter = logging_json.JSONFormatter(fields={ 99 | "level_name": "levelname", 100 | "thread_name": "threadName", 101 | "process_name": "processName" 102 | }) 103 | ``` 104 | 105 | ### Using logging.config.dictConfig 106 | 107 | You can configure your logging as advertise by python, by using the `logging.config.dictConfig` function. 108 | 109 | #### dict configuration 110 | 111 | ```python 112 | import logging.config 113 | 114 | logging.config.dictConfig({ 115 | "version": 1, 116 | "formatters": { 117 | "json": { 118 | '()': 'logging_json.JSONFormatter', 119 | 'fields':{ 120 | "level_name": "levelname", 121 | "thread_name": "threadName", 122 | "process_name": "processName" 123 | } 124 | } 125 | }, 126 | "handlers": { 127 | "standard_output": { 128 | 'class': 'logging.StreamHandler', 129 | 'formatter': 'json', 130 | 'stream': 'ext://sys.stdout' 131 | }, 132 | }, 133 | "loggers": { 134 | "my_app": {"level": "DEBUG"} 135 | }, 136 | "root": { 137 | "level": "INFO", 138 | "handlers": ["standard_output"] 139 | } 140 | }) 141 | ``` 142 | 143 | #### YAML logging configuration 144 | 145 | You can use YAML to store your logging configuration, as in the following sample: 146 | 147 | ```python 148 | import logging.config 149 | import yaml 150 | 151 | with open("path/to/logging_configuration.yaml", "r") as config_file: 152 | logging.config.dictConfig(yaml.load(config_file)) 153 | ``` 154 | 155 | Where `logging_configuration.yaml` can be a file containing the following sample: 156 | 157 | ```yaml 158 | version: 1 159 | formatters: 160 | json: 161 | '()': logging_json.JSONFormatter 162 | fields: 163 | level_name: levelname 164 | thread_name: threadName 165 | process_name: processName 166 | handlers: 167 | standard_output: 168 | class: logging.StreamHandler 169 | formatter: json 170 | stream: ext://sys.stdout 171 | loggers: 172 | my_app: 173 | level: DEBUG 174 | root: 175 | level: INFO 176 | handlers: [standard_output] 177 | ``` 178 | 179 | ## How to install 180 | 1. [python 3.7+](https://www.python.org/downloads/) must be installed 181 | 2. Use pip to install module: 182 | ```sh 183 | python -m pip install logging_json 184 | ``` 185 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /logging_json/__init__.py: -------------------------------------------------------------------------------- 1 | from logging_json.version import __version__ 2 | from logging_json._formatter import JSONFormatter 3 | -------------------------------------------------------------------------------- /logging_json/_formatter.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import datetime 3 | import json 4 | import logging 5 | from typing import Any, Dict, Optional 6 | 7 | standard_attributes = ( 8 | "name", 9 | "msg", 10 | "args", 11 | "levelname", 12 | "levelno", 13 | "pathname", 14 | "filename", 15 | "module", 16 | "exc_info", 17 | "exc_text", 18 | "stack_info", 19 | "lineno", 20 | "funcName", 21 | "created", 22 | "msecs", 23 | "relativeCreated", 24 | "thread", 25 | "threadName", 26 | "processName", 27 | "process", 28 | "message", 29 | "asctime", 30 | "taskName", 31 | ) 32 | 33 | 34 | def _extra_attributes(record: logging.LogRecord) -> Dict[str, Any]: 35 | return { 36 | name: record.__dict__[name] 37 | for name in set(record.__dict__).difference(standard_attributes) 38 | } 39 | 40 | 41 | def _value(record: logging.LogRecord, field_name_or_value: Any) -> Any: 42 | """ 43 | Retrieve value from record if possible. Otherwise use value. 44 | :param record: The record to extract a field named as in field_name_or_value. 45 | :param field_name_or_value: The field name to extract from record or the default value to use if not present. 46 | """ 47 | try: 48 | return getattr(record, field_name_or_value) 49 | except: 50 | return field_name_or_value 51 | 52 | 53 | def default_converter(obj: Any) -> str: 54 | if isinstance(obj, datetime.datetime): 55 | return obj.isoformat() 56 | return str(obj) 57 | 58 | 59 | def has_exception_info(record: logging.LogRecord) -> bool: 60 | return record.exc_info and record.exc_info[0] is not None 61 | 62 | 63 | class JSONFormatter(logging.Formatter): 64 | def __init__( 65 | self, 66 | *args, 67 | fields: Dict[str, Any] = None, 68 | message_field_name: str = "message", 69 | exception_field_name: Optional[str] = "exception", 70 | default_time_format: str = "%Y-%m-%d %H:%M:%S", 71 | default_msec_format: str = "%s,%03d", 72 | **kwargs, 73 | ): 74 | # Allow to provide any formatter setting (useful to provide a custom date format) 75 | super().__init__(*args, **kwargs) 76 | self.fields = fields or {} 77 | self.message_field_name = message_field_name 78 | self.exception_field_name = exception_field_name 79 | # (Allows to) overrides logging.Formatter default behavior 80 | self.usesTime = lambda: "asctime" in self.fields.values() 81 | self.default_time_format = default_time_format 82 | self.default_msec_format = default_msec_format 83 | 84 | def format(self, record: logging.LogRecord): 85 | # Let python set every additional record field 86 | super().format(record) 87 | 88 | message = { 89 | field_name: _value(record, field_value) 90 | for field_name, field_value in self.fields.items() 91 | } 92 | if isinstance(record.msg, collections.abc.Mapping): 93 | message.update(record.msg) 94 | else: 95 | message[self.message_field_name] = super().formatMessage(record) 96 | 97 | message.update(_extra_attributes(record)) 98 | 99 | if self.exception_field_name and has_exception_info(record): 100 | message[self.exception_field_name] = { 101 | "type": record.exc_info[0].__name__, 102 | "message": str(record.exc_info[1]), 103 | "stack": self.formatException(record.exc_info), 104 | } 105 | 106 | if len(message) == 1 and self.message_field_name in message: 107 | return super().formatMessage(record) 108 | 109 | return json.dumps(message, ensure_ascii=False, default=default_converter) 110 | 111 | def formatMessage(self, record: logging.LogRecord) -> str: 112 | # Speed up this step by doing nothing 113 | return "" 114 | -------------------------------------------------------------------------------- /logging_json/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Colin-b/logging_json/fcff26106bc3de505ef41824666f6763cddcc960/logging_json/py.typed -------------------------------------------------------------------------------- /logging_json/version.py: -------------------------------------------------------------------------------- 1 | # Version number as Major.Minor.Patch 2 | # The version modification must respect the following rules: 3 | # Major should be incremented in case there is a breaking change. (eg: 2.5.8 -> 3.0.0) 4 | # Minor should be incremented in case there is an enhancement. (eg: 2.5.8 -> 2.6.0) 5 | # Patch should be incremented in case there is a bug fix. (eg: 2.5.8 -> 2.5.9) 6 | __version__ = "0.6.0" 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools_scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "logging_json" 7 | description = "JSON formatter for python logging" 8 | readme = "README.md" 9 | requires-python = ">=3.9" 10 | license = {file = "LICENSE"} 11 | authors = [ 12 | {name = "Colin Bounouar", email = "colin.bounouar.dev@gmail.com" } 13 | ] 14 | maintainers = [ 15 | {name = "Colin Bounouar", email = "colin.bounouar.dev@gmail.com" } 16 | ] 17 | keywords = ["logging", "json"] 18 | classifiers=[ 19 | "Development Status :: 5 - Production/Stable", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Natural Language :: English", 23 | "Typing :: Typed", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Programming Language :: Python :: 3.13", 31 | "Topic :: Software Development :: Build Tools", 32 | ] 33 | dependencies = [] 34 | dynamic = ["version"] 35 | 36 | [project.urls] 37 | documentation = "https://colin-b.github.io/logging_json/" 38 | repository = "https://github.com/Colin-b/logging_json" 39 | changelog = "https://github.com/Colin-b/logging_json/blob/master/CHANGELOG.md" 40 | issues = "https://github.com/Colin-b/logging_json/issues" 41 | 42 | [project.optional-dependencies] 43 | testing = [ 44 | # Used to freeze time 45 | "time-machine==2.*", 46 | # Used to check coverage 47 | "pytest-cov==6.*", 48 | ] 49 | 50 | [tool.setuptools.packages.find] 51 | exclude = ["tests*"] 52 | 53 | [tool.setuptools.dynamic] 54 | version = {attr = "logging_json.version.__version__"} 55 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Colin-b/logging_json/fcff26106bc3de505ef41824666f6763cddcc960/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_formatter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | import datetime 4 | import pytest 5 | import sys 6 | import time_machine 7 | 8 | import logging_json 9 | 10 | 11 | class MyException(Exception): 12 | pass 13 | 14 | 15 | def test_empty_dict_message(caplog): 16 | caplog.set_level("INFO") 17 | logging.info({}) 18 | assert fmt(caplog) == "{}" 19 | 20 | 21 | def test_dict_message(caplog): 22 | caplog.set_level("INFO") 23 | logging.info({"key 1": "value 1", "key 2": 2}) 24 | assert fmt(caplog) == '{"key 1": "value 1", "key 2": 2}' 25 | 26 | 27 | def test_str_message(caplog): 28 | caplog.set_level("INFO") 29 | logging.info("message 1") 30 | assert fmt(caplog) == "message 1" 31 | 32 | 33 | def test_str_with_args_message(caplog): 34 | caplog.set_level("INFO") 35 | logging.info("message %s", "1") 36 | assert fmt(caplog) == "message 1" 37 | 38 | 39 | def test_str_with_message_field_name(caplog): 40 | caplog.set_level("INFO") 41 | logging.info("message 1") 42 | assert fmt(caplog, message_field_name="msg") == "message 1" 43 | 44 | 45 | def test_str_with_extra_message(caplog): 46 | caplog.set_level("INFO") 47 | logging.info("message 1", extra={"key1": "value 1"}) 48 | assert dict_fmt(caplog) == {"message": "message 1", "key1": "value 1"} 49 | 50 | 51 | def test_str_with_message_field_name_and_fields(caplog): 52 | caplog.set_level("INFO") 53 | logging.info("message 1") 54 | assert dict_fmt( 55 | caplog, message_field_name="msg", fields={"level": "levelname"} 56 | ) == {"msg": "message 1", "level": "INFO"} 57 | 58 | 59 | def test_str_with_args_and_extra_message(caplog): 60 | caplog.set_level("INFO") 61 | logging.info("message %s", "1", extra={"key1": "value 1"}) 62 | assert dict_fmt(caplog) == {"message": "message 1", "key1": "value 1"} 63 | 64 | 65 | @time_machine.travel("2020-09-10 13:12:33.007667") 66 | def test_dict_message_with_asctime(caplog): 67 | caplog.set_level("INFO") 68 | logging.info({"key 1": "value 1", "key 2": 2}) 69 | actual = dict_fmt(caplog, fields={"date_time": "asctime"}) 70 | assert actual.pop("date_time") == "2020-09-10 13:12:33,007" 71 | assert actual == {"key 1": "value 1", "key 2": 2} 72 | 73 | 74 | @time_machine.travel("2020-09-10 13:12:33.007667") 75 | def test_str_message_with_asctime(caplog): 76 | caplog.set_level("INFO") 77 | logging.info("message 1") 78 | actual = dict_fmt(caplog, fields={"date_time": "asctime"}) 79 | assert actual.pop("date_time") == "2020-09-10 13:12:33,007" 80 | assert actual == {"message": "message 1"} 81 | 82 | 83 | @time_machine.travel("2020-09-10 13:12:33.007667") 84 | def test_str_with_args_message_with_asctime(caplog): 85 | caplog.set_level("INFO") 86 | logging.info("message %s", "1") 87 | actual = dict_fmt(caplog, fields={"date_time": "asctime"}) 88 | assert actual.pop("date_time") == "2020-09-10 13:12:33,007" 89 | assert actual == {"message": "message 1"} 90 | 91 | 92 | @time_machine.travel("2020-09-10 13:12:33.007667") 93 | def test_str_with_args_and_extra_message_with_asctime(caplog): 94 | caplog.set_level("INFO") 95 | logging.info("message %s", "1", extra={"key1": "value 1"}) 96 | actual = dict_fmt(caplog, fields={"date_time": "asctime"}) 97 | assert actual.pop("date_time") == "2020-09-10 13:12:33,007" 98 | assert actual == {"message": "message 1", "key1": "value 1"} 99 | 100 | 101 | def test_dict_message_at_exception_level(caplog): 102 | caplog.set_level("INFO") 103 | try: 104 | raise MyException("this is the exception message") 105 | except MyException: 106 | logging.exception({"key 1": "value 1", "key 2": 2}) 107 | actual = dict_fmt(caplog) 108 | actual["exception"].pop("stack") 109 | assert actual == { 110 | "key 1": "value 1", 111 | "key 2": 2, 112 | "exception": { 113 | "message": "this is the exception message", 114 | "type": "MyException", 115 | }, 116 | } 117 | 118 | 119 | def test_dict_message_at_exception_level_with_different_field_name(caplog): 120 | caplog.set_level("INFO") 121 | try: 122 | raise MyException("this is the exception message") 123 | except MyException: 124 | logging.exception({"key 1": "value 1", "key 2": 2}) 125 | actual = dict_fmt(caplog, exception_field_name="info_about_exception") 126 | actual["info_about_exception"].pop("stack") 127 | assert actual == { 128 | "key 1": "value 1", 129 | "key 2": 2, 130 | "info_about_exception": { 131 | "message": "this is the exception message", 132 | "type": "MyException", 133 | }, 134 | } 135 | 136 | 137 | @pytest.mark.parametrize("value", [None, ""]) 138 | def test_dict_message_at_exception_level_without_exception_field(caplog, value): 139 | caplog.set_level("INFO") 140 | try: 141 | raise MyException("this is the exception message") 142 | except MyException: 143 | logging.exception({"key 1": "value 1", "key 2": 2}) 144 | actual = dict_fmt(caplog, exception_field_name=value) 145 | assert actual == { 146 | "key 1": "value 1", 147 | "key 2": 2, 148 | } 149 | 150 | 151 | def test_str_message_at_exception_level(caplog): 152 | caplog.set_level("INFO") 153 | try: 154 | raise MyException("this is the exception message") 155 | except MyException: 156 | logging.exception("message 1") 157 | actual = dict_fmt(caplog) 158 | actual["exception"].pop("stack") 159 | assert actual == { 160 | "message": "message 1", 161 | "exception": { 162 | "message": "this is the exception message", 163 | "type": "MyException", 164 | }, 165 | } 166 | 167 | 168 | def test_str_with_args_message_at_exception_level(caplog): 169 | caplog.set_level("INFO") 170 | try: 171 | raise MyException("this is the exception message") 172 | except MyException: 173 | logging.exception("message %s", "1") 174 | actual = dict_fmt(caplog) 175 | actual["exception"].pop("stack") 176 | assert actual == { 177 | "message": "message 1", 178 | "exception": { 179 | "message": "this is the exception message", 180 | "type": "MyException", 181 | }, 182 | } 183 | 184 | 185 | @time_machine.travel("2020-09-10T13:12:33.007667+00:00", tick=False) 186 | def test_documented_record_attributes(caplog): 187 | caplog.set_level("INFO") 188 | logging.info({}) 189 | actual = dict_fmt( 190 | caplog, 191 | fields={ 192 | "logger_name": "name", 193 | "level_number": "levelno", 194 | "level_name": "levelname", 195 | "file_path": "pathname", 196 | "file_name": "filename", 197 | "module_name": "module", 198 | "line_number": "lineno", 199 | "function_name": "funcName", 200 | "timestamp": "created", 201 | "timestamp_milliseconds": "msecs", 202 | "relative_timestamp": "relativeCreated", 203 | "thread_id": "thread", 204 | "thread_name": "threadName", 205 | "process_id": "process", 206 | "process_name": "processName", 207 | "task_name": "taskName", 208 | "record_message": "message", 209 | "extra": "this is a value", 210 | }, 211 | ) 212 | actual.pop("file_path") 213 | actual.pop("thread_id") 214 | actual.pop("process_id") 215 | actual.pop("relative_timestamp") 216 | actual.pop("line_number") 217 | python_minor = sys.version_info.minor 218 | assert actual == { 219 | "extra": "this is a value", 220 | "file_name": "test_formatter.py", 221 | "function_name": "test_documented_record_attributes", 222 | "level_name": "INFO", 223 | "level_number": 20, 224 | "logger_name": "root", 225 | "module_name": "test_formatter", 226 | "process_name": "MainProcess", 227 | "record_message": "{}", 228 | "thread_name": "MainThread", 229 | "task_name": None if python_minor >= 12 else "taskName", 230 | "timestamp": 1599743553.0076668, 231 | "timestamp_milliseconds": 7.0 if python_minor >= 10 else 7.666826248168945, 232 | } 233 | 234 | 235 | def test_with_extra_in_fields_and_message(caplog): 236 | caplog.set_level("INFO") 237 | logging.info("message 1", extra={"key1": "value 1"}) 238 | assert dict_fmt(caplog, fields={"extra": "key1", "key2": "value 2",},) == { 239 | "extra": "value 1", 240 | "key1": "value 1", 241 | "key2": "value 2", 242 | "message": "message 1", 243 | } 244 | 245 | 246 | @time_machine.travel("2020-09-10 13:12:33.0076675") 247 | def test_asctime_with_datefmt(caplog): 248 | caplog.set_level("INFO") 249 | logging.info("message 1") 250 | actual = dict_fmt(caplog, fields={"date_time": "asctime"}, datefmt="%Y-%m-%dT%H:%M:%S") 251 | assert actual.pop("date_time") == "2020-09-10T13:12:33" 252 | assert actual == {"message": "message 1"} 253 | 254 | 255 | @time_machine.travel("2020-09-10 13:12:33.0076675") 256 | def test_asctime_with_default_time_format_and_default_msec_format(caplog): 257 | caplog.set_level("INFO") 258 | logging.info("message 1") 259 | actual = dict_fmt(caplog, fields={"date_time": "asctime"}, default_time_format="%Y-%m-%dT%H:%M:%S", default_msec_format="%s.%03d") 260 | assert actual.pop("date_time") == "2020-09-10T13:12:33.007" 261 | assert actual == {"message": "message 1"} 262 | 263 | 264 | def test_encoding_with_str(caplog): 265 | caplog.set_level("INFO") 266 | logging.info("테스트") 267 | actual = fmt(caplog) 268 | assert actual == "테스트" 269 | 270 | 271 | def test_encoding_with_dict(caplog): 272 | caplog.set_level("INFO") 273 | logging.info("테스트", extra={"테스트": "테스트"}) 274 | actual = fmt(caplog) 275 | assert actual == '{"message": "테스트", "테스트": "테스트"}' 276 | 277 | 278 | def test_json_dumps_error(caplog): 279 | class CustomWithoutStr: 280 | pass 281 | 282 | custom = CustomWithoutStr() 283 | 284 | class CustomWithStr: 285 | def __str__(self): 286 | return "Custom instance" 287 | 288 | caplog.set_level("INFO") 289 | logging.info( 290 | { 291 | "my_datetime": datetime.datetime( 292 | 2020, 1, 10, 3, 14, 11, tzinfo=datetime.timezone.utc 293 | ), 294 | "my_date": datetime.date(2020, 1, 10), 295 | "my_time": datetime.time(3, 14, 11, tzinfo=datetime.timezone.utc), 296 | "my_custom_obj": custom, 297 | "my_custom_obj_with_str": CustomWithStr(), 298 | } 299 | ) 300 | assert dict_fmt(caplog,) == { 301 | "my_custom_obj": str(custom), 302 | "my_custom_obj_with_str": "Custom instance", 303 | "my_date": "2020-01-10", 304 | "my_datetime": "2020-01-10T03:14:11+00:00", 305 | "my_time": "03:14:11+00:00", 306 | } 307 | 308 | 309 | def test_exception_logging_without_exception_info(caplog): 310 | caplog.set_level("INFO") 311 | logging.exception("There is no exception context") 312 | 313 | assert fmt(caplog) == "There is no exception context" 314 | 315 | 316 | def test_exception_logging_in_finally(caplog): 317 | caplog.set_level("INFO") 318 | try: 319 | raise MyException("this is the exception message") 320 | except MyException: 321 | pass 322 | finally: 323 | logging.exception("Something happened") 324 | 325 | assert fmt(caplog) == "Something happened" 326 | 327 | 328 | def fmt(caplog, *formatter_args, **formatter_kwargs) -> str: 329 | return logging_json.JSONFormatter(*formatter_args, **formatter_kwargs).format( 330 | caplog.records[0] 331 | ) 332 | 333 | 334 | def dict_fmt(caplog, *formatter_args, **formatter_kwargs) -> dict: 335 | return json.loads(fmt(caplog, *formatter_args, **formatter_kwargs)) 336 | --------------------------------------------------------------------------------