mirror of
https://github.com/kristoferssolo/bunyan-formatter.git
synced 2026-02-04 06:22:05 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d32365f4bf | |||
| 1cfaad6c69 | |||
| ea5c9e7f7f | |||
| 461911b3d6 | |||
| b24a924a4f | |||
| 1a1b80ab5f | |||
| 5ccd804b9b | |||
| 44ffa512a1 | |||
| ad7db29ac1 |
207
README.md
207
README.md
@@ -1,3 +1,206 @@
|
||||
# bunyan-formatter
|
||||
# Bunyan Formatter
|
||||
|
||||
Describe your project here.
|
||||
<!-- toc -->
|
||||
|
||||
- [Description](#description)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
* [Django](#django)
|
||||
- [Examples](#examples)
|
||||
* [Basic Logging](#basic-logging)
|
||||
* [Error Logging with Exception](#error-logging-with-exception)
|
||||
* [Custom Fields](#custom-fields)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
<!-- tocstop -->
|
||||
|
||||
A custom formatter for Python's logging module that outputs logs in the Bunyan
|
||||
JSON format.
|
||||
|
||||
## Description
|
||||
|
||||
This package provides a `BunyanFormatter` class that formats log records into
|
||||
the Bunyan JSON format. Bunyan is a lightweight JSON logger for Node.js, but
|
||||
this formatter allows you to use the same log format in Python projects.
|
||||
|
||||
Key features:
|
||||
|
||||
- Outputs logs in JSON format
|
||||
- Includes project name, hostname, file path, line number, and other metadata
|
||||
- Supports various log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
- Handles both project and external file paths
|
||||
|
||||
## Installation
|
||||
|
||||
To install the Bunyan Formatter package, run:
|
||||
|
||||
```bash
|
||||
pip install bunyan-formatter
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Here's a basic example of how to use the Bunyan Formatter in your Python project:
|
||||
|
||||
```python
|
||||
import logging
|
||||
from bunyan_formatter import BunyanFormatter
|
||||
|
||||
# Create a logger
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Create a handler and set the formatter
|
||||
handler = logging.StreamHandler()
|
||||
formatter = BunyanFormatter(project_name="MyProject", project_root="/path/to/my/project")
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
# Add the handler to the logger
|
||||
logger.addHandler(handler)
|
||||
|
||||
# Now you can use the logger
|
||||
logger.debug("This is a debug message")
|
||||
logger.info("This is an info message")
|
||||
logger.warning("This is a warning message")
|
||||
logger.error("This is an error message")
|
||||
logger.critical("This is a critical message")
|
||||
```
|
||||
|
||||
### Django
|
||||
|
||||
In your Django project's `settings.py` file, add the following logging configuration:
|
||||
|
||||
```python
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"bunyan": {
|
||||
"()": BunyanFormatter,
|
||||
"project_name": "MyProject",
|
||||
"project_root": BASE_DIR,
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "bunyan",
|
||||
"stream": "ext://sys.stdout",
|
||||
},
|
||||
"file": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.FileHandler",
|
||||
"filename": BASE_DIR / "logs" / "django.log",
|
||||
"formatter": "bunyan",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": "DEBUG",
|
||||
"handlers": ["console", "file"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Logging
|
||||
|
||||
```python
|
||||
logger.info("User logged in", extra={"username": "john_doe"})
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 0,
|
||||
"name": "MyProject",
|
||||
"msg": "User logged in",
|
||||
"level": 30,
|
||||
"levelname": "INFO",
|
||||
"hostname": "your-hostname",
|
||||
"target": "__main__",
|
||||
"line": 10,
|
||||
"file": "main.py",
|
||||
"extra": {
|
||||
"username": "john_doe"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Logging with Exception
|
||||
|
||||
```python
|
||||
try:
|
||||
result = 1 / 0
|
||||
except ZeroDivisionError as e:
|
||||
logger.exception("An error occurred", exc_info=True)
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 0,
|
||||
"name": "MyProject",
|
||||
"msg": "An error occurred",
|
||||
"level": 50,
|
||||
"levelname": "ERROR",
|
||||
"hostname": "your-hostname",
|
||||
"target": "__main__",
|
||||
"line": 15,
|
||||
"file": "main.py",
|
||||
"err": {
|
||||
"message": "division by zero",
|
||||
"name": "ZeroDivisionError",
|
||||
"stack": [
|
||||
// Stack trace here
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Fields
|
||||
|
||||
You can add custom fields to your log entries:
|
||||
|
||||
```python
|
||||
logger.info("Order processed", extra={
|
||||
"order_id": 12345,
|
||||
"customer_id": 67890,
|
||||
"total_amount": 100.00
|
||||
})
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 0,
|
||||
"name": "MyProject",
|
||||
"msg": "Order processed",
|
||||
"level": 30,
|
||||
"levelname": "INFO",
|
||||
"hostname": "your-hostname",
|
||||
"target": "__main__",
|
||||
"line": 20,
|
||||
"file": "main.py",
|
||||
"extra": {
|
||||
"order_id": 12345,
|
||||
"customer_id": 67890,
|
||||
"total_amount": 100.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please submit pull requests or issues on our GitHub repository.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
[project]
|
||||
name = "bunyan-formatter"
|
||||
version = "1.0.0"
|
||||
description = "Bunyan Formatter for Python"
|
||||
version = "0.1.3"
|
||||
description = "A custom formatter for Python's logging module that outputs logs in the Bunyan JSON format."
|
||||
dependencies = []
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
authors = [{ name = "Kristofers Solo", email = "dev@kristofers.xyz" }]
|
||||
license = { file = "LICENSE" }
|
||||
keywords = ["logger", "logging", "bunyan", "formatter"]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Intended Audience :: Developers",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/kristoferssolo/bunyan-formatter"
|
||||
Tracker = "https://github.com/kristoferssolo/bunyan-formatter/issues"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -2,21 +2,46 @@ import json
|
||||
import logging
|
||||
import socket
|
||||
import time
|
||||
import traceback
|
||||
from logging import Formatter, LogRecord
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
DEFAULT_FIELDS = {
|
||||
"name",
|
||||
"msg",
|
||||
"args",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"pathname",
|
||||
"filename",
|
||||
"module",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"stack_info",
|
||||
"lineno",
|
||||
"funcName",
|
||||
"created",
|
||||
"msecs",
|
||||
"relativeCreated",
|
||||
"thread",
|
||||
"threadName",
|
||||
"processName",
|
||||
"process",
|
||||
"taskName",
|
||||
}
|
||||
|
||||
class BunyanFormatter(Formatter):
|
||||
LEVEL_MAP: ClassVar[dict[int, int]] = {
|
||||
LEVEL_MAP = {
|
||||
logging.NOTSET: 10,
|
||||
logging.DEBUG: 20,
|
||||
logging.INFO: 30,
|
||||
logging.WARNING: 40,
|
||||
logging.ERROR: 50,
|
||||
logging.CRITICAL: 60,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BunyanFormatter(Formatter):
|
||||
def __init__(self, project_name: str, project_root: Path) -> None:
|
||||
super().__init__()
|
||||
self.project_name = project_name
|
||||
@@ -24,8 +49,6 @@ class BunyanFormatter(Formatter):
|
||||
self.hostname = socket.gethostname()
|
||||
|
||||
def format(self, record: LogRecord) -> str:
|
||||
hostname = socket.gethostname()
|
||||
|
||||
file_path = Path(record.pathname)
|
||||
try:
|
||||
relative_path = file_path.relative_to(self.project_root)
|
||||
@@ -36,9 +59,9 @@ class BunyanFormatter(Formatter):
|
||||
"v": 0,
|
||||
"name": self.project_name,
|
||||
"msg": record.getMessage(),
|
||||
"level": self.LEVEL_MAP.get(record.levelno, record.levelno),
|
||||
"level": LEVEL_MAP.get(record.levelno, record.levelno),
|
||||
"levelname": record.levelname,
|
||||
"hostname": hostname,
|
||||
"hostname": self.hostname,
|
||||
"pid": record.process,
|
||||
"time": self.formatTime(record),
|
||||
"target": record.name,
|
||||
@@ -46,6 +69,15 @@ class BunyanFormatter(Formatter):
|
||||
"file": str(relative_path),
|
||||
}
|
||||
|
||||
# Handle extra fields
|
||||
extra_fields = {k: v for k, v in record.__dict__.items() if k not in DEFAULT_FIELDS}
|
||||
if extra_fields:
|
||||
log_entry["extra"] = extra_fields
|
||||
|
||||
# Handle exception information
|
||||
if record.exc_info and all(record.exc_info):
|
||||
log_entry["err"] = self._format_exception(record)
|
||||
|
||||
return json.dumps(log_entry)
|
||||
|
||||
def formatTime(self, record: LogRecord, datefmt: Optional[str] = None) -> str: # noqa: N802
|
||||
@@ -56,3 +88,30 @@ class BunyanFormatter(Formatter):
|
||||
t = time.strftime("%Y-%m-%dT%H:%M:%S", ct)
|
||||
s = f"{t}.{int(record.msecs):03d}Z"
|
||||
return s
|
||||
|
||||
def _format_exception(self, record: LogRecord) -> dict[str, Any]:
|
||||
exc_info = record.exc_info
|
||||
|
||||
if exc_info is None or len(exc_info) != 3:
|
||||
return {}
|
||||
|
||||
exc_type, exc_value, exc_traceback = exc_info
|
||||
|
||||
if exc_type is None or exc_value is None or exc_traceback is None:
|
||||
return {}
|
||||
|
||||
stack = traceback.extract_tb(exc_traceback)
|
||||
|
||||
return {
|
||||
"message": str(exc_value),
|
||||
"name": getattr(exc_type, "__name__", "UnknownException"),
|
||||
"stack": [
|
||||
{
|
||||
"file": frame.filename,
|
||||
"line": frame.lineno,
|
||||
"function": frame.name,
|
||||
"text": frame.line.strip() if frame.line is not None else "",
|
||||
}
|
||||
for frame in stack
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
import logging
|
||||
from logging import LogRecord
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
from logging import LogRecord, StreamHandler
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest import TestCase
|
||||
@@ -10,7 +12,11 @@ from bunyan_formatter import BunyanFormatter
|
||||
|
||||
|
||||
class TestBunyanFormatter(TestCase):
|
||||
def setUp(self) -> None:
|
||||
@patch("socket.gethostname")
|
||||
def setUp(self, mock_gethostname: Optional[Mock]) -> None:
|
||||
if mock_gethostname is None:
|
||||
raise ValueError("mock_gethostname should not be None")
|
||||
mock_gethostname.return_value = "test_host"
|
||||
self.project_name = "test_project"
|
||||
self.project_root = Path("/path/to/project")
|
||||
self.formatter = BunyanFormatter(self.project_name, self.project_root)
|
||||
@@ -18,12 +24,7 @@ class TestBunyanFormatter(TestCase):
|
||||
def create_log_record(self, level: int, msg: str, pathname: str) -> LogRecord:
|
||||
return LogRecord(name="test_logger", level=level, pathname=pathname, lineno=42, msg=msg, args=(), exc_info=None)
|
||||
|
||||
@patch("socket.gethostname")
|
||||
def test_format_basic(self, mock_gethostname: Optional[Mock]) -> None:
|
||||
if mock_gethostname is None:
|
||||
raise ValueError("mock_gethostname should not be None")
|
||||
|
||||
mock_gethostname.return_value = "test_host"
|
||||
def test_format_basic(self) -> None:
|
||||
record = self.create_log_record(logging.INFO, "Test message", "/path/to/project/test.py")
|
||||
|
||||
formatted = self.formatter.format(record)
|
||||
@@ -56,11 +57,7 @@ class TestBunyanFormatter(TestCase):
|
||||
log_entry = json.loads(formatted)
|
||||
assert log_entry["file"] == "/path/outside/project/test.py"
|
||||
|
||||
@patch("socket.gethostname")
|
||||
def test_format_hostname_consistency(self, mock_gethostname: Optional[Mock]) -> None:
|
||||
if mock_gethostname is None:
|
||||
raise ValueError("mock_gethostname should not be None")
|
||||
mock_gethostname.return_value = "test_host"
|
||||
def test_format_hostname_consistency(self) -> None:
|
||||
record1 = self.create_log_record(logging.INFO, "Message 1", "/path/to/project/test1.py")
|
||||
record2 = self.create_log_record(logging.INFO, "Message 2", "/path/to/project/test2.py")
|
||||
|
||||
@@ -84,3 +81,126 @@ class TestBunyanFormatter(TestCase):
|
||||
datetime.strptime(log_entry["time"], "%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
except ValueError:
|
||||
self.fail("Time is not in the correct format")
|
||||
|
||||
def test_format_exception(self) -> None:
|
||||
logger = logging.getLogger("test_logger")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
stream = StringIO()
|
||||
handler = StreamHandler(stream)
|
||||
handler.setFormatter(self.formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
try:
|
||||
raise ValueError("Test error")
|
||||
except ValueError:
|
||||
logger.exception("An error occurred")
|
||||
|
||||
# Get the last logged message
|
||||
last_message = handler.stream.getvalue().splitlines()[-1]
|
||||
log_entry = json.loads(last_message)
|
||||
|
||||
assert log_entry["v"] == 0
|
||||
assert log_entry["name"] == self.project_name
|
||||
assert log_entry["msg"] == "An error occurred"
|
||||
assert log_entry["level"] == 50
|
||||
assert log_entry["levelname"] == "ERROR"
|
||||
assert log_entry["hostname"] == "test_host"
|
||||
assert log_entry["target"] == "test_logger"
|
||||
assert "err" in log_entry
|
||||
assert "message" in log_entry["err"]
|
||||
assert "name" in log_entry["err"]
|
||||
assert "stack" in log_entry["err"]
|
||||
|
||||
def test_format_custom_fields(self) -> None:
|
||||
logger = logging.getLogger("test_logger")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
stream = StringIO()
|
||||
handler = StreamHandler(stream)
|
||||
handler.setFormatter(self.formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
logger.info("User logged in", extra={"username": "john_doe", "ip_address": "192.168.1.100"})
|
||||
|
||||
# Get the last logged message
|
||||
last_message = handler.stream.getvalue().splitlines()[-1]
|
||||
log_entry = json.loads(last_message)
|
||||
|
||||
assert log_entry["v"] == 0
|
||||
assert log_entry["name"] == self.project_name
|
||||
assert log_entry["msg"] == "User logged in"
|
||||
assert log_entry["level"] == 30
|
||||
assert log_entry["levelname"] == "INFO"
|
||||
assert log_entry["target"] == "test_logger"
|
||||
assert "extra" in log_entry
|
||||
assert log_entry["extra"]["username"] == "john_doe"
|
||||
assert log_entry["extra"]["ip_address"] == "192.168.1.100"
|
||||
|
||||
def test_format_nested_custom_fields(self) -> None:
|
||||
logger = logging.getLogger("test_logger")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
stream = StringIO()
|
||||
handler = StreamHandler(stream)
|
||||
handler.setFormatter(self.formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
nested_data = {
|
||||
"user": {"id": 123, "email": "user@example.com"},
|
||||
"action": "login",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
logger.info("Complex action performed", extra={"data": nested_data})
|
||||
|
||||
# Get the last logged message
|
||||
last_message = handler.stream.getvalue().splitlines()[-1]
|
||||
log_entry = json.loads(last_message)
|
||||
|
||||
assert log_entry["v"] == 0
|
||||
assert log_entry["name"] == self.project_name
|
||||
assert log_entry["msg"] == "Complex action performed"
|
||||
assert log_entry["level"] == 30
|
||||
assert log_entry["levelname"] == "INFO"
|
||||
assert log_entry["target"] == "test_logger"
|
||||
assert "extra" in log_entry
|
||||
assert "data" in log_entry["extra"]
|
||||
assert isinstance(log_entry["extra"]["data"], dict)
|
||||
assert log_entry["extra"]["data"]["user"]["id"] == 123
|
||||
assert log_entry["extra"]["data"]["user"]["email"] == "user@example.com"
|
||||
assert log_entry["extra"]["data"]["action"] == "login"
|
||||
assert "timestamp" in log_entry["extra"]["data"]
|
||||
|
||||
def test_format_exception_with_custom_fields(self) -> None:
|
||||
logger = logging.getLogger("test_logger")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
stream = StringIO()
|
||||
handler = StreamHandler(stream)
|
||||
handler.setFormatter(self.formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
try:
|
||||
raise ValueError("Test error")
|
||||
except ValueError:
|
||||
logger.exception("An error occurred", extra={"error_code": "E001", "user_id": 456})
|
||||
|
||||
# Get the last logged message
|
||||
last_message = handler.stream.getvalue().splitlines()[-1]
|
||||
log_entry = json.loads(last_message)
|
||||
|
||||
assert log_entry["v"] == 0
|
||||
assert log_entry["name"] == self.project_name
|
||||
assert log_entry["msg"] == "An error occurred"
|
||||
assert log_entry["level"] == 50
|
||||
assert log_entry["levelname"] == "ERROR"
|
||||
assert log_entry["hostname"] == "test_host"
|
||||
assert log_entry["target"] == "test_logger"
|
||||
assert "err" in log_entry
|
||||
assert "message" in log_entry["err"]
|
||||
assert "name" in log_entry["err"]
|
||||
assert "stack" in log_entry["err"]
|
||||
assert "extra" in log_entry
|
||||
assert log_entry["extra"]["error_code"] == "E001"
|
||||
assert log_entry["extra"]["user_id"] == 456
|
||||
|
||||
Reference in New Issue
Block a user