Skip to content

Commit 2224795

Browse files
committed
Initial commit
0 parents  commit 2224795

8 files changed

Lines changed: 379 additions & 0 deletions

File tree

.editorconfig

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
root = true
2+
3+
[Makefile]
4+
indent_style = tab
5+
6+
[*.py]
7+
charset = utf-8
8+
indent_style = space
9+
indent_size = 4

.gitignore

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Byte-compiled / optimized / DLL files
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
6+
# C extensions
7+
*.so
8+
9+
# Distribution / packaging
10+
.Python
11+
build/
12+
develop-eggs/
13+
dist/
14+
downloads/
15+
eggs/
16+
.eggs/
17+
lib/
18+
lib64/
19+
parts/
20+
sdist/
21+
var/
22+
wheels/
23+
pip-wheel-metadata/
24+
share/python-wheels/
25+
*.egg-info/
26+
.installed.cfg
27+
*.egg
28+
MANIFEST
29+
30+
# PyInstaller
31+
# Usually these files are written by a python script from a template
32+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
33+
*.manifest
34+
*.spec
35+
36+
# Installer logs
37+
pip-log.txt
38+
pip-delete-this-directory.txt
39+
40+
# Unit test / coverage reports
41+
htmlcov/
42+
.tox/
43+
.nox/
44+
.coverage
45+
.coverage.*
46+
.cache
47+
nosetests.xml
48+
coverage.xml
49+
*.cover
50+
.hypothesis/
51+
.pytest_cache/
52+
53+
# Translations
54+
*.mo
55+
*.pot
56+
57+
# Django stuff:
58+
*.log
59+
local_settings.py
60+
db.sqlite3
61+
62+
# Flask stuff:
63+
instance/
64+
.webassets-cache
65+
66+
# Scrapy stuff:
67+
.scrapy
68+
69+
# Sphinx documentation
70+
docs/_build/
71+
72+
# PyBuilder
73+
target/
74+
75+
# Jupyter Notebook
76+
.ipynb_checkpoints
77+
78+
# IPython
79+
profile_default/
80+
ipython_config.py
81+
82+
# pyenv
83+
.python-version
84+
85+
# celery beat schedule file
86+
celerybeat-schedule
87+
88+
# SageMath parsed files
89+
*.sage.py
90+
91+
# Environments
92+
.env
93+
.venv
94+
env/
95+
venv/
96+
ENV/
97+
env.bak/
98+
venv.bak/
99+
100+
# Spyder project settings
101+
.spyderproject
102+
.spyproject
103+
104+
# Rope project settings
105+
.ropeproject
106+
107+
# mkdocs documentation
108+
/site
109+
110+
# mypy
111+
.mypy_cache/
112+
.dmypy.json
113+
dmypy.json
114+
115+
# Pyre type checker
116+
.pyre/
117+
118+
# JetBrains IDE
119+
.idea
120+
121+
# MacOS
122+
.DS_Store

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2019 Andrey Maslov
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
python-logging-loki
2+
===================
3+
4+
[![PyPI version](https://img.shields.io/pypi/v/python-logging-loki.svg)](https://pypi.org/project/python-logging-loki/)
5+
[![Python version](https://img.shields.io/badge/python-3.5%20%7C%203.6%20%7C%203.7-blue.svg)](https://www.python.org/)
6+
[![License](https://img.shields.io/pypi/l/python-logging-loki.svg)](https://opensource.org/licenses/MIT)
7+
8+
Python logging handler for Loki.
9+
https://grafana.com/loki
10+
11+
Installation
12+
============
13+
```bash
14+
pip install python-logging-loki
15+
```
16+
17+
Usage
18+
=====
19+
20+
```python
21+
import logging
22+
import logging_loki
23+
24+
25+
handler = logging_loki.LokiHandler(
26+
url="https://my-loki-instnace/api/prom/push",
27+
tags={"application": "my-app"},
28+
auth=("username", "password"),
29+
)
30+
31+
logger = logging.getLogger("my-logger")
32+
logger.addHandler(handler)
33+
logger.error(
34+
"Something happened",
35+
extra={"tags": {"service": "my-service"}},
36+
)
37+
```
38+
39+
Example above will send `Something happened` message along with these labels:
40+
- Default labels from handler
41+
- Message level as `serverity`
42+
- Logger's name as `logger`
43+
- Labels from `tags` item of `extra` dict
44+
45+
The given example is blocking (i.e. each call will wait for the message to be sent).
46+
But you can use the built-in `QueueHandler` and` QueueListener` to send messages in a separate thread.
47+
48+
```python
49+
import logging.handlers
50+
import logging_loki
51+
from queue import Queue
52+
53+
54+
queue = Queue(-1)
55+
handler = logging.handlers.QueueHandler(queue)
56+
handler_loki = logging_loki.LokiHandler(
57+
url="https://my-loki-instnace/api/prom/push",
58+
tags={"application": "my-app"},
59+
auth=("username", "password"),
60+
)
61+
logging.handlers.QueueListener(queue, handler_loki)
62+
63+
logger = logging.getLogger("my-logger")
64+
logger.addHandler(handler)
65+
logger.error(...)
66+
```
67+
68+
Or you can use `LokiQueueHandler` shortcut, which will automatically create listener and handler.
69+
70+
```python
71+
import logging.handlers
72+
import logging_loki
73+
from queue import Queue
74+
75+
76+
handler = logging_loki.LokiQueueHandler(
77+
Queue(-1),
78+
url="https://my-loki-instnace/api/prom/push",
79+
tags={"application": "my-app"},
80+
auth=("username", "password"),
81+
)
82+
83+
logger = logging.getLogger("my-logger")
84+
logger.addHandler(handler)
85+
logger.error(...)
86+
```

logging_loki/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .handlers import LokiHandler, LokiQueueHandler
2+
3+
__all__ = ["LokiHandler", "LokiQueueHandler"]
4+
__version__ = "0.1.0"
5+
name = "logging_loki"

logging_loki/handlers.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import logging
2+
from logging.handlers import QueueHandler, QueueListener
3+
from queue import Queue
4+
from typing import Optional, Tuple, Dict, Any
5+
6+
import requests
7+
import rfc3339
8+
9+
BasicAuth = Optional[Tuple[str, str]]
10+
11+
12+
class LokiQueueHandler(QueueHandler):
13+
"""
14+
This handler automatically creates listener and `LokiHandler` to handle logs queue.
15+
"""
16+
17+
def __init__(self, queue: Queue, url: str, tags: Optional[dict] = None, auth: BasicAuth = None):
18+
super().__init__(queue)
19+
self.handler = LokiHandler(url, tags, auth)
20+
self.listener = QueueListener(self.queue, self.handler)
21+
self.listener.start()
22+
23+
24+
class LokiHandler(logging.Handler):
25+
"""
26+
This handler sends log records to Loki via HTTP API.
27+
https://github.com/grafana/loki/blob/master/docs/api.md
28+
"""
29+
30+
level_tag: str = "severity"
31+
logger_tag: str = "logger"
32+
33+
def __init__(self, url: str, tags: Optional[dict] = None, auth: BasicAuth = None):
34+
super().__init__()
35+
36+
# Tags that will be added to all records handled by this handler.
37+
self.tags = tags or {}
38+
39+
# Loki HTTP API endpoint (e.g `http://127.0.0.1/api/prom/push`)
40+
self.url = url
41+
42+
# Optional tuple with username and password for basic authentication
43+
self.auth = auth
44+
45+
self._session: requests.Session = None
46+
47+
@property
48+
def session(self) -> requests.Session:
49+
if self._session is None:
50+
self._session = requests.Session()
51+
self._session.auth = self.auth or None
52+
return self._session
53+
54+
def handleError(self, record):
55+
super().handleError(record)
56+
if self._session is not None:
57+
self._session.close()
58+
self._session = None
59+
60+
def emit(self, record: logging.LogRecord):
61+
"""
62+
Send log record to Loki.
63+
"""
64+
# noinspection PyBroadException
65+
try:
66+
labels = self.build_labels(record)
67+
ts = rfc3339.format(record.created)
68+
line = self.format(record)
69+
payload = {"streams": [{"labels": labels, "entries": [{"ts": ts, "line": line}]}]}
70+
resp = self.session.post(self.url, json=payload)
71+
if resp.status_code != 204:
72+
raise ValueError("Unexpected Loki API response status code: %s" % resp.status_code)
73+
except Exception:
74+
self.handleError(record)
75+
76+
def build_labels(self, record: logging.LogRecord) -> str:
77+
"""
78+
Return Loki labels string.
79+
"""
80+
tags = self.build_tags(record)
81+
labels = ",".join(['%s="%s"' % (k, str(v).replace('"', '\\"')) for k, v in tags.items()])
82+
return "{%s}" % labels
83+
84+
def build_tags(self, record: logging.LogRecord) -> Dict[str, Any]:
85+
"""
86+
Return tags that must be send to Loki with a log record.
87+
"""
88+
tags = self.tags.copy()
89+
tags[self.level_tag] = record.levelname.lower()
90+
tags[self.logger_tag] = record.name
91+
92+
extra_tags = getattr(record, "tags", {})
93+
if isinstance(extra_tags, dict):
94+
tags.update(extra_tags)
95+
96+
return tags

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[tool.black]
2+
line-length = 120
3+
py36 = true
4+
exclude = '''
5+
/(
6+
\.git
7+
)/
8+
'''

setup.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import setuptools
2+
3+
with open("README.md", "r") as fh:
4+
long_description = fh.read()
5+
6+
setuptools.setup(
7+
name="python-logging-loki",
8+
version="0.1.0",
9+
description="Python logging handler for Grafana Loki.",
10+
long_description=long_description,
11+
long_description_content_type="text/markdown",
12+
license="MIT",
13+
author="Andrey Maslov",
14+
author_email="greyzmeem@gmail.com",
15+
url="https://github.com/greyzmeem/python-logging-loki",
16+
packages=setuptools.find_packages(),
17+
python_requires=">=3.5",
18+
install_requires=["rfc3339", "requests"],
19+
classifiers=[
20+
"Development Status :: 4 - Beta",
21+
"Intended Audience :: Developers",
22+
"License :: OSI Approved :: MIT License",
23+
"Operating System :: OS Independent",
24+
"Programming Language :: Python",
25+
"Programming Language :: Python :: 3",
26+
"Programming Language :: Python :: 3.6",
27+
"Programming Language :: Python :: 3.7",
28+
"Topic :: Software Development :: Libraries :: Python Modules",
29+
"Topic :: System :: Logging",
30+
"Topic :: Internet :: WWW/HTTP",
31+
],
32+
)

0 commit comments

Comments
 (0)