Skip to content

Commit baba913

Browse files
committed
feat: add support for log extras
1 parent 7e3e282 commit baba913

6 files changed

Lines changed: 185 additions & 17 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,6 @@ cython_debug/
205205
marimo/_static/
206206
marimo/_lsp/
207207
__marimo__/
208+
209+
# VS Code
210+
.vscode/

Makefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
test:
2-
python -m unittest discover
1+
coverage:
2+
coverage run -m unittest discover tests/
3+
coverage report -m

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,15 @@ A Python log handler for Victoria Logs.
88
[![codecov](https://codecov.io/gh/ErikKalkoken/python-victorialogs-handler/graph/badge.svg?token=2pPb3lid2k)](https://codecov.io/gh/ErikKalkoken/python-victorialogs-handler)
99
[![license](https://img.shields.io/badge/license-MIT-green)](https://gitlab.com/ErikKalkoken/python-victorialogs-handler/-/blob/master/LICENSE)
1010

11-
In development...
11+
STATUS: In development
12+
13+
## Description
14+
15+
This package provides a Python log handler for Victoria Logs. The log handler is designed to work with Python's default logging module and will send all log events to a configured Victoria Logs server for log ingestion.
16+
17+
## Key Features
18+
19+
- Asynchronous design: Log events are queued and then processed in a separate thread so that the performance impact on the main program remains minimal.
20+
- Request batching: Log events are processed without delay. Multiple log events will be sent in a single request as batch to minimize the number of requests to the VictoriaLogs server.
21+
- Supports extras: Extra fields are supported. This includes non-standard types like sets and datetime objects. Fields that can not be serialized to JSON (e.g. functions) will be converted into their string representation
22+
- Supports dict config: The handler supports dict configuration, e.g. for a Django server

src/vlogs_handler/__init__.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@
33
from .handler import VictoriaLogsHandler # noqa: F401
44

55
__title__ = "Victoria Logs Handler"
6-
__version__ = "0.1.0dev1"
6+
__version__ = "0.1.0dev2"
77

88
# TO-DOs
9-
# [x] Batch requests
10-
# [x] tests
11-
# [x] timeout configurable
129
# [ ] Options for defining stream

src/vlogs_handler/handler.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Module handler provides the implementation of the vlogs handler."""
22

3+
import datetime as dt
34
import io
45
import json
56
import logging
@@ -16,6 +17,31 @@
1617
_VLOGS_PARAMS = "_stream_fields=stream&_time_field=timestamp&_msg_field=message"
1718

1819

20+
_STANDARD_ATTRS = {
21+
"args",
22+
"created",
23+
"exc_info",
24+
"exc_text",
25+
"filename",
26+
"funcName",
27+
"levelname",
28+
"levelno",
29+
"lineno",
30+
"message",
31+
"module",
32+
"msecs",
33+
"msg",
34+
"name",
35+
"pathname",
36+
"process",
37+
"processName",
38+
"relativeCreated",
39+
"stack_info",
40+
"thread",
41+
"threadName",
42+
}
43+
44+
1945
class VictoriaLogsHandler(logging.Handler):
2046
"""VictoriaLogsHandler dispatches log events to a Victoria Logs server.
2147
@@ -73,6 +99,11 @@ def format_log_entry(self, record: logging.LogRecord) -> Dict[str, Any]:
7399
}
74100
if record.exc_info:
75101
entry["exception"] = _format_exception(record.exc_info)
102+
103+
for k, v in record.__dict__.items():
104+
if k not in _STANDARD_ATTRS:
105+
entry[k] = v
106+
76107
return entry
77108

78109
def _worker(self):
@@ -92,7 +123,8 @@ def _send(self, entries: List[Dict[str, Any]]):
92123
lines = []
93124
for entry in entries:
94125
try:
95-
lines.append(json.dumps(entry))
126+
data = json.dumps(entry, cls=JSONEncoderPlus)
127+
lines.append(data)
96128
except Exception as ex:
97129
log.exception("convert entry to JSON", ex, entry=entry)
98130
continue
@@ -139,3 +171,24 @@ def _calc_stream_from_record(record: logging.LogRecord):
139171
else:
140172
stream = record.name
141173
return stream
174+
175+
176+
class JSONEncoderPlus(json.JSONEncoder):
177+
"""JSONEncoderPlus is an improved encoder that can convert dates and does not break.
178+
179+
Instead of breaking it will return a string representation
180+
for unserializable fields.
181+
"""
182+
183+
def default(self, obj):
184+
if isinstance(obj, (dt.datetime, dt.date)):
185+
return obj.isoformat()
186+
187+
if isinstance(obj, set):
188+
return list(obj)
189+
190+
try:
191+
return super().default(obj)
192+
except TypeError:
193+
# If we can't serialize it, return None or a string representation
194+
return str(obj)

tests/test_handler.py

Lines changed: 112 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
import datetime as dt
12
import json
23
import logging
3-
import time
44
import unittest
5+
from threading import Event
56

67
import requests_mock
78

89
from vlogs_handler import VictoriaLogsHandler
10+
from vlogs_handler.handler import JSONEncoderPlus
911

1012

1113
@requests_mock.Mocker()
@@ -15,29 +17,87 @@ def setUp(self):
1517
self.logger = logging.getLogger("test_logger")
1618
self.logger.addHandler(self.handler)
1719
self.logger.setLevel(logging.INFO)
20+
self.ready_event = Event()
1821

1922
def tearDown(self):
2023
self.logger.removeHandler(self.handler)
2124

22-
def test_handler_sends_log(self, m: requests_mock.Mocker):
25+
def text_callback(self, request, context):
26+
self.ready_event.set()
27+
return ""
28+
29+
def test_handler_should_send_normal_log(self, m: requests_mock.Mocker):
2330
# given
2431
m.register_uri(
2532
"POST",
2633
"http://localhost:30123/insert/jsonline",
2734
status_code=200,
35+
text=self.text_callback,
2836
)
2937

3038
# when
3139
self.logger.info("Alpha")
32-
time.sleep(0.5)
3340

3441
# then
42+
called_in_time = self.ready_event.wait(timeout=2.0)
43+
self.assertTrue(called_in_time)
44+
45+
self.assertEqual(m.call_count, 1)
46+
got = m.last_request.json() # type: ignore
47+
self.assertEqual(got["stream"], "test_logger")
48+
self.assertEqual(got["level"], "INFO")
49+
self.assertEqual(got["logger"], "test_logger")
50+
self.assertEqual(got["message"], "Alpha")
51+
52+
def test_handler_should_send_log_with_extras(self, m: requests_mock.Mocker):
53+
# given
54+
m.register_uri(
55+
"POST",
56+
"http://localhost:30123/insert/jsonline",
57+
status_code=200,
58+
text=self.text_callback,
59+
)
60+
my_date = dt.datetime(2026, 1, 11, 12, 15, 42, 99, tzinfo=dt.UTC)
61+
62+
# when
63+
self.logger.info("Alpha", extra={"planet": "Jupiter", "deadline": my_date})
64+
65+
# then
66+
called_in_time = self.ready_event.wait(timeout=2.0)
67+
self.assertTrue(called_in_time)
68+
69+
self.assertEqual(m.call_count, 1)
70+
got = m.last_request.json() # type: ignore
71+
self.assertEqual(got["message"], "Alpha")
72+
self.assertEqual(got["planet"], "Jupiter")
73+
self.assertEqual(got["deadline"], "2026-01-11T12:15:42.000099+00:00")
74+
75+
def test_handler_should_send_exception_log(self, m: requests_mock.Mocker):
76+
# given
77+
m.register_uri(
78+
"POST",
79+
"http://localhost:30123/insert/jsonline",
80+
status_code=200,
81+
text=self.text_callback,
82+
)
83+
84+
# when
85+
try:
86+
_ = 1 / 0
87+
except ZeroDivisionError:
88+
self.logger.exception("Bravo")
89+
90+
# then
91+
called_in_time = self.ready_event.wait(timeout=2.0)
92+
self.assertTrue(called_in_time)
93+
3594
self.assertEqual(m.call_count, 1)
36-
data = m.last_request.json() # type: ignore
37-
self.assertEqual(data["stream"], "test_logger")
38-
self.assertEqual(data["level"], "INFO")
39-
self.assertEqual(data["logger"], "test_logger")
40-
self.assertEqual(data["message"], "Alpha")
95+
got = m.last_request.json() # type: ignore
96+
self.assertEqual(got["stream"], "test_logger")
97+
self.assertEqual(got["level"], "ERROR")
98+
self.assertEqual(got["logger"], "test_logger")
99+
self.assertEqual(got["message"], "Bravo")
100+
self.assertIn("ZeroDivisionError", got["exception"])
41101

42102

43103
@requests_mock.Mocker()
@@ -49,25 +109,33 @@ def setUp(self):
49109
self.logger = logging.getLogger("test_logger")
50110
self.logger.addHandler(self.handler)
51111
self.logger.setLevel(logging.INFO)
112+
self.ready_event = Event()
52113

53114
def tearDown(self):
54115
self.logger.removeHandler(self.handler)
55116

117+
def text_callback(self, request, context):
118+
self.ready_event.set()
119+
return ""
120+
56121
def test_handler_sends_log(self, m: requests_mock.Mocker):
57122
# given
58123
m.register_uri(
59124
"POST",
60125
"http://localhost:30123/insert/jsonline",
61126
status_code=200,
127+
text=self.text_callback,
62128
)
63129

64130
# when
65131
self.logger.info("Alpha")
66132
self.logger.info("Bravo")
67133
self.handler.start()
68-
time.sleep(0.5)
69134

70135
# then
136+
called_in_time = self.ready_event.wait(timeout=2.0)
137+
self.assertTrue(called_in_time)
138+
71139
self.assertEqual(m.call_count, 1)
72140
data = m.last_request.text # type: ignore
73141
lines = data.splitlines()
@@ -78,3 +146,38 @@ def test_handler_sends_log(self, m: requests_mock.Mocker):
78146

79147
e2 = json.loads(lines[1])
80148
self.assertEqual(e2["message"], "Bravo")
149+
self.assertEqual(e2["message"], "Bravo")
150+
151+
152+
class TestJSONEncoderPlus(unittest.TestCase):
153+
def test_should_encode(self):
154+
# given
155+
def my_func():
156+
pass
157+
158+
my_date = dt.datetime(2026, 1, 11, 12, 15, 42, 99, tzinfo=dt.UTC)
159+
data = {
160+
"class": JSONEncoderPlus,
161+
"date": my_date.date(),
162+
"datetime": my_date,
163+
"float": 1.23,
164+
"func": my_func,
165+
"integer": 1,
166+
"set": {1, 2, 3},
167+
"text": "Alpha",
168+
}
169+
170+
# when
171+
got = json.loads(json.dumps(data, cls=JSONEncoderPlus))
172+
173+
# then
174+
self.assertEqual(
175+
got["class"], "<class 'vlogs_handler.handler.JSONEncoderPlus'>"
176+
)
177+
self.assertEqual(got["date"], "2026-01-11")
178+
self.assertEqual(got["datetime"], "2026-01-11T12:15:42.000099+00:00")
179+
self.assertEqual(got["float"], 1.23)
180+
self.assertIn("my_func at", got["func"])
181+
self.assertEqual(got["integer"], 1)
182+
self.assertEqual(got["set"], [1, 2, 3])
183+
self.assertEqual(got["text"], "Alpha")

0 commit comments

Comments
 (0)