Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Formatter handling improvements and extending python compatibility #13

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,30 @@ jobs:
deploy:

runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/loki-logger-handler
permissions:
id-token: write

strategy:
matrix:
python-version: ["2.7", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v3
- name: Set up Python

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: '3.x'
python-version: ${{ matrix.python-version }}

- name: Install Build Dependencies
run: pip install build twine
run: |
pip install --upgrade pip
pip install build twine

- name: Extract Tag Version
id: extract_tag
run: echo ::set-output name=tag::${GITHUB_REF#refs/tags/}
run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV

- name: Set Package Version
run: sed -i "s/version=.*/version='${{ steps.extract_tag.outputs.tag }}',/" setup.py
run: |
sed -i "s/version=.*/version='${{ env.tag }}',/" setup.py

- name: Build Distribution
run: python -m build
Expand Down
17 changes: 11 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,23 @@ on:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["2.7", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]

steps:
- name: Checkout Code
uses: actions/checkout@v2
uses: actions/checkout@v3

- name: Set up Python
uses: actions/setup-python@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: 3.x
python-version: ${{ matrix.python-version }}

- name: Install Dependencies
run: pip install -r requirements-test.txt
run: |
pip install --upgrade pip
pip install -r requirements-test.txt

- name: Run Tests
run: python -m unittest discover tests/
run: python -m unittest discover tests/
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,5 +157,5 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.idea/
.vscode
42 changes: 23 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
# loki_logger_handler

A logging handler that sends log messages to Loki in JSON format
[![PyPI](https://img.shields.io/pypi/v/loki_logger_handler?color=blue&label=pypi%20version)]()
[![PyPI](https://img.shields.io/pypi/pyversions/loki_logger_handler.svg)]()
[![Downloads](https://pepy.tech/badge/loki_logger_handler)](https://pepy.tech/project/loki_logger_handler)

A logging handler that sends log messages to **(Grafana) Loki** in JSON format.

## Features

* Logs pushed in JSON format
* Logs pushed in JSON format by default
* Custom labels definition
* Allows defining loguru and logger extra keys as labels
* Allows defining *loguru* and *logger* extra keys as labels
* Logger extra keys added automatically as keys into pushed JSON
* Publish in batch of Streams
* Publis logs compressed
* Publish logs compressed

## Args

* url (str): The URL of the Loki server.
* labels (dict): A dictionary of labels to attach to each log message.
* labelKeys (dict, optional): A dictionary of keys to extract from each log message and use as labels. Defaults to None.
* timeout (int, optional): The time in seconds to wait before flushing the buffer. Defaults to 10.
* label_keys (dict, optional): A dictionary of keys to extract from each log message and use as labels. Defaults to None.
* additional_headers (dict, optional): Additional headers for the Loki request. Defaults to None.
* timeout (int, optional): Timeout interval in seconds to wait before flushing the buffer. Defaults to 10 seconds.
* compressed (bool, optional): Whether to compress the log messages before sending them to Loki. Defaults to True.
* defaultFormatter (logging.Formatter, optional): The formatter to use for log messages. Defaults to LoggerFormatter().
* loguru (bool, optional): Whether to use `LoguruFormatter`. Defaults to False.
* default_formatter (logging.Formatter, optional): Formatter for the log records. If not provided,`LoggerFormatter` or `LoguruFormatter` will be used.

## Formatters
* LoggerFormatter: Formater for default python logging implementation
* LoguruFormatter: Formater for Loguru python library
* **LoggerFormatter**: Formatter for default python logging implementation
* **LoguruFormatter**: Formatter for Loguru python library

## How to use

Expand All @@ -39,16 +45,14 @@ logger.setLevel(logging.DEBUG)
# Create an instance of the custom handler
custom_handler = LokiLoggerHandler(
url=os.environ["LOKI_URL"],
labels={"application": "Test", "envornment": "Develop"},
labelKeys={},
labels={"application": "Test", "environment": "Develop"},
label_keys={},
timeout=10,
)
# Create an instance of the custom handler

logger.addHandler(custom_handler)
logger.debug("Debug message", extra={'custom_field': 'custom_value'})


```


Expand All @@ -63,10 +67,10 @@ os.environ["LOKI_URL"]="https://USER:PASSWORD@logs-prod-eu-west-0.grafana.net/lo

custom_handler = LokiLoggerHandler(
url=os.environ["LOKI_URL"],
labels={"application": "Test", "envornment": "Develop"},
labelKeys={},
labels={"application": "Test", "environment": "Develop"},
label_keys={},
timeout=10,
defaultFormatter=LoguruFormatter(),
default_formatter=LoguruFormatter(),
)
logger.configure(handlers=[{"sink": custom_handler, "serialize": True}])

Expand Down Expand Up @@ -130,18 +134,18 @@ logger.info(
Loki query sample :

```
{envornment="Develop"} |= `` | json
{environment="Develop"} |= `` | json
```

Filter by level:

```
{envornment="Develop", level="INFO"} |= `` | json
{environment="Develop", level="INFO"} |= `` | json
```
Filter by extra:

```
{envornment="Develop", level="INFO"} |= `` | json | code=`200`
{environment="Develop", level="INFO"} |= `` | json | code=`200`
```

## License
Expand Down
38 changes: 34 additions & 4 deletions loki_logger_handler/formatters/logger_formatter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import traceback
import logging


class LoggerFormatter:
"""
A custom formatter for log records that formats the log record into a structured dictionary.
"""
LOG_RECORD_FIELDS = {
"msg",
"levelname",
Expand Down Expand Up @@ -29,6 +33,15 @@ def __init__(self):
pass

def format(self, record):
"""
Format a log record into a structured dictionary.

Args:
record (logging.LogRecord): The log record to format.

Returns:
dict: A dictionary representation of the log record.
"""
formatted = {
"message": record.getMessage(),
"timestamp": record.created,
Expand All @@ -40,16 +53,33 @@ def format(self, record):
"level": record.levelname,
}

# Capture any custom fields added to the log record
record_keys = set(record.__dict__.keys())
missing_fields = record_keys - self.LOG_RECORD_FIELDS
custom_fields = record_keys - self.LOG_RECORD_FIELDS

for key in missing_fields:
for key in custom_fields:
formatted[key] = getattr(record, key)

if record.levelname == "ERROR":
# Check if the log level indicates an error (case-insensitive and can be partial)
if record.levelname.upper().startswith("ER"):
formatted["file"] = record.filename
formatted["path"] = record.pathname
formatted["line"] = record.lineno
formatted["stacktrace"] = traceback.format_exc()
formatted["stacktrace"] = self._format_stacktrace(record.exc_info)

return formatted

@staticmethod
def _format_stacktrace(exc_info):
"""
Format the stacktrace if exc_info is present.

Args:
exc_info (tuple or None): Exception info tuple as returned by sys.exc_info().

Returns:
str or None: Formatted stacktrace as a string, or None if exc_info is not provided.
"""
if exc_info:
return "".join(traceback.format_exception(*exc_info))
return None
53 changes: 43 additions & 10 deletions loki_logger_handler/formatters/loguru_formatter.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,71 @@
import traceback
import sys


class LoguruFormatter:
"""
A custom formatter for log records generated by Loguru, formatting the record into a structured dictionary.
"""
def __init__(self):
pass

def format(self, record):
"""
Format a Loguru log record into a structured dictionary.

Args:
record (dict): The Loguru log record to format.

Returns:
dict: A dictionary representation of the log record.
"""
# Convert timestamp to a standard format across Python versions
timestamp = record.get("time")
if hasattr(timestamp, "timestamp"):
# Python 3.x
timestamp = timestamp.timestamp()
else:
# Python 2.7: Convert datetime to a Unix timestamp
timestamp = (timestamp - timestamp.utcoffset()).total_seconds()

formatted = {
"message": record.get("message"),
"timestamp": record.get("time").timestamp(),
"timestamp": timestamp,
"process": record.get("process").id,
"thread": record.get("thread").id,
"function": record.get("function"),
"module": record.get("module"),
"name": record.get("name"),
"level": record.get("level").name,
"level": record.get("level").name.upper(),
}

if record.get("extra"):
if record.get("extra").get("extra"):
formatted.update(record.get("extra").get("extra"))
# Update with extra fields if available
extra = record.get("extra", {})
if isinstance(extra, dict):
# Handle the nested "extra" key correctly
if "extra" in extra and isinstance(extra["extra"], dict):
formatted.update(extra["extra"])
else:
formatted.update(record.get("extra"))
formatted.update(extra)

if record.get("level").name == "ERROR":
# Check if the log level indicates an error (case-insensitive and can be partial)
if formatted["level"].startswith("ER"):
formatted["file"] = record.get("file").name
formatted["path"] = record.get("file").path
formatted["line"] = record.get("line")

if record.get("exception"):
exc_type, exc_value, exc_traceback = record.get("exception")
formatted_traceback = traceback.format_exception(
exc_type, exc_value, exc_traceback
)
if sys.version_info[0] == 2:
# Python 2.7: Use the older method for formatting exceptions
formatted_traceback = traceback.format_exception(
exc_type, exc_value, exc_traceback
)
else:
# Python 3.x: This is the same
formatted_traceback = traceback.format_exception(
exc_type, exc_value, exc_traceback
)
formatted["stacktrace"] = "".join(formatted_traceback)

return formatted
Loading