WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* @RomanR-dev
41 changes: 41 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Python Tests with Tox

on:
pull_request:
branches: [ master ]


jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11']

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install tox
run: |
python -m pip install --upgrade pip
pip install tox tox-gh-actions

- name: Run tox
run: tox

- name: Code Coverage Summary Report
uses: irongut/[email protected]
with:
filename: coverage.xml
badge: true
format: markdown
thresholds: '80 100'
output: 'both'
fail_below_min: 'true'
indicators: 'true'
8 changes: 0 additions & 8 deletions .travis.yml

This file was deleted.

140 changes: 86 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,90 +1,122 @@
python-logging-loki
===================
# 🚀 python-logging-loki-v2

[![PyPI version](https://img.shields.io/pypi/v/python-logging-loki.svg)](https://pypi.org/project/python-logging-loki/)
[![Python version](https://img.shields.io/badge/python-3.6%20%7C%203.7%20%7C%203.8-blue.svg)](https://www.python.org/)
[![License](https://img.shields.io/pypi/l/python-logging-loki.svg)](https://opensource.org/licenses/MIT)
[![Build Status](https://travis-ci.org/GreyZmeem/python-logging-loki.svg?branch=master)](https://travis-ci.org/GreyZmeem/python-logging-loki)
> Modern Python logging handler for Grafana Loki

Python logging handler for Loki.
https://grafana.com/loki
[![PyPI version](https://img.shields.io/pypi/v/python-logging-loki-v2.svg)](https://pypi.org/project/python-logging-loki-v2/)
[![Python](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/)

## Documented by Grafana: https://github.com/grafana/loki/pull/16397

Send Python logs directly to [Grafana Loki](https://grafana.com/loki) with minimal configuration.

---

## ✨ Features

- 📤 **Direct Integration** - Send logs straight to Loki
- 🔐 **Authentication Support** - Basic auth and custom headers
- 🏷️ **Custom Labels** - Flexible tagging system
- ⚡ **Async Support** - Non-blocking queue handler included
- 🔒 **SSL Verification** - Configurable SSL/TLS settings
- 🎯 **Multi-tenant** - Support for Loki multi-tenancy

---

## 📦 Installation

Installation
============
```bash
pip install python-logging-loki
pip install python-logging-loki-v2
```

Usage
=====
---

## 🎯 Quick Start

### Basic Usage

```python
import logging
import logging_loki


handler = logging_loki.LokiHandler(
url="https://my-loki-instance/loki/api/v1/push",
tags={"application": "my-app"},
url="https://loki.example.com/loki/api/v1/push",
tags={"app": "my-application"},
auth=("username", "password"),
version="1",
version="2"
)

logger = logging.getLogger("my-logger")
logger = logging.getLogger("my-app")
logger.addHandler(handler)
logger.error(
"Something happened",
extra={"tags": {"service": "my-service"}},
)
logger.info("Application started", extra={"tags": {"env": "production"}})
```

Example above will send `Something happened` message along with these labels:
- Default labels from handler
- Message level as `serverity`
- Logger's name as `logger`
- Labels from `tags` item of `extra` dict
### Async/Non-blocking Mode

The given example is blocking (i.e. each call will wait for the message to be sent).
But you can use the built-in `QueueHandler` and` QueueListener` to send messages in a separate thread.
For high-throughput applications, use the queue handler to avoid blocking:

```python
import logging.handlers
import logging_loki
from multiprocessing import Queue


queue = Queue(-1)
handler = logging.handlers.QueueHandler(queue)
handler_loki = logging_loki.LokiHandler(
url="https://my-loki-instance/loki/api/v1/push",
tags={"application": "my-app"},
auth=("username", "password"),
version="1",
handler = logging_loki.LokiQueueHandler(
Queue(-1),
url="https://loki.example.com/loki/api/v1/push",
tags={"app": "my-application"},
version="2"
)
logging.handlers.QueueListener(queue, handler_loki)

logger = logging.getLogger("my-logger")
logger = logging.getLogger("my-app")
logger.addHandler(handler)
logger.error(...)
logger.info("Non-blocking log message")
```

Or you can use `LokiQueueHandler` shortcut, which will automatically create listener and handler.
---

```python
import logging.handlers
import logging_loki
from multiprocessing import Queue
## ⚙️ Configuration Options

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `url` | `str` | *required* | Loki push endpoint URL |
| `tags` | `dict` | `{}` | Default labels for all logs |
| `auth` | `tuple` | `None` | Basic auth credentials `(username, password)` |
| `headers` | `dict` | `None` | Custom HTTP headers (e.g., for multi-tenancy) |
| `version` | `str` | `"1"` | Loki API version (`"0"`, `"1"`, or `"2"`) |
| `verify_ssl` | `bool` | `True` | Enable/disable SSL certificate verification |

handler = logging_loki.LokiQueueHandler(
Queue(-1),
url="https://my-loki-instance/loki/api/v1/push",
tags={"application": "my-app"},
auth=("username", "password"),
version="1",
---

## 🏷️ Labels

Logs are automatically labeled with:
- **severity** - Log level (INFO, ERROR, etc.)
- **logger** - Logger name
- **Custom tags** - From handler and `extra={"tags": {...}}`

```python
logger.error(
"Database connection failed",
extra={"tags": {"service": "api", "region": "us-east"}}
)
```

logger = logging.getLogger("my-logger")
logger.addHandler(handler)
logger.error(...)
---

## 🔐 Multi-tenant Setup

```python
handler = logging_loki.LokiHandler(
url="https://loki.example.com/loki/api/v1/push",
headers={"X-Scope-OrgID": "tenant-1"},
tags={"app": "my-app"}
)
```

---
Based on [python-logging-loki](https://github.com/GreyZmeem/python-logging-loki) by GreyZmeem.

### Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

---
3 changes: 1 addition & 2 deletions logging_loki/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-

from logging_loki.handlers import LokiHandler
from logging_loki.handlers import LokiQueueHandler
from logging_loki.handlers import LokiHandler, LokiQueueHandler

__all__ = ["LokiHandler", "LokiQueueHandler"]
__version__ = "0.3.1"
Expand Down
37 changes: 27 additions & 10 deletions logging_loki/emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@
import logging
import time
from logging.config import ConvertingDict
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Tuple
from typing import Any, Dict, List, Optional, Tuple

import requests
import rfc3339
Expand All @@ -30,29 +26,33 @@ class LokiEmitter(abc.ABC):
label_replace_with = const.label_replace_with
session_class = requests.Session

def __init__(self, url: str, tags: Optional[dict] = None, auth: BasicAuth = None):
def __init__(self, url: str, tags: dict | None = None, auth: BasicAuth = None, headers: dict | None = None, verify_ssl: bool = True):
"""
Create new Loki emitter.

Arguments:
url: Endpoint used to send log entries to Loki (e.g. `https://my-loki-instance/loki/api/v1/push`).
tags: Default tags added to every log record.
auth: Optional tuple with username and password for basic HTTP authentication.

headers: Optional dict with HTTP headers to send.
"""
#: Tags that will be added to all records handled by this handler.
self.tags = tags or {}
#: Loki JSON push endpoint (e.g `http://127.0.0.1/loki/api/v1/push`)
self.url = url
#: Optional tuple with username and password for basic authentication.
self.auth = auth
#: Optional headers for post request
self.headers = headers or {}
#: Verify the host's ssl certificate
self.verify_ssl = verify_ssl

self._session: Optional[requests.Session] = None
self._session: requests.Session | None = None

def __call__(self, record: logging.LogRecord, line: str):
"""Send log record to Loki."""
payload = self.build_payload(record, line)
resp = self.session.post(self.url, json=payload)
resp = self.session.post(self.url, json=payload, headers=self.headers, verify=self.verify_ssl)
if resp.status_code != self.success_response_code:
raise ValueError("Unexpected Loki API response status code: {0}".format(resp.status_code))

Expand Down Expand Up @@ -135,9 +135,26 @@ def build_payload(self, record: logging.LogRecord, line) -> dict:
"""Build JSON payload with a log entry."""
labels = self.build_tags(record)
ns = 1e9
ts = str(int(time.time() * ns))
ts = str(int(record.created * ns))
stream = {
"stream": labels,
"values": [[ts, line]],
}
return {"streams": [stream]}


class LokiEmitterV2(LokiEmitterV1):
"""
Emitter for Loki >= 0.4.0.
Enables passing additional headers to requests
"""

def __init__(self, url: str, tags: dict | None = None, auth: BasicAuth = None, headers: dict = None, verify_ssl: bool = True):
super().__init__(url, tags, auth, headers, verify_ssl)

def __call__(self, record: logging.LogRecord, line: str):
"""Send log record to Loki."""
payload = self.build_payload(record, line)
resp = self.session.post(self.url, json=payload, headers=self.headers, verify=self.verify_ssl)
if resp.status_code != self.success_response_code:
raise ValueError("Unexpected Loki API response status code: {0}".format(resp.status_code))
30 changes: 9 additions & 21 deletions logging_loki/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,11 @@

import logging
import warnings
from logging.handlers import QueueHandler
from logging.handlers import QueueListener
from logging.handlers import QueueHandler, QueueListener
from queue import Queue
from typing import Dict
from typing import Optional
from typing import Type
from typing import Dict, Type

from logging_loki import const
from logging_loki import emitter
from logging_loki import const, emitter


class LokiQueueHandler(QueueHandler):
Expand All @@ -19,7 +15,7 @@ class LokiQueueHandler(QueueHandler):
def __init__(self, queue: Queue, **kwargs):
"""Create new logger handler with the specified queue and kwargs for the `LokiHandler`."""
super().__init__(queue)
self.handler = LokiHandler(**kwargs) # noqa: WPS110
self.handler = LokiHandler(**kwargs)
self.listener = QueueListener(self.queue, self.handler)
self.listener.start()

Expand All @@ -31,18 +27,9 @@ class LokiHandler(logging.Handler):
`Loki API <https://github.com/grafana/loki/blob/master/docs/api.md>`_
"""

emitters: Dict[str, Type[emitter.LokiEmitter]] = {
"0": emitter.LokiEmitterV0,
"1": emitter.LokiEmitterV1,
}

def __init__(
self,
url: str,
tags: Optional[dict] = None,
auth: Optional[emitter.BasicAuth] = None,
version: Optional[str] = None,
):
emitters: Dict[str, Type[emitter.LokiEmitter]] = {"0": emitter.LokiEmitterV0, "1": emitter.LokiEmitterV1, "2": emitter.LokiEmitterV2}

def __init__(self, url: str, tags: dict | None = None, auth: emitter.BasicAuth | None = None, version: str | None = None, headers: dict | None = None, verify_ssl: bool = True):
"""
Create new Loki logging handler.

Expand All @@ -51,6 +38,7 @@ def __init__(
tags: Default tags added to every log record.
auth: Optional tuple with username and password for basic HTTP authentication.
version: Version of Loki emitter to use.
verify_ssl: If set to False, the endpoint's SSL certificates are not verified

"""
super().__init__()
Expand All @@ -67,7 +55,7 @@ def __init__(
version = version or const.emitter_ver
if version not in self.emitters:
raise ValueError("Unknown emitter version: {0}".format(version))
self.emitter = self.emitters[version](url, tags, auth)
self.emitter = self.emitters[version](url, tags, auth, headers, verify_ssl)

def handleError(self, record): # noqa: N802
"""Close emitter and let default handler take actions on error."""
Expand Down
Loading