# Copyright 2014 The Chromium Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. from __future__ import absolute_import from __future__ import division from __future__ import print_function import collections import gzip import json import logging import os import platform import shutil import subprocess import tempfile import time import traceback import six try: StringTypes = six.string_types # pylint: disable=invalid-name except NameError: StringTypes = str _TRACING_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.path.pardir, os.path.pardir) _TRACE2HTML_PATH = os.path.join(_TRACING_DIR, 'bin', 'trace2html') MIB = 1024 * 1024 class TraceDataPart(object): """Trace data can come from a variety of tracing agents. Data from each agent is collected into a trace "part" and accessed by the following fixed field names. """ def __init__(self, raw_field_name): self._raw_field_name = raw_field_name def __repr__(self): return 'TraceDataPart("%s")' % self._raw_field_name @property def raw_field_name(self): return self._raw_field_name def __eq__(self, other): return self.raw_field_name == other.raw_field_name def __hash__(self): return hash(self.raw_field_name) ANDROID_PROCESS_DATA_PART = TraceDataPart('androidProcessDump') ATRACE_PART = TraceDataPart('systemTraceEvents') ATRACE_PROCESS_DUMP_PART = TraceDataPart('atraceProcessDump') CHROME_TRACE_PART = TraceDataPart('traceEvents') CPU_TRACE_DATA = TraceDataPart('cpuSnapshots') TELEMETRY_PART = TraceDataPart('telemetry') WALT_TRACE_PART = TraceDataPart('waltTraceEvents') CGROUP_TRACE_PART = TraceDataPart('cgroupDump') ALL_TRACE_PARTS = {ANDROID_PROCESS_DATA_PART, ATRACE_PART, ATRACE_PROCESS_DUMP_PART, CHROME_TRACE_PART, CPU_TRACE_DATA, TELEMETRY_PART} class _TraceData(object): """Provides read access to traces collected from multiple tracing agents. Instances are created by calling the AsData() method on a TraceDataWriter. """ def __init__(self, raw_data): self._raw_data = raw_data def HasTracesFor(self, part): return bool(self.GetTracesFor(part)) def GetTracesFor(self, part): """Return the list of traces for |part| in string or dictionary forms.""" if not isinstance(part, TraceDataPart): raise TypeError('part must be a TraceDataPart instance') return self._raw_data.get(part.raw_field_name, []) def GetTraceFor(self, part): traces = self.GetTracesFor(part) assert len(traces) == 1 return traces[0] _TraceItem = collections.namedtuple( '_TraceItem', ['part_name', 'handle']) class TraceDataException(Exception): """Exception raised by TraceDataBuilder via RecordTraceDataException().""" class TraceDataBuilder(object): """TraceDataBuilder helps build up a trace from multiple trace agents. Note: the collected trace data is maintained in a set of temporary files to be later processed e.g. by the Serialize() method. To ensure proper clean up of such files clients must call the CleanUpTraceData() method or, even easier, use the context manager API, e.g.: with trace_data.TraceDataBuilder() as builder: builder.AddTraceFor(trace_part, data) builder.Serialize(output_file) """ def __init__(self): self._traces = [] self._frozen = False self._temp_dir = tempfile.mkdtemp() self._exceptions = [] def __enter__(self): return self def __exit__(self, *args): self.CleanUpTraceData() def OpenTraceHandleFor(self, part, suffix): """Open a file handle for writing trace data into it. Args: part: A TraceDataPart instance. suffix: A string used as file extension and identifier for the format of the trace contents, e.g. '.json'. Can also append '.gz' to indicate gzipped content, e.g. '.json.gz'. """ if not isinstance(part, TraceDataPart): raise TypeError('part must be a TraceDataPart instance') if self._frozen: raise RuntimeError('trace data builder is no longer open for writing') trace = _TraceItem( part_name=part.raw_field_name, handle=tempfile.NamedTemporaryFile( delete=False, dir=self._temp_dir, suffix=suffix)) self._traces.append(trace) return trace.handle def AddTraceFileFor(self, part, trace_file): """Move a file with trace data into this builder. This is useful for situations where a client might want to start collecting trace data into a file, even before the TraceDataBuilder itself is created. Args: part: A TraceDataPart instance. trace_file: A path to a file containing trace data. Note: for efficiency the file is moved rather than copied into the builder. Therefore the source file will no longer exist after calling this method; and the lifetime of the trace data will thereafter be managed by this builder. """ _, suffix = os.path.splitext(trace_file) with self.OpenTraceHandleFor(part, suffix) as handle: pass if os.name == 'nt': # On windows os.rename won't overwrite, so the destination path needs to # be removed first. os.remove(handle.name) os.rename(trace_file, handle.name) def AddTraceFor(self, part, data, allow_unstructured=False): """Record new trace data into this builder. Args: part: A TraceDataPart instance. data: The trace data to write: a json-serializable dict, or unstructured text data as a string. allow_unstructured: This must be set to True to allow passing unstructured text data as input. Note: the use of this flag is discouraged and only exists to support legacy clients; new tracing agents should all produce structured trace data (e.g. proto or json). """ if isinstance(data, StringTypes): if not allow_unstructured: raise ValueError('must pass allow_unstructured=True for text data') do_write = lambda d, f: f.write(d) suffix = '.txt' # Used for atrace and systrace data. elif isinstance(data, dict): do_write = json.dump suffix = '.json' else: raise TypeError('invalid trace data type') with self.OpenTraceHandleFor(part, suffix) as handle: do_write(data, handle) def Freeze(self): """Do not allow writing any more data into this builder.""" self._frozen = True return self def CleanUpTraceData(self): """Clean up resources used by the data builder. Will also re-raise any exceptions previously added by RecordTraceCollectionException(). """ if self._traces is None: return # Already cleaned up. self.Freeze() for trace in self._traces: # Make sure all trace handles are closed. It's fine if we close some # of them multiple times. trace.handle.close() shutil.rmtree(self._temp_dir) self._temp_dir = None self._traces = None if self._exceptions: raise TraceDataException( 'Exceptions raised during trace data collection:\n' + '\n'.join(self._exceptions)) def Serialize(self, file_path, trace_title=None): """Serialize the trace data to a file in HTML format.""" self.Freeze() assert self._traces, 'trace data has already been cleaned up' trace_files = [trace.handle.name for trace in self._traces] SerializeAsHtml(trace_files, file_path, trace_title) def AsData(self): """Allow in-memory access to read the collected JSON trace data. This method is only provided for writing tests which require read access to the collected trace data (e.g. for tracing agents to test they correctly write data), and to support legacy TBMv1 metric computation. Only traces in JSON format are supported. Be careful: this may require a lot of memory if the traces to process are very large. This has lead in the past to OOM errors (e.g. crbug/672097). TODO(crbug/928278): Ideally, this method should be removed when it can be entirely replaced by calls to an external trace processor. """ self.Freeze() assert self._traces, 'trace data has already been cleaned up' raw_data = {} for trace in self._traces: is_compressed_json = trace.handle.name.endswith('.json.gz') is_json = trace.handle.name.endswith('.json') or is_compressed_json if is_json: traces_for_part = raw_data.setdefault(trace.part_name, []) opener = gzip.open if is_compressed_json else open with opener(trace.handle.name, 'rb') as f: traces_for_part.append(json.load(f)) else: logging.info('Skipping over non-json trace: %s', trace.handle.name) return _TraceData(raw_data) def IterTraceParts(self): """Iterates over trace parts. Return value: iterator over pairs (part_name, file_path). """ for trace in self._traces: yield trace.part_name, trace.handle.name def RecordTraceDataException(self): """Records the most recent exception to be re-raised during cleanup. Exceptions raised during trace data collection can be stored temporarily in the builder. They will be re-raised when the builder is cleaned up later. This way, any collected trace data can still be retained before the benchmark is aborted. This method is intended to be called from within an "except" handler, e.g.: try: # Collect trace data. except Exception: # pylint: disable=broad-except builder.RecordTraceDataException() """ self._exceptions.append(traceback.format_exc()) def CreateTestTrace(number=1): """Convenient helper method to create trace data objects for testing. Objects are created via the usual trace data writing route, so clients are also responsible for cleaning up trace data themselves. Clients are meant to treat these test traces as opaque. No guarantees are made about their contents, which they shouldn't try to read. """ builder = TraceDataBuilder() builder.AddTraceFor(CHROME_TRACE_PART, {'traceEvents': [{'test': number}]}) return builder.Freeze() def CreateFromRawChromeEvents(events): """Convenient helper to create trace data objects from raw Chrome events. This bypasses trace data writing, going directly to the in-memory json trace representation, so there is no need for trace file cleanup. This is used only for testing legacy clients that still read trace data. """ assert isinstance(events, list) return _TraceData({ CHROME_TRACE_PART.raw_field_name: [{'traceEvents': events}]}) def SerializeAsHtml(trace_files, html_file, trace_title=None): """Serialize a set of traces to a single file in HTML format. Args: trace_files: a list of file names, each containing a trace from one of the tracing agents. html_file: a name of the output file. trace_title: optional. A title for the resulting trace. """ if not trace_files: raise ValueError('trace files list is empty') input_size = sum(os.path.getsize(trace_file) for trace_file in trace_files) cmd = [] if platform.system() == 'Windows': version_cmd = ['python', '-c', 'import sys\nprint(sys.version_info.major)'] version = subprocess.check_output(version_cmd) if version.strip() == '3': raise RuntimeError('trace2html cannot run with python 3.') cmd.append('python') cmd.append(_TRACE2HTML_PATH) cmd.extend(trace_files) cmd.extend(['--output', html_file]) if trace_title is not None: cmd.extend(['--title', trace_title]) start_time = time.time() subprocess.check_output(cmd) elapsed_time = time.time() - start_time logging.info('trace2html processed %.01f MiB of trace data in %.02f seconds.', 1.0 * input_size / MIB, elapsed_time)