[scripts] flog: A friendly log listener

flog is a wrapper for loglistener and fsymbolize with following features:
 - Automatically decode symbols from crash logs
 - Archive crash logs with date-timed file name
 - Selectively show logs by begin-triggers, end-triggers, and suppress-triggers
 - Colorize keywords that are configurable
 - Colorize lines matching keywords that are configurable
 - Pass through remaining arguments to loglistener

[How to use]
[Example #1]
$ ./flog [device-name]

[Example #2]
$ ./flog --help

[Example #3]
$ flog --begin 'my module starts,rare event'
       --end 'my module ends'
       --suppress 'verbose,chatty'
       --lines 'error msg:red,warn:blue'
       --words 'register 0x00:green,exit:yellow'

[Screenshots for code reviewers]
https://screenshot.googleplex.com/2BUaamf0JgN
https://screenshot.googleplex.com/Cp05FbqbQzg

Change-Id: Ic68b675b82ff1c3c7e826fb31c4cb581b2b5daec
diff --git a/flog b/flog
new file mode 100755
index 0000000..52981b9
--- /dev/null
+++ b/flog
@@ -0,0 +1,318 @@
+#!/usr/bin/env python
+# Copyright 2017 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""flog is a developer friendly log listener with automatic crash symbol decoder.
+
+The crash is archived to /tmp/fuchsia-crash or a specified directory.
+Configurable keywords or lines with them can be color coded.
+"""
+
+import argparse
+import os
+import re
+import subprocess
+import sys
+from time import gmtime
+from time import strftime
+
+BEGINS = []
+ENDS = []
+SUPPRESSES = []
+CRASH_DIR = '/tmp/fuchsia_crash'
+
+COLOR_LINES = {'error': 'red'}
+COLOR_WORDS = {
+    'warn': 'white-on-red',
+    'error': 'white-on-red',
+    'fail': 'white-on-red',
+    'exception': 'white-on-red',
+    'address': 'green',
+    'did not add device in bind': 'green',
+}
+
+RESET = '\033[1;0m'
+
+COLORS = {
+    'WHITE-ON-RED': '\033[41;37m',
+    'RED': '\033[1;31m',
+    'GREEN': '\033[1;32m',
+    'YELLOW': '\033[1:33m',
+    'BLUE': '\033[1:34m',
+    'MAGENTA': '\033[1:35m',
+    'CYAN': '\033[1:36m',
+}
+
+
+def static_vars(**kwargs):
+
+  def decorate(func):
+    for k in kwargs:
+      setattr(func, k, kwargs[k])
+    return func
+
+  return decorate
+
+
+def now_str():
+  return strftime('%Y%m%d_%H%M%S', gmtime())
+
+
+def get_log_listener(args):
+  # return 'cat /tmp/z' # Unit test
+  listener = 'out/build-zircon/tools/loglistener'
+  fuchsia_dir = os.environ['FUCHSIA_DIR']
+  return '{}/{} {}'.format(fuchsia_dir, listener, ' '.join(x for x in args))
+
+
+@static_vars(crash_dump=[])
+@static_vars(is_crashing=False)
+def monitor_crash(line):
+  if '<==' in line and 'exception' in line:
+    monitor_crash.is_crashing = True
+
+  if monitor_crash.is_crashing is True:
+    monitor_crash.crash_dump.append(line)
+
+  if ': end' in line and 'bt#' in line:
+    decode_backtrace(monitor_crash.crash_dump, CRASH_DIR)
+    monitor_crash.crash_dump = []
+    monitor_crash.is_crashing = False
+
+
+def color(string, color_name):
+  if color_name.upper() not in COLORS:
+    return string
+
+  return COLORS[color_name.upper()] + string + RESET
+
+
+@static_vars(is_in_session=False)
+def is_suppressed(line):
+  """Test if the log may be printed or not.
+
+  Args:
+    line (str): A log line.
+
+  Returns:
+    True if the log line should be suppressed. False otherwise.
+  """
+  if not BEGINS or any(keyword in line for keyword in BEGINS):
+    if not is_suppressed.is_in_session:
+      print '\n' * 3
+      is_suppressed.is_in_session = True
+
+  if any(keyword in line for keyword in ENDS):
+    is_suppressed.is_in_session = False
+
+  if not is_suppressed.is_in_session:
+    return True
+
+  if any(keyword in line for keyword in SUPPRESSES):
+    return True
+
+  return False
+
+
+def colorize(line_incoming):
+  """Color-code the log line.
+
+  Args:
+    line_incoming: log line.
+
+  Returns:
+    color-coded log line
+  """
+
+  # TODO (porce): Case-insensitive search
+  # Treat "warn", "WARN", "wArN" in the same way.
+  line = line_incoming
+
+  if any(k in line for k in COLOR_LINES.keys()):
+    for k, v in COLOR_LINES.iteritems():
+      if k not in line:
+        continue
+      line = color(line, v)
+      break
+
+  if any(k in line for k in COLOR_WORDS.keys()):
+    rep = {}
+
+    for k, v in COLOR_WORDS.iteritems():
+      rep[k] = color(k, v)
+
+    rep = dict((re.escape(k), v) for k, v in rep.iteritems())
+    pattern = re.compile('|'.join(rep.keys()))
+    line = pattern.sub(lambda m: rep[re.escape(m.group(0))], line)
+
+  return line
+
+
+def print_log(line):
+  if is_suppressed(line):
+    return
+
+  print colorize(line),
+
+
+def hijack_stdout(cmd):
+  proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE)
+  return iter(proc.stdout.readline, '')
+
+
+def get_decode_cmd():
+  fuchsia_dir = os.environ['FUCHSIA_DIR']
+  zircon_build_dir = os.environ['ZIRCON_BUILD_DIR']
+  fuchsia_build_dir = os.environ['FUCHSIA_BUILD_DIR']
+
+  decode_cmd = '{}/zircon/scripts/symbolize --build-dir {} {}'.format(
+      fuchsia_dir, zircon_build_dir, fuchsia_build_dir)
+  return decode_cmd
+
+
+def print_bt(line):
+  """Print backtrace with some colors.
+
+  Args:
+    line (str): Backtrace line.
+  """
+  tokens = line.split(' at ')
+
+  methods = tokens[:-1]
+  methods.append('')
+  methods_text = ' at '.join(x for x in methods)
+
+  path = tokens[-1]
+  path_tokens = path.split('/')
+  paths = path_tokens[:-1]
+  paths.append('')
+
+  path_text = '/'.join(x for x in paths)
+  path_colored = path_text + color(path_tokens[-1], 'red')
+
+  print methods_text, path_colored,
+
+
+def decode_backtrace(crash_dump, dst_dir):
+  """A wrapper to fsymbolize.
+
+  Args:
+    crash_dump (array): array of log lines.
+    dst_dir    (str): directory to archive to.
+  """
+  os.system('mkdir -p {}'.format(dst_dir))
+  crash_file_path = '{}/{}.crash'.format(dst_dir, now_str())
+  tmp_file = '{}/tmp.dump'.format(dst_dir)
+
+  f = open(tmp_file, 'w')
+  for line in crash_dump:
+    f.write(line)
+  f.close()
+
+  cmd = '{} < {} > {}'.format(get_decode_cmd(), tmp_file, crash_file_path)
+  os.system(cmd)
+
+  print '\n\n'
+
+  is_start = False
+  with open(crash_file_path, 'r') as f:
+    for line in f:
+      if 'start of symbolized stack:' in line:
+        is_start = True
+
+      if is_start:
+        print_bt(line)
+  print '\n\n'
+
+
+def parse_color_map(string):
+  """Converted comma separated text into a color code map.
+
+  Args:
+    string (str): a text line
+
+  Returns:
+   Dictonary whose key is a text pattern and value is the color name.
+  """
+  m = {}
+  items = string.split(',')
+  for item in items:
+    sep = ':'
+    if sep not in item:
+      continue
+
+    idx = item.rfind(sep)
+    text = item[:idx]
+    color_name = item[idx + 1:]
+
+    if text.__len__() == 0:
+      continue
+
+    m[text] = color_name
+
+  return m
+
+
+def proc_cmdline():
+  """Argument parser.
+
+  Returns:
+    args.
+  """
+  example_commands = """
+
+  Pro tip: Use comma seperated texts for multiple matches
+
+  Example:
+  $ flog --begin \'my module starts,rare event\'
+         --end \'my module ends\'
+         --suppress \'verbose,chatty\'
+         --lines \'error msg:red,warn:blue\'
+         --words \'register 0x00:green,exit:yellow\'
+
+  """
+
+  p = argparse.ArgumentParser(
+      description='A friendly Fuchsia log listener',
+      epilog=example_commands,
+      formatter_class=argparse.RawDescriptionHelpFormatter)
+
+  p.add_argument('--begin', type=str, help='trigger texts to start logging')
+  p.add_argument('--end', type=str, help='trigger texts to end logging')
+  p.add_argument('--suppress', type=str, help='text to suppress the line')
+  p.add_argument('--lines', type=str, help='colorize the line. {text:color}')
+  p.add_argument('--words', type=str, help='colorize the word. {text:color}')
+  p.add_argument('--crashdir', type=str, help='directory to store crash files.')
+  p.add_argument(
+      'remainders',
+      nargs=argparse.REMAINDER,
+      help='arguments passed to loglistener')
+
+  args = p.parse_args()
+
+  if args.begin:
+    BEGINS.extend(args.begin.split(','))
+  if args.end:
+    ENDS.extend(args.end.split(','))
+  if args.suppress:
+    SUPPRESSES.extend(args.suppress.split(','))
+  if args.lines:
+    COLOR_LINES.update(parse_color_map(args.lines))
+  if args.words:
+    COLOR_WORDS.update(parse_color_map(args.words))
+  if args.crashdir:
+    global CRASH_DIR
+    CRASH_DIR = args.crashdir
+
+  return args
+
+
+def main():
+  args = proc_cmdline()
+  cmd = get_log_listener(args.remainders)
+  for line in hijack_stdout(cmd):
+    print_log(line)
+    monitor_crash(line)
+
+
+main()