mirror of
https://github.com/kristoferssolo/bunyan-formatter.git
synced 2025-10-21 17:20:35 +00:00
Merge pull request #2 from kristoferssolo/docs
Add reame and fix custom fields
This commit is contained in:
commit
1a1b80ab5f
205
README.md
205
README.md
@ -1,3 +1,204 @@
|
||||
# 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
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"bunyan": {
|
||||
"()": BunyanFormatter,
|
||||
"project_name": "cashflow",
|
||||
"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,6 +1,6 @@
|
||||
[project]
|
||||
name = "bunyan-formatter"
|
||||
version = "1.0.0"
|
||||
version = "0.1.0"
|
||||
description = "Bunyan Formatter for Python"
|
||||
dependencies = []
|
||||
readme = "README.md"
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
LEVEL_MAP = {
|
||||
logging.NOTSET: 10,
|
||||
logging.DEBUG: 20,
|
||||
logging.INFO: 30,
|
||||
logging.WARNING: 40,
|
||||
logging.ERROR: 50,
|
||||
logging.CRITICAL: 60,
|
||||
}
|
||||
|
||||
|
||||
class BunyanFormatter(Formatter):
|
||||
LEVEL_MAP: ClassVar[dict[int, int]] = {
|
||||
logging.NOTSET: 10,
|
||||
logging.DEBUG: 20,
|
||||
logging.INFO: 30,
|
||||
logging.WARNING: 40,
|
||||
logging.ERROR: 50,
|
||||
logging.CRITICAL: 60,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user