Extensible, reusable, awesome log formatter for Python
You too are tiered of rewriting log handling for each project? Here's my simple, extensible formatter, designed so we never have to write it again.
This log formatter ships with commonly used features, like JSON serialization, attribute injection, error handling and more.
Adding log attributes beside the message is made simple, like contextual data, runtime information, request information, basicly whatever you may need. Those attribute providers can easily be shared between your services, streamlining the development experince between your services.
- Extensible - Add attributes to logs with ease, including simple error handling
- Reusable - Share your attribute providers across projects
- Contextual - Automatically adds useful context to logs
- Out-of-the-box - Include common providers for HTTP data, runtime, and more
My log record's default attributes are mostly compatible with DataDog's Standard Attributes.
- Django - Automatically adds request context
- FastAPI - Automatically adds request context (including Starlette support)
- Flask - Automatically adds request context
- Celery - Automatically adds task context
- orjson - Uses orSON for serialization
- ujson - Uses uJSON for serialization
Install my package using pip:
pip install dans-log-formatter
Then set up your logging configuration:
import logging.config
from dans_log_formatter.providers.context import ContextProvider
logging.config.dictConfig({
"version": 1,
"formatters": {
"json": {
"()": "dans_log_formatter.JsonLogFormatter",
"providers": [
ContextProvider(),
], # optional
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "json",
}
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
})
Then, use it in your project:
import logging
logger = logging.getLogger(__name__)
def main():
logger.info("Hello, world!")
if __name__ == "__main__":
main()
# STDOUT: {'timestamp': 1704060000.0, 'status': 'INFO', 'message': 'hello world!', 'location': 'my_module-main#4', 'file': '/Users/danyi1212/projects/my-project/my_module.py'}
Providers add attributes to logs. You can use the built-in providers or create your own.
Inject context into logs using decorator or context manager.
from dans_log_formatter import JsonLogFormatter
from dans_log_formatter.providers.context import ContextProvider
formatter = JsonLogFormatter(providers=[ContextProvider()])
Then use the inject_log_context()
as a context manager
import logging
from dans_log_formatter.providers.context import inject_log_context
logger = logging.getLogger(__name__)
with inject_log_context({"user_id": 123}):
logger.info("Hello, world!")
# STDOUT: {'timestamp': 1704060000.0, 'status': 'INFO', 'message': 'hello world!', 'user_id': 123, ...}
Alternatively, use it as @inject_log_context()
decorator
import logging
from dans_log_formatter.providers.context import inject_log_context
logger = logging.getLogger(__name__)
@inject_log_context({"custom_context": "value"})
def my_function():
logger.info("Hello, world!")
# STDOUT: {'timestamp': 1704060000.0, 'status': 'INFO', 'message': 'hello world!', 'custom_context': 'value', ...}
Add ExtraProvider()
from dans_log_formatter.providers.extra
, then use the extra={}
argument in your log calls
import logging
logger = logging.getLogger(__name__)
logger.info("Hello, world!", extra={"user_id": 123})
# STDOUT: {'timestamp': 1704060000.0, 'status': 'INFO', 'message': 'hello world!', 'user_id': 123, ...}
Add RuntimeProvider()
from dans_log_formatter.providers.runtime
to add runtime information to logs.
process
- Current process name and ID (e.g.main (12345)
)thread
- Current thread name and ID (e.g.MainThread (12345)
)task
- Current asyncio task name (e.g.my_corrutine
)
from logging import LogRecord
from typing import Any
from dans_log_formatter.providers.abstract import AbstractProvider
class MyProvider(AbstractProvider):
"""Add 'my_attribute' to all logs"""
def get_attributes(self, record: LogRecord) -> dict[str, Any]:
return {"my_attribute": "some value"}
You can also use the abstract context provider to add data from contextvars
from contextvars import ContextVar
import logging
from typing import Any
from dataclasses import dataclass
from dans_log_formatter.providers.abstract_context import AbstractContextProvider
@dataclass
class User:
id: int
name: str
current_user_context: ContextVar[User | None] = ContextVar("current_user_context", default=None)
class MyContextProvider(AbstractContextProvider):
"""Add user.id and user.name context to logs"""
def __init__(self):
super().__init__(current_user_context) # Pass the context
def get_context_attributes(self, record: logging.LogRecord, current_user: User) -> dict[str, Any]:
return {"user.id": current_user.id, "user.name": current_user.name}
logger = logging.getLogger(__name__)
token = current_user_context.set(User(id=123, name="John Doe"))
logger.info("Hello, world!")
current_user_context.reset(token)
# STDOUT: {'timestamp': 1704060000.0, 'status': 'INFO', 'message': 'Hello, world!', 'user.id': 123, 'user.name': 'John Doe', ...}
Install using 'pip install dans-log-formatter[django]'
Add the 'LogContextMiddleware' to your Django middlewares at the very beginning.
# settings.py
MIDDLEWARE = [
"dans_log_formatter.contrib.django.middleware.LogContextMiddleware",
...
]
Then, add DjangoRequestProvider()
to your formatter.
# settings.py
from dans_log_formatter.contrib.django.provider import DjangoRequestProvider
LOGGING = {
"version": 1,
"formatters": {
"json": {
"()": "dans_log_formatter.JsonLogFormatter",
"providers": [
DjangoRequestProvider(),
],
}
},
# ...
}
resource
- View route (e.g.POST /api/users/<int:user_id>/delete
)http.url
- Full URL (e.g.https://example.com/api/users/123/delete
)http.method
- HTTP method (e.g.POST
)http.referrer
-Referrer
header (e.g.https://example.com/previous-page
)http.user_agent
-useragent
headerhttp.remote_addr
-X-Forwarded-For
orREMOTE_ADDR
headeruser.id
- User IDuser.name
- User's usernameuser.email
- User email
Note: The
user
attributes available only inside thedjango.contrib.auth.middleware.AuthenticationMiddleware
middleware.
Install using 'pip install dans-log-formatter[fastapi]'
Add the 'LogContextMiddleware' to your FastAPI app.
from fastapi import FastAPI
from dans_log_formatter.contrib.fastapi.middleware import LogContextMiddleware
app = FastAPI()
app.add_middleware(LogContextMiddleware)
Then, add FastAPIRequestProvider()
to your formatter.
import logging.config
from dans_log_formatter.contrib.fastapi.provider import FastAPIRequestProvider
logging.config.dictConfig({
"version": 1,
"formatters": {
"json": {
"()": "dans_log_formatter.JsonLogFormatter",
"providers": [
FastAPIRequestProvider(),
],
}
},
# ...
})
resource
- Route path (e.g.POST /api/users/{user_id}/delete
)http.url
- Full URL (e.g.https://example.com/api/users/123/delete
)http.method
- HTTP method (e.g.POST
)http.referrer
-Referrer
header (e.g.https://example.com/previous-page
)http.user_agent
-useragent
headerhttp.remote_addr
-X-Forwarded-For
header or therequest.client.host
attribute
Install using 'pip install dans-log-formatter[flask]'
Add the 'FlastRequestProvider' to your formatter, and its magic!
import logging.config
from dans_log_formatter.contrib.flask.provider import FlaskRequestProvider
logging.config.dictConfig({
"version": 1,
"formatters": {
"json": {
"()": "dans_log_formatter.JsonLogFormatter",
"providers": [
FlaskRequestProvider(),
],
}
},
# ...
})
resource
- URL path (e.g.POST /api/users/123/delete
)http.url
- Full URL (e.g.https://example.com/api/users/123/delete
)http.method
- HTTP method (e.g.POST
)http.referrer
-Referrer
header (e.g.https://example.com/previous-page
)http.user_agent
-useragent
headerhttp.remote_addr
-request.remote_addr
attribute
Install using 'pip install dans-log-formatter[celery]'
Add the 'CeleryTaskProvider' to your formatter, and its magic!
import logging.config
from dans_log_formatter.contrib.celery.provider import CeleryTaskProvider
logging.config.dictConfig({
"version": 1,
"formatters": {
"json": {
"()": "dans_log_formatter.JsonLogFormatter",
"providers": [
CeleryTaskProvider(), # optional include_args=True
],
}
},
# ...
})
resource
- Task name (e.g.my_project.tasks.my_task
)task.id
- Task IDtask.retries
- Number of retriestask.root_id
- Root task IDtask.parent_id
- Parent task IDtask.origin
- Producer host nametask.delivery_info
- Delivery info ( e.g.{"exchange": "my_exchange", "routing_key": "my_routing_key", "queue": "my_queue"}
)task.worker
- Worker hostnametask.args
- Task arguments (ifinclude_args=True
)task.kwargs
- Task keyword arguments (ifinclude_args=True
)
Warning: Including task arguments can expose sensitive information, and may result in very large logs.
Install using 'pip install dans-log-formatter[ujson]'
Uses ujson
for JSON serialization of the log records.
import logging.config
logging.config.dictConfig({
"version": 1,
"formatters": {
"json": {
"()": "dans_log_formatter.contrib.ujson.UJsonLogFormatter",
"providers": [], # optional
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "json",
}
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
})
Install using 'pip install dans-log-formatter[orjson]'
Uses orjson
for JSON serialization of the log records.
import logging.config
logging.config.dictConfig({
"version": 1,
"formatters": {
"json": {
"()": "dans_log_formatter.contrib.orjson.OrJsonLogFormatter",
"providers": [], # optional
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "json",
}
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
})
By default, all formatter includes the following attributes:
timestamp
- Unix timestamp (same as therecord.created
attribute, or the value returned bytime.time()
. See the docs)status
- Log level name (e.g.INFO
,ERROR
,CRITICAL
)message
- Log messagelocation
- Location of the log call (e.g.my_module-my_func#4
)file
- File path of the log call (e.g./Users/danyi1212/projects/my-project/my_module.py
)error
- Exception message and traceback (whenexec_info=True
)stack_info
- Stack trace (whenstack_info=True
)formatter_errors
- Errors from the formatter or providers (when an error occurs)
By default, the message
value is truncated to 64k characters, and the error
, 'stack_info', and formatter_errors
values are truncated to 128k characters.
You can override the default truncation using:
import logging.config
logging.config.dictConfig({
"version": 1,
"formatters": {
"json": {
"()": "dans_log_formatter.JsonLogFormatter",
"message_size_limit": 1024, # Set None to unlimited
"stack_size_limit": 1024, # Set None to unlimited
}
},
# ...
})
Format log records as JSON using json.dumps()
.
Format log records as human-readable text using logging.Formatter
(See the docs).
All attributes are available to use in the format string.
The timestamp
attribute is formatted using the datefmt
like in the logging.Formatter
.
import logging.config
from dans_log_formatter.providers.context import ContextProvider, inject_log_context
logging.config.dictConfig({
"version": 1,
"formatters": {
"text": {
"()": "dans_log_formatter.TextLogFormatter",
"providers": [ContextProvider()],
"fmt": "{timestamp} {status} | {user_id} - {message}",
"datefmt": "%H:%M:%S",
"style": "{"
}
},
# ...
})
logger = logging.getLogger(__name__)
with inject_log_context({"user_id": 123}):
logger.info("Hello, world!")
# STDOUT: 12:00:42 INFO | 123 - Hello, world!
You can extend the JsonLogFormatter
to modify the default attributes, add new ones, use other log record serializer or anything else.
import socket
from logging import LogRecord
import xml.etree.ElementTree as ET
from dans_log_formatter import JsonLogFormatter
class MyCustomFormatter(JsonLogFormatter):
root_tag = "log"
def format(self, record: LogRecord) -> str:
# Serialize to XML instead of JSON
return self.attributes_to_xml(self.get_attributes(record))
def attributes_to_xml(self, attributes: dict[str, str]) -> str:
root = ET.Element(self.root_tag)
for key, value in attributes.items():
element = ET.SubElement(root, key)
element.text = value
return ET.tostring(root, encoding="unicode")
def format_status(self, record: LogRecord) -> int:
return record.levelno # Use the level number instead of the level name
def format_location(self, record: LogRecord) -> str:
return f"{record.module}-{record.funcName}" # Use only the module and function name, without the line number
def format_exception(self, record: LogRecord) -> str:
return f"{record.exc_info[0].__name__}: {record.exc_info[1]}" # Use only the exception name and message
def get_attributes(self, record: LogRecord) -> dict:
attributes = super().get_attributes(record)
attributes["hostname"] = socket.gethostname() # Add an extra hostname default attribute
return attributes
Note: Creating a custom
HostnameProvider
is a better way to add the hostname attribute.
When an error occurs in the formatter or providers, the formatter_errors
attribute is added to the log record.
Silent errors can be added to the formatter_errors
attribute using the record_error()
method.
from dans_log_formatter.providers.abstract import AbstractProvider
class MyProvider(AbstractProvider):
def get_attributes(self, record: LogRecord) -> dict[str, Any]:
self.record_error("Something went wrong") # Add an error to the formatter_errors attribute
return {'my_attribute': 'some value'}
Exception traceback context is automatically added to the recorded error or caught exceptions described in the formatter_errors
attribute.
Before contributing, please read the contributing guidelines for guidance on how to get started.
This project is licensed under the MIT License.