Skip to content

Commit 12cb604

Browse files
committed
add ContextFilter for logging trace_id contextvars
1 parent 54ffdcd commit 12cb604

File tree

5 files changed

+321
-1
lines changed

5 files changed

+321
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
3. add `morebuiltins.logs.LogHelper` to quickly bind a logging handler to a logger, with a StreamHandler or SizedTimedRotatingFileHandler.
1010
4. **Compatibility Warnings**:
1111
1. move `async_logger`, `AsyncQueueListener`, `LogHelper`, `RotatingFileWriter`, and `SizedTimedRotatingFileHandler` from `morebuiltins.funcs` to `morebuiltins.logs`.
12+
5. add `ContextFilter` for logging, to add context variables to log records.
1213

1314
### 1.3.1 (2025-06-19)
1415
1. add `check_recursion` to `morebuiltins.funcs`, to check if a function is recursive.

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,21 @@ print(morebuiltins.__file__)
275275
18.2 `SharedBytes` - Shared Memory for Python, for python 3.8+.
276276

277277

278+
## 19. morebuiltins.logs
279+
280+
19.1 `async_logger` - Asynchronous non-blocking QueueListener that manages logger handlers.
281+
282+
19.2 `AsyncQueueListener` - Asynchronous non-blocking QueueListener that manages logger handlers.
283+
284+
19.3 `LogHelper` - Quickly bind a logging handler to a logger, with a StreamHandler or SizedTimedRotatingFileHandler.
285+
286+
19.4 `RotatingFileWriter` - RotatingFileWriter class for writing to a file with rotation support.
287+
288+
19.5 `SizedTimedRotatingFileHandler` - TimedRotatingFileHandler with maxSize, to avoid files that are too large.
289+
290+
19.6 `ContextFilter` - A logging filter that injects context variables into extra of log records. ContextVar is used to manage context-specific data in a thread-safe / async-safe manner.
291+
292+
278293
<!-- end -->
279294

280295
## cmd utils

doc.md

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2677,3 +2677,257 @@ b''
26772677
---
26782678

26792679

2680+
## 19. morebuiltins.logs
2681+
2682+
2683+
2684+
19.1 `async_logger` - Asynchronous non-blocking QueueListener that manages logger handlers.
2685+
2686+
2687+
```python
2688+
logger is a logging.Logger instance.
2689+
queue is a Queue or ProcessQueue instance.
2690+
respect_handler_level is a boolean that determines if the handler level should be respected.
2691+
2692+
Example:
2693+
2694+
async def main():
2695+
# Create logger with a blocking handler
2696+
logger = logging.getLogger("example")
2697+
logger.setLevel(logging.INFO)
2698+
stream_handler = logging.StreamHandler()
2699+
stream_handler.setFormatter(
2700+
logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
2701+
)
2702+
logger.addHandler(stream_handler)
2703+
# Use async queue listener
2704+
async with AsyncQueueListener(logger):
2705+
# Log won't block the event loop
2706+
for i in range(5):
2707+
logger.info("log info")
2708+
logger.debug("log debug")
2709+
await asyncio.sleep(0.01)
2710+
2711+
```
2712+
2713+
2714+
---
2715+
2716+
2717+
2718+
19.2 `AsyncQueueListener` - Asynchronous non-blocking QueueListener that manages logger handlers.
2719+
2720+
2721+
```python
2722+
logger is a logging.Logger instance.
2723+
queue is a Queue or ProcessQueue instance.
2724+
respect_handler_level is a boolean that determines if the handler level should be respected.
2725+
2726+
Example:
2727+
2728+
async def main():
2729+
# Create logger with a blocking handler
2730+
logger = logging.getLogger("example")
2731+
logger.setLevel(logging.INFO)
2732+
stream_handler = logging.StreamHandler()
2733+
stream_handler.setFormatter(
2734+
logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
2735+
)
2736+
logger.addHandler(stream_handler)
2737+
# Use async queue listener
2738+
async with AsyncQueueListener(logger):
2739+
# Log won't block the event loop
2740+
for i in range(5):
2741+
logger.info("log info")
2742+
logger.debug("log debug")
2743+
await asyncio.sleep(0.01)
2744+
2745+
```
2746+
2747+
2748+
---
2749+
2750+
2751+
2752+
19.3 `LogHelper` - Quickly bind a logging handler to a logger, with a StreamHandler or SizedTimedRotatingFileHandler.
2753+
2754+
2755+
```python
2756+
2757+
The default handler is a StreamHandler to sys.stderr.
2758+
The default file handler is a SizedTimedRotatingFileHandler, which can rotate logs by both time and size.
2759+
2760+
Examples::
2761+
2762+
import logging
2763+
from morebuiltins.log import LogHelper
2764+
2765+
LogHelper.shorten_level()
2766+
logger = LogHelper.bind_handler(name="mylogger", filename=sys.stdout, maxBytes=100 * 1024**2, backupCount=7)
2767+
# use logging.getLogger to get the same logger instance
2768+
logger2 = logging.getLogger("mylogger")
2769+
assert logger is logger2
2770+
logger.info("This is an info message")
2771+
logger.fatal("This is a critical message")
2772+
2773+
```
2774+
2775+
2776+
---
2777+
2778+
2779+
2780+
19.4 `RotatingFileWriter` - RotatingFileWriter class for writing to a file with rotation support.
2781+
2782+
2783+
```python
2784+
2785+
Demo::
2786+
2787+
>>> # test normal usage
2788+
>>> writer = RotatingFileWriter("test.log", max_size=10 * 1024, max_backups=1)
2789+
>>> writer.write("1" * 10)
2790+
>>> writer.path.stat().st_size
2791+
0
2792+
>>> writer.flush()
2793+
>>> writer.path.stat().st_size
2794+
10
2795+
>>> writer.clean_backups(writer.max_backups)
2796+
>>> writer.unlink_file()
2797+
>>> # test rotating
2798+
>>> writer = RotatingFileWriter("test.log", max_size=20, max_backups=2)
2799+
>>> writer.write("1" * 15)
2800+
>>> writer.write("1" * 15)
2801+
>>> writer.write("1" * 15, flush=True)
2802+
>>> writer.path.stat().st_size
2803+
15
2804+
>>> len(writer.backup_path_list())
2805+
2
2806+
>>> writer.clean_backups(writer.max_backups)
2807+
>>> writer.unlink_file()
2808+
>>> # test no backups
2809+
>>> writer = RotatingFileWriter("test.log", max_size=20, max_backups=0)
2810+
>>> writer.write("1" * 15)
2811+
>>> writer.write("1" * 15)
2812+
>>> writer.write("1" * 15, flush=True)
2813+
>>> writer.path.stat().st_size
2814+
15
2815+
>>> len(writer.backup_path_list())
2816+
0
2817+
>>> writer.clean_backups(writer.max_backups)
2818+
>>> len(writer.backup_path_list())
2819+
0
2820+
>>> writer = RotatingFileWriter("test.log", max_size=20, max_backups=3)
2821+
>>> writer.print("1" * 100)
2822+
>>> writer.unlink(rotate=False)
2823+
>>> len(writer.backup_path_list())
2824+
1
2825+
>>> writer.unlink(rotate=True)
2826+
>>> len(writer.backup_path_list())
2827+
0
2828+
>>> writer = RotatingFileWriter("test.log", max_size=20, max_backups=3, compress=True)
2829+
>>> writer.print("1" * 100)
2830+
>>> len(writer.backup_path_list())
2831+
1
2832+
>>> writer.unlink(rotate=True)
2833+
>>> len(writer.backup_path_list())
2834+
0
2835+
2836+
```
2837+
2838+
2839+
---
2840+
2841+
2842+
2843+
19.5 `SizedTimedRotatingFileHandler` - TimedRotatingFileHandler with maxSize, to avoid files that are too large.
2844+
2845+
2846+
```python
2847+
2848+
2849+
Demo::
2850+
2851+
import logging
2852+
import time
2853+
from morebuiltins.funcs import SizedTimedRotatingFileHandler
2854+
2855+
logger = logging.getLogger("test1")
2856+
h = SizedTimedRotatingFileHandler(
2857+
"logs/test1.log", "d", 1, 3, maxBytes=1, ensure_dir=True
2858+
)
2859+
h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
2860+
logger.addHandler(h)
2861+
2862+
for i in range(5):
2863+
logger.warning(str(i) * 102400)
2864+
time.sleep(1)
2865+
# 102434 test1.log
2866+
# 102434 test1.log.20241113_231000
2867+
# 102434 test1.log.20241113_231001
2868+
# 102434 test1.log.20241113_231002
2869+
logger = logging.getLogger("test2")
2870+
h = SizedTimedRotatingFileHandler(
2871+
"logs/test2.log", "d", 1, 3, maxBytes=1, compress=True
2872+
)
2873+
h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
2874+
logger.addHandler(h)
2875+
2876+
for i in range(5):
2877+
logger.warning(str(i) * 102400)
2878+
time.sleep(1)
2879+
# 102434 test2.log
2880+
# 186 test2.log.20241113_231005.gz
2881+
# 186 test2.log.20241113_231006.gz
2882+
# 186 test2.log.20241113_231007.gz
2883+
2884+
2885+
```
2886+
2887+
2888+
---
2889+
2890+
2891+
2892+
19.6 `ContextFilter` - A logging filter that injects context variables into extra of log records. ContextVar is used to manage context-specific data in a thread-safe / async-safe manner.
2893+
2894+
2895+
```python
2896+
RequestID / TraceID / TaskID can be used to trace logs belonging to the same request or operation across different threads or async tasks.
2897+
2898+
Example::
2899+
2900+
import random
2901+
import time
2902+
import typing
2903+
from concurrent.futures import ThreadPoolExecutor
2904+
from contextvars import ContextVar
2905+
from logging import Filter, Formatter, StreamHandler, getLogger
2906+
from threading import current_thread
2907+
2908+
def test(trace_id: int = 0):
2909+
trace_id_var.set(trace_id)
2910+
for _ in range(3):
2911+
time.sleep(random.random())
2912+
logger.debug(f"msg from thread: {current_thread().ident}")
2913+
2914+
2915+
trace_id_var: ContextVar = ContextVar("trace_id")
2916+
logger = getLogger()
2917+
logger.addFilter(ContextFilter({"trace_id": trace_id_var}))
2918+
formatter = Formatter("%(asctime)s | [%(trace_id)s] %(message)s")
2919+
handler = StreamHandler()
2920+
handler.setFormatter(formatter)
2921+
logger.addHandler(handler)
2922+
logger.setLevel("DEBUG")
2923+
2924+
with ThreadPoolExecutor(max_workers=2) as executor:
2925+
future = [executor.submit(test, _) for _ in range(3)]
2926+
2927+
2928+
```
2929+
2930+
2931+
---
2932+
2933+

morebuiltins/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@
2020
"morebuiltins.cmd.systemd.timer",
2121
"morebuiltins.sqlite",
2222
"morebuiltins.shared_memory",
23+
"morebuiltins.logs",
2324
]

morebuiltins/logs.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
import re
44
import sys
55
import time
6-
from contextvars import copy_context
6+
import typing
7+
from contextvars import ContextVar, copy_context
78
from functools import partial
89
from gzip import GzipFile
910
from io import TextIOBase
11+
from logging import Filter
1012
from logging.handlers import QueueHandler, QueueListener, TimedRotatingFileHandler
1113
from multiprocessing import Queue as ProcessQueue
1214
from pathlib import Path
@@ -21,6 +23,7 @@
2123
"LogHelper",
2224
"RotatingFileWriter",
2325
"SizedTimedRotatingFileHandler",
26+
"ContextFilter",
2427
]
2528

2629

@@ -694,6 +697,52 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
694697
async_logger = AsyncQueueListener
695698

696699

700+
class ContextFilter(Filter):
701+
"""A logging filter that injects context variables into extra of log records. ContextVar is used to manage context-specific data in a thread-safe / async-safe manner.
702+
RequestID / TraceID / TaskID can be used to trace logs belonging to the same request or operation across different threads or async tasks.
703+
704+
Example::
705+
706+
import random
707+
import time
708+
import typing
709+
from concurrent.futures import ThreadPoolExecutor
710+
from contextvars import ContextVar
711+
from logging import Filter, Formatter, StreamHandler, getLogger
712+
from threading import current_thread
713+
714+
def test(trace_id: int = 0):
715+
trace_id_var.set(trace_id)
716+
for _ in range(3):
717+
time.sleep(random.random())
718+
logger.debug(f"msg from thread: {current_thread().ident}")
719+
720+
721+
trace_id_var: ContextVar = ContextVar("trace_id")
722+
logger = getLogger()
723+
logger.addFilter(ContextFilter({"trace_id": trace_id_var}))
724+
formatter = Formatter("%(asctime)s | [%(trace_id)s] %(message)s")
725+
handler = StreamHandler()
726+
handler.setFormatter(formatter)
727+
logger.addHandler(handler)
728+
logger.setLevel("DEBUG")
729+
730+
with ThreadPoolExecutor(max_workers=2) as executor:
731+
future = [executor.submit(test, _) for _ in range(3)]
732+
733+
"""
734+
735+
def __init__(self, context_vars: typing.Dict[str, ContextVar], name: str = ""):
736+
super().__init__(name)
737+
self._context_vars = context_vars
738+
739+
def filter(self, record):
740+
record_dict = record.__dict__
741+
for key, var in self._context_vars.items():
742+
record_dict.setdefault(key, var.get(None))
743+
return True
744+
745+
697746
def test_LogHelper():
698747
logger = LogHelper.bind_handler(
699748
"app_test", filename="app_test.log", maxBytes=1, backupCount=2

0 commit comments

Comments
 (0)