userver: /home/antonyzhilin/arcadia/taxi/uservices/userver/testsuite/pytest_plugins/pytest_userver/utils/colorize.py Source File
All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Friends Macros Modules Pages Concepts
colorize.py
1import argparse
2import contextlib
3import enum
4import itertools
5import json
6import sys
7
8from . import tskv
9
10
11class ColorArg(enum.Enum):
12 AUTO = 'auto'
13 ALWAYS = 'always'
14 NEVER = 'never'
15
16
17# Info about colors https://misc.flogisoft.com/bash/tip_colors_and_formatting
18class Colors:
19 BLACK = '\033[30m'
20 RED = '\033[31m'
21 GREEN = '\033[32m'
22 YELLOW = '\033[33m'
23 BLUE = '\033[34m'
24 GRAY = '\033[37m'
25 DARK_GRAY = '\033[37m'
26 BRIGHT_RED = '\033[91m'
27 BRIGHT_GREEN = '\033[92m'
28 BRIGHT_YELLOW = '\033[93m'
29 DEFAULT = '\033[0m'
30 DEFAULT_BG = '\033[49m'
31 BG_BLACK = '\033[40m'
32
33 # No bright red color, no close colors, no too dark colors
34 __NICE_COLORS = [
35 '\033[38;5;{}m'.format(x)
36 for x in itertools.chain(
37 range(2, 7),
38 range(10, 15),
39 range(38, 51, 2),
40 range(75, 87, 2),
41 range(112, 123, 3),
42 range(128, 159, 3),
43 range(164, 195, 3),
44 range(203, 231, 3),
45 )
46 ]
47
48 @staticmethod
49 def colorize(value):
50 ind = hash(value) % len(Colors.__NICE_COLORS)
51 return Colors.__NICE_COLORS[ind]
52
53
54LEVEL_COLORS = {
55 'TRACE': Colors.DARK_GRAY,
56 'DEBUG': Colors.GRAY,
57 'INFO': Colors.GREEN,
58 'WARNING': Colors.YELLOW,
59 'ERROR': Colors.RED,
60 'CRITICAL': Colors.BRIGHT_RED,
61 'none': Colors.DEFAULT,
62}
63HTTP_STATUS_COLORS = {
64 '2': Colors.GREEN,
65 '3': Colors.GREEN,
66 '4': Colors.YELLOW,
67 '5': Colors.RED,
68}
69
70HTTP_LOCALHOST_PREFIX = 'http://localhost'
71
72
74 def __init__(self, *, verbose=False, colors_enabled=True):
75 self._requests = {}
76 self.verbose = verbose
77 self.colors_enabled = colors_enabled
78
79 def colorize_line(self, line):
80 if not line.startswith('tskv\t'):
81 return line
82 return self.colorize_tskv(line)
83
84 def colorize_tskv(self, line):
85 row = tskv.parse_line(line)
86 return self.colorize_row(row)
87
88 def colorize_row(self, row):
89 row = row.copy()
90 flowid = '-'.join([row.get(key, '') for key in ('link', 'trace_id')])
91
92 entry_type = row.pop('_type', None)
93 link = row.get('link', None)
94 level = row.pop('level', 'none')
95 text = row.pop('text', '')
96
97 extra_fields = []
98 if entry_type == 'request':
99 self._requests[link] = self._build_request_info(row)
100
101 uri = row.pop('uri')
102 uri_sep_pos = uri.find('?')
103 if uri_sep_pos != -1:
104 text += self.textcolor(uri[uri_sep_pos:], Colors.GREEN)
105
106 if row.get('body'):
107 extra_fields.append(
108 'request_body='
109 + self.textcolor(
110 try_reformat_json(row.pop('body')),
111 Colors.YELLOW,
112 ),
113 )
114 elif entry_type == 'response':
115 if 'body' not in row:
116 raise RuntimeError(
117 f'Response log record without "body" tag. Looks like in the C++ code the tracing::Span of a'
118 f'request was moved out or corrupted. Link: {link}. Text: {text}. Other: {row}'
119 )
120 if 'meta_code' in row:
121 status_code = row.pop('meta_code')
122 extra_fields.append(
123 self._http_status('meta_code', status_code),
124 )
125 if not text:
126 text = 'Response finished'
127 if row.get('body'):
128 extra_fields.append(
129 'response_body='
130 + self.textcolor(
131 try_reformat_json(row.pop('body')),
132 Colors.YELLOW,
133 ),
134 )
135 elif entry_type == 'mockserver_request':
136 text = 'Mockserver request finished'
137 if 'meta_code' in row:
138 status_code = str(row.pop('meta_code'))
139 extra_fields.append(
140 self._http_status('meta_code', status_code),
141 )
142 for key in ('method', 'url', 'status', 'exc_info', 'delay'):
143 value = row.pop(key, None)
144 if value:
145 extra_fields.append(f'{key}={value}')
146
147 if link in self._requests:
148 logid = f'[{self._requests[link]}]'
149 elif link is not None:
150 logid = f'[{link}]'
151 else:
152 logid = '<userver>'
153
154 level_color = LEVEL_COLORS.get(level)
155 flow_color = Colors.colorize(flowid)
156
157 fields = [
158 self.textcolor(f'{level:<8}', level_color),
159 ]
160
161 if 'service' in row:
162 service = row.pop('service')
163 fields.append(self.textcolor(f'[{service}]', Colors.colorize(service)))
164
165 fields.append(self.textcolor(logid, flow_color))
166
167 if text:
168 if 'http_url' in row:
169 localhost_pos = text.find(HTTP_LOCALHOST_PREFIX)
170 if localhost_pos != -1:
171 start_url_pos = text.find('/', localhost_pos + len(HTTP_LOCALHOST_PREFIX))
172 text = text[:localhost_pos] + self.textcolor(text[start_url_pos:], Colors.GREEN)
173 else:
174 start_url_pos = text.rfind(' ')
175 text = text[:start_url_pos] + self.textcolor(text[start_url_pos:], Colors.GREEN)
176
177 meta_code = row.pop('meta_code', None)
178 if meta_code:
179 extra_fields.append(
180 self._http_status('meta_code', meta_code),
181 )
182
183 if row.get('body'):
184 extra_fields.append(
185 'body='
186 + self.textcolor(
187 try_reformat_json(row.pop('body')),
188 Colors.YELLOW,
189 ),
190 )
191
192 if 'db_statement_name' in row:
193 extra_fields.append(
194 'db_statement_name='
195 + self.textcolor(
196 row['db_statement_name'],
197 Colors.YELLOW,
198 ),
199 )
200
201 fields.append(text)
202 elif self.verbose:
203 fields.append('<NO TEXT>')
204 else:
205 return None
206
207 fields.extend(extra_fields)
208 result = ' '.join(fields)
209
210 if self.verbose:
211 result += '\n' + self.textcolor(' '.join([f'{k}={v}' for k, v in row.items()]), Colors.GRAY)
212
213 return result
214
215 def textcolor(self, text, color):
216 if not self.colors_enabled:
217 return str(text)
218 return f'{color}{text}{Colors.DEFAULT}'
219
220 def _http_status(self, key, status):
221 color = HTTP_STATUS_COLORS.get(status[:1], Colors.DEFAULT)
222 return self.textcolor(f'{key}={status}', color)
223
224 def _build_request_info(self, row):
225 if 'meta_type' not in row:
226 return None
227 meta_type = row['meta_type']
228 method = row.get('method', 'UNKNOWN')
229 return f'{method} {meta_type}'
230
231
232def format_json(obj):
233 encoded = json.dumps(
234 obj,
235 indent=2,
236 separators=(',', ': '),
237 sort_keys=True,
238 ensure_ascii=False,
239 )
240 return encoded
241
242
243def try_reformat_json(body):
244 try:
245 data = json.loads(body)
246 return format_json(data)
247 except ValueError:
248 return body
249
250
251def colorize(stream, verbose=False, colors_enabled=True):
252 colorizer = Colorizer(verbose=verbose, colors_enabled=colors_enabled)
253 for line in stream:
254 line = line.rstrip('\r\n')
255 color_line = colorizer.colorize_line(line)
256 if color_line is not None:
257 print(color_line)
258
259
260def parse_color(value):
261 if value in ('always', 'force', 'yes', 'enable'):
262 return ColorArg.ALWAYS
263 if value in ('never', 'no', 'disable'):
264 return ColorArg.NEVER
265 if value == 'auto':
266 return ColorArg.AUTO
267 raise ValueError(f'Unknown color option {value!r}')
268
269
270def colorize_main():
271 parser = argparse.ArgumentParser(description='Colorize userver log file.')
272 parser.add_argument(
273 '--verbose',
274 '-v',
275 action='store_true',
276 help='Be verbose',
277 )
278 parser.add_argument(
279 '--color',
280 metavar='WHEN',
281 help=('Control color highlighting, WHEN is always, never or auto (default)'),
282 nargs='?',
283 type=parse_color,
284 default=ColorArg.AUTO,
285 const=ColorArg.ALWAYS,
286 )
287 parser.add_argument(
288 '--no-color',
289 help='Turn off color highlighting',
290 dest='color',
291 action='store_const',
292 const=ColorArg.NEVER,
293 )
294 parser.add_argument(
295 'log',
296 help='File to colorize, by default stdin is used',
297 default='-',
298 nargs='?',
299 )
300 args = parser.parse_args()
301
302 if args.log == '-':
303 stream = sys.stdin
304 else:
305 stream = open(args.log, 'r')
306
307 if args.color == ColorArg.AUTO:
308 colors_enabled = sys.stdout.isatty()
309 elif args.color == ColorArg.ALWAYS:
310 colors_enabled = True
311 else:
312 colors_enabled = False
313
314 with contextlib.closing(stream):
315 colorize(stream, verbose=args.verbose, colors_enabled=colors_enabled)
316
317
318if __name__ == '__main__':
319 colorize_main()