/ test / functional / combine_logs.py
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()