Skip to content

Commit 2ac9fcd

Browse files
committed
perf: better json performance
1 parent 332e93b commit 2ac9fcd

8 files changed

Lines changed: 67 additions & 63 deletions

File tree

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ STATUS: In development
2626

2727
- **Standard Configuration Support**: Fully compatible with `logging.config.dictConfig`, making it easy to drop into frameworks like Django, Flask, or FastAPI.
2828

29-
- **Zero dependency**: Does not use 3rd party libraries to avoid potential conflicts when processing log events from any 3rd party library.
30-
3129
## Installation
3230

3331
The handler can be installed with PIP from PyPI:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ classifiers = [
1616
"Programming Language :: Python :: 3.13",
1717
"Topic :: System :: Logging",
1818
]
19+
dependencies = ["orjson>3.10"]
1920
dynamic = ["version", "description"]
2021
license = "MIT"
2122
license-files = ["LICENSE"]

src/vlogs_handler/__init__.py

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

55
__title__ = "VictoriaLogs Handler"
6-
__version__ = "0.1.0dev6"
6+
__version__ = "0.1.0dev7"
77

88
# TODO
9-
# [ ] - Split very large requests
10-
# [ ] - Consider using a persistent queue
11-
# [ ] - Enable logging and switch to full debug logging in prod for testing
9+
# - Consider storing logs as strings or bytes to reduce memory footprint
10+
# - Consider using a persistent queue
11+
# - Enable logging and switch to full debug logging in prod for testing

src/vlogs_handler/encoder.py

Lines changed: 0 additions & 25 deletions
This file was deleted.

src/vlogs_handler/handler.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Module handler provides the implementation of the vlogs handler.
22
33
- The handler collects log events and sends them asynchronously to the vlogs server
4-
- Log records are converted into JSON objects. The handler uses an extension
5-
of Python's default json encoder to serialize additional types and improve robustness.
4+
- Log records are converted into JSON objects.
5+
The handler uses orjson for significantly better performance
6+
and support of additional data types
67
- The handler uses the vlogs's JSON Stream API for data ingestion.
78
- Logs are submitted when the flush interval expires or when the batch size is exceeded
8-
- Multiple logs are batched together into a single request using the ndjson protocol
9+
- Multiple logs are chunked together into a single request using the ndjson protocol
910
to minimize the number of requests.
1011
- When submission to the log server fails, logs are returns into the buffer
1112
for later retry.

src/vlogs_handler/request.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
"""Module request provides the functionality to send HTTP requests."""
22

3-
import io
4-
import json
53
import logging
64
import urllib.error
75
import urllib.parse
86
import urllib.request
97
from typing import Any, List, Optional
108

11-
from . import encoder
9+
import orjson # significantly better performance than standard json library
1210

1311
logger = logging.getLogger(__name__)
1412

@@ -33,16 +31,17 @@ def post_ndjson(*, url: str, data: List[Any], timeout: Optional[float] = None) -
3331
Settings it to None will disable the timeout.
3432
"""
3533

36-
with io.StringIO() as buffer:
37-
for obj in data:
38-
try:
39-
json.dump(obj, buffer, cls=encoder.JSON)
40-
buffer.write("\n")
41-
except (TypeError, ValueError, RecursionError):
42-
logger.exception("convert obj to JSON. Discarded", extra={"entry": obj})
43-
continue
44-
45-
data_bytes = buffer.getvalue().encode("utf-8")
34+
chunks: List[bytes] = []
35+
for obj in data:
36+
try:
37+
chunks.append(orjson.dumps(obj, option=orjson.OPT_APPEND_NEWLINE))
38+
except (TypeError, ValueError):
39+
logger.exception(
40+
"Could not convert obj to JSON. Discarded", extra={"log": obj}
41+
)
42+
continue
43+
44+
data_bytes = b"".join(chunks)
4645

4746
req = urllib.request.Request(url, data=data_bytes, method="POST")
4847
req.add_header("Content-Type", "application/x-ndjson")

tests/test_encoder.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import datetime as dt
2-
import json
32
import unittest
43

5-
from vlogs_handler import encoder
4+
import orjson
65

76

8-
class TestJSONEncoderPlus(unittest.TestCase):
7+
class MyClass:
8+
pass
9+
10+
11+
def my_func():
12+
pass
13+
14+
15+
class TestOrjsonEncoder(unittest.TestCase):
916
def test_should_encode(self):
1017
# given
11-
def my_func():
12-
pass
13-
1418
my_date = dt.datetime(2026, 1, 11, 12, 15, 42, 99, tzinfo=dt.timezone.utc)
1519
data = {
16-
"class": encoder.JSON,
20+
"class": MyClass,
1721
"date": my_date.date(),
1822
"datetime": my_date,
1923
"float": 1.23,
@@ -24,15 +28,21 @@ def my_func():
2428
}
2529

2630
# when
27-
got = json.loads(json.dumps(data, cls=encoder.JSON))
31+
got = orjson.loads(orjson.dumps(data, default=str))
2832

2933
# then
30-
self.assertEqual(got["class"], "<class 'vlogs_handler.encoder.JSON'>")
34+
self.assertEqual(got["class"], "<class 'tests.test_encoder.MyClass'>")
3135
self.assertEqual(got["date"], "2026-01-11")
3236
self.assertEqual(got["datetime"], "2026-01-11T12:15:42.000099+00:00")
3337
self.assertEqual(got["float"], 1.23)
3438
self.assertIn("my_func at", got["func"])
3539
self.assertEqual(got["integer"], 1)
36-
self.assertEqual(got["set"], [1, 2, 3])
40+
self.assertEqual(got["set"], "{1, 2, 3}")
41+
self.assertEqual(got["text"], "Alpha")
42+
self.assertEqual(got["text"], "Alpha")
43+
self.assertEqual(got["text"], "Alpha")
44+
self.assertEqual(got["text"], "Alpha")
45+
self.assertEqual(got["text"], "Alpha")
46+
self.assertEqual(got["text"], "Alpha")
3747
self.assertEqual(got["text"], "Alpha")
3848
self.assertEqual(got["text"], "Alpha")

tests/test_request.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from contextlib import contextmanager
55
from http.client import HTTPMessage
66
from io import BytesIO
7-
from typing import NamedTuple
7+
from typing import List, NamedTuple
88
from unittest.mock import MagicMock, patch
99

1010
from vlogs_handler import request
@@ -13,6 +13,7 @@
1313

1414

1515
def make_http_error(status=404, message="Not Found", url="https://example.com"):
16+
"""Create a HTTPError."""
1617
headers = HTTPMessage()
1718
fp = BytesIO(b"Error body content")
1819
return urllib.error.HTTPError(
@@ -21,15 +22,24 @@ def make_http_error(status=404, message="Not Found", url="https://example.com"):
2122

2223

2324
def make_urlopen_fake(exception=None):
25+
"""Create and return a fake for urlopen and the list of recorded requests.
26+
27+
The requests list is updated which every new request.
28+
"""
29+
requests_history: List[urllib.request.Request] = []
30+
2431
@contextmanager
2532
def urlopen_fake(req: urllib.request.Request, timeout):
2633
if not isinstance(req, urllib.request.Request):
2734
raise ValueError("req must be a Request")
35+
36+
requests_history.append(req)
37+
2838
if exception:
2939
raise exception
3040
yield MagicMock()
3141

32-
return urlopen_fake
42+
return urlopen_fake, requests_history
3343

3444

3545
class TestPostNdjson(unittest.TestCase):
@@ -40,40 +50,50 @@ def setUp(self) -> None:
4050
def test_should_submit_successfully(self):
4151
# when
4252
with patch(MODULE_PATH + ".urllib.request.urlopen") as m:
43-
m.side_effect = make_urlopen_fake()
53+
m.side_effect, requests_history = make_urlopen_fake()
4454
got = request.post_ndjson(url=self.url, data=self.data)
4555

4656
# then
4757
self.assertTrue(got)
58+
self.assertEqual(len(requests_history), 1)
59+
req = requests_history[0]
60+
self.assertEqual(req.full_url, self.url)
61+
self.assertEqual(req.get_header("Content-type"), "application/x-ndjson")
62+
self.assertEqual(req.data, b'{"event":"test"}\n{"event":"more_data"}\n')
4863

4964
def test_should_handle_http_exception(self):
5065
# when
5166
with patch(MODULE_PATH + ".urllib.request.urlopen") as m:
52-
m.side_effect = make_urlopen_fake(exception=make_http_error())
67+
m.side_effect, requests_history = make_urlopen_fake(
68+
exception=make_http_error()
69+
)
5370
got = request.post_ndjson(url=self.url, data=self.data)
5471

5572
# then
5673
self.assertFalse(got)
74+
self.assertEqual(len(requests_history), 1)
5775

5876
def test_should_handle_url_exception(self):
5977
# when
6078
with patch(MODULE_PATH + ".urllib.request.urlopen") as m:
61-
m.side_effect = make_urlopen_fake(
79+
m.side_effect, requests_history = make_urlopen_fake(
6280
exception=urllib.error.URLError("Network is unreachable")
6381
)
6482
got = request.post_ndjson(url=self.url, data=self.data)
6583

6684
# then
6785
self.assertFalse(got)
86+
self.assertEqual(len(requests_history), 1)
6887

6988
def test_should_handle_general_exception(self):
7089
# when
7190
with patch(MODULE_PATH + ".urllib.request.urlopen") as m:
72-
m.side_effect = make_urlopen_fake(exception=RuntimeError)
91+
m.side_effect, requests_history = make_urlopen_fake(exception=RuntimeError)
7392
got = request.post_ndjson(url=self.url, data=self.data)
7493

7594
# then
7695
self.assertFalse(got)
96+
self.assertEqual(len(requests_history), 1)
7797

7898

7999
class TestIsURL(unittest.TestCase):

0 commit comments

Comments
 (0)