1# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import json
6import logging
7import math
8import os
9import pprint
10import re
11import StringIO
12
13from autotest_lib.client.common_lib import error, utils
14from autotest_lib.client.common_lib.cros import dev_server
15from autotest_lib.server import afe_utils
16
17
18TELEMETRY_RUN_BENCHMARKS_SCRIPT = 'tools/perf/run_benchmark'
19TELEMETRY_RUN_CROS_TESTS_SCRIPT = 'chrome/test/telemetry/run_cros_tests'
20TELEMETRY_RUN_GPU_TESTS_SCRIPT = 'content/test/gpu/run_gpu_test.py'
21TELEMETRY_RUN_TESTS_SCRIPT = 'tools/telemetry/run_tests'
22TELEMETRY_TIMEOUT_MINS = 120
23
24# Result Statuses
25SUCCESS_STATUS = 'SUCCESS'
26WARNING_STATUS = 'WARNING'
27FAILED_STATUS = 'FAILED'
28
29# Regex for the RESULT output lines understood by chrome buildbot.
30# Keep in sync with
31# chromium/tools/build/scripts/slave/performance_log_processor.py.
32RESULTS_REGEX = re.compile(r'(?P<IMPORTANT>\*)?RESULT '
33                           r'(?P<GRAPH>[^:]*): (?P<TRACE>[^=]*)= '
34                           r'(?P<VALUE>[\{\[]?[-\d\., ]+[\}\]]?)('
35                           r' ?(?P<UNITS>.+))?')
36HISTOGRAM_REGEX = re.compile(r'(?P<IMPORTANT>\*)?HISTOGRAM '
37                             r'(?P<GRAPH>[^:]*): (?P<TRACE>[^=]*)= '
38                             r'(?P<VALUE_JSON>{.*})(?P<UNITS>.+)?')
39
40
41class TelemetryResult(object):
42    """Class to represent the results of a telemetry run.
43
44    This class represents the results of a telemetry run, whether it ran
45    successful, failed or had warnings.
46    """
47
48
49    def __init__(self, exit_code=0, stdout='', stderr=''):
50        """Initializes this TelemetryResultObject instance.
51
52        @param status: Status of the telemtry run.
53        @param stdout: Stdout of the telemetry run.
54        @param stderr: Stderr of the telemetry run.
55        """
56        if exit_code == 0:
57            self.status = SUCCESS_STATUS
58        else:
59            self.status = FAILED_STATUS
60
61        # A list of perf values, e.g.
62        # [{'graph': 'graphA', 'trace': 'page_load_time',
63        #   'units': 'secs', 'value':0.5}, ...]
64        self.perf_data = []
65        self._stdout = stdout
66        self._stderr = stderr
67        self.output = '\n'.join([stdout, stderr])
68
69
70    def _cleanup_perf_string(self, str):
71        """Clean up a perf-related string by removing illegal characters.
72
73        Perf keys stored in the chromeOS database may contain only letters,
74        numbers, underscores, periods, and dashes.  Transform an inputted
75        string so that any illegal characters are replaced by underscores.
76
77        @param str: The perf string to clean up.
78
79        @return The cleaned-up perf string.
80        """
81        return re.sub(r'[^\w.-]', '_', str)
82
83
84    def _cleanup_units_string(self, units):
85        """Cleanup a units string.
86
87        Given a string representing units for a perf measurement, clean it up
88        by replacing certain illegal characters with meaningful alternatives.
89        Any other illegal characters should then be replaced with underscores.
90
91        Examples:
92            count/time -> count_per_time
93            % -> percent
94            units! --> units_
95            score (bigger is better) -> score__bigger_is_better_
96            score (runs/s) -> score__runs_per_s_
97
98        @param units: The units string to clean up.
99
100        @return The cleaned-up units string.
101        """
102        if '%' in units:
103            units = units.replace('%', 'percent')
104        if '/' in units:
105            units = units.replace('/','_per_')
106        return self._cleanup_perf_string(units)
107
108
109    def parse_benchmark_results(self):
110        """Parse the results of a telemetry benchmark run.
111
112        Stdout has the output in RESULT block format below.
113
114        The lines of interest start with the substring "RESULT".  These are
115        specially-formatted perf data lines that are interpreted by chrome
116        builbot (when the Telemetry tests run for chrome desktop) and are
117        parsed to extract perf data that can then be displayed on a perf
118        dashboard.  This format is documented in the docstring of class
119        GraphingLogProcessor in this file in the chrome tree:
120
121        chromium/tools/build/scripts/slave/process_log_utils.py
122
123        Example RESULT output lines:
124        RESULT average_commit_time_by_url: http___www.ebay.com= 8.86528 ms
125        RESULT CodeLoad: CodeLoad= 6343 score (bigger is better)
126        RESULT ai-astar: ai-astar= [614,527,523,471,530,523,577,625,614,538] ms
127
128        Currently for chromeOS, we can only associate a single perf key (string)
129        with a perf value.  That string can only contain letters, numbers,
130        dashes, periods, and underscores, as defined by write_keyval() in:
131
132        chromeos/src/third_party/autotest/files/client/common_lib/
133        base_utils.py
134
135        We therefore parse each RESULT line, clean up the strings to remove any
136        illegal characters not accepted by chromeOS, and construct a perf key
137        string based on the parsed components of the RESULT line (with each
138        component separated by a special delimiter).  We prefix the perf key
139        with the substring "TELEMETRY" to identify it as a telemetry-formatted
140        perf key.
141
142        Stderr has the format of Warnings/Tracebacks. There is always a default
143        warning of the display enviornment setting, followed by warnings of
144        page timeouts or a traceback.
145
146        If there are any other warnings we flag the test as warning. If there
147        is a traceback we consider this test a failure.
148        """
149        if not self._stdout:
150            # Nothing in stdout implies a test failure.
151            logging.error('No stdout, test failed.')
152            self.status = FAILED_STATUS
153            return
154
155        stdout_lines = self._stdout.splitlines()
156        for line in stdout_lines:
157            results_match = RESULTS_REGEX.search(line)
158            histogram_match = HISTOGRAM_REGEX.search(line)
159            if results_match:
160                self._process_results_line(results_match)
161            elif histogram_match:
162                self._process_histogram_line(histogram_match)
163
164        pp = pprint.PrettyPrinter(indent=2)
165        logging.debug('Perf values: %s', pp.pformat(self.perf_data))
166
167        if self.status is SUCCESS_STATUS:
168            return
169
170        # Otherwise check if simply a Warning occurred or a Failure,
171        # i.e. a Traceback is listed.
172        self.status = WARNING_STATUS
173        for line in self._stderr.splitlines():
174            if line.startswith('Traceback'):
175                self.status = FAILED_STATUS
176
177    def _process_results_line(self, line_match):
178        """Processes a line that matches the standard RESULT line format.
179
180        Args:
181          line_match: A MatchObject as returned by re.search.
182        """
183        match_dict = line_match.groupdict()
184        graph_name = self._cleanup_perf_string(match_dict['GRAPH'].strip())
185        trace_name = self._cleanup_perf_string(match_dict['TRACE'].strip())
186        units = self._cleanup_units_string(
187                (match_dict['UNITS'] or 'units').strip())
188        value = match_dict['VALUE'].strip()
189        unused_important = match_dict['IMPORTANT'] or False  # Unused now.
190
191        if value.startswith('['):
192            # A list of values, e.g., "[12,15,8,7,16]".  Extract just the
193            # numbers, compute the average and use that.  In this example,
194            # we'd get 12+15+8+7+16 / 5 --> 11.6.
195            value_list = [float(x) for x in value.strip('[],').split(',')]
196            value = float(sum(value_list)) / len(value_list)
197        elif value.startswith('{'):
198            # A single value along with a standard deviation, e.g.,
199            # "{34.2,2.15}".  Extract just the value itself and use that.
200            # In this example, we'd get 34.2.
201            value_list = [float(x) for x in value.strip('{},').split(',')]
202            value = value_list[0]  # Position 0 is the value.
203        elif re.search('^\d+$', value):
204            value = int(value)
205        else:
206            value = float(value)
207
208        self.perf_data.append({'graph':graph_name, 'trace': trace_name,
209                               'units': units, 'value': value})
210
211    def _process_histogram_line(self, line_match):
212        """Processes a line that matches the HISTOGRAM line format.
213
214        Args:
215          line_match: A MatchObject as returned by re.search.
216        """
217        match_dict = line_match.groupdict()
218        graph_name = self._cleanup_perf_string(match_dict['GRAPH'].strip())
219        trace_name = self._cleanup_perf_string(match_dict['TRACE'].strip())
220        units = self._cleanup_units_string(
221                (match_dict['UNITS'] or 'units').strip())
222        histogram_json = match_dict['VALUE_JSON'].strip()
223        unused_important = match_dict['IMPORTANT'] or False  # Unused now.
224        histogram_data = json.loads(histogram_json)
225
226        # Compute geometric mean
227        count = 0
228        sum_of_logs = 0
229        for bucket in histogram_data['buckets']:
230            mean = (bucket['low'] + bucket['high']) / 2.0
231            if mean > 0:
232                sum_of_logs += math.log(mean) * bucket['count']
233                count += bucket['count']
234
235        value = math.exp(sum_of_logs / count) if count > 0 else 0.0
236
237        self.perf_data.append({'graph':graph_name, 'trace': trace_name,
238                               'units': units, 'value': value})
239
240class TelemetryRunner(object):
241    """Class responsible for telemetry for a given build.
242
243    This class will extract and install telemetry on the devserver and is
244    responsible for executing the telemetry benchmarks and returning their
245    output to the caller.
246    """
247
248    def __init__(self, host, local=False):
249        """Initializes this telemetry runner instance.
250
251        If telemetry is not installed for this build, it will be.
252
253        @param host: Host where the test will be run.
254        @param local: If set, no devserver will be used, test will be run
255                      locally.
256        """
257        self._host = host
258        self._devserver = None
259        self._telemetry_path = None
260        # TODO (llozano crbug.com/324964). Remove conditional code.
261        # Use a class hierarchy instead.
262        if local:
263            self._setup_local_telemetry()
264        else:
265            self._setup_devserver_telemetry()
266
267        logging.debug('Telemetry Path: %s', self._telemetry_path)
268
269
270    def _setup_devserver_telemetry(self):
271        """Setup Telemetry to use the devserver."""
272        logging.debug('Setting up telemetry for devserver testing')
273        logging.debug('Grabbing build from AFE.')
274
275        build = afe_utils.get_build(self._host)
276        if not build:
277            logging.error('Unable to locate build label for host: %s.',
278                          self._host.hostname)
279            raise error.AutotestError('Failed to grab build for host %s.' %
280                                      self._host.hostname)
281
282        logging.debug('Setting up telemetry for build: %s', build)
283
284        self._devserver = dev_server.ImageServer.resolve(build)
285        self._devserver.stage_artifacts(build, ['autotest_packages'])
286        self._telemetry_path = self._devserver.setup_telemetry(build=build)
287
288
289    def _setup_local_telemetry(self):
290        """Setup Telemetry to use local path to its sources.
291
292        First look for chrome source root, either externally mounted, or inside
293        the chroot.  Prefer chrome-src-internal source tree to chrome-src.
294        """
295        TELEMETRY_DIR = 'src'
296        CHROME_LOCAL_SRC = '/var/cache/chromeos-cache/distfiles/target/'
297        CHROME_EXTERNAL_SRC = os.path.expanduser('~/chrome_root/')
298
299        logging.debug('Setting up telemetry for local testing')
300
301        sources_list = ('chrome-src-internal', 'chrome-src')
302        dir_list = [CHROME_EXTERNAL_SRC]
303        dir_list.extend(
304                [os.path.join(CHROME_LOCAL_SRC, x) for x in sources_list])
305        if 'CHROME_ROOT' in os.environ:
306            dir_list.insert(0, os.environ['CHROME_ROOT'])
307
308        telemetry_src = ''
309        for dir in dir_list:
310            if os.path.exists(dir):
311                telemetry_src = os.path.join(dir, TELEMETRY_DIR)
312                break
313        else:
314            raise error.TestError('Telemetry source directory not found.')
315
316        self._devserver = None
317        self._telemetry_path = telemetry_src
318
319
320    def _get_telemetry_cmd(self, script, test_or_benchmark, *args):
321        """Build command to execute telemetry based on script and benchmark.
322
323        @param script: Telemetry script we want to run. For example:
324                       [path_to_telemetry_src]/src/tools/telemetry/run_tests.
325        @param test_or_benchmark: Name of the test or benchmark we want to run,
326                                  with the page_set (if required) as part of
327                                  the string.
328        @param args: additional list of arguments to pass to the script.
329
330        @returns Full telemetry command to execute the script.
331        """
332        telemetry_cmd = []
333        if self._devserver:
334            devserver_hostname = self._devserver.url().split(
335                    'http://')[1].split(':')[0]
336            telemetry_cmd.extend(['ssh', devserver_hostname])
337
338        telemetry_cmd.extend(
339                ['python',
340                 script,
341                 '--verbose',
342                 '--browser=cros-chrome',
343                 '--remote=%s' % self._host.hostname])
344        telemetry_cmd.extend(args)
345        telemetry_cmd.append(test_or_benchmark)
346
347        return telemetry_cmd
348
349
350    def _run_telemetry(self, script, test_or_benchmark, *args):
351        """Runs telemetry on a dut.
352
353        @param script: Telemetry script we want to run. For example:
354                       [path_to_telemetry_src]/src/tools/telemetry/run_tests.
355        @param test_or_benchmark: Name of the test or benchmark we want to run,
356                                 with the page_set (if required) as part of the
357                                 string.
358        @param args: additional list of arguments to pass to the script.
359
360        @returns A TelemetryResult Instance with the results of this telemetry
361                 execution.
362        """
363        # TODO (sbasi crbug.com/239933) add support for incognito mode.
364
365        telemetry_cmd = self._get_telemetry_cmd(script,
366                                                test_or_benchmark,
367                                                *args)
368        logging.debug('Running Telemetry: %s', ' '.join(telemetry_cmd))
369
370        output = StringIO.StringIO()
371        error_output = StringIO.StringIO()
372        exit_code = 0
373        try:
374            result = utils.run(' '.join(telemetry_cmd), stdout_tee=output,
375                               stderr_tee=error_output,
376                               timeout=TELEMETRY_TIMEOUT_MINS*60)
377            exit_code = result.exit_status
378        except error.CmdError as e:
379            # Telemetry returned a return code of not 0; for benchmarks this
380            # can be due to a timeout on one of the pages of the page set and
381            # we may still have data on the rest. For a test however this
382            # indicates failure.
383            logging.debug('Error occurred executing telemetry.')
384            exit_code = e.result_obj.exit_status
385
386        stdout = output.getvalue()
387        stderr = error_output.getvalue()
388        logging.debug('Telemetry completed with exit code: %d.\nstdout:%s\n'
389                      'stderr:%s', exit_code, stdout, stderr)
390
391        return TelemetryResult(exit_code=exit_code, stdout=stdout,
392                               stderr=stderr)
393
394
395    def _run_test(self, script, test, *args):
396        """Runs a telemetry test on a dut.
397
398        @param script: Which telemetry test script we want to run. Can be
399                       telemetry's base test script or the Chrome OS specific
400                       test script.
401        @param test: Telemetry test we want to run.
402        @param args: additional list of arguments to pass to the script.
403
404        @returns A TelemetryResult Instance with the results of this telemetry
405                 execution.
406        """
407        logging.debug('Running telemetry test: %s', test)
408        telemetry_script = os.path.join(self._telemetry_path, script)
409        result = self._run_telemetry(telemetry_script, test, *args)
410        if result.status is FAILED_STATUS:
411            raise error.TestFail('Telemetry test %s failed.' % test)
412        return result
413
414
415    def run_telemetry_test(self, test, *args):
416        """Runs a telemetry test on a dut.
417
418        @param test: Telemetry test we want to run.
419        @param args: additional list of arguments to pass to the telemetry
420                     execution script.
421
422        @returns A TelemetryResult Instance with the results of this telemetry
423                 execution.
424        """
425        return self._run_test(TELEMETRY_RUN_TESTS_SCRIPT, test, *args)
426
427
428    def run_cros_telemetry_test(self, test, *args):
429        """Runs a cros specific telemetry test on a dut.
430
431        @param test: Telemetry test we want to run.
432        @param args: additional list of arguments to pass to the telemetry
433                     execution script.
434
435        @returns A TelemetryResult instance with the results of this telemetry
436                 execution.
437        """
438        return self._run_test(TELEMETRY_RUN_CROS_TESTS_SCRIPT, test, *args)
439
440
441    def run_gpu_test(self, test, *args):
442        """Runs a gpu test on a dut.
443
444        @param test: Gpu test we want to run.
445        @param args: additional list of arguments to pass to the telemetry
446                     execution script.
447
448        @returns A TelemetryResult instance with the results of this telemetry
449                 execution.
450        """
451        return self._run_test(TELEMETRY_RUN_GPU_TESTS_SCRIPT, test, *args)
452
453
454    @staticmethod
455    def _output_perf_value(perf_value_writer, perf_data):
456        """Output perf values to result dir.
457
458        The perf values will be output to the result dir and
459        be subsequently uploaded to perf dashboard.
460
461        @param perf_value_writer: Should be an instance with the function
462                                  output_perf_value(), if None, no perf value
463                                  will be written. Typically this will be the
464                                  job object from an autotest test.
465        @param perf_data: A list of perf values, each value is
466                          a dictionary that looks like
467                          {'graph':'GraphA', 'trace':'metric1',
468                           'units':'secs', 'value':0.5}
469        """
470        for perf_value in perf_data:
471            perf_value_writer.output_perf_value(
472                    description=perf_value['trace'],
473                    value=perf_value['value'],
474                    units=perf_value['units'],
475                    graph=perf_value['graph'])
476
477
478    def run_telemetry_benchmark(self, benchmark, perf_value_writer=None,
479                                *args):
480        """Runs a telemetry benchmark on a dut.
481
482        @param benchmark: Benchmark we want to run.
483        @param perf_value_writer: Should be an instance with the function
484                                  output_perf_value(), if None, no perf value
485                                  will be written. Typically this will be the
486                                  job object from an autotest test.
487        @param args: additional list of arguments to pass to the telemetry
488                     execution script.
489
490        @returns A TelemetryResult Instance with the results of this telemetry
491                 execution.
492        """
493        logging.debug('Running telemetry benchmark: %s', benchmark)
494        telemetry_script = os.path.join(self._telemetry_path,
495                                        TELEMETRY_RUN_BENCHMARKS_SCRIPT)
496        result = self._run_telemetry(telemetry_script, benchmark, *args)
497        result.parse_benchmark_results()
498
499        if perf_value_writer:
500            self._output_perf_value(perf_value_writer, result.perf_data)
501
502        if result.status is WARNING_STATUS:
503            raise error.TestWarn('Telemetry Benchmark: %s'
504                                 ' exited with Warnings.' % benchmark)
505        if result.status is FAILED_STATUS:
506            raise error.TestFail('Telemetry Benchmark: %s'
507                                 ' failed to run.' % benchmark)
508
509        return result
510