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