• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2015 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
5# pylint: disable=module-missing-docstring
6# pylint: disable=docstring-section-name
7
8import csv
9import glob
10import httplib
11import json
12import logging
13import os
14import re
15import shutil
16import time
17import urllib
18import urllib2
19
20from autotest_lib.client.bin import test
21from autotest_lib.client.bin import utils
22from autotest_lib.client.common_lib import error
23from autotest_lib.client.cros import constants
24
25# TODO(scunningham): Return to 72000 (20 hrs) after server-side stabilizes.
26TEST_DURATION = 10800  # Duration of test (3 hrs) in seconds.
27SAMPLE_INTERVAL = 60  # Length of measurement samples in seconds.
28METRIC_INTERVAL = 3600  # Length between metric calculation in seconds.
29STABILIZATION_DURATION = 60  # Time for test stabilization in seconds.
30TMP_DIRECTORY = '/tmp/'
31EXIT_FLAG_FILE = TMP_DIRECTORY + 'longevity_terminate'
32PERF_FILE_NAME_PREFIX = 'perf'
33OLD_FILE_AGE = 14400  # Age of old files to be deleted in minutes = 10 days.
34# The manifest.json file for a Chrome Extension contains the app name, id,
35# version, and other app info. It is accessible by the OS only when the app
36# is running, and thus it's cryptohome directory mounted. Only one Kiosk app
37# can be running at a time.
38MANIFEST_PATTERN = '/home/.shadow/*/mount/user/Extensions/%s/*/manifest.json'
39VERSION_PATTERN = r'^(\d+)\.(\d+)\.(\d+)\.(\d+)$'
40DASHBOARD_UPLOAD_URL = 'https://chromeperf.appspot.com/add_point'
41
42
43class PerfUploadingError(Exception):
44    """Exception raised in perf_uploader."""
45    pass
46
47
48class longevity_Tracker(test.test):
49    """Monitor device and App stability over long periods of time."""
50
51    version = 1
52
53    def initialize(self):
54        self.temp_dir = os.path.split(self.tmpdir)[0]
55
56    def _get_cpu_usage(self):
57        """Compute percent CPU in active use over the sample interval.
58
59        Note: This method introduces a sleep period into the test, equal to
60        90% of the sample interval.
61
62        @returns float of percent active use of CPU.
63        """
64        # Time between measurements is ~90% of the sample interval.
65        measurement_time_delta = SAMPLE_INTERVAL * 0.90
66        cpu_usage_start = utils.get_cpu_usage()
67        time.sleep(measurement_time_delta)
68        cpu_usage_end = utils.get_cpu_usage()
69        return utils.compute_active_cpu_time(cpu_usage_start,
70                                                  cpu_usage_end) * 100
71
72    def _get_mem_usage(self):
73        """Compute percent memory in active use.
74
75        @returns float of percent memory in use.
76        """
77        total_memory = utils.get_mem_total()
78        free_memory = utils.get_mem_free()
79        return ((total_memory - free_memory) / total_memory) * 100
80
81    def _get_max_temperature(self):
82        """Get temperature of hottest sensor in Celsius.
83
84        @returns float of temperature of hottest sensor.
85        """
86        temperature = utils.get_current_temperature_max()
87        if not temperature:
88            temperature = 0
89        return temperature
90
91    def _get_hwid(self):
92        """Get hwid of test device, e.g., 'WOLF C4A-B2B-A47'.
93
94        @returns string of hwid (Hardware ID) of device under test.
95        """
96        with os.popen('crossystem hwid 2>/dev/null', 'r') as hwid_proc:
97            hwid = hwid_proc.read()
98        if not hwid:
99            hwid = 'undefined'
100        return hwid
101
102    def elapsed_time(self, mark_time):
103        """Get time elapsed since |mark_time|.
104
105        @param mark_time: point in time from which elapsed time is measured.
106        @returns time elapsed since the marked time.
107        """
108        return time.time() - mark_time
109
110    def modulo_time(self, timer, interval):
111        """Get time eplased on |timer| for the |interval| modulus.
112
113        Value returned is used to adjust the timer so that it is synchronized
114        with the current interval.
115
116        @param timer: time on timer, in seconds.
117        @param interval: period of time in seconds.
118        @returns time elapsed from the start of the current interval.
119        """
120        return timer % int(interval)
121
122    def syncup_time(self, timer, interval):
123        """Get time remaining on |timer| for the |interval| modulus.
124
125        Value returned is used to induce sleep just long enough to put the
126        process back in sync with the timer.
127
128        @param timer: time on timer, in seconds.
129        @param interval: period of time in seconds.
130        @returns time remaining till the end of the current interval.
131        """
132        return interval - (timer % int(interval))
133
134    def _record_perf_measurements(self, perf_values, perf_writer):
135        """Record attribute performance measurements, and write to file.
136
137        @param perf_values: dict of attribute performance values.
138        @param perf_writer: file to write performance measurements.
139        """
140        # Get performance measurements.
141        cpu_usage = '%.3f' % self._get_cpu_usage()
142        mem_usage = '%.3f' % self._get_mem_usage()
143        max_temp = '%.3f' % self._get_max_temperature()
144
145        # Append measurements to attribute lists in perf values dictionary.
146        perf_values['cpu'].append(cpu_usage)
147        perf_values['mem'].append(mem_usage)
148        perf_values['temp'].append(max_temp)
149
150        # Write performance measurements to perf timestamped file.
151        time_stamp = time.strftime('%Y/%m/%d %H:%M:%S')
152        perf_writer.writerow([time_stamp, cpu_usage, mem_usage, max_temp])
153        logging.info('Time: %s, CPU: %s, Mem: %s, Temp: %s',
154                     time_stamp, cpu_usage, mem_usage, max_temp)
155
156    def _record_90th_metrics(self, perf_values, perf_metrics):
157        """Record 90th percentile metric of attribute performance values.
158
159        @param perf_values: dict attribute performance values.
160        @param perf_metrics: dict attribute 90%-ile performance metrics.
161        """
162        # Calculate 90th percentile for each attribute.
163        cpu_values = perf_values['cpu']
164        mem_values = perf_values['mem']
165        temp_values = perf_values['temp']
166        cpu_metric = sorted(cpu_values)[(len(cpu_values) * 9) // 10]
167        mem_metric = sorted(mem_values)[(len(mem_values) * 9) // 10]
168        temp_metric = sorted(temp_values)[(len(temp_values) * 9) // 10]
169        logging.info('== Performance values: %s', perf_values)
170        logging.info('== 90th percentile: cpu: %s, mem: %s, temp: %s',
171                     cpu_metric, mem_metric, temp_metric)
172
173        # Append 90th percentile to each attribute performance metric.
174        perf_metrics['cpu'].append(cpu_metric)
175        perf_metrics['mem'].append(mem_metric)
176        perf_metrics['temp'].append(temp_metric)
177
178    def _get_median_metrics(self, metrics):
179        """Returns median of each attribute performance metric.
180
181        If no metric values were recorded, return 0 for each metric.
182
183        @param metrics: dict of attribute performance metric lists.
184        @returns dict of attribute performance metric medians.
185        """
186        if len(metrics['cpu']):
187            cpu_metric = sorted(metrics['cpu'])[len(metrics['cpu']) // 2]
188            mem_metric = sorted(metrics['mem'])[len(metrics['mem']) // 2]
189            temp_metric = sorted(metrics['temp'])[len(metrics['temp']) // 2]
190        else:
191            cpu_metric = 0
192            mem_metric = 0
193            temp_metric = 0
194        logging.info('== Median: cpu: %s, mem: %s, temp: %s',
195                     cpu_metric, mem_metric, temp_metric)
196        return {'cpu': cpu_metric, 'mem': mem_metric, 'temp': temp_metric}
197
198    def _append_to_aggregated_file(self, ts_file, ag_file):
199        """Append contents of perf timestamp file to perf aggregated file.
200
201        @param ts_file: file handle for performance timestamped file.
202        @param ag_file: file handle for performance aggregated file.
203        """
204        next(ts_file)  # Skip fist line (the header) of timestamped file.
205        for line in ts_file:
206            ag_file.write(line)
207
208    def _copy_aggregated_to_resultsdir(self, aggregated_fpath):
209        """Copy perf aggregated file to results dir for AutoTest results.
210
211        Note: The AutoTest results default directory is located at /usr/local/
212        autotest/results/default/longevity_Tracker/results
213
214        @param aggregated_fpath: file path to Aggregated performance values.
215        """
216        results_fpath = os.path.join(self.resultsdir, 'perf.csv')
217        shutil.copy(aggregated_fpath, results_fpath)
218        logging.info('Copied %s to %s)', aggregated_fpath, results_fpath)
219
220    def _write_perf_keyvals(self, perf_results):
221        """Write perf results to keyval file for AutoTest results.
222
223        @param perf_results: dict of attribute performance metrics.
224        """
225        perf_keyval = {}
226        perf_keyval['cpu_usage'] = perf_results['cpu']
227        perf_keyval['memory_usage'] = perf_results['mem']
228        perf_keyval['temperature'] = perf_results['temp']
229        self.write_perf_keyval(perf_keyval)
230
231    def _write_perf_results(self, perf_results):
232        """Write perf results to results-chart.json file for Perf Dashboard.
233
234        @param perf_results: dict of attribute performance metrics.
235        """
236        cpu_metric = perf_results['cpu']
237        mem_metric = perf_results['mem']
238        ec_metric = perf_results['temp']
239        self.output_perf_value(description='cpu_usage', value=cpu_metric,
240                               units='%', higher_is_better=False)
241        self.output_perf_value(description='mem_usage', value=mem_metric,
242                               units='%', higher_is_better=False)
243        self.output_perf_value(description='max_temp', value=ec_metric,
244                               units='Celsius', higher_is_better=False)
245
246    def _read_perf_results(self):
247        """Read perf results from results-chart.json file for Perf Dashboard.
248
249        @returns dict of perf results, formatted as JSON chart data.
250        """
251        results_file = os.path.join(self.resultsdir, 'results-chart.json')
252        with open(results_file, 'r') as fp:
253            contents = fp.read()
254            chart_data = json.loads(contents)
255        return chart_data
256
257    def _get_point_id(self, cros_version, epoch_minutes):
258        """Compute point ID from ChromeOS version number and epoch minutes.
259
260        @param cros_version: String of ChromeOS version number.
261        @param epoch_minutes: String of minutes since 1970.
262
263        @return unique integer ID computed from given version and epoch.
264        """
265        # Number of digits from each part of the Chrome OS version string.
266        cros_version_col_widths = [0, 4, 3, 2]
267
268        def get_digits(version_num, column_widths):
269            if re.match(VERSION_PATTERN, version_num):
270                computed_string = ''
271                version_parts = version_num.split('.')
272                for i, version_part in enumerate(version_parts):
273                    if column_widths[i]:
274                        computed_string += version_part.zfill(column_widths[i])
275                return computed_string
276            else:
277                return None
278
279        cros_digits = get_digits(cros_version, cros_version_col_widths)
280        epoch_digits = epoch_minutes[-8:]
281        if not cros_digits:
282            return None
283        return int(epoch_digits + cros_digits)
284
285    def _get_kiosk_app_info(self, app_id):
286        """Get kiosk app name and version from manifest.json file.
287
288        Get the Kiosk App name and version strings from the manifest file of
289        the specified |app_id| Extension in the currently running session. If
290        |app_id| is empty or None, then return 'none' for the kiosk app info.
291
292        Raise an error if no manifest is found (ie, |app_id| is not running),
293        or if multiple manifest files are found (ie, |app_id| is running, but
294        the |app_id| dir contains multiple versions or manifest files).
295
296        @param app_id: string kiosk application identification.
297        @returns dict of Kiosk name and version number strings.
298        @raises: An error.TestError if single manifest is not found.
299        """
300        kiosk_app_info = {'name': 'none', 'version': 'none'}
301        if not app_id:
302            return kiosk_app_info
303
304        # Get path to manifest file of the running Kiosk app_id.
305        app_manifest_pattern = (MANIFEST_PATTERN % app_id)
306        logging.info('app_manifest_pattern: %s', app_manifest_pattern)
307        file_paths = glob.glob(app_manifest_pattern)
308        # Raise error if current session has no Kiosk Apps running.
309        if len(file_paths) == 0:
310            raise error.TestError('Kiosk App ID=%s is not running.' % app_id)
311        # Raise error if running Kiosk App has multiple manifest files.
312        if len(file_paths) > 1:
313            raise error.TestError('Kiosk App ID=%s has multiple manifest '
314                                  'files.' % app_id)
315        kiosk_manifest = open(file_paths[0], 'r').read()
316        manifest_json = json.loads(kiosk_manifest)
317        # If manifest is missing name or version key, set to 'undefined'.
318        kiosk_app_info['name'] = manifest_json.get('name', 'undefined')
319        kiosk_app_info['version'] = manifest_json.get('version', 'undefined')
320        return kiosk_app_info
321
322    def _format_data_for_upload(self, chart_data):
323        """Collect chart data into an uploadable data JSON object.
324
325        @param chart_data: performance results formatted as chart data.
326        """
327        perf_values = {
328            'format_version': '1.0',
329            'benchmark_name': self.test_suite_name,
330            'charts': chart_data,
331        }
332
333        dash_entry = {
334            'master': 'ChromeOS_Enterprise',
335            'bot': 'cros-%s' % self.board_name,
336            'point_id': self.point_id,
337            'versions': {
338                'cros_version': self.chromeos_version,
339                'chrome_version': self.chrome_version,
340            },
341            'supplemental': {
342                'default_rev': 'r_cros_version',
343                'hardware_identifier': 'a_' + self.hw_id,
344                'kiosk_app_name': 'a_' + self.kiosk_app_name,
345                'kiosk_app_version': 'r_' + self.kiosk_app_version
346            },
347            'chart_data': perf_values
348        }
349        return {'data': json.dumps(dash_entry)}
350
351    def _send_to_dashboard(self, data_obj):
352        """Send formatted perf data to the perf dashboard.
353
354        @param data_obj: data object as returned by _format_data_for_upload().
355
356        @raises PerfUploadingError if an exception was raised when uploading.
357        """
358        logging.debug('data_obj: %s', data_obj)
359        encoded = urllib.urlencode(data_obj)
360        req = urllib2.Request(DASHBOARD_UPLOAD_URL, encoded)
361        try:
362            urllib2.urlopen(req)
363        except urllib2.HTTPError as e:
364            raise PerfUploadingError('HTTPError: %d %s for JSON %s\n' %
365                                     (e.code, e.msg, data_obj['data']))
366        except urllib2.URLError as e:
367            raise PerfUploadingError('URLError: %s for JSON %s\n' %
368                                     (str(e.reason), data_obj['data']))
369        except httplib.HTTPException:
370            raise PerfUploadingError('HTTPException for JSON %s\n' %
371                                     data_obj['data'])
372
373    def _get_chrome_version(self):
374        """Get the Chrome version number and milestone as strings.
375
376        Invoke "chrome --version" to get the version number and milestone.
377
378        @return A tuple (chrome_ver, milestone) where "chrome_ver" is the
379            current Chrome version number as a string (in the form "W.X.Y.Z")
380            and "milestone" is the first component of the version number
381            (the "W" from "W.X.Y.Z").  If the version number cannot be parsed
382            in the "W.X.Y.Z" format, the "chrome_ver" will be the full output
383            of "chrome --version" and the milestone will be the empty string.
384        """
385        chrome_version = utils.system_output(constants.CHROME_VERSION_COMMAND,
386                                             ignore_status=True)
387        chrome_version = utils.parse_chrome_version(chrome_version)
388        return chrome_version
389
390    def _open_perf_file(self, file_path):
391        """Open a perf file. Write header line if new. Return file object.
392
393        If the file on |file_path| already exists, then open file for
394        appending only. Otherwise open for writing only.
395
396        @param file_path: file path for perf file.
397        @returns file object for the perf file.
398        """
399        # If file exists, open it for appending. Do not write header.
400        if os.path.isfile(file_path):
401            perf_file = open(file_path, 'a+')
402        # Otherwise, create it for writing. Write header on first line.
403        else:
404            perf_file = open(file_path, 'w')  # Erase if existing file.
405            perf_file.write('Time,CPU,Memory,Temperature (C)\r\n')
406        return perf_file
407
408    def _run_test_cycle(self):
409        """Track performance of Chrome OS over a long period of time.
410
411        This method collects performance measurements, and calculates metrics
412        to upload to the performance dashboard. It creates two files to
413        collect and store performance values and results: perf_<timestamp>.csv
414        and perf_aggregated.csv.
415
416        At the start, it creates a unique perf timestamped file in the test's
417        temp_dir. As the cycle runs, it saves a time-stamped performance
418        value after each sample interval. Periodically, it calculates
419        the 90th percentile performance metrics from these values.
420
421        The perf_<timestamp> files on the device will survive multiple runs
422        of the longevity_Tracker by the server-side test, and will also
423        survive multiple runs of the server-side test. The script will
424        delete them after 10 days, to prevent filling up the SSD.
425
426        At the end, it opens the perf aggregated file in the test's temp_dir,
427        and appends the contents of the perf timestamped file. It then
428        copies the perf aggregated file to the results directory as perf.csv.
429        This perf.csv file will be consumed by the AutoTest backend when the
430        server-side test ends.
431
432        Note that the perf_aggregated.csv file will grow larger with each run
433        of longevity_Tracker on the device by the server-side test. However,
434        the server-side test will delete file in the end.
435
436        This method also calculates 90th percentile and median metrics, and
437        returns the median metrics. Median metrics will be pushed to the perf
438        dashboard with a unique point_id.
439
440        @returns list of median performance metrics.
441        """
442        # Allow system to stabilize before start taking measurements.
443        test_start_time = time.time()
444        time.sleep(STABILIZATION_DURATION)
445
446        perf_values = {'cpu': [], 'mem': [], 'temp': []}
447        perf_metrics = {'cpu': [], 'mem': [], 'temp': []}
448
449        # Create perf_<timestamp> file and writer.
450        timestamp_fname = (PERF_FILE_NAME_PREFIX +
451                           time.strftime('_%Y-%m-%d_%H-%M') + '.csv')
452        timestamp_fpath = os.path.join(self.temp_dir, timestamp_fname)
453        timestamp_file = self._open_perf_file(timestamp_fpath)
454        timestamp_writer = csv.writer(timestamp_file)
455
456        # Align time of loop start with the sample interval.
457        test_elapsed_time = self.elapsed_time(test_start_time)
458        time.sleep(self.syncup_time(test_elapsed_time, SAMPLE_INTERVAL))
459        test_elapsed_time = self.elapsed_time(test_start_time)
460
461        metric_start_time = time.time()
462        metric_prev_time = metric_start_time
463
464        metric_elapsed_prev_time = self.elapsed_time(metric_prev_time)
465        offset = self.modulo_time(metric_elapsed_prev_time, METRIC_INTERVAL)
466        metric_timer = metric_elapsed_prev_time + offset
467        while self.elapsed_time(test_start_time) <= TEST_DURATION:
468            if os.path.isfile(EXIT_FLAG_FILE):
469                logging.info('Exit flag file detected. Exiting test.')
470                break
471            self._record_perf_measurements(perf_values, timestamp_writer)
472
473            # Periodically calculate and record 90th percentile metrics.
474            metric_elapsed_prev_time = self.elapsed_time(metric_prev_time)
475            metric_timer = metric_elapsed_prev_time + offset
476            if metric_timer >= METRIC_INTERVAL:
477                self._record_90th_metrics(perf_values, perf_metrics)
478                perf_values = {'cpu': [], 'mem': [], 'temp': []}
479
480                # Set previous time to current time.
481                metric_prev_time = time.time()
482                metric_elapsed_prev_time = self.elapsed_time(metric_prev_time)
483
484                # Calculate offset based on the original start time.
485                metric_elapsed_time = self.elapsed_time(metric_start_time)
486                offset = self.modulo_time(metric_elapsed_time, METRIC_INTERVAL)
487
488                # Set the timer to time elapsed plus offset to next interval.
489                metric_timer = metric_elapsed_prev_time + offset
490
491            # Sync the loop time to the sample interval.
492            test_elapsed_time = self.elapsed_time(test_start_time)
493            time.sleep(self.syncup_time(test_elapsed_time, SAMPLE_INTERVAL))
494
495        # Close perf timestamp file.
496        timestamp_file.close()
497
498        # Open perf timestamp file to read, and aggregated file to append.
499        timestamp_file = open(timestamp_fpath, 'r')
500        aggregated_fname = (PERF_FILE_NAME_PREFIX + '_aggregated.csv')
501        aggregated_fpath = os.path.join(self.temp_dir, aggregated_fname)
502        aggregated_file = self._open_perf_file(aggregated_fpath)
503
504        # Append contents of perf timestamp file to perf aggregated file.
505        self._append_to_aggregated_file(timestamp_file, aggregated_file)
506        timestamp_file.close()
507        aggregated_file.close()
508
509        # Copy perf aggregated file to test results directory.
510        self._copy_aggregated_to_resultsdir(aggregated_fpath)
511
512        # Return median of each attribute performance metric.
513        return self._get_median_metrics(perf_metrics)
514
515    def run_once(self, kiosk_app_attributes=None):
516        if kiosk_app_attributes:
517            app_name, app_id, ext_page = (
518                kiosk_app_attributes.rstrip().split(':'))
519        self.subtest_name = app_name
520        self.board_name = utils.get_board()
521        self.hw_id = self._get_hwid()
522        self.chrome_version = self._get_chrome_version()[0]
523        self.chromeos_version = '0.' + utils.get_chromeos_release_version()
524        self.epoch_minutes = str(int(time.time() / 60))  # Minutes since 1970.
525        self.point_id = self._get_point_id(self.chromeos_version,
526                                           self.epoch_minutes)
527
528        kiosk_info = self._get_kiosk_app_info(app_id)
529        self.kiosk_app_name = kiosk_info['name']
530        self.kiosk_app_version = kiosk_info['version']
531        self.test_suite_name = self.tagged_testname
532        if self.subtest_name:
533            self.test_suite_name += '.' + self.subtest_name
534
535        # Delete exit flag file at start of test run.
536        if os.path.isfile(EXIT_FLAG_FILE):
537            os.remove(EXIT_FLAG_FILE)
538
539        # Run a single test cycle.
540        self.perf_results = {'cpu': '0', 'mem': '0', 'temp': '0'}
541        self.perf_results = self._run_test_cycle()
542
543        # Write results for AutoTest to pick up at end of test.
544        self._write_perf_keyvals(self.perf_results)
545        self._write_perf_results(self.perf_results)
546
547        # Post perf results directly to performance dashboard. You may view
548        # uploaded data at https://chromeperf.appspot.com/new_points,
549        # with test path pattern=ChromeOS_Enterprise/cros-*/longevity*/*
550        chart_data = self._read_perf_results()
551        data_obj = self._format_data_for_upload(chart_data)
552        self._send_to_dashboard(data_obj)
553
554    def cleanup(self):
555        """Delete aged perf data files and the exit flag file."""
556        cmd = ('find %s -name %s* -type f -mmin +%s -delete' %
557               (self.temp_dir, PERF_FILE_NAME_PREFIX, OLD_FILE_AGE))
558        os.system(cmd)
559        if os.path.isfile(EXIT_FLAG_FILE):
560            os.remove(EXIT_FLAG_FILE)
561