comparison roundup/logcontext.py @ 8487:b09ef85f0da6

feat: add nanoid pkg trace_id gen and decorator for setting processName nanoid is a shorter unique id generator and faster than uuid. I truncate nanoid id's to 12 chars to make it more readable. Also added decorator to allow setting the default processName definition in the logging module. admin.py and wsgi_handler now set processName. configuration.py knows how to overide the processName if set to the default MainProcess. Updated install docs to add nanoid as optional, how to switch to different trace_id output. pydoc generated docs include logcontext module and are referenced from admin.py.
author John Rouillard <rouilj@ieee.org>
date Mon, 08 Dec 2025 00:23:14 -0500
parents df9fc9080e5a
children f80c566f5726
comparison
equal deleted inserted replaced
8478:ed4ef394d5d6 8487:b09ef85f0da6
1 """Generate and store thread local logging context including unique
2 trace id for request, request source etc. to be logged.
3
4 Trace id generator can use nanoid or uuid.uuid4 stdlib function.
5 Nanoid is preferred if nanoid is installed using pip as nanoid is
6 faster and generates a shorter id. If nanoid is installed in the
7 tracker's lib subdirectory, it must be enabled using the tracker's
8 interfaces.py by adding::
9
10 # if nanoid is installed in tracker's lib directory or
11 # if you want to change the length of the nanoid from 12
12 # to 14 chars use:
13 from functools import partial
14 from nanoid import generate
15 import roundup.logcontext
16 # change 14 to 12 to get the default nanoid size.
17 roundup.logcontext.idgen=partial(generate, size=14)
18
19 # to force use of shortened uuid when nanoid is
20 # loaded by default
21 import roundup.logcontext
22 roundup.logcontext.idgen=roundup.logcontext.short_uuid
23
24 """
1 import contextvars 25 import contextvars
2 import functools 26 import functools
3 import logging 27 import logging
4 import os 28 import os
5 import uuid 29 import uuid
30
31
32 def short_uuid():
33 """Encode a UUID integer in a shorter form for display.
34
35 A uuid is long. Make a shorter version that takes less room
36 in a log line and is easier to store.
37 """
38 alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"
39 result = ""
40 alphabet_len = len(alphabet)
41 uuid_int = uuid.uuid4().int
42 while uuid_int:
43 uuid_int, t = divmod(uuid_int, alphabet_len)
44 result += alphabet[t]
45 return result or "0"
46
47
48 try:
49 from nanoid import generate
50 # With size=12 and the normal alphabet, it take ~4 months
51 # with 1000 nanoid's/sec to generate a collision with 1%
52 # probability. That's 100 users sec continously. These id's
53 # are used to link logging messages/traces that are all done
54 # in a few seconds. Collisions ae unlikely to happen in the
55 # same time period leading to confusion.
56 #
57 # nanoid is faster than shortened uuids.
58 # 1,000,000 generate(size=12) timeit.timeit at 25.4 seconds
59 # 1,000,000 generate(size=21) timeit.timeit at 33.7 seconds
60
61 #: Variable used for setting the id generator.
62 idgen = functools.partial(generate, size=12)
63 except ImportError:
64 # 1,000,000 of short_uuid() timeit.timeit at 54.1 seconds
65 idgen = short_uuid #: :meta hide-value:
66
6 67
7 logger = logging.getLogger("roundup.logcontext") 68 logger = logging.getLogger("roundup.logcontext")
8 69
9 70
10 class SimpleSentinel: 71 class SimpleSentinel:
44 # even if they have not been set. 105 # even if they have not been set.
45 ctx_vars = {} 106 ctx_vars = {}
46 107
47 # set up sentinel values that will print a suitable error value 108 # set up sentinel values that will print a suitable error value
48 # and the context vars they are associated with. 109 # and the context vars they are associated with.
110 _SENTINEL_PROCESSNAME = SimpleSentinel("processName", None)
111 ctx_vars['processName'] = contextvars.ContextVar("processName",
112 default=_SENTINEL_PROCESSNAME)
113
114
49 _SENTINEL_ID = SimpleSentinel("trace_id", "not set") 115 _SENTINEL_ID = SimpleSentinel("trace_id", "not set")
50 ctx_vars['trace_id'] = contextvars.ContextVar("trace_id", default=_SENTINEL_ID) 116 ctx_vars['trace_id'] = contextvars.ContextVar("trace_id", default=_SENTINEL_ID)
51 117
52 118
53 _SENTINEL_REASON = SimpleSentinel("trace_reason", "missing") 119 _SENTINEL_REASON = SimpleSentinel("trace_reason", "missing")
54 ctx_vars['trace_reason'] = contextvars.ContextVar("trace_reason", 120 ctx_vars['trace_reason'] = contextvars.ContextVar("trace_reason",
55 default=_SENTINEL_REASON) 121 default=_SENTINEL_REASON)
56 122
57 123
58 def shorten_int_uuid(uuid):
59 """Encode a UUID integer in a shorter form for display.
60
61 A uuid is long. Make a shorter version that takes less room
62 in a log line.
63 """
64
65 alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"
66 result = ""
67 while uuid:
68 uuid, t = divmod(uuid, len(alphabet))
69 result += alphabet[t]
70 return result or "0"
71
72
73 def gen_trace_id(): 124 def gen_trace_id():
74 """Decorator to generate a trace id (encoded uuid4) and add to contextvar 125 """Decorator to generate a trace id (nanoid or encoded uuid4) as contextvar
75 126
76 The logging routine uses this to label every log line. All 127 The logging routine uses this to label every log line. All
77 logs with the same trace_id should be generated from a 128 logs with the same trace_id should be generated from a
78 single request. 129 single request.
79 130
82 points. As a result, this decorator can be called multiple 133 points. As a result, this decorator can be called multiple
83 times as some entry points can traverse another entry point 134 times as some entry points can traverse another entry point
84 used by a different invocation method. It will not set a 135 used by a different invocation method. It will not set a
85 trace_id if one is already assigned. 136 trace_id if one is already assigned.
86 137
87 A uuid4() is used as the uuid, but to shorten the log line, 138 If a uuid4() is used as the id, the uuid4 integer is encoded
88 the uuid4 integer is encoded into a 62 character ascii 139 into a 62 character alphabet (A-Za-z0-9) to shorten
89 alphabet (A-Za-z0-9). 140 the log line.
90 141
91 This decorator may produce duplicate (colliding) trace_id's 142 This decorator may produce duplicate (colliding) trace_id's
92 when used with multiple processes on some platforms where 143 when used with multiple processes on some platforms where
93 uuid.uuid4().is_safe is unknown. Probability of a collision 144 uuid.uuid4().is_safe is unknown. Probability of a collision
94 is unknown. 145 is unknown.
95 146
147 If nanoid is used to generate the id, it is 12 chars long and
148 uses a 64 char ascii alphabet, the 62 above with '_' and '-'.
149 The shorter nanoid has < 1% chance of collision in ~4 months
150 when generating 1000 id's per second.
151
152 See the help text for the module to change how the id is
153 generated.
96 """ 154 """
97 def decorator(func): 155 def decorator(func):
98 @functools.wraps(func) 156 @functools.wraps(func)
99 def wrapper(*args, **kwargs): 157 def wrapper(*args, **kwargs):
100 prev = None 158 prev = None
101 trace_id = ctx_vars['trace_id'] 159 trace_id = ctx_vars['trace_id']
102 if trace_id.get() is _SENTINEL_ID: 160 if trace_id.get() is _SENTINEL_ID:
103 prev = trace_id.set(shorten_int_uuid(uuid.uuid4().int)) 161 prev = trace_id.set(idgen())
104 try: 162 try:
105 r = func(*args, **kwargs) 163 r = func(*args, **kwargs)
106 finally: 164 finally:
107 if prev: 165 if prev:
108 trace_id.reset(prev) 166 trace_id.reset(prev)
167 return r
168 return wrapper
169 return decorator
170
171
172 def set_processName(name):
173 """Decorator to set the processName used in the LogRecord
174 """
175 def decorator(func):
176 @functools.wraps(func)
177 def wrapper(*args, **kwargs):
178 prev = None
179 processName = ctx_vars['processName']
180 if processName.get() is _SENTINEL_PROCESSNAME:
181 prev = processName.set(name)
182 try:
183 r = func(*args, **kwargs)
184 finally:
185 if prev:
186 processName.reset(prev)
109 return r 187 return r
110 return wrapper 188 return wrapper
111 return decorator 189 return decorator
112 190
113 191
193 return [(name, ctx.get()) for name, ctx in ctx_vars.items()] 271 return [(name, ctx.get()) for name, ctx in ctx_vars.items()]
194 272
195 273
196 #Is returning a dict for this info more pythonic? 274 #Is returning a dict for this info more pythonic?
197 def get_context_dict(): 275 def get_context_dict():
198 """Return dict of context var tuples ["var_name": "var_value", ...}""" 276 """Return dict of context var tuples {"var_name": "var_value", ...}"""
199 return {name: ctx.get() for name, ctx in ctx_vars.items()} 277 return {name: ctx.get() for name, ctx in ctx_vars.items()}
200 278
201 # Dummy no=op implementation of this module: 279 # Dummy no=op implementation of this module:
202 # 280 #
203 #def noop_decorator(*args, **kwargs): 281 #def noop_decorator(*args, **kwargs):

Roundup Issue Tracker: http://roundup-tracker.org/