1#!/usr/bin/env python 2 3# This Source Code Form is subject to the terms of the Mozilla Public 4# License, v. 2.0. If a copy of the MPL was not distributed with this 5# file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 7""" 8objects and methods for parsing and serializing Talos results 9see https://wiki.mozilla.org/Buildbot/Talos/DataFormat 10""" 11 12import json 13import os 14import re 15import csv 16from talos import output, utils, filter 17 18 19class TalosResults(object): 20 """Container class for Talos results""" 21 22 def __init__(self): 23 self.results = [] 24 self.extra_options = [] 25 26 def add(self, test_results): 27 self.results.append(test_results) 28 29 def add_extra_option(self, extra_option): 30 self.extra_options.append(extra_option) 31 32 def output(self, output_formats): 33 """ 34 output all results to appropriate URLs 35 - output_formats: a dict mapping formats to a list of URLs 36 """ 37 38 tbpl_output = {} 39 try: 40 41 for key, urls in output_formats.items(): 42 _output = output.Output(self) 43 results = _output() 44 for url in urls: 45 _output.output(results, url, tbpl_output) 46 47 except utils.TalosError as e: 48 # print to results.out 49 try: 50 _output = output.GraphserverOutput(self) 51 results = _output() 52 _output.output( 53 'file://%s' % os.path.join(os.getcwd(), 'results.out'), 54 results 55 ) 56 except: 57 pass 58 print('\nFAIL: %s' % str(e).replace('\n', '\nRETURN:')) 59 raise e 60 61 if tbpl_output: 62 print("TinderboxPrint: TalosResult: %s" % json.dumps(tbpl_output)) 63 64 65class TestResults(object): 66 """container object for all test results across cycles""" 67 68 def __init__(self, test_config, global_counters=None, framework=None): 69 self.results = [] 70 self.test_config = test_config 71 self.format = None 72 self.global_counters = global_counters or {} 73 self.all_counter_results = [] 74 self.framework = framework 75 self.using_xperf = False 76 77 def name(self): 78 return self.test_config['name'] 79 80 def mainthread(self): 81 return self.test_config['mainthread'] 82 83 def add(self, results, counter_results=None): 84 """ 85 accumulate one cycle of results 86 - results : TalosResults instance or path to browser log 87 - counter_results : counters accumulated for this cycle 88 """ 89 90 # convert to a results class via parsing the browser log 91 browserLog = BrowserLogResults( 92 results, 93 counter_results=counter_results, 94 global_counters=self.global_counters 95 ) 96 results = browserLog.results() 97 98 self.using_xperf = browserLog.using_xperf 99 # ensure the results format matches previous results 100 if self.results: 101 if not results.format == self.results[0].format: 102 raise utils.TalosError("Conflicting formats for results") 103 else: 104 self.format = results.format 105 106 self.results.append(results) 107 108 if counter_results: 109 self.all_counter_results.append(counter_results) 110 111 112class Results(object): 113 def filter(self, testname, filters): 114 """ 115 filter the results set; 116 applies each of the filters in order to the results data 117 filters should be callables that take a list 118 the last filter should return a scalar (float or int) 119 returns a list of [[data, page], ...] 120 """ 121 retval = [] 122 for result in self.results: 123 page = result['page'] 124 data = result['runs'] 125 remaining_filters = [] 126 127 # ignore* functions return a filtered set of data 128 for f in filters: 129 if f.func.__name__.startswith('ignore'): 130 data = f.apply(data) 131 else: 132 remaining_filters.append(f) 133 134 # apply the summarization filters 135 for f in remaining_filters: 136 if f.func.__name__ == "v8_subtest": 137 # for v8_subtest we need to page for reference data 138 data = filter.v8_subtest(data, page) 139 else: 140 data = f.apply(data) 141 142 summary = { 143 'filtered': data, # backward compatibility with perfherder 144 'value': data 145 } 146 147 retval.append([summary, page]) 148 149 return retval 150 151 def raw_values(self): 152 return [(result['page'], result['runs']) for result in self.results] 153 154 def values(self, testname, filters): 155 """return filtered (value, page) for each value""" 156 return [[val, page] for val, page in self.filter(testname, filters) 157 if val['filtered'] > -1] 158 159 160class TsResults(Results): 161 """ 162 results for Ts tests 163 """ 164 165 format = 'tsformat' 166 167 def __init__(self, string, counter_results=None): 168 self.counter_results = counter_results 169 170 string = string.strip() 171 lines = string.splitlines() 172 173 # gather the data 174 self.results = [] 175 index = 0 176 177 # Handle the case where we support a pagename in the results 178 # (new format) 179 for line in lines: 180 result = {} 181 r = line.strip().split(',') 182 r = [i for i in r if i] 183 if len(r) <= 1: 184 continue 185 result['index'] = index 186 result['page'] = r[0] 187 # note: if we have len(r) >1, then we have pagename,raw_results 188 result['runs'] = [float(i) for i in r[1:]] 189 self.results.append(result) 190 index += 1 191 192 # The original case where we just have numbers and no pagename 193 if not self.results: 194 result = {} 195 result['index'] = index 196 result['page'] = 'NULL' 197 result['runs'] = [float(val) for val in string.split('|')] 198 self.results.append(result) 199 200 201class PageloaderResults(Results): 202 """ 203 results from a browser_dump snippet 204 https://wiki.mozilla.org/Buildbot/Talos/DataFormat#browser_output.txt 205 """ 206 207 format = 'tpformat' 208 209 def __init__(self, string, counter_results=None): 210 """ 211 - string : string of relevent part of browser dump 212 - counter_results : counter results dictionary 213 """ 214 215 self.counter_results = counter_results 216 217 string = string.strip() 218 lines = string.splitlines() 219 220 # currently we ignore the metadata on top of the output (e.g.): 221 # _x_x_mozilla_page_load 222 # _x_x_mozilla_page_load_details 223 # |i|pagename|runs| 224 lines = [line for line in lines if ';' in line] 225 226 # gather the data 227 self.results = [] 228 for line in lines: 229 result = {} 230 r = line.strip('|').split(';') 231 r = [i for i in r if i] 232 if len(r) <= 2: 233 continue 234 result['index'] = int(r[0]) 235 result['page'] = r[1] 236 result['runs'] = [float(i) for i in r[2:]] 237 238 # fix up page 239 result['page'] = self.format_pagename(result['page']) 240 241 self.results.append(result) 242 243 def format_pagename(self, page): 244 """ 245 fix up the page for reporting 246 """ 247 page = page.rstrip('/') 248 if '/' in page: 249 page = page.split('/')[0] 250 return page 251 252 253class BrowserLogResults(object): 254 """parse the results from the browser log output""" 255 256 # tokens for the report types 257 report_tokens = [ 258 ('tsformat', ('__start_report', '__end_report')), 259 ('tpformat', ('__start_tp_report', '__end_tp_report')) 260 ] 261 262 # tokens for timestamps, in order (attribute, (start_delimeter, 263 # end_delimter)) 264 time_tokens = [ 265 ('startTime', ('__startTimestamp', '__endTimestamp')), 266 ('beforeLaunchTime', ('__startBeforeLaunchTimestamp', 267 '__endBeforeLaunchTimestamp')), 268 ('endTime', ('__startAfterTerminationTimestamp', 269 '__endAfterTerminationTimestamp')) 270 ] 271 272 # regular expression for failure case if we can't parse the tokens 273 RESULTS_REGEX_FAIL = re.compile('__FAIL(.*?)__FAIL', 274 re.DOTALL | re.MULTILINE) 275 276 # regular expression for RSS results 277 RSS_REGEX = re.compile('RSS:\s+([a-zA-Z0-9]+):\s+([0-9]+)$') 278 279 # regular expression for responsiveness results 280 RESULTS_RESPONSIVENESS_REGEX = re.compile( 281 'MOZ_EVENT_TRACE\ssample\s\d*?\s(\d*\.?\d*)$', 282 re.DOTALL | re.MULTILINE 283 ) 284 285 # classes for results types 286 classes = {'tsformat': TsResults, 287 'tpformat': PageloaderResults} 288 289 # If we are using xperf, we do not upload the regular results, only 290 # xperf counters 291 using_xperf = False 292 293 def __init__(self, results_raw, counter_results=None, 294 global_counters=None): 295 """ 296 - shutdown : whether to record shutdown results or not 297 """ 298 299 self.counter_results = counter_results 300 self.global_counters = global_counters 301 302 self.results_raw = results_raw 303 304 # parse the results 305 try: 306 match = self.RESULTS_REGEX_FAIL.search(self.results_raw) 307 if match: 308 self.error(match.group(1)) 309 raise utils.TalosError(match.group(1)) 310 311 self.parse() 312 except utils.TalosError: 313 # TODO: consider investigating this further or adding additional 314 # information 315 raise # reraise failing exception 316 317 # accumulate counter results 318 self.counters(self.counter_results, self.global_counters) 319 320 def error(self, message): 321 """raise a TalosError for bad parsing of the browser log""" 322 raise utils.TalosError(message) 323 324 def parse(self): 325 position = -1 326 327 # parse the report 328 for format, tokens in self.report_tokens: 329 report, position = self.get_single_token(*tokens) 330 if report is None: 331 continue 332 self.browser_results = report 333 self.format = format 334 previous_tokens = tokens 335 break 336 else: 337 self.error("Could not find report in browser output: %s" 338 % self.report_tokens) 339 340 # parse the timestamps 341 for attr, tokens in self.time_tokens: 342 343 # parse the token contents 344 value, _last_token = self.get_single_token(*tokens) 345 346 # check for errors 347 if not value: 348 self.error("Could not find %s in browser output: (tokens: %s)" 349 % (attr, tokens)) 350 try: 351 value = int(value) 352 except ValueError: 353 self.error("Could not cast %s to an integer: %s" 354 % (attr, value)) 355 if _last_token < position: 356 self.error("%s [character position: %s] found before %s" 357 " [character position: %s]" 358 % (tokens, _last_token, previous_tokens, position)) 359 360 # process 361 setattr(self, attr, value) 362 position = _last_token 363 previous_tokens = tokens 364 365 def get_single_token(self, start_token, end_token): 366 """browser logs should only have a single instance of token pairs""" 367 try: 368 parts, last_token = utils.tokenize(self.results_raw, 369 start_token, end_token) 370 except AssertionError as e: 371 self.error(str(e)) 372 if not parts: 373 return None, -1 # no match 374 if len(parts) != 1: 375 self.error("Multiple matches for %s,%s" % (start_token, end_token)) 376 return parts[0], last_token 377 378 def results(self): 379 """return results instance appropriate to the format detected""" 380 381 if self.format not in self.classes: 382 raise utils.TalosError( 383 "Unable to find a results class for format: %s" 384 % repr(self.format) 385 ) 386 387 return self.classes[self.format](self.browser_results) 388 389 # methods for counters 390 391 def counters(self, counter_results=None, global_counters=None): 392 """accumulate all counters""" 393 394 if counter_results is not None: 395 self.rss(counter_results) 396 397 if global_counters is not None: 398 if 'shutdown' in global_counters: 399 self.shutdown(global_counters) 400 if 'responsiveness' in global_counters: 401 global_counters['responsiveness'].extend(self.responsiveness()) 402 self.xperf(global_counters) 403 404 def xperf(self, counter_results): 405 """record xperf counters in counter_results dictionary""" 406 407 counters = ['main_startup_fileio', 408 'main_startup_netio', 409 'main_normal_fileio', 410 'main_normal_netio', 411 'nonmain_startup_fileio', 412 'nonmain_normal_fileio', 413 'nonmain_normal_netio'] 414 415 mainthread_counter_keys = ['readcount', 'readbytes', 'writecount', 416 'writebytes'] 417 mainthread_counters = ['_'.join(['mainthread', counter_key]) 418 for counter_key in mainthread_counter_keys] 419 420 self.mainthread_io(counter_results) 421 422 if not set(counters).union(set(mainthread_counters))\ 423 .intersection(counter_results.keys()): 424 # no xperf counters to accumulate 425 return 426 427 filename = 'etl_output_thread_stats.csv' 428 if not os.path.exists(filename): 429 print("Warning: we are looking for xperf results file %s, and" 430 " didn't find it" % filename) 431 return 432 433 contents = open(filename).read() 434 lines = contents.splitlines() 435 reader = csv.reader(lines) 436 header = None 437 for row in reader: 438 # Read CSV 439 row = [i.strip() for i in row] 440 if not header: 441 # We are assuming the first row is the header and all other 442 # data is counters 443 header = row 444 continue 445 values = dict(zip(header, row)) 446 447 # Format for talos 448 thread = values['thread'] 449 counter = values['counter'].rsplit('_io_bytes', 1)[0] 450 counter_name = '%s_%s_%sio' % (thread, values['stage'], counter) 451 value = float(values['value']) 452 453 # Accrue counter 454 if counter_name in counter_results: 455 counter_results.setdefault(counter_name, []).append(value) 456 self.using_xperf = True 457 458 if (set(mainthread_counters).intersection(counter_results.keys())): 459 filename = 'etl_output.csv' 460 if not os.path.exists(filename): 461 print("Warning: we are looking for xperf results file" 462 " %s, and didn't find it" % filename) 463 return 464 465 contents = open(filename).read() 466 lines = contents.splitlines() 467 reader = csv.reader(lines) 468 header = None 469 for row in reader: 470 row = [i.strip() for i in row] 471 if not header: 472 # We are assuming the first row is the header and all 473 # other data is counters 474 header = row 475 continue 476 values = dict(zip(header, row)) 477 for i, mainthread_counter in enumerate(mainthread_counters): 478 if int(values[mainthread_counter_keys[i]]) > 0: 479 counter_results.setdefault(mainthread_counter, [])\ 480 .append([int(values[mainthread_counter_keys[i]]), 481 values['filename']]) 482 483 def rss(self, counter_results): 484 """record rss counters in counter_results dictionary""" 485 486 counters = ['Main', 'Content'] 487 if not set(['%s_RSS' % i for i in counters])\ 488 .intersection(counter_results.keys()): 489 # no RSS counters to accumulate 490 return 491 for line in self.results_raw.split('\n'): 492 rssmatch = self.RSS_REGEX.search(line) 493 if rssmatch: 494 (type, value) = (rssmatch.group(1), rssmatch.group(2)) 495 # type will be 'Main' or 'Content' 496 counter_name = '%s_RSS' % type 497 if counter_name in counter_results: 498 counter_results[counter_name].append(value) 499 500 def mainthread_io(self, counter_results): 501 """record mainthread IO counters in counter_results dictionary""" 502 503 # we want to measure mtio on xperf runs. 504 # this will be shoved into the xperf results as we ignore those 505 SCRIPT_DIR = \ 506 os.path.abspath(os.path.realpath(os.path.dirname(__file__))) 507 filename = os.path.join(SCRIPT_DIR, 'mainthread_io.json') 508 try: 509 contents = open(filename).read() 510 counter_results.setdefault('mainthreadio', []).append(contents) 511 self.using_xperf = True 512 except: 513 # silent failure is fine here as we will only see this on tp5n runs 514 pass 515 516 def shutdown(self, counter_results): 517 """record shutdown time in counter_results dictionary""" 518 counter_results.setdefault('shutdown', [])\ 519 .append(int(self.endTime - self.startTime)) 520 521 def responsiveness(self): 522 return self.RESULTS_RESPONSIVENESS_REGEX.findall(self.results_raw) 523