combine_logs.py
1 #!/usr/bin/env python3 2 # Copyright (c) 2017-2021 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 the debug.log file 83 glob = pathlib.Path(tmp_dir).glob('node0/**/debug.log') 84 path = next(glob, None) 85 if path: 86 assert next(glob, None) is None # more than one debug.log, should never happen 87 chain = re.search(r'node0/(.+?)/debug\.log$', path.as_posix()).group(1) # extract the chain name 88 else: 89 chain = 'regtest' # fallback to regtest (should only happen when none exists) 90 91 files = [("test", "%s/test_framework.log" % tmp_dir)] 92 for i in itertools.count(): 93 logfile = "{}/node{}/{}/debug.log".format(tmp_dir, i, chain) 94 if not os.path.isfile(logfile): 95 break 96 files.append(("node%d" % i, logfile)) 97 98 return heapq.merge(*[get_log_events(source, f) for source, f in files]) 99 100 101 def print_node_warnings(tmp_dir, colors): 102 """Print nodes' errors and warnings""" 103 104 warnings = [] 105 for stream in ['stdout', 'stderr']: 106 for i in itertools.count(): 107 folder = "{}/node{}/{}".format(tmp_dir, i, stream) 108 if not os.path.isdir(folder): 109 break 110 for (_, _, fns) in os.walk(folder): 111 for fn in fns: 112 warning = pathlib.Path('{}/{}'.format(folder, fn)).read_text().strip() 113 if warning: 114 warnings.append(("node{} {}".format(i, stream), warning)) 115 116 print() 117 for w in warnings: 118 print("{} {} {} {}".format(colors[w[0].split()[0]], w[0], w[1], colors["reset"])) 119 120 121 def find_latest_test_dir(): 122 """Returns the latest tmpfile test directory prefix.""" 123 tmpdir = tempfile.gettempdir() 124 125 def join_tmp(basename): 126 return os.path.join(tmpdir, basename) 127 128 def is_valid_test_tmpdir(basename): 129 fullpath = join_tmp(basename) 130 return ( 131 os.path.isdir(fullpath) 132 and basename.startswith(TMPDIR_PREFIX) 133 and os.access(fullpath, os.R_OK) 134 ) 135 136 testdir_paths = [ 137 join_tmp(name) for name in os.listdir(tmpdir) if is_valid_test_tmpdir(name) 138 ] 139 140 return max(testdir_paths, key=os.path.getmtime) if testdir_paths else None 141 142 143 def get_log_events(source, logfile): 144 """Generator function that returns individual log events. 145 146 Log events may be split over multiple lines. We use the timestamp 147 regex match as the marker for a new log event.""" 148 try: 149 with open(logfile, 'r', encoding='utf-8') as infile: 150 event = '' 151 timestamp = '' 152 for line in infile: 153 # skip blank lines 154 if line == '\n': 155 continue 156 # if this line has a timestamp, it's the start of a new log event. 157 time_match = TIMESTAMP_PATTERN.match(line) 158 if time_match: 159 if event: 160 yield LogEvent(timestamp=timestamp, source=source, event=event.rstrip()) 161 timestamp = time_match.group() 162 if time_match.group(1) is None: 163 # timestamp does not have microseconds. Add zeroes. 164 timestamp_micro = timestamp.replace("Z", ".000000Z") 165 line = line.replace(timestamp, timestamp_micro) 166 timestamp = timestamp_micro 167 event = line 168 # if it doesn't have a timestamp, it's a continuation line of the previous log. 169 else: 170 # Add the line. Prefix with space equivalent to the source + timestamp so log lines are aligned 171 event += " " + line 172 # Flush the final event 173 yield LogEvent(timestamp=timestamp, source=source, event=event.rstrip()) 174 except FileNotFoundError: 175 print("File %s could not be opened. Continuing without it." % logfile, file=sys.stderr) 176 177 178 def print_logs_plain(log_events, colors): 179 """Renders the iterator of log events into text.""" 180 for event in log_events: 181 lines = event.event.splitlines() 182 print("{0} {1: <5} {2} {3}".format(colors[event.source.rstrip()], event.source, lines[0], colors["reset"])) 183 if len(lines) > 1: 184 for line in lines[1:]: 185 print("{0}{1}{2}".format(colors[event.source.rstrip()], line, colors["reset"])) 186 187 188 def print_logs_html(log_events): 189 """Renders the iterator of log events into html.""" 190 try: 191 import jinja2 #type:ignore 192 except ImportError: 193 print("jinja2 not found. Try `pip install jinja2`") 194 sys.exit(1) 195 print(jinja2.Environment(loader=jinja2.FileSystemLoader('./')) 196 .get_template('combined_log_template.html') 197 .render(title="Combined Logs from testcase", log_events=[event._asdict() for event in log_events])) 198 199 200 if __name__ == '__main__': 201 main()