Home | History | Annotate | Download | only in perf
      1 #!/usr/bin/env python
      2 # Copyright (c) 2012 The Chromium Authors. All rights reserved.
      3 # Use of this source code is governed by a BSD-style license that can be
      4 # found in the LICENSE file.
      5 
      6 """Script to parse perf data from Chrome Endure test executions, to be graphed.
      7 
      8 This script connects via HTTP to a buildbot master in order to scrape and parse
      9 perf data from Chrome Endure tests that have been run.  The perf data is then
     10 stored in local text files to be graphed by the Chrome Endure graphing code.
     11 
     12 It is assumed that any Chrome Endure tests that show up on the waterfall have
     13 names that are of the following form:
     14 
     15 "endure_<webapp_name>-<test_name>" (non-Web Page Replay tests)
     16 
     17 or
     18 
     19 "endure_<webapp_name>_wpr-<test_name>" (Web Page Replay tests)
     20 
     21 For example: "endure_gmail_wpr-testGmailComposeDiscard"
     22 
     23 This script accepts either a URL or a local path as a buildbot location.
     24 It switches its behavior if a URL is given, or a local path is given.
     25 
     26 When a URL is given, it gets buildbot logs from the buildbot builders URL
     27 e.g. http://build.chromium.org/p/chromium.endure/builders/.
     28 
     29 When a local path is given, it gets buildbot logs from buildbot's internal
     30 files in the directory e.g. /home/chrome-bot/buildbot.
     31 """
     32 
     33 import cPickle
     34 import getpass
     35 import logging
     36 import optparse
     37 import os
     38 import re
     39 import simplejson
     40 import socket
     41 import string
     42 import sys
     43 import time
     44 import urllib
     45 import urllib2
     46 
     47 
     48 CHROME_ENDURE_SLAVE_NAMES = [
     49   'Linux QA Perf (0)',
     50   'Linux QA Perf (1)',
     51   'Linux QA Perf (2)',
     52   'Linux QA Perf (3)',
     53   'Linux QA Perf (4)',
     54   'Linux QA Perf (dbg)(0)',
     55   'Linux QA Perf (dbg)(1)',
     56   'Linux QA Perf (dbg)(2)',
     57   'Linux QA Perf (dbg)(3)',
     58   'Linux QA Perf (dbg)(4)',
     59 ]
     60 
     61 BUILDER_URL_BASE = 'http://build.chromium.org/p/chromium.endure/builders/'
     62 LAST_BUILD_NUM_PROCESSED_FILE = os.path.join(os.path.dirname(__file__),
     63                                              '_parser_last_processed.txt')
     64 LOCAL_GRAPH_DIR = '/home/%s/www/chrome_endure_clean' % getpass.getuser()
     65 MANGLE_TRANSLATION = string.maketrans(' ()', '___')
     66 
     67 def SetupBaseGraphDirIfNeeded(webapp_name, test_name, dest_dir):
     68   """Sets up the directory containing results for a particular test, if needed.
     69 
     70   Args:
     71     webapp_name: The string name of the webapp associated with the given test.
     72     test_name: The string name of the test.
     73     dest_dir: The name of the destination directory that needs to be set up.
     74   """
     75   if not os.path.exists(dest_dir):
     76     os.mkdir(dest_dir)  # Test name directory.
     77     os.chmod(dest_dir, 0755)
     78 
     79   # Create config file.
     80   config_file = os.path.join(dest_dir, 'config.js')
     81   if not os.path.exists(config_file):
     82     with open(config_file, 'w') as f:
     83       f.write('var Config = {\n')
     84       f.write('buildslave: "Chrome Endure Bots",\n')
     85       f.write('title: "Chrome Endure %s Test: %s",\n' % (webapp_name.upper(),
     86                                                          test_name))
     87       f.write('};\n')
     88     os.chmod(config_file, 0755)
     89 
     90   # Set up symbolic links to the real graphing files.
     91   link_file = os.path.join(dest_dir, 'index.html')
     92   if not os.path.exists(link_file):
     93     os.symlink('../../endure_plotter.html', link_file)
     94   link_file = os.path.join(dest_dir, 'endure_plotter.js')
     95   if not os.path.exists(link_file):
     96     os.symlink('../../endure_plotter.js', link_file)
     97   link_file = os.path.join(dest_dir, 'js')
     98   if not os.path.exists(link_file):
     99     os.symlink('../../js', link_file)
    100 
    101 
    102 def WriteToDataFile(new_line, existing_lines, revision, data_file):
    103   """Writes a new entry to an existing perf data file to be graphed.
    104 
    105   If there's an existing line with the same revision number, overwrite its data
    106   with the new line.  Else, prepend the info for the new revision.
    107 
    108   Args:
    109     new_line: A dictionary representing perf information for the new entry.
    110     existing_lines: A list of string lines from the existing perf data file.
    111     revision: The string revision number associated with the new perf entry.
    112     data_file: The string name of the perf data file to which to write.
    113   """
    114   overwritten = False
    115   for i, line in enumerate(existing_lines):
    116     line_dict = simplejson.loads(line)
    117     if line_dict['rev'] == revision:
    118       existing_lines[i] = simplejson.dumps(new_line)
    119       overwritten = True
    120       break
    121     elif int(line_dict['rev']) < int(revision):
    122       break
    123   if not overwritten:
    124     existing_lines.insert(0, simplejson.dumps(new_line))
    125 
    126   with open(data_file, 'w') as f:
    127     f.write('\n'.join(existing_lines))
    128   os.chmod(data_file, 0755)
    129 
    130 
    131 def OutputPerfData(revision, graph_name, values, units, units_x, dest_dir,
    132                    is_stacked=False, stack_order=[]):
    133   """Outputs perf data to a local text file to be graphed.
    134 
    135   Args:
    136     revision: The string revision number associated with the perf data.
    137     graph_name: The string name of the graph on which to plot the data.
    138     values: A dict which maps a description to a value.  A value is either a
    139         single data value to be graphed, or a list of 2-tuples
    140         representing (x, y) points to be graphed for long-running tests.
    141     units: The string description for the y-axis units on the graph.
    142     units_x: The string description for the x-axis units on the graph.  Should
    143         be set to None if the results are not for long-running graphs.
    144     dest_dir: The name of the destination directory to which to write.
    145     is_stacked: True to draw a "stacked" graph.  First-come values are
    146         stacked at bottom by default.
    147     stack_order: A list that contains key strings in the order to stack values
    148         in the graph.
    149   """
    150   # Update graphs.dat, which contains metadata associated with each graph.
    151   existing_graphs = []
    152   graphs_file = os.path.join(dest_dir, 'graphs.dat')
    153   if os.path.exists(graphs_file):
    154     with open(graphs_file, 'r') as f:
    155       existing_graphs = simplejson.loads(f.read())
    156   is_new_graph = True
    157   for graph in existing_graphs:
    158     if graph['name'] == graph_name:
    159       is_new_graph = False
    160       break
    161   if is_new_graph:
    162     new_graph =  {
    163       'name': graph_name,
    164       'units': units,
    165       'important': False,
    166     }
    167     if units_x:
    168       new_graph['units_x'] = units_x
    169     existing_graphs.append(new_graph)
    170     existing_graphs = sorted(existing_graphs, key=lambda x: x['name'])
    171     with open(graphs_file, 'w') as f:
    172       f.write(simplejson.dumps(existing_graphs, indent=2))
    173     os.chmod(graphs_file, 0755)
    174 
    175   # Update summary data file, containing the actual data to be graphed.
    176   data_file_name = graph_name + '-summary.dat'
    177   existing_lines = []
    178   data_file = os.path.join(dest_dir, data_file_name)
    179   if os.path.exists(data_file):
    180     with open(data_file, 'r') as f:
    181       existing_lines = f.readlines()
    182   existing_lines = map(lambda x: x.strip(), existing_lines)
    183   new_traces = {}
    184   for description in values:
    185     value = values[description]
    186     if units_x:
    187       points = []
    188       for point in value:
    189         points.append([str(point[0]), str(point[1])])
    190       new_traces[description] = points
    191     else:
    192       new_traces[description] = [str(value), str(0.0)]
    193   new_line = {
    194     'traces': new_traces,
    195     'rev': revision
    196   }
    197   if is_stacked:
    198     new_line['stack'] = True
    199     new_line['stack_order'] = stack_order
    200 
    201   WriteToDataFile(new_line, existing_lines, revision, data_file)
    202 
    203 
    204 def OutputEventData(revision, event_dict, dest_dir):
    205   """Outputs event data to a local text file to be graphed.
    206 
    207   Args:
    208     revision: The string revision number associated with the event data.
    209     event_dict: A dict which maps a description to an array of tuples
    210         representing event data to be graphed.
    211     dest_dir: The name of the destination directory to which to write.
    212   """
    213   data_file_name = '_EVENT_-summary.dat'
    214   existing_lines = []
    215   data_file = os.path.join(dest_dir, data_file_name)
    216   if os.path.exists(data_file):
    217     with open(data_file, 'r') as f:
    218       existing_lines = f.readlines()
    219   existing_lines = map(lambda x: x.strip(), existing_lines)
    220 
    221   new_events = {}
    222   for description in event_dict:
    223     event_list = event_dict[description]
    224     value_list = []
    225     for event_time, event_data in event_list:
    226       value_list.append([str(event_time), event_data])
    227     new_events[description] = value_list
    228 
    229   new_line = {
    230     'rev': revision,
    231     'events': new_events
    232   }
    233 
    234   WriteToDataFile(new_line, existing_lines, revision, data_file)
    235 
    236 
    237 def UpdatePerfDataFromFetchedContent(
    238     revision, content, webapp_name, test_name, graph_dir, only_dmp=False):
    239   """Update perf data from fetched stdio data.
    240 
    241   Args:
    242     revision: The string revision number associated with the new perf entry.
    243     content: Fetched stdio data.
    244     webapp_name: A name of the webapp.
    245     test_name: A name of the test.
    246     graph_dir: A path to the graph directory.
    247     only_dmp: True if only Deep Memory Profiler results should be used.
    248   """
    249   perf_data_raw = []
    250 
    251   def AppendRawPerfData(graph_name, description, value, units, units_x,
    252                         webapp_name, test_name, is_stacked=False):
    253     perf_data_raw.append({
    254       'graph_name': graph_name,
    255       'description': description,
    256       'value': value,
    257       'units': units,
    258       'units_x': units_x,
    259       'webapp_name': webapp_name,
    260       'test_name': test_name,
    261       'stack': is_stacked,
    262     })
    263 
    264   # First scan for short-running perf test results.
    265   for match in re.findall(
    266       r'RESULT ([^:]+): ([^=]+)= ([-\d\.]+) (\S+)', content):
    267     if (not only_dmp) or match[0].endswith('-DMP'):
    268       try:
    269         match2 = eval(match[2])
    270       except SyntaxError:
    271         match2 = None
    272       if match2:
    273         AppendRawPerfData(match[0], match[1], match2, match[3], None,
    274                           webapp_name, webapp_name)
    275 
    276   # Next scan for long-running perf test results.
    277   for match in re.findall(
    278       r'RESULT ([^:]+): ([^=]+)= (\[[^\]]+\]) (\S+) (\S+)', content):
    279     if (not only_dmp) or match[0].endswith('-DMP'):
    280       try:
    281         match2 = eval(match[2])
    282       except SyntaxError:
    283         match2 = None
    284       # TODO(dmikurube): Change the condition to use stacked graph when we
    285       # determine how to specify it.
    286       if match2:
    287         AppendRawPerfData(match[0], match[1], match2, match[3], match[4],
    288                           webapp_name, test_name, match[0].endswith('-DMP'))
    289 
    290   # Next scan for events in the test results.
    291   for match in re.findall(
    292       r'RESULT _EVENT_: ([^=]+)= (\[[^\]]+\])', content):
    293     try:
    294       match1 = eval(match[1])
    295     except SyntaxError:
    296       match1 = None
    297     if match1:
    298       AppendRawPerfData('_EVENT_', match[0], match1, None, None,
    299                         webapp_name, test_name)
    300 
    301   # For each graph_name/description pair that refers to a long-running test
    302   # result or an event, concatenate all the results together (assume results
    303   # in the input file are in the correct order).  For short-running test
    304   # results, keep just one if more than one is specified.
    305   perf_data = {}  # Maps a graph-line key to a perf data dictionary.
    306   for data in perf_data_raw:
    307     key_graph = data['graph_name']
    308     key_description = data['description']
    309     if not key_graph in perf_data:
    310       perf_data[key_graph] = {
    311         'graph_name': data['graph_name'],
    312         'value': {},
    313         'units': data['units'],
    314         'units_x': data['units_x'],
    315         'webapp_name': data['webapp_name'],
    316         'test_name': data['test_name'],
    317       }
    318     perf_data[key_graph]['stack'] = data['stack']
    319     if 'stack_order' not in perf_data[key_graph]:
    320       perf_data[key_graph]['stack_order'] = []
    321     if (data['stack'] and
    322         data['description'] not in perf_data[key_graph]['stack_order']):
    323       perf_data[key_graph]['stack_order'].append(data['description'])
    324 
    325     if data['graph_name'] != '_EVENT_' and not data['units_x']:
    326       # Short-running test result.
    327       perf_data[key_graph]['value'][key_description] = data['value']
    328     else:
    329       # Long-running test result or event.
    330       if key_description in perf_data[key_graph]['value']:
    331         perf_data[key_graph]['value'][key_description] += data['value']
    332       else:
    333         perf_data[key_graph]['value'][key_description] = data['value']
    334 
    335   # Finally, for each graph-line in |perf_data|, update the associated local
    336   # graph data files if necessary.
    337   for perf_data_key in perf_data:
    338     perf_data_dict = perf_data[perf_data_key]
    339 
    340     dest_dir = os.path.join(graph_dir, perf_data_dict['webapp_name'])
    341     if not os.path.exists(dest_dir):
    342       os.mkdir(dest_dir)  # Webapp name directory.
    343       os.chmod(dest_dir, 0755)
    344     dest_dir = os.path.join(dest_dir, perf_data_dict['test_name'])
    345 
    346     SetupBaseGraphDirIfNeeded(perf_data_dict['webapp_name'],
    347                               perf_data_dict['test_name'], dest_dir)
    348     if perf_data_dict['graph_name'] == '_EVENT_':
    349       OutputEventData(revision, perf_data_dict['value'], dest_dir)
    350     else:
    351       OutputPerfData(revision, perf_data_dict['graph_name'],
    352                      perf_data_dict['value'],
    353                      perf_data_dict['units'], perf_data_dict['units_x'],
    354                      dest_dir,
    355                      perf_data_dict['stack'], perf_data_dict['stack_order'])
    356 
    357 
    358 def SlaveLocation(master_location, slave_info):
    359   """Returns slave location for |master_location| and |slave_info|."""
    360   if master_location.startswith('http://'):
    361     return master_location + urllib.quote(slave_info['slave_name'])
    362   else:
    363     return os.path.join(master_location,
    364                         slave_info['slave_name'].translate(MANGLE_TRANSLATION))
    365 
    366 
    367 def GetRevisionAndLogs(slave_location, build_num):
    368   """Get a revision number and log locations.
    369 
    370   Args:
    371     slave_location: A URL or a path to the build slave data.
    372     build_num: A build number.
    373 
    374   Returns:
    375     A pair of the revision number and a list of strings that contain locations
    376     of logs.  (False, []) in case of error.
    377   """
    378   if slave_location.startswith('http://'):
    379     location = slave_location + '/builds/' + str(build_num)
    380   else:
    381     location = os.path.join(slave_location, str(build_num))
    382 
    383   revision = False
    384   logs = []
    385   fp = None
    386   try:
    387     if location.startswith('http://'):
    388       fp = urllib2.urlopen(location)
    389       contents = fp.read()
    390       revisions = re.findall(r'<td class="left">got_revision</td>\s+'
    391                              '<td>(\d+)</td>\s+<td>Source</td>', contents)
    392       if revisions:
    393         revision = revisions[0]
    394         logs = [location + link + '/text' for link
    395                 in re.findall(r'(/steps/endure[^/]+/logs/stdio)', contents)]
    396     else:
    397       fp = open(location, 'rb')
    398       build = cPickle.load(fp)
    399       properties = build.getProperties()
    400       if properties.has_key('got_revision'):
    401         revision = build.getProperty('got_revision')
    402         candidates = os.listdir(slave_location)
    403         logs = [os.path.join(slave_location, filename)
    404                 for filename in candidates
    405                 if re.match(r'%d-log-endure[^/]+-stdio' % build_num, filename)]
    406 
    407   except urllib2.URLError, e:
    408     logging.exception('Error reading build URL "%s": %s', location, str(e))
    409     return False, []
    410   except (IOError, OSError), e:
    411     logging.exception('Error reading build file "%s": %s', location, str(e))
    412     return False, []
    413   finally:
    414     if fp:
    415       fp.close()
    416 
    417   return revision, logs
    418 
    419 
    420 def ExtractTestNames(log_location, is_dbg):
    421   """Extract test names from |log_location|.
    422 
    423   Returns:
    424     A dict of a log location, webapp's name and test's name.  False if error.
    425   """
    426   if log_location.startswith('http://'):
    427     location = urllib.unquote(log_location)
    428     test_pattern = r'endure_([^_]+)(_test |-)([^/]+)/'
    429     wpr_test_pattern = r'endure_([^_]+)_wpr(_test |-)([^/]+)/'
    430   else:
    431     location = log_location
    432     test_pattern = r'endure_([^_]+)(_test_|-)([^/]+)-stdio'
    433     wpr_test_pattern = 'endure_([^_]+)_wpr(_test_|-)([^/]+)-stdio'
    434 
    435   found_wpr_result = False
    436   match = re.findall(test_pattern, location)
    437   if not match:
    438     match = re.findall(wpr_test_pattern, location)
    439     if match:
    440       found_wpr_result = True
    441     else:
    442       logging.error('Test name not in expected format: ' + location)
    443       return False
    444   match = match[0]
    445   webapp_name = match[0] + '_wpr' if found_wpr_result else match[0]
    446   webapp_name = webapp_name + '_dbg' if is_dbg else webapp_name
    447   test_name = match[2]
    448 
    449   return {
    450       'location': log_location,
    451       'webapp_name': webapp_name,
    452       'test_name': test_name,
    453       }
    454 
    455 
    456 def GetStdioContents(stdio_location):
    457   """Gets appropriate stdio contents.
    458 
    459   Returns:
    460     A content string of the stdio log.  None in case of error.
    461   """
    462   fp = None
    463   contents = ''
    464   try:
    465     if stdio_location.startswith('http://'):
    466       fp = urllib2.urlopen(stdio_location, timeout=60)
    467       # Since in-progress test output is sent chunked, there's no EOF.  We need
    468       # to specially handle this case so we don't hang here waiting for the
    469       # test to complete.
    470       start_time = time.time()
    471       while True:
    472         data = fp.read(1024)
    473         if not data:
    474           break
    475         contents += data
    476         if time.time() - start_time >= 30:  # Read for at most 30 seconds.
    477           break
    478     else:
    479       fp = open(stdio_location)
    480       data = fp.read()
    481       contents = ''
    482       index = 0
    483 
    484       # Buildbot log files are stored in the netstring format.
    485       # http://en.wikipedia.org/wiki/Netstring
    486       while index < len(data):
    487         index2 = index
    488         while data[index2].isdigit():
    489           index2 += 1
    490         if data[index2] != ':':
    491           logging.error('Log file is not in expected format: %s' %
    492                         stdio_location)
    493           contents = None
    494           break
    495         length = int(data[index:index2])
    496         index = index2 + 1
    497         channel = int(data[index])
    498         index += 1
    499         if data[index+length-1] != ',':
    500           logging.error('Log file is not in expected format: %s' %
    501                         stdio_location)
    502           contents = None
    503           break
    504         if channel == 0:
    505           contents += data[index:(index+length-1)]
    506         index += length
    507 
    508   except (urllib2.URLError, socket.error, IOError, OSError), e:
    509     # Issue warning but continue to the next stdio link.
    510     logging.warning('Error reading test stdio data "%s": %s',
    511                     stdio_location, str(e))
    512   finally:
    513     if fp:
    514       fp.close()
    515 
    516   return contents
    517 
    518 
    519 def UpdatePerfDataForSlaveAndBuild(
    520     slave_info, build_num, graph_dir, master_location):
    521   """Process updated perf data for a particular slave and build number.
    522 
    523   Args:
    524     slave_info: A dictionary containing information about the slave to process.
    525     build_num: The particular build number on the slave to process.
    526     graph_dir: A path to the graph directory.
    527     master_location: A URL or a path to the build master data.
    528 
    529   Returns:
    530     True if the perf data for the given slave/build is updated properly, or
    531     False if any critical error occurred.
    532   """
    533   if not master_location.startswith('http://'):
    534     # Source is a file.
    535     from buildbot.status import builder
    536 
    537   slave_location = SlaveLocation(master_location, slave_info)
    538   logging.debug('  %s, build %d.', slave_info['slave_name'], build_num)
    539   is_dbg = '(dbg)' in slave_info['slave_name']
    540 
    541   revision, logs = GetRevisionAndLogs(slave_location, build_num)
    542   if not revision:
    543     return False
    544 
    545   stdios = []
    546   for log_location in logs:
    547     stdio = ExtractTestNames(log_location, is_dbg)
    548     if not stdio:
    549       return False
    550     stdios.append(stdio)
    551 
    552   for stdio in stdios:
    553     stdio_location = stdio['location']
    554     contents = GetStdioContents(stdio_location)
    555 
    556     if contents:
    557       UpdatePerfDataFromFetchedContent(revision, contents,
    558                                        stdio['webapp_name'],
    559                                        stdio['test_name'],
    560                                        graph_dir, is_dbg)
    561 
    562   return True
    563 
    564 
    565 def GetMostRecentBuildNum(master_location, slave_name):
    566   """Gets the most recent buld number for |slave_name| in |master_location|."""
    567   most_recent_build_num = None
    568 
    569   if master_location.startswith('http://'):
    570     slave_url = master_location + urllib.quote(slave_name)
    571 
    572     url_contents = ''
    573     fp = None
    574     try:
    575       fp = urllib2.urlopen(slave_url, timeout=60)
    576       url_contents = fp.read()
    577     except urllib2.URLError, e:
    578       logging.exception('Error reading builder URL: %s', str(e))
    579       return None
    580     finally:
    581       if fp:
    582         fp.close()
    583 
    584     matches = re.findall(r'/(\d+)/stop', url_contents)
    585     if matches:
    586       most_recent_build_num = int(matches[0])
    587     else:
    588       matches = re.findall(r'#(\d+)</a></td>', url_contents)
    589       if matches:
    590         most_recent_build_num = sorted(map(int, matches), reverse=True)[0]
    591 
    592   else:
    593     slave_path = os.path.join(master_location,
    594                               slave_name.translate(MANGLE_TRANSLATION))
    595     files = os.listdir(slave_path)
    596     number_files = [int(filename) for filename in files if filename.isdigit()]
    597     if number_files:
    598       most_recent_build_num = sorted(number_files, reverse=True)[0]
    599 
    600   if most_recent_build_num:
    601     logging.debug('%s most recent build number: %s',
    602                   slave_name, most_recent_build_num)
    603   else:
    604     logging.error('Could not identify latest build number for slave %s.',
    605                   slave_name)
    606 
    607   return most_recent_build_num
    608 
    609 
    610 def UpdatePerfDataFiles(graph_dir, master_location):
    611   """Updates the Chrome Endure graph data files with the latest test results.
    612 
    613   For each known Chrome Endure slave, we scan its latest test results looking
    614   for any new test data.  Any new data that is found is then appended to the
    615   data files used to display the Chrome Endure graphs.
    616 
    617   Args:
    618     graph_dir: A path to the graph directory.
    619     master_location: A URL or a path to the build master data.
    620 
    621   Returns:
    622     True if all graph data files are updated properly, or
    623     False if any error occurred.
    624   """
    625   slave_list = []
    626   for slave_name in CHROME_ENDURE_SLAVE_NAMES:
    627     slave_info = {}
    628     slave_info['slave_name'] = slave_name
    629     slave_info['most_recent_build_num'] = None
    630     slave_info['last_processed_build_num'] = None
    631     slave_list.append(slave_info)
    632 
    633   # Identify the most recent build number for each slave.
    634   logging.debug('Searching for latest build numbers for each slave...')
    635   for slave in slave_list:
    636     slave_name = slave['slave_name']
    637     slave['most_recent_build_num'] = GetMostRecentBuildNum(
    638         master_location, slave_name)
    639 
    640   # Identify the last-processed build number for each slave.
    641   logging.debug('Identifying last processed build numbers...')
    642   if not os.path.exists(LAST_BUILD_NUM_PROCESSED_FILE):
    643     for slave_info in slave_list:
    644       slave_info['last_processed_build_num'] = 0
    645   else:
    646     with open(LAST_BUILD_NUM_PROCESSED_FILE, 'r') as fp:
    647       file_contents = fp.read()
    648       for match in re.findall(r'([^:]+):(\d+)', file_contents):
    649         slave_name = match[0].strip()
    650         last_processed_build_num = match[1].strip()
    651         for slave_info in slave_list:
    652           if slave_info['slave_name'] == slave_name:
    653             slave_info['last_processed_build_num'] = int(
    654                 last_processed_build_num)
    655     for slave_info in slave_list:
    656       if not slave_info['last_processed_build_num']:
    657         slave_info['last_processed_build_num'] = 0
    658   logging.debug('Done identifying last processed build numbers.')
    659 
    660   # For each Chrome Endure slave, process each build in-between the last
    661   # processed build num and the most recent build num, inclusive.  To process
    662   # each one, first get the revision number for that build, then scan the test
    663   # result stdio for any performance data, and add any new performance data to
    664   # local files to be graphed.
    665   for slave_info in slave_list:
    666     logging.debug('Processing %s, builds %d-%d...',
    667                   slave_info['slave_name'],
    668                   slave_info['last_processed_build_num'],
    669                   slave_info['most_recent_build_num'])
    670     curr_build_num = slave_info['last_processed_build_num']
    671     while curr_build_num <= slave_info['most_recent_build_num']:
    672       if not UpdatePerfDataForSlaveAndBuild(slave_info, curr_build_num,
    673                                             graph_dir, master_location):
    674         # Do not give up.  The first files might be removed by buildbot.
    675         logging.warning('Logs do not exist in buildbot for #%d of %s.' %
    676                         (curr_build_num, slave_info['slave_name']))
    677       curr_build_num += 1
    678 
    679   # Log the newly-processed build numbers.
    680   logging.debug('Logging the newly-processed build numbers...')
    681   with open(LAST_BUILD_NUM_PROCESSED_FILE, 'w') as f:
    682     for slave_info in slave_list:
    683       f.write('%s:%s\n' % (slave_info['slave_name'],
    684                            slave_info['most_recent_build_num']))
    685 
    686   return True
    687 
    688 
    689 def GenerateIndexPage(graph_dir):
    690   """Generates a summary (landing) page for the Chrome Endure graphs.
    691 
    692   Args:
    693     graph_dir: A path to the graph directory.
    694   """
    695   logging.debug('Generating new index.html page...')
    696 
    697   # Page header.
    698   page = """
    699   <html>
    700 
    701   <head>
    702     <title>Chrome Endure Overview</title>
    703     <script language="javascript">
    704       function DisplayGraph(name, graph) {
    705         document.write(
    706             '<td><iframe scrolling="no" height="438" width="700" src="');
    707         document.write(name);
    708         document.write('"></iframe></td>');
    709       }
    710     </script>
    711   </head>
    712 
    713   <body>
    714     <center>
    715 
    716       <h1>
    717         Chrome Endure
    718       </h1>
    719   """
    720   # Print current time.
    721   page += '<p>Updated: %s</p>\n' % (
    722       time.strftime('%A, %B %d, %Y at %I:%M:%S %p %Z'))
    723 
    724   # Links for each webapp.
    725   webapp_names = [x for x in os.listdir(graph_dir) if
    726                   x not in ['js', 'old_data', '.svn', '.git'] and
    727                   os.path.isdir(os.path.join(graph_dir, x))]
    728   webapp_names = sorted(webapp_names)
    729 
    730   page += '<p> ['
    731   for i, name in enumerate(webapp_names):
    732     page += '<a href="#%s">%s</a>' % (name.upper(), name.upper())
    733     if i < len(webapp_names) - 1:
    734       page += ' | '
    735   page += '] </p>\n'
    736 
    737   # Print out the data for each webapp.
    738   for webapp_name in webapp_names:
    739     page += '\n<h1 id="%s">%s</h1>\n' % (webapp_name.upper(),
    740                                          webapp_name.upper())
    741 
    742     # Links for each test for this webapp.
    743     test_names = [x for x in
    744                   os.listdir(os.path.join(graph_dir, webapp_name))]
    745     test_names = sorted(test_names)
    746 
    747     page += '<p> ['
    748     for i, name in enumerate(test_names):
    749       page += '<a href="#%s">%s</a>' % (name, name)
    750       if i < len(test_names) - 1:
    751         page += ' | '
    752     page += '] </p>\n'
    753 
    754     # Print out the data for each test for this webapp.
    755     for test_name in test_names:
    756       # Get the set of graph names for this test.
    757       graph_names = [x[:x.find('-summary.dat')] for x in
    758                      os.listdir(os.path.join(graph_dir,
    759                                              webapp_name, test_name))
    760                      if '-summary.dat' in x and '_EVENT_' not in x]
    761       graph_names = sorted(graph_names)
    762 
    763       page += '<h2 id="%s">%s</h2>\n' % (test_name, test_name)
    764       page += '<table>\n'
    765 
    766       for i, graph_name in enumerate(graph_names):
    767         if i % 2 == 0:
    768           page += '  <tr>\n'
    769         page += ('    <script>DisplayGraph("%s/%s?graph=%s&lookout=1");'
    770                  '</script>\n' % (webapp_name, test_name, graph_name))
    771         if i % 2 == 1:
    772           page += '  </tr>\n'
    773       if len(graph_names) % 2 == 1:
    774         page += '  </tr>\n'
    775       page += '</table>\n'
    776 
    777   # Page footer.
    778   page += """
    779     </center>
    780   </body>
    781 
    782   </html>
    783   """
    784 
    785   index_file = os.path.join(graph_dir, 'index.html')
    786   with open(index_file, 'w')  as f:
    787     f.write(page)
    788   os.chmod(index_file, 0755)
    789 
    790 
    791 def main():
    792   parser = optparse.OptionParser()
    793   parser.add_option(
    794       '-v', '--verbose', action='store_true', default=False,
    795       help='Use verbose logging.')
    796   parser.add_option(
    797       '-s', '--stdin', action='store_true', default=False,
    798       help='Input from stdin instead of slaves for testing this script.')
    799   parser.add_option(
    800       '-b', '--buildbot', dest='buildbot', metavar="BUILDBOT",
    801       default=BUILDER_URL_BASE,
    802       help='Use log files in a buildbot at BUILDBOT.  BUILDBOT can be a '
    803            'buildbot\'s builder URL or a local path to a buildbot directory.  '
    804            'Both an absolute path and a relative path are available, e.g. '
    805            '"/home/chrome-bot/buildbot" or "../buildbot".  '
    806            '[default: %default]')
    807   parser.add_option(
    808       '-g', '--graph', dest='graph_dir', metavar="DIR", default=LOCAL_GRAPH_DIR,
    809       help='Output graph data files to DIR.  [default: %default]')
    810   options, _ = parser.parse_args(sys.argv)
    811 
    812   logging_level = logging.DEBUG if options.verbose else logging.INFO
    813   logging.basicConfig(level=logging_level,
    814                       format='[%(asctime)s] %(levelname)s: %(message)s')
    815 
    816   if options.stdin:
    817     content = sys.stdin.read()
    818     UpdatePerfDataFromFetchedContent(
    819         '12345', content, 'webapp', 'test', options.graph_dir)
    820   else:
    821     if options.buildbot.startswith('http://'):
    822       master_location = options.buildbot
    823     else:
    824       build_dir = os.path.join(options.buildbot, 'build')
    825       third_party_dir = os.path.join(build_dir, 'third_party')
    826       sys.path.append(third_party_dir)
    827       sys.path.append(os.path.join(third_party_dir, 'buildbot_8_4p1'))
    828       sys.path.append(os.path.join(third_party_dir, 'twisted_10_2'))
    829       master_location = os.path.join(build_dir, 'masters',
    830                                      'master.chromium.endure')
    831     success = UpdatePerfDataFiles(options.graph_dir, master_location)
    832     if not success:
    833       logging.error('Failed to update perf data files.')
    834       sys.exit(0)
    835 
    836   GenerateIndexPage(options.graph_dir)
    837   logging.debug('All done!')
    838 
    839 
    840 if __name__ == '__main__':
    841   main()
    842