From 62114b8b6887f7af29771b7b5a021dbee3fb578e Mon Sep 17 00:00:00 2001 From: Roma R Date: Mon, 13 Jan 2025 20:40:38 +0200 Subject: [PATCH 01/11] feat: add support for headers to be passed with each emit - emitterV2 --- logging_loki/emitter.py | 28 ++++++++-- logging_loki/handlers.py | 4 +- tests/test_emitter_v2.py | 109 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 tests/test_emitter_v2.py diff --git a/logging_loki/emitter.py b/logging_loki/emitter.py index 949ceea..1efb33b 100644 --- a/logging_loki/emitter.py +++ b/logging_loki/emitter.py @@ -5,6 +5,7 @@ import functools import logging import time + from logging.config import ConvertingDict from typing import Any from typing import Dict @@ -30,7 +31,7 @@ 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: Optional[dict] = None, auth: BasicAuth = None, headers: Optional[dict] = None): """ Create new Loki emitter. @@ -38,7 +39,7 @@ def __init__(self, url: str, tags: Optional[dict] = None, auth: BasicAuth = None 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 {} @@ -46,13 +47,15 @@ def __init__(self, url: str, tags: Optional[dict] = None, auth: BasicAuth = None self.url = url #: Optional tuple with username and password for basic authentication. self.auth = auth + #: Optional headers for post request + self.headers = headers or {} self._session: Optional[requests.Session] = 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) if resp.status_code != self.success_response_code: raise ValueError("Unexpected Loki API response status code: {0}".format(resp.status_code)) @@ -113,7 +116,7 @@ def build_payload(self, record: logging.LogRecord, line) -> dict: labels = self.build_labels(record) ts = rfc3339.format_microsecond(record.created) stream = { - "labels": labels, + "labels" : labels, "entries": [{"ts": ts, "line": line}], } return {"streams": [stream]} @@ -141,3 +144,20 @@ def build_payload(self, record: logging.LogRecord, line) -> dict: "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: Optional[dict] = None, auth: BasicAuth = None, headers: dict = None): + super().__init__(url, tags, auth, headers) + + 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) + if resp.status_code != self.success_response_code: + raise ValueError("Unexpected Loki API response status code: {0}".format(resp.status_code)) diff --git a/logging_loki/handlers.py b/logging_loki/handlers.py index 74a55cb..57ea96d 100644 --- a/logging_loki/handlers.py +++ b/logging_loki/handlers.py @@ -34,6 +34,7 @@ class LokiHandler(logging.Handler): emitters: Dict[str, Type[emitter.LokiEmitter]] = { "0": emitter.LokiEmitterV0, "1": emitter.LokiEmitterV1, + "2": emitter.LokiEmitterV2 } def __init__( @@ -42,6 +43,7 @@ def __init__( tags: Optional[dict] = None, auth: Optional[emitter.BasicAuth] = None, version: Optional[str] = None, + headers: Optional[dict] = None ): """ Create new Loki logging handler. @@ -67,7 +69,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) def handleError(self, record): # noqa: N802 """Close emitter and let default handler take actions on error.""" diff --git a/tests/test_emitter_v2.py b/tests/test_emitter_v2.py new file mode 100644 index 0000000..fd9c11d --- /dev/null +++ b/tests/test_emitter_v2.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- + +import logging + +from typing import Tuple +from unittest.mock import MagicMock + +import pytest + +from logging_loki.emitter import LokiEmitterV2 + +emitter_url: str = "https://example.net/loki/api/v1/push/" +headers = {"X-Scope-OrgID": "some_tenant"} +record_kwargs = { + "name" : "test", + "level" : logging.WARNING, + "fn" : "", + "lno" : "", + "msg" : "Test", + "args" : None, + "exc_info": None, +} + + +@pytest.fixture() +def emitter_v2() -> Tuple[LokiEmitterV2, MagicMock]: + """Create v2 emitter with mocked http session.""" + response = MagicMock() + response.status_code = LokiEmitterV2.success_response_code + session = MagicMock() + session().post = MagicMock(return_value=response) + + instance = LokiEmitterV2(url=emitter_url, headers=headers) + instance.session_class = session + + return instance, session + + +@pytest.fixture() +def emitter_v2_no_headers() -> Tuple[LokiEmitterV2, MagicMock]: + """Create v2 emitter with mocked http session.""" + response = MagicMock() + response.status_code = LokiEmitterV2.success_response_code + session = MagicMock() + session().post = MagicMock(return_value=response) + + instance = LokiEmitterV2(url=emitter_url) + instance.session_class = session + + return instance, session + + +def create_record(**kwargs) -> logging.LogRecord: + """Create test logging record.""" + log = logging.Logger(__name__) + return log.makeRecord(**{**record_kwargs, **kwargs}) + + +def get_stream(session: MagicMock) -> dict: + """Return first stream item from json payload.""" + kwargs = session().post.call_args[1] + streams = kwargs["json"]["streams"] + return streams[0] + + +def get_request(session: MagicMock) -> dict: + kwargs = session().post.call_args[1] + return kwargs + + +def test_record_sent_to_emitter_url(emitter_v2): + emitter, session = emitter_v2 + emitter(create_record(), "") + + got = session().post.call_args + assert got[0][0] == emitter_url + + +def test_default_tags_added_to_payload(emitter_v2): + emitter, session = emitter_v2 + emitter.tags = {"app": "emitter"} + emitter(create_record(), "") + + stream = get_stream(session) + level = logging.getLevelName(record_kwargs["level"]).lower() + expected = { + emitter.level_tag : level, + emitter.logger_tag: record_kwargs["name"], + "app" : "emitter", + } + assert stream["stream"] == expected + + +def test_headers_added(emitter_v2): + emitter, session = emitter_v2 + emitter.tags = {"app": "emitter"} + emitter(create_record(), "") + + kwargs = get_request(session) + assert kwargs['headers']['X-Scope-OrgID'] == headers['X-Scope-OrgID'] + + +def test_no_headers_added(emitter_v2_no_headers): + emitter, session = emitter_v2_no_headers + emitter.tags = {"app": "emitter"} + emitter(create_record(), "") + + kwargs = get_request(session) + assert kwargs['headers'] is not None and kwargs['headers'] == {} From cee415230fb6bf11406f344fd248550149b5378a Mon Sep 17 00:00:00 2001 From: Roma R Date: Mon, 13 Jan 2025 21:24:55 +0200 Subject: [PATCH 02/11] misc: update versions for pypi upload and desc --- README.md | 14 ++++++++++---- setup.py | 12 ++++++------ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 54c7642..9676997 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,20 @@ -python-logging-loki +python-logging-loki-v2 [Based on: https://github.com/GreyZmeem/python-logging-loki.] =================== -[![PyPI version](https://img.shields.io/pypi/v/python-logging-loki.svg)](https://pypi.org/project/python-logging-loki/) +[![PyPI version](https://img.shields.io/pypi/v/python-logging-loki-v2.svg)](https://pypi.org/project/python-logging-loki-v2/) [![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) -Python logging handler for Loki. +[//]: # ([![Build Status](https://travis-ci.org/GreyZmeem/python-logging-loki.svg?branch=master)](https://travis-ci.org/GreyZmeem/python-logging-loki)) + +Python logging handler for Loki. https://grafana.com/loki +New +=========== +0.4.0: support to headers (ability to pass tenants for multi tenant loki configuration) + + Installation ============ ```bash diff --git a/setup.py b/setup.py index 153a2b5..58d65ba 100644 --- a/setup.py +++ b/setup.py @@ -6,15 +6,15 @@ long_description = fh.read() setuptools.setup( - name="python-logging-loki", - version="0.3.1", - description="Python logging handler for Grafana Loki.", + name="python-logging-loki-v2", + version="0.4.3", + description="Python logging handler for Grafana Loki, with support to headers.", long_description=long_description, long_description_content_type="text/markdown", license="MIT", - author="Andrey Maslov", - author_email="greyzmeem@gmail.com", - url="https://github.com/greyzmeem/python-logging-loki", + author="Roman Rapoport", + author_email="cryos10@gmail.com", + url="https://github.com/RomanR-dev/python-logging-loki", packages=setuptools.find_packages(exclude=("tests",)), python_requires=">=3.6", install_requires=["rfc3339>=6.1", "requests"], From 4a7c68f86a341337b3f845d51190ad31aa67dbb1 Mon Sep 17 00:00:00 2001 From: Roman Rapoport <80348145+RomanR-dev@users.noreply.github.com> Date: Fri, 21 Feb 2025 16:08:04 +0200 Subject: [PATCH 03/11] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9676997..f9de827 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ New Installation ============ ```bash -pip install python-logging-loki +pip install python-logging-loki-v2 ``` Usage From d167a1b114c23b5f5da11f45a8c796c5c58ece24 Mon Sep 17 00:00:00 2001 From: Roma R Date: Fri, 21 Feb 2025 16:14:11 +0200 Subject: [PATCH 04/11] update readme --- README.md | 10 ++++++---- setup.py | 2 +- tests/test_emitter_v2.py | 8 ++++++++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f9de827..c30842d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -python-logging-loki-v2 [Based on: https://github.com/GreyZmeem/python-logging-loki.] +## python-logging-loki-v2 + +# [Based on: https://github.com/GreyZmeem/python-logging-loki.] =================== [![PyPI version](https://img.shields.io/pypi/v/python-logging-loki-v2.svg)](https://pypi.org/project/python-logging-loki-v2/) @@ -33,7 +35,7 @@ handler = logging_loki.LokiHandler( url="https://my-loki-instance/loki/api/v1/push", tags={"application": "my-app"}, auth=("username", "password"), - version="1", + version="2", ) logger = logging.getLogger("my-logger") @@ -65,7 +67,7 @@ handler_loki = logging_loki.LokiHandler( url="https://my-loki-instance/loki/api/v1/push", tags={"application": "my-app"}, auth=("username", "password"), - version="1", + version="2", ) logging.handlers.QueueListener(queue, handler_loki) @@ -87,7 +89,7 @@ handler = logging_loki.LokiQueueHandler( url="https://my-loki-instance/loki/api/v1/push", tags={"application": "my-app"}, auth=("username", "password"), - version="1", + version="2", ) logger = logging.getLogger("my-logger") diff --git a/setup.py b/setup.py index 58d65ba..6625e27 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setuptools.setup( name="python-logging-loki-v2", - version="0.4.3", + version="0.4.4", description="Python logging handler for Grafana Loki, with support to headers.", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tests/test_emitter_v2.py b/tests/test_emitter_v2.py index fd9c11d..4ed7275 100644 --- a/tests/test_emitter_v2.py +++ b/tests/test_emitter_v2.py @@ -107,3 +107,11 @@ def test_no_headers_added(emitter_v2_no_headers): kwargs = get_request(session) assert kwargs['headers'] is not None and kwargs['headers'] == {} + + +def test_soemthing_fun(): + import os + a = "a" + b = "b" + c = "/c" + print(os.path.join(a, b, c)) From 2c972f8eb11524d56876b4e4a69038be7590e1eb Mon Sep 17 00:00:00 2001 From: padmalcom Date: Fri, 28 Nov 2025 16:29:59 +0100 Subject: [PATCH 05/11] Added verify_ssl to emitter and handlers * Added verify_ssl to emitter and handlers. * Added verify_ssl to README.md --- README.md | 3 +++ logging_loki/emitter.py | 8 +++++--- logging_loki/handlers.py | 6 ++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c30842d..1e14060 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ handler = logging_loki.LokiHandler( tags={"application": "my-app"}, auth=("username", "password"), version="2", + verify_ssl=True ) logger = logging.getLogger("my-logger") @@ -68,6 +69,7 @@ handler_loki = logging_loki.LokiHandler( tags={"application": "my-app"}, auth=("username", "password"), version="2", + verify_ssl=True ) logging.handlers.QueueListener(queue, handler_loki) @@ -90,6 +92,7 @@ handler = logging_loki.LokiQueueHandler( tags={"application": "my-app"}, auth=("username", "password"), version="2", + verify_ssl=True ) logger = logging.getLogger("my-logger") diff --git a/logging_loki/emitter.py b/logging_loki/emitter.py index 1efb33b..51b62c6 100644 --- a/logging_loki/emitter.py +++ b/logging_loki/emitter.py @@ -31,7 +31,7 @@ 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, headers: Optional[dict] = None): + def __init__(self, url: str, tags: Optional[dict] = None, auth: BasicAuth = None, headers: Optional[dict] = None, verify_ssl: bool = True): """ Create new Loki emitter. @@ -49,13 +49,15 @@ def __init__(self, url: str, tags: Optional[dict] = None, auth: BasicAuth = None self.auth = auth #: Optional headers for post request self.headers = headers or {} + #: Verfify the host's ssl certificate + self.verify_ssl = verify_ssl self._session: Optional[requests.Session] = 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, headers=self.headers) + 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)) @@ -158,6 +160,6 @@ def __init__(self, url: str, tags: Optional[dict] = None, auth: BasicAuth = 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, headers=self.headers) + 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)) diff --git a/logging_loki/handlers.py b/logging_loki/handlers.py index 57ea96d..d576c81 100644 --- a/logging_loki/handlers.py +++ b/logging_loki/handlers.py @@ -43,7 +43,8 @@ def __init__( tags: Optional[dict] = None, auth: Optional[emitter.BasicAuth] = None, version: Optional[str] = None, - headers: Optional[dict] = None + headers: Optional[dict] = None, + verify_ssl: bool = True ): """ Create new Loki logging handler. @@ -53,6 +54,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__() @@ -69,7 +71,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, headers) + 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.""" From 414d1af855bac82223e6630c7d4a332223ed9cda Mon Sep 17 00:00:00 2001 From: Roma R Date: Fri, 28 Nov 2025 18:07:16 +0200 Subject: [PATCH 06/11] feat: add github actions, move to ruff --- .github/CODEOWNERS | 1 + .github/workflows/test.yml | 29 ++++++++ README.md | 137 +++++++++++++++++++++---------------- logging_loki/__init__.py | 3 +- logging_loki/emitter.py | 15 ++-- logging_loki/handlers.py | 30 ++------ pyproject.toml | 36 ++++++++++ setup.py | 8 +-- tests/test_emitter_v0.py | 2 +- tests/test_emitter_v1.py | 2 +- tests/test_emitter_v2.py | 22 +++--- tox.ini | 33 ++++----- 12 files changed, 187 insertions(+), 131 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/test.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..04f48a4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @RomanR-dev diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8ead16b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +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 diff --git a/README.md b/README.md index 1e14060..18a0680 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,120 @@ -## python-logging-loki-v2 +# 🚀 python-logging-loki-v2 -# [Based on: https://github.com/GreyZmeem/python-logging-loki.] -=================== +> Modern Python logging handler for Grafana Loki [![PyPI version](https://img.shields.io/pypi/v/python-logging-loki-v2.svg)](https://pypi.org/project/python-logging-loki-v2/) -[![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) +[![Python](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/) -[//]: # ([![Build Status](https://travis-ci.org/GreyZmeem/python-logging-loki.svg?branch=master)](https://travis-ci.org/GreyZmeem/python-logging-loki)) +Send Python logs directly to [Grafana Loki](https://grafana.com/loki) with minimal configuration. -Python logging handler for Loki. -https://grafana.com/loki +--- -New -=========== -0.4.0: support to headers (ability to pass tenants for multi tenant loki 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-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="2", - verify_ssl=True + 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="2", - verify_ssl=True +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="2", - verify_ssl=True +--- + +## 🏷️ 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. + +--- diff --git a/logging_loki/__init__.py b/logging_loki/__init__.py index f9d6949..2e7fef4 100644 --- a/logging_loki/__init__.py +++ b/logging_loki/__init__.py @@ -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" diff --git a/logging_loki/emitter.py b/logging_loki/emitter.py index 51b62c6..f5f484c 100644 --- a/logging_loki/emitter.py +++ b/logging_loki/emitter.py @@ -5,13 +5,8 @@ import functools 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 @@ -31,7 +26,7 @@ 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, headers: Optional[dict] = None, verify_ssl: bool = True): + def __init__(self, url: str, tags: dict | None = None, auth: BasicAuth = None, headers: dict | None = None, verify_ssl: bool = True): """ Create new Loki emitter. @@ -52,7 +47,7 @@ def __init__(self, url: str, tags: Optional[dict] = None, auth: BasicAuth = None #: Verfify 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.""" @@ -118,7 +113,7 @@ def build_payload(self, record: logging.LogRecord, line) -> dict: labels = self.build_labels(record) ts = rfc3339.format_microsecond(record.created) stream = { - "labels" : labels, + "labels": labels, "entries": [{"ts": ts, "line": line}], } return {"streams": [stream]} @@ -154,7 +149,7 @@ class LokiEmitterV2(LokiEmitterV1): Enables passing additional headers to requests """ - def __init__(self, url: str, tags: Optional[dict] = None, auth: BasicAuth = None, headers: dict = None): + def __init__(self, url: str, tags: dict | None = None, auth: BasicAuth = None, headers: dict = None): super().__init__(url, tags, auth, headers) def __call__(self, record: logging.LogRecord, line: str): diff --git a/logging_loki/handlers.py b/logging_loki/handlers.py index d576c81..030aa2b 100644 --- a/logging_loki/handlers.py +++ b/logging_loki/handlers.py @@ -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): @@ -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() @@ -31,21 +27,9 @@ class LokiHandler(logging.Handler): `Loki API `_ """ - emitters: Dict[str, Type[emitter.LokiEmitter]] = { - "0": emitter.LokiEmitterV0, - "1": emitter.LokiEmitterV1, - "2": emitter.LokiEmitterV2 - } - - def __init__( - self, - url: str, - tags: Optional[dict] = None, - auth: Optional[emitter.BasicAuth] = None, - version: Optional[str] = None, - headers: Optional[dict] = None, - verify_ssl: bool = True - ): + 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. diff --git a/pyproject.toml b/pyproject.toml index 5428332..c05ed4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,39 @@ [build-system] requires = ["setuptools >= 40.0.0"] build-backend = "setuptools.build_meta" + + +[tool.ruff] +line-length = 200 + +[tool.ruff.lint] +# Enable flake8-style rules and more +# You can customize this based on wemake-python-styleguide rules you want +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify +] + +# Ignore specific rules if needed +ignore = [ + "UP009", # UTF-8 encoding declaration is unnecessary + "UP035", # typing.X is deprecated, use x instead + "UP006", # Use x instead of X for type annotation + "UP030", # Use implicit references for positional format fields + "UP032", # Use f-string instead of format call + "UP015", # Unnecessary mode argument + "UP045", # New union syntax + "B019", # Use of functools.lru_cache on methods can lead to memory leaks + "B028", # No explicit stacklevel keyword argument found +] + +[tool.ruff.format] +# Use double quotes (ruff default, similar to black) +quote-style = "double" diff --git a/setup.py b/setup.py index 6625e27..94bd39f 100644 --- a/setup.py +++ b/setup.py @@ -2,13 +2,13 @@ import setuptools -with open("README.md", "r") as fh: +with open("README.md", encoding="utf-8") as fh: long_description = fh.read() setuptools.setup( name="python-logging-loki-v2", - version="0.4.4", - description="Python logging handler for Grafana Loki, with support to headers.", + version="1.0.0", + description="Python logging handler for Grafana Loki", long_description=long_description, long_description_content_type="text/markdown", license="MIT", @@ -16,7 +16,7 @@ author_email="cryos10@gmail.com", url="https://github.com/RomanR-dev/python-logging-loki", packages=setuptools.find_packages(exclude=("tests",)), - python_requires=">=3.6", + python_requires=">=3.11", install_requires=["rfc3339>=6.1", "requests"], classifiers=[ "Development Status :: 4 - Beta", diff --git a/tests/test_emitter_v0.py b/tests/test_emitter_v0.py index 0aafd8d..dff2e2d 100644 --- a/tests/test_emitter_v0.py +++ b/tests/test_emitter_v0.py @@ -157,7 +157,7 @@ def test_session_is_closed(emitter_v0): emitter(create_record(), "") emitter.close() session().close.assert_called_once() - assert emitter._session is None # noqa: WPS437 + assert emitter._session is None def test_can_build_tags_from_converting_dict(emitter_v0): diff --git a/tests/test_emitter_v1.py b/tests/test_emitter_v1.py index b5656e1..2963474 100644 --- a/tests/test_emitter_v1.py +++ b/tests/test_emitter_v1.py @@ -152,7 +152,7 @@ def test_session_is_closed(emitter_v1): emitter(create_record(), "") emitter.close() session().close.assert_called_once() - assert emitter._session is None # noqa: WPS437 + assert emitter._session is None def test_can_build_tags_from_converting_dict(emitter_v1): diff --git a/tests/test_emitter_v2.py b/tests/test_emitter_v2.py index 4ed7275..359f131 100644 --- a/tests/test_emitter_v2.py +++ b/tests/test_emitter_v2.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- import logging - from typing import Tuple from unittest.mock import MagicMock @@ -12,12 +11,12 @@ emitter_url: str = "https://example.net/loki/api/v1/push/" headers = {"X-Scope-OrgID": "some_tenant"} record_kwargs = { - "name" : "test", - "level" : logging.WARNING, - "fn" : "", - "lno" : "", - "msg" : "Test", - "args" : None, + "name": "test", + "level": logging.WARNING, + "fn": "", + "lno": "", + "msg": "Test", + "args": None, "exc_info": None, } @@ -84,9 +83,9 @@ def test_default_tags_added_to_payload(emitter_v2): stream = get_stream(session) level = logging.getLevelName(record_kwargs["level"]).lower() expected = { - emitter.level_tag : level, + emitter.level_tag: level, emitter.logger_tag: record_kwargs["name"], - "app" : "emitter", + "app": "emitter", } assert stream["stream"] == expected @@ -97,7 +96,7 @@ def test_headers_added(emitter_v2): emitter(create_record(), "") kwargs = get_request(session) - assert kwargs['headers']['X-Scope-OrgID'] == headers['X-Scope-OrgID'] + assert kwargs["headers"]["X-Scope-OrgID"] == headers["X-Scope-OrgID"] def test_no_headers_added(emitter_v2_no_headers): @@ -106,11 +105,12 @@ def test_no_headers_added(emitter_v2_no_headers): emitter(create_record(), "") kwargs = get_request(session) - assert kwargs['headers'] is not None and kwargs['headers'] == {} + assert kwargs["headers"] is not None and kwargs["headers"] == {} def test_soemthing_fun(): import os + a = "a" b = "b" c = "/c" diff --git a/tox.ini b/tox.ini index a611104..7eda15d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,8 @@ [tox] -envlist = - py{36,37,38}, - flake8, - black +# List of Python versions to test against +envlist = py311,py312,ruff isolated_build = true -[travis] -python = - 3.6: py36 - 3.7: py37, flake8, black - 3.8: py38 - [testenv] setenv = LC_ALL = en_US.UTF-8 @@ -21,14 +13,15 @@ deps = freezegun commands = coverage run -m pytest [] -[testenv:flake8] -skip_install = true -basepython = python3.7 -deps = wemake-python-styleguide -commands = flake8 . - -[testenv:black] +[testenv:ruff] skip_install = true -basepython = python3.7 -deps = black==19.10b0 -commands = black --check --diff -l 120 -t py36 . +# Use whatever python3 is available on your system +basepython = python3.11 +deps = ruff +commands = + # First fix auto-fixable issues + ruff check --fix . + ruff format . + # Then check for any remaining issues + ruff check . + ruff format --check . From c14e14895ce087b2d0f9b2acfcb3a414f240f6f1 Mon Sep 17 00:00:00 2001 From: Roma R Date: Fri, 28 Nov 2025 18:23:14 +0200 Subject: [PATCH 07/11] feat: add coverage to ci --- .github/workflows/test.yml | 12 ++++++++++++ .travis.yml | 8 -------- README.md | 1 + setup.py | 7 ++----- tox.ini | 5 ++++- 5 files changed, 19 insertions(+), 14 deletions(-) delete mode 100644 .travis.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8ead16b..75da162 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,7 @@ on: pull_request: branches: [ master ] + jobs: test: runs-on: ubuntu-latest @@ -27,3 +28,14 @@ jobs: - name: Run tox run: tox + + - name: Code Coverage Summary Report + uses: irongut/CodeCoverageSummary@v1.3.0 + with: + filename: coverage.xml + badge: true + format: markdown + thresholds: '80 100' + output: 'both' + fail_below_min: 'true' + indicators: 'true' diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index dadfade..0000000 --- a/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -language: python -python: - - "3.6" - - "3.7" - - "3.8" -dist: xenial -install: pip install tox tox-venv tox-travis -script: tox \ No newline at end of file diff --git a/README.md b/README.md index 18a0680..dc2e42e 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![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/) + Send Python logs directly to [Grafana Loki](https://grafana.com/loki) with minimal configuration. --- diff --git a/setup.py b/setup.py index 94bd39f..a52f8c2 100644 --- a/setup.py +++ b/setup.py @@ -19,15 +19,12 @@ python_requires=">=3.11", install_requires=["rfc3339>=6.1", "requests"], classifiers=[ - "Development Status :: 4 - Beta", + "Development Status :: 4 - Release", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.11", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Logging", "Topic :: Internet :: WWW/HTTP", diff --git a/tox.ini b/tox.ini index 7eda15d..658ecc4 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,10 @@ deps = pytest coverage freezegun -commands = coverage run -m pytest [] +commands = + coverage run -m pytest [] + coverage report + coverage xml [testenv:ruff] skip_install = true From 33c6d2bd1cf9fa5f0985b5a13c7058a2d833b63c Mon Sep 17 00:00:00 2001 From: Roma R Date: Fri, 28 Nov 2025 18:47:38 +0200 Subject: [PATCH 08/11] chore: add grafana PR for this package --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index dc2e42e..b54f8c1 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![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. From 7bf08287d75e61a874afeca4de02351ab8c5d7cd Mon Sep 17 00:00:00 2001 From: Roma R Date: Thu, 4 Dec 2025 16:22:49 +0200 Subject: [PATCH 09/11] fix: emitterV2 cotr --- logging_loki/emitter.py | 6 +++--- setup.py | 2 +- tests/test_emitter_v2.py | 6 ++++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/logging_loki/emitter.py b/logging_loki/emitter.py index f5f484c..1c45d86 100644 --- a/logging_loki/emitter.py +++ b/logging_loki/emitter.py @@ -44,7 +44,7 @@ def __init__(self, url: str, tags: dict | None = None, auth: BasicAuth = None, h self.auth = auth #: Optional headers for post request self.headers = headers or {} - #: Verfify the host's ssl certificate + #: Verify the host's ssl certificate self.verify_ssl = verify_ssl self._session: requests.Session | None = None @@ -149,8 +149,8 @@ class LokiEmitterV2(LokiEmitterV1): Enables passing additional headers to requests """ - def __init__(self, url: str, tags: dict | None = None, auth: BasicAuth = None, headers: dict = None): - super().__init__(url, tags, auth, headers) + 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.""" diff --git a/setup.py b/setup.py index a52f8c2..59c22f6 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setuptools.setup( name="python-logging-loki-v2", - version="1.0.0", + version="1.1.0", description="Python logging handler for Grafana Loki", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tests/test_emitter_v2.py b/tests/test_emitter_v2.py index 359f131..88049b8 100644 --- a/tests/test_emitter_v2.py +++ b/tests/test_emitter_v2.py @@ -35,6 +35,12 @@ def emitter_v2() -> Tuple[LokiEmitterV2, MagicMock]: return instance, session +def test_init(): + LokiEmitterV2(url=emitter_url, headers=headers) + LokiEmitterV2(url=emitter_url, headers=headers, tags={}) + LokiEmitterV2(url=emitter_url, headers=headers, tags={}, verify_ssl=True) + + @pytest.fixture() def emitter_v2_no_headers() -> Tuple[LokiEmitterV2, MagicMock]: """Create v2 emitter with mocked http session.""" From 7d4df1d7c2c19a793c217d2bceb8bd1585c624ba Mon Sep 17 00:00:00 2001 From: Dom Postorivo Date: Thu, 4 Dec 2025 10:30:49 -0500 Subject: [PATCH 10/11] Use record.created for ts in EmitterV1+ --- logging_loki/emitter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logging_loki/emitter.py b/logging_loki/emitter.py index 1c45d86..aeacea6 100644 --- a/logging_loki/emitter.py +++ b/logging_loki/emitter.py @@ -135,7 +135,7 @@ 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]], From 36d08f5322bcf14a04467140f2aecb08461daaea Mon Sep 17 00:00:00 2001 From: Roma R Date: Fri, 12 Dec 2025 18:44:24 +0200 Subject: [PATCH 11/11] release: 1.1.1 --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 59c22f6..2aebd6d 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setuptools.setup( name="python-logging-loki-v2", - version="1.1.0", + version="1.1.1", description="Python logging handler for Grafana Loki", long_description=long_description, long_description_content_type="text/markdown", @@ -19,7 +19,7 @@ python_requires=">=3.11", install_requires=["rfc3339>=6.1", "requests"], classifiers=[ - "Development Status :: 4 - Release", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent",