combine_logs.py
1 #!/usr/bin/env python3 2 # Copyright (c) 2017-present The Bitcoin Core developers 3 # Distributed under the MIT software license, see the accompanying 4 # file COPYING or http://www.opensource.org/licenses/mit-license.php. 5 """Combine logs from multiple bitcoin nodes as well as the test_framework log. 6 7 This streams the combined log output to stdout. Use combine_logs.py > outputfile 8 to write to an outputfile. 9 10 If no argument is provided, the most recent test directory will be used.""" 11 12 import argparse 13 from collections import defaultdict, namedtuple 14 import heapq 15 import itertools 16 import os 17 import pathlib 18 import re 19 import sys 20 import tempfile 21 22 # N.B.: don't import any local modules here - this script must remain executable 23 # without the parent module installed. 24 25 # Should match same symbol in `test_framework.test_framework`. 26 TMPDIR_PREFIX = "bitcoin_func_test_" 27 28 # Matches on the date format at the start of the log event 29 TIMESTAMP_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{6})?Z") 30 31 LogEvent = namedtuple('LogEvent', ['timestamp', 'source', 'event']) 32 33 def main(): 34 """Main function. Parses args, reads the log files and renders them as text or html.""" 35 parser = argparse.ArgumentParser( 36 description=__doc__, formatter_class=argparse.RawTextHelpFormatter) 37 parser.add_argument( 38 'testdir', nargs='?', default='', 39 help=('temporary test directory to combine logs from. ' 40 'Defaults to the most recent')) 41 parser.add_argument('-c', '--color', dest='color', action='store_true', help='outputs the combined log with events colored by source (requires posix terminal colors. Use less -r for viewing)') 42 parser.add_argument('--html', dest='html', action='store_true', help='outputs the combined log as html. Requires jinja2. pip install jinja2') 43 args = parser.parse_args() 44 45 if args.html and args.color: 46 print("Only one out of --color or --html should be specified") 47 sys.exit(1) 48 49 testdir = args.testdir or find_latest_test_dir() 50 51 if not testdir: 52 print("No test directories found") 53 sys.exit(1) 54 55 if not args.testdir: 56 print("Opening latest test directory: {}".format(testdir), file=sys.stderr) 57 58 colors = defaultdict(lambda: '') 59 if args.color: 60 colors["test"] = "\033[0;36m" # CYAN 61 colors["node0"] = "\033[0;34m" # BLUE 62 colors["node1"] = "\033[0;32m" # GREEN 63 colors["node2"] = "\033[0;31m" # RED 64 colors["node3"] = "\033[0;33m" # YELLOW 65 colors["reset"] = "\033[0m" # Reset font color 66 67 log_events = read_logs(testdir) 68 69 if args.html: 70 print_logs_html(log_events) 71 else: 72 print_logs_plain(log_events, colors) 73 print_node_warnings(testdir, colors) 74 75 76 def read_logs(tmp_dir): 77 """Reads log files. 78 79 Delegates to generator function get_log_events() to provide individual log events 80 for each of the input log files.""" 81 82 # Find out what the folder is called that holds node 0's debug.log file 83 debug_logs = list(pathlib.Path(tmp_dir).glob('node0/**/debug.log')) 84 match len(debug_logs): 85 case 0: 86 chain = 'regtest' # fallback to regtest 87 case 1: 88 chain = re.search(r'node0/(.+?)/debug\.log$', debug_logs[0].as_posix()).group(1) 89 case _: 90 raise RuntimeError('Max one debug.log is supported, found several:\n\t' + 91 '\n\t'.join(map(str, debug_logs))) 92 93 files = [("test", "%s/test_framework.log" % tmp_dir)] 94 for i in itertools.count(): 95 logfile = "{}/node{}/{}/debug.log".format(tmp_dir, i, chain) 96 if not os.path.isfile(logfile): 97 break 98 files.append(("node%d" % i, logfile)) 99 100 return heapq.merge(*[get_log_events(source, f) for source, f in files]) 101 102 103 def print_node_warnings(tmp_dir, colors): 104 """Print nodes' errors and warnings""" 105 106 warnings = [] 107 for stream in ['stdout', 'stderr']: 108 for i in itertools.count(): 109 folder = "{}/node{}/{}".format(tmp_dir, i, stream) 110 if not os.path.isdir(folder): 111 break 112 for (_, _, fns) in os.walk(folder): 113 for fn in fns: 114 warning = pathlib.Path('{}/{}'.format(folder, fn)).read_text().strip() 115 if warning: 116 warnings.append(("node{} {}".format(i, stream), warning)) 117 118 print() 119 for w in warnings: 120 print("{} {} {} {}".format(colors[w[0].split()[0]], w[0], w[1], colors["reset"])) 121 122 123 def find_latest_test_dir(): 124 """Returns the latest tmpfile test directory prefix.""" 125 tmpdir = tempfile.gettempdir() 126 127 def join_tmp(basename): 128 return os.path.join(tmpdir, basename) 129 130 def is_valid_test_tmpdir(basename): 131 fullpath = join_tmp(basename) 132 return ( 133 os.path.isdir(fullpath) 134 and basename.startswith(TMPDIR_PREFIX) 135 and os.access(fullpath, os.R_OK) 136 ) 137 138 testdir_paths = [ 139 join_tmp(name) for name in os.listdir(tmpdir) if is_valid_test_tmpdir(name) 140 ] 141 142 return max(testdir_paths, key=os.path.getmtime) if testdir_paths else None 143 144 145 def get_log_events(source, logfile): 146 """Generator function that returns individual log events. 147 148 Log events may be split over multiple lines. We use the timestamp 149 regex match as the marker for a new log event.""" 150 try: 151 with open(logfile, 'r') as infile: 152 event = '' 153 timestamp = '' 154 for line in infile: 155 # skip blank lines 156 if line == '\n': 157 continue 158 # if this line has a timestamp, it's the start of a new log event. 159 time_match = TIMESTAMP_PATTERN.match(line) 160 if time_match: 161 if event: 162 yield LogEvent(timestamp=timestamp, source=source, event=event.rstrip()) 163 timestamp = time_match.group() 164 if time_match.group(1) is None: 165 # timestamp does not have microseconds. Add zeroes. 166 timestamp_micro = timestamp.replace("Z", ".000000Z") 167 line = line.replace(timestamp, timestamp_micro) 168 timestamp = timestamp_micro 169 event = line 170 # if it doesn't have a timestamp, it's a continuation line of the previous log. 171 else: 172 # Add the line. Prefix with space equivalent to the source + timestamp so log lines are aligned 173 event += " " + line 174 # Flush the final event 175 yield LogEvent(timestamp=timestamp, source=source, event=event.rstrip()) 176 except FileNotFoundError: 177 print("File %s could not be opened. Continuing without it." % logfile, file=sys.stderr) 178 179 180 def print_logs_plain(log_events, colors): 181 """Renders the iterator of log events into text.""" 182 for event in log_events: 183 lines = event.event.splitlines() 184 print("{0} {1: <5} {2} {3}".format(colors[event.source.rstrip()], event.source, lines[0], colors["reset"])) 185 if len(lines) > 1: 186 for line in lines[1:]: 187 print("{0}{1}{2}".format(colors[event.source.rstrip()], line, colors["reset"])) 188 189 190 def print_logs_html(log_events): 191 """Renders the iterator of log events into html.""" 192 try: 193 import jinja2 #type:ignore 194 except ImportError: 195 print("jinja2 not found. Try `pip install jinja2`") 196 sys.exit(1) 197 print(jinja2.Environment(loader=jinja2.FileSystemLoader('./')) 198 .get_template('combined_log_template.html') 199 .render(title="Combined Logs from testcase", log_events=[event._asdict() for event in log_events])) 200 201 202 if __name__ == '__main__': 203 main()