summaryrefslogtreecommitdiff
path: root/buildscripts/mobile/adb_monitor.py
blob: 26966d4a21851f3980cf5fe60562b3c57c7a9ced (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
#!/usr/bin/env python
"""ADB utilities to collect adb samples from a locally connected Android device."""

import distutils.spawn  # pylint: disable=no-name-in-module
import logging
import optparse
import os
import pipes
import re
import shlex
import sys
import tempfile
import threading
import time
import warnings

# pylint: disable=wrong-import-position
# Get relative imports to work when the package is not installed on the PYTHONPATH.
if __name__ == "__main__" and __package__ is None:
    sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))

from buildscripts.util import fileops
from buildscripts.util import runcommand

# Initialize the global logger.
logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s", level=logging.INFO)
logging.Formatter.converter = time.gmtime
LOGGER = logging.getLogger(__name__)


class Adb(object):
    """Class to abstract calls to adb."""

    def __init__(self, adb_binary="adb", logger=LOGGER):
        """Initialize the Adb object."""
        self._cmd = None
        self.logger = logger
        self.adb_path = distutils.spawn.find_executable(adb_binary)
        if not self.adb_path:
            raise EnvironmentError(
                "Executable '{}' does not exist or is not in the PATH.".format(adb_binary))

        # We support specifying a path the adb binary to use; however, systrace.py only
        # knows how to find it using the PATH environment variable. It is possible that
        # 'adb_binary' is an absolute path specified by the user, so we add its parent
        # directory to the PATH manually.
        adb_dir = os.path.dirname(self.adb_path)
        os.environ["PATH"] = "{}{}{}".format(os.environ["PATH"], os.path.pathsep, adb_dir)

        # systrace.py should be in <adb_dir>/systrace/systrace.py
        self.systrace_script = os.path.join(adb_dir, "systrace", "systrace.py")
        if not os.path.isfile(self.systrace_script):
            raise EnvironmentError("Script '{}' cannot be found.".format(self.systrace_script))
        self._tempfile = None

    @staticmethod
    def adb_cmd(adb_command, output_file=None, append_file=False, output_string=False):
        """Run an adb command and return result."""
        cmd = runcommand.RunCommand("adb {}".format(adb_command), output_file, append_file)
        if output_string or not output_file:
            return cmd.execute_with_output()
        return cmd.execute_save_output()

    @staticmethod
    def shell(adb_shell_command):
        """Run an adb shell command and return output_string.

        Raise an exception if the exit status is non-zero.

        Since the adb shell command does not return an exit status. We simulate it by
        saving the exit code in the output and then stripping if off.

        See https://stackoverflow.com/questions/9379400/adb-error-codes
        """
        cmd_prefix = "set -o errexit; function _exit_ { echo __EXIT__:$?; } ; trap _exit_ EXIT ;"
        cmd = runcommand.RunCommand("adb shell {} {}".format(cmd_prefix, adb_shell_command))
        cmd_output = cmd.execute_with_output()
        if "__EXIT__" in cmd_output:
            exit_code = int(cmd_output.split()[-1].split(":")[1])
            cmd_output_stripped = re.split("__EXIT__.*\n", cmd_output)[0]
            if exit_code:
                raise RuntimeError("{}: {}".format(exit_code, cmd_output_stripped))
            return cmd_output_stripped
        return cmd_output

    def devices(self):
        """Return the available ADB devices and the uptime."""
        return self.adb_cmd("devices -l", output_string=True)

    def device_available(self):
        """Return the the uptime of the connected device."""
        # If the device is not available this will throw an exception.
        return self.adb_cmd("shell uptime", output_string=True)

    def push(self, files, remote_dir, sync=False):
        """Push a list of files over adb to remote_dir."""
        # We can specify files as a single file name or a list of files.
        if isinstance(files, list):
            files = " ".join(files)
        sync_opt = "--sync " if sync else ""
        return self.adb_cmd("push {}{} {}".format(sync_opt, files, remote_dir), output_string=True)

    def pull(self, files, local_dir):
        """Pull a list of remote files over adb to local_dir."""
        # We can specify files as a single file name or a list of files.
        if isinstance(files, list):
            files = " ".join(files)
        return self.adb_cmd("pull {} {}".format(files, local_dir), output_string=True)

    def _battery_cmd(self, option, output_file=None, append_file=False):
        self.adb_cmd("shell dumpsys batterystats {}".format(option), output_file, append_file)

    def battery(self, reset=False, output_file=None, append_file=False):
        """Collect the battery stats and save to the output_file."""
        if reset:
            self._battery_cmd("--reset")
        self._battery_cmd("--checkin", output_file, append_file)

    def memory(self, output_file=None, append_file=False):
        """Collect the memory stats and save to the output_file."""
        self.adb_cmd("shell dumpsys meminfo -c -d", output_file, append_file)

    def systrace_start(self, output_file=None):
        """Start the systrace.py script to collect CPU usage."""
        self._tempfile = tempfile.NamedTemporaryFile(delete=False).name
        self._cmd = runcommand.RunCommand(output_file=self._tempfile, propagate_signals=False)
        self._cmd.add_file(sys.executable)
        self._cmd.add_file(self.systrace_script)
        self._cmd.add("--json")
        self._cmd.add("-o")
        self._cmd.add_file(output_file)
        self._cmd.add("dalvik sched freq idle load")
        self._cmd.start_process()

    def systrace_stop(self, output_file=None):
        """Stop the systrace.py script."""
        self._cmd.send_to_process("bye")
        with open(self._tempfile) as fh:
            buff = fh.read()
        os.remove(self._tempfile)
        self.logger.debug("systrace_stop: %s", buff)
        if "Wrote trace" not in buff:
            self.logger.error("CPU file not saved: %s", buff)
            if os.path.isfile(output_file):
                os.remove(output_file)


class AdbControl(object):  # pylint: disable=too-many-instance-attributes
    """Class to controls calls to adb."""

    _JOIN_TIMEOUT = 24 * 60 * 60  # 24 hours (a long time to have the monitor run for)

    def __init__(  # pylint: disable=too-many-arguments
            self, adb, logger=LOGGER, battery_file=None, memory_file=None, cpu_file=None,
            append_file=False, num_samples=0, collection_time_secs=0, sample_interval_ms=0):
        """Initialize AdbControl object."""

        self.adb = adb

        self.logger = logger

        output_files = [battery_file, memory_file, cpu_file]
        if not any(output_files):
            raise ValueError("There are no collection sample files selected.")
        self.battery_file = battery_file
        self.memory_file = memory_file
        self.cpu_file = cpu_file

        # The AdbResourceMonitor will always append results to the specified file.
        # If append_file is specified in this init, then if there's an existing file
        # we do not overwrite it.
        for output_file in output_files:
            if not append_file:
                fileops.create_empty(output_file)

        self.append_file = append_file
        # collection_time_secs overrides num_samples
        self.num_samples = num_samples if collection_time_secs == 0 else 0
        self.collection_time_secs = collection_time_secs
        self.sample_interval_ms = sample_interval_ms

        self.should_stop = threading.Event()
        self.should_stop.clear()
        self.sample_based_threads = []
        self.all_threads = []

    def start(self):
        """Start adb sample collection."""
        if self.cpu_file:
            monitor = AdbContinuousResourceMonitor(self.cpu_file, self.should_stop,
                                                   self.adb.systrace_start, self.adb.systrace_stop)
            self.all_threads.append(monitor)
            monitor.start()

        if self.battery_file:
            monitor = AdbSampleBasedResourceMonitor(self.battery_file, self.should_stop,
                                                    self.adb.battery, self.num_samples,
                                                    self.sample_interval_ms)
            self.sample_based_threads.append(monitor)
            self.all_threads.append(monitor)
            monitor.start()

        if self.memory_file:
            monitor = AdbSampleBasedResourceMonitor(self.memory_file, self.should_stop,
                                                    self.adb.memory, self.num_samples,
                                                    self.sample_interval_ms)
            self.sample_based_threads.append(monitor)
            self.all_threads.append(monitor)
            monitor.start()

    def stop(self):
        """Stop adb sample collection."""
        self.should_stop.set()
        self.wait()

    def wait(self):
        """Wait for all sample collections to complete."""
        try:
            # We either wait for the specified amount of time or for the sample-based monitors
            # to have collected the specified number of samples.
            if self.collection_time_secs > 0:
                self.should_stop.wait(self.collection_time_secs)
            else:
                for thread in self.sample_based_threads:
                    # We must specify a timeout to threading.Thread.join() to ensure that the
                    # wait is interruptible. The main thread would otherwise never be able to
                    # receive a KeyboardInterrupt.
                    thread.join(self._JOIN_TIMEOUT)
        except KeyboardInterrupt:
            # The user has interrupted the script, so we signal to all of the monitor threads
            # that they should exit as quickly as they can.
            pass
        finally:
            self.should_stop.set()
            # Wait for all of the monitor threads to exit, by specifying a timeout to
            # threading.Thread.join() in case the user tries to interrupt the script again.
            for thread in self.all_threads:
                thread.join(self._JOIN_TIMEOUT)

        self.logger.info("Collections stopped.")

        # If any of the monitor threads encountered an error, then reraise the exception in the
        # main thread.
        for thread in self.all_threads:
            if thread.exception is not None:
                raise thread.exception


class AdbResourceMonitor(threading.Thread):
    """Thread to collect information about a specific resource using adb."""

    def __init__(self, output_file, should_stop, logger=LOGGER):
        """Initialize the AdbResourceMonitor object."""
        threading.Thread.__init__(self, name="AdbResourceMonitor {}".format(output_file))
        self._output_file = output_file
        self._should_stop = should_stop
        self.logger = logger
        self.exception = None

    def run(self):
        """Collect adb samples."""
        try:
            self._do_monitoring()
        except Exception as err:  # pylint: disable=broad-except
            self.logger.error("%s: Encountered an error: %s", self._output_file, err)
            self.exception = err
            self._should_stop.set()


class AdbSampleBasedResourceMonitor(AdbResourceMonitor):
    """Subclass for ADB sample based monitor."""

    def __init__(  # pylint: disable=too-many-arguments
            self, output_file, should_stop, adb_cmd, num_samples, sample_interval_ms):
        """Initialize AdbSampleBasedResourceMonitor."""
        AdbResourceMonitor.__init__(self, output_file, should_stop)
        self.adb_cmd = adb_cmd
        self._num_samples = num_samples
        self._sample_interval_ms = sample_interval_ms

    def _do_monitoring(self):
        """Monitor function."""
        collected_samples = 0
        now = time.time()

        while not self._should_stop.is_set():
            if self._num_samples > 0 and collected_samples >= self._num_samples:
                break
            if collected_samples > 0:
                self.logger.debug("%s: Sleeping %d ms.", self._output_file,
                                  self._sample_interval_ms)
                self._should_stop.wait(self._sample_interval_ms / 1000.0)
            collected_samples += 1
            self._take_sample(collected_samples)

        total_time_ms = (time.time() - now) * 1000
        self.logger.info("%s: Stopping monitoring, %d samples collected in %d ms.",
                         self._output_file, collected_samples, total_time_ms)

    def _take_sample(self, collected_samples):
        """Collect sample."""
        self.logger.debug("%s: Collecting sample %d of %d", self._output_file, collected_samples,
                          self._num_samples)
        self.adb_cmd(output_file=self._output_file, append_file=True)


class AdbContinuousResourceMonitor(AdbResourceMonitor):
    """Subclass for ADB continuous sample based monitoring."""

    def __init__(self, output_file, should_stop, adb_start_cmd, adb_stop_cmd):
        """Initialize AdbContinuousResourceMonitor."""
        AdbResourceMonitor.__init__(self, output_file, should_stop)
        self._adb_start_cmd = adb_start_cmd
        self._adb_stop_cmd = adb_stop_cmd

    def _do_monitoring(self):
        """Monitor function."""
        self.logger.debug("%s: Starting monitoring.", self._output_file)
        now = time.time()
        self._adb_start_cmd(output_file=self._output_file)
        self._should_stop.wait()
        total_time_ms = (time.time() - now) * 1000
        self.logger.info("%s: Stopping monitoring after %d ms.", self._output_file, total_time_ms)
        self._adb_stop_cmd(output_file=self._output_file)


def main():  #pylint: disable=too-many-statements
    """Execute Main program."""

    parser = optparse.OptionParser()

    program_options = optparse.OptionGroup(parser, "Program Options")
    battery_options = optparse.OptionGroup(parser, "Battery Options")
    memory_options = optparse.OptionGroup(parser, "Memory Options")
    systrace_options = optparse.OptionGroup(parser, "Systrace Options")

    program_options.add_option("--adbBinary", dest="adb_binary",
                               help="The path for adb. Defaults to '%default', which is in $PATH.",
                               default="adb")

    program_options.add_option(
        "--samples", dest="num_samples",
        help="Number of samples to collect, 0 indicates infinite. [Default: %default]", type=int,
        default=0)

    program_options.add_option(
        "--collectionTime", dest="collection_time_secs",
        help="Time in seconds to collect samples, if specifed overrides '--samples'.", type=int,
        default=0)

    program_options.add_option(
        "--sampleIntervalMs", dest="sample_interval_ms",
        help="Time in milliseconds between collecting a sample. [Default: %default]", type=int,
        default=500)

    log_levels = ["debug", "error", "info", "warning"]
    program_options.add_option(
        "--logLevel", dest="log_level", choices=log_levels,
        help="The log level. Accepted values are: {}. [default: '%default'].".format(log_levels),
        default="info")

    battery_options.add_option(
        "--batteryFile", dest="battery_file",
        help="The destination file for battery stats (CSV format).  [Default: %default].",
        default="battery.csv")

    battery_options.add_option("--noBattery", dest="no_battery",
                               help="Disable collection of battery samples.", action="store_true",
                               default=False)

    memory_options.add_option(
        "--memoryFile", dest="memory_file",
        help="The destination file for memory stats (CSV format). [Default: %default].",
        default="memory.csv")

    memory_options.add_option("--noMemory", dest="no_memory",
                              help="Disable collection of memory samples.", action="store_true",
                              default=False)

    systrace_options.add_option(
        "--cpuFile", dest="cpu_file",
        help="The destination file for CPU stats (JSON format). [Default: %default].",
        default="cpu.json")

    systrace_options.add_option("--noCpu", dest="no_cpu", help="Disable collection of CPU samples.",
                                action="store_true", default=False)

    parser.add_option_group(program_options)
    parser.add_option_group(battery_options)
    parser.add_option_group(memory_options)
    parser.add_option_group(systrace_options)

    options, _ = parser.parse_args()

    output_files = {}
    if options.no_battery:
        options.battery_file = None
    else:
        output_files[options.battery_file] = fileops.getmtime(options.battery_file)

    if options.no_memory:
        options.memory_file = None
    else:
        output_files[options.memory_file] = fileops.getmtime(options.memory_file)

    if options.no_cpu:
        options.cpu_file = None
    else:
        output_files[options.cpu_file] = fileops.getmtime(options.cpu_file)

    LOGGER.setLevel(options.log_level.upper())
    LOGGER.info("This program can be cleanly terminated by issuing the following command:"
                "\n\t\t'kill -INT %d'", os.getpid())

    adb = Adb(options.adb_binary)
    LOGGER.info("Detected devices by adb:\n%s%s", adb.devices(), adb.device_available())

    adb_control = AdbControl(adb=adb, battery_file=options.battery_file,
                             memory_file=options.memory_file, cpu_file=options.cpu_file,
                             num_samples=options.num_samples,
                             collection_time_secs=options.collection_time_secs,
                             sample_interval_ms=options.sample_interval_ms)

    adb_control.start()
    try:
        adb_control.wait()
    finally:
        files_saved = []
        for path in output_files:
            if fileops.getmtime(path) > output_files[path] and not fileops.is_empty(path):
                files_saved.append(path)
        LOGGER.info("Files saved: %s", files_saved)


if __name__ == "__main__":
    main()