| # Copyright 2016 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. |
| |
| """Monitor tracing events on chrome via chrome remote debugging.""" |
| |
| import itertools |
| import logging |
| import operator |
| |
| import clovis_constants |
| import devtools_monitor |
| |
| |
| class TracingTrack(devtools_monitor.Track): |
| """Grabs and processes trace event messages. |
| |
| See https://goo.gl/Qabkqk for details on the protocol. |
| """ |
| def __init__(self, connection, categories, fetch_stream=False): |
| """Initialize this TracingTrack. |
| |
| Args: |
| connection: a DevToolsConnection. |
| categories: ([str] or None) If set, a list of categories to enable or |
| disable in Chrome tracing. Categories prefixed with '-' are |
| disabled. |
| fetch_stream: if true, use a websocket stream to fetch tracing data rather |
| than dataCollected events. It appears based on very limited testing that |
| a stream is slower than the default reporting as dataCollected events. |
| """ |
| super(TracingTrack, self).__init__(connection) |
| if connection: |
| connection.RegisterListener('Tracing.dataCollected', self) |
| |
| self._categories = set(categories) |
| params = {} |
| params['categories'] = ','.join(self._categories) |
| if fetch_stream: |
| params['transferMode'] = 'ReturnAsStream' |
| |
| if connection: |
| connection.SyncRequestNoResponse('Tracing.start', params) |
| |
| self._events = [] |
| self._base_msec = None |
| self._interval_tree = None |
| self._main_frame_id = None |
| |
| def Handle(self, method, event): |
| for e in event['params']['value']: |
| event = Event(e) |
| self._events.append(event) |
| if self._base_msec is None or event.start_msec < self._base_msec: |
| self._base_msec = event.start_msec |
| # Invalidate our index rather than trying to be fancy and incrementally |
| # update. |
| self._interval_tree = None |
| |
| def Categories(self): |
| """Returns the set of categories in this trace.""" |
| return self._categories |
| |
| def GetFirstEventMillis(self): |
| """Find the canonical start time for this track. |
| |
| Returns: |
| The millisecond timestamp of the first request. |
| """ |
| return self._base_msec |
| |
| def GetEvents(self): |
| """Returns a list of tracing.Event. Not sorted.""" |
| return self._events |
| |
| def GetMatchingEvents(self, category, name): |
| """Gets events matching |category| and |name|.""" |
| return [e for e in self.GetEvents() if e.Matches(category, name)] |
| |
| def GetMatchingMainFrameEvents(self, category, name): |
| """Gets events matching |category| and |name| that occur in the main frame. |
| |
| Events without a 'frame' key in their |args| are discarded. |
| """ |
| matching_events = self.GetMatchingEvents(category, name) |
| return [e for e in matching_events |
| if 'frame' in e.args and e.args['frame'] == self.GetMainFrameID()] |
| |
| def GetMainFrameRoutingID(self): |
| """Returns the main frame routing ID.""" |
| for event in self.GetMatchingEvents( |
| 'navigation', 'RenderFrameImpl::OnNavigate'): |
| return event.args['id'] |
| assert False |
| |
| def GetMainFrameID(self): |
| """Returns the main frame ID.""" |
| if not self._main_frame_id: |
| navigation_start_events = self.GetMatchingEvents( |
| 'blink.user_timing', 'navigationStart') |
| first_event = min(navigation_start_events, key=lambda e: e.start_msec) |
| self._main_frame_id = first_event.args['frame'] |
| |
| return self._main_frame_id |
| |
| def SetMainFrameID(self, frame_id): |
| """Set the main frame ID. Normally this is used only for testing.""" |
| self._main_frame_id = frame_id |
| |
| def EventsAt(self, msec): |
| """Gets events active at a timestamp. |
| |
| Args: |
| msec: tracing milliseconds to query. Tracing milliseconds appears to be |
| since chrome startup (ie, arbitrary epoch). |
| |
| Returns: |
| List of events active at that timestamp. Instantaneous (ie, instant, |
| sample and counter) events are never included. Event end times are |
| exclusive, so that an event ending at the usec parameter will not be |
| returned. |
| """ |
| self._IndexEvents() |
| return self._interval_tree.EventsAt(msec) |
| |
| def Filter(self, pid=None, tid=None, categories=None): |
| """Returns a new TracingTrack with a subset of the events. |
| |
| Args: |
| pid: (int or None) Selects events from this PID. |
| tid: (int or None) Selects events from this TID. |
| categories: (set([str]) or None) Selects events belonging to one of the |
| categories. |
| """ |
| events = self._events |
| if pid is not None: |
| events = filter(lambda e : e.tracing_event['pid'] == pid, events) |
| if tid is not None: |
| events = filter(lambda e : e.tracing_event['tid'] == tid, events) |
| if categories is not None: |
| events = filter( |
| lambda e : set(e.category.split(',')).intersection(categories), |
| events) |
| tracing_track = TracingTrack(None, clovis_constants.DEFAULT_CATEGORIES) |
| tracing_track._events = events |
| tracing_track._categories = self._categories |
| if categories is not None: |
| tracing_track._categories = self._categories.intersection(categories) |
| return tracing_track |
| |
| def ToJsonDict(self): |
| return {'categories': list(self._categories), |
| 'events': [e.ToJsonDict() for e in self._events]} |
| |
| @classmethod |
| def FromJsonDict(cls, json_dict): |
| if not json_dict: |
| return None |
| assert 'events' in json_dict |
| events = [Event(e) for e in json_dict['events']] |
| tracing_track = TracingTrack(None, clovis_constants.DEFAULT_CATEGORIES) |
| tracing_track._categories = set(json_dict.get('categories', [])) |
| tracing_track._events = events |
| tracing_track._base_msec = events[0].start_msec if events else 0 |
| for e in events[1:]: |
| if e.type == 'M': |
| continue # No timestamp for metadata events. |
| assert e.start_msec > 0 |
| if e.start_msec < tracing_track._base_msec: |
| tracing_track._base_msec = e.start_msec |
| return tracing_track |
| |
| def OverlappingEvents(self, start_msec, end_msec): |
| self._IndexEvents() |
| return self._interval_tree.OverlappingEvents(start_msec, end_msec) |
| |
| def EventsEndingBetween(self, start_msec, end_msec): |
| """Gets the list of events ending within an interval. |
| |
| Args: |
| start_msec: the start of the range to query, in milliseconds, inclusive. |
| end_msec: the end of the range to query, in milliseconds, inclusive. |
| |
| Returns: |
| See OverlappingEvents() above. |
| """ |
| overlapping_events = self.OverlappingEvents(start_msec, end_msec) |
| return [e for e in overlapping_events |
| if start_msec <= e.end_msec <= end_msec] |
| |
| def EventFromStep(self, step_event): |
| """Returns the Event associated with a step event, or None. |
| |
| Args: |
| step_event: (Event) Step event. |
| |
| Returns: |
| an Event that matches the step event, or None. |
| """ |
| self._IndexEvents() |
| assert 'step' in step_event.args and step_event.tracing_event['ph'] == 'T' |
| candidates = self._interval_tree.EventsAt(step_event.start_msec) |
| for event in candidates: |
| # IDs are only unique within a process (often they are pointers). |
| if (event.pid == step_event.pid and event.tracing_event['ph'] != 'T' |
| and event.name == step_event.name and event.id == step_event.id): |
| return event |
| return None |
| |
| def _IndexEvents(self, strict=False): |
| if self._interval_tree: |
| return |
| complete_events = [] |
| spanning_events = self._SpanningEvents() |
| for event in self._events: |
| if not event.IsIndexable(): |
| continue |
| if event.IsComplete(): |
| complete_events.append(event) |
| continue |
| matched_event = spanning_events.Match(event, strict) |
| if matched_event is not None: |
| complete_events.append(matched_event) |
| self._interval_tree = _IntervalTree.FromEvents(complete_events) |
| |
| if strict and spanning_events.HasPending(): |
| raise devtools_monitor.DevToolsConnectionException( |
| 'Pending spanning events: %s' % |
| '\n'.join([str(e) for e in spanning_events.PendingEvents()])) |
| |
| def _GetEvents(self): |
| self._IndexEvents() |
| return self._interval_tree.GetEvents() |
| |
| def HasLoadingSucceeded(self): |
| """Returns whether the loading has succeed at recording time.""" |
| main_frame_id = self.GetMainFrameRoutingID() |
| for event in self.GetMatchingEvents( |
| 'navigation', 'RenderFrameImpl::didFailProvisionalLoad'): |
| if event.args['id'] == main_frame_id: |
| return False |
| for event in self.GetMatchingEvents( |
| 'navigation', 'RenderFrameImpl::didFailLoad'): |
| if event.args['id'] == main_frame_id: |
| return False |
| return True |
| |
| class _SpanningEvents(object): |
| def __init__(self): |
| self._duration_stack = [] |
| self._async_stacks = {} |
| self._objects = {} |
| self._MATCH_HANDLER = { |
| 'B': self._DurationBegin, |
| 'E': self._DurationEnd, |
| 'b': self._AsyncStart, |
| 'e': self._AsyncEnd, |
| 'S': self._AsyncStart, |
| 'F': self._AsyncEnd, |
| 'N': self._ObjectCreated, |
| 'D': self._ObjectDestroyed, |
| 'M': self._Ignore, |
| 'X': self._Ignore, |
| 'R': self._Ignore, |
| 'p': self._Ignore, |
| '(': self._Ignore, # Context events. |
| ')': self._Ignore, # Ditto. |
| None: self._Ignore, |
| } |
| |
| def Match(self, event, strict=False): |
| return self._MATCH_HANDLER.get( |
| event.type, self._Unsupported)(event, strict) |
| |
| def HasPending(self): |
| return (self._duration_stack or |
| self._async_stacks or |
| self._objects) |
| |
| def PendingEvents(self): |
| return itertools.chain( |
| (e for e in self._duration_stack), |
| (o for o in self._objects), |
| itertools.chain.from_iterable(( |
| (e for e in s) for s in self._async_stacks.itervalues()))) |
| |
| def _AsyncKey(self, event, _): |
| return (event.tracing_event['cat'], event.id) |
| |
| def _Ignore(self, _event, _): |
| return None |
| |
| def _Unsupported(self, event, _): |
| raise devtools_monitor.DevToolsConnectionException( |
| 'Unsupported spanning event type: %s' % event) |
| |
| def _DurationBegin(self, event, _): |
| self._duration_stack.append(event) |
| return None |
| |
| def _DurationEnd(self, event, _): |
| if not self._duration_stack: |
| raise devtools_monitor.DevToolsConnectionException( |
| 'Unmatched duration end: %s' % event) |
| start = self._duration_stack.pop() |
| start.SetClose(event) |
| return start |
| |
| def _AsyncStart(self, event, strict): |
| key = self._AsyncKey(event, strict) |
| self._async_stacks.setdefault(key, []).append(event) |
| return None |
| |
| def _AsyncEnd(self, event, strict): |
| key = self._AsyncKey(event, strict) |
| if key not in self._async_stacks: |
| message = 'Unmatched async end %s: %s' % (key, event) |
| if strict: |
| raise devtools_monitor.DevToolsConnectionException(message) |
| else: |
| logging.warning(message) |
| return None |
| stack = self._async_stacks[key] |
| start = stack.pop() |
| if not stack: |
| del self._async_stacks[key] |
| start.SetClose(event) |
| return start |
| |
| def _ObjectCreated(self, event, _): |
| # The tracing event format has object deletion timestamps being exclusive, |
| # that is the timestamp for a deletion my equal that of the next create at |
| # the same address. This asserts that does not happen in practice as it is |
| # inconvenient to handle that correctly here. |
| if event.id in self._objects: |
| raise devtools_monitor.DevToolsConnectionException( |
| 'Multiple objects at same address: %s, %s' % |
| (event, self._objects[event.id])) |
| self._objects[event.id] = event |
| return None |
| |
| def _ObjectDestroyed(self, event, _): |
| if event.id not in self._objects: |
| raise devtools_monitor.DevToolsConnectionException( |
| 'Missing object creation for %s' % event) |
| start = self._objects[event.id] |
| del self._objects[event.id] |
| start.SetClose(event) |
| return start |
| |
| |
| class Event(object): |
| """Wraps a tracing event.""" |
| CLOSING_EVENTS = {'E': 'B', |
| 'e': 'b', |
| 'F': 'S', |
| 'D': 'N'} |
| __slots__ = ('_tracing_event', 'start_msec', 'end_msec', '_synthetic') |
| def __init__(self, tracing_event, synthetic=False): |
| """Creates Event. |
| |
| Intended to be created only by TracingTrack. |
| |
| Args: |
| tracing_event: JSON tracing event, as defined in https://goo.gl/Qabkqk. |
| synthetic: True if the event is synthetic. This is only used for indexing |
| internal to TracingTrack. |
| """ |
| if not synthetic and tracing_event['ph'] in ['s', 't', 'f']: |
| raise devtools_monitor.DevToolsConnectionException( |
| 'Unsupported event: %s' % tracing_event) |
| |
| self._tracing_event = tracing_event |
| # Note tracing event times are in microseconds. |
| self.start_msec = tracing_event['ts'] / 1000.0 |
| self.end_msec = None |
| self._synthetic = synthetic |
| if self.type == 'X': |
| # Some events don't have a duration. |
| duration = (tracing_event['dur'] |
| if 'dur' in tracing_event else tracing_event['tdur']) |
| self.end_msec = self.start_msec + duration / 1000.0 |
| |
| @property |
| def type(self): |
| if self._synthetic: |
| return None |
| return self._tracing_event['ph'] |
| |
| @property |
| def category(self): |
| return self._tracing_event['cat'] |
| |
| @property |
| def pid(self): |
| return self._tracing_event['pid'] |
| |
| @property |
| def args(self): |
| return self._tracing_event.get('args', {}) |
| |
| @property |
| def id(self): |
| return self._tracing_event.get('id') |
| |
| @property |
| def name(self): |
| return self._tracing_event['name'] |
| |
| @property |
| def tracing_event(self): |
| return self._tracing_event |
| |
| @property |
| def synthetic(self): |
| return self._synthetic |
| |
| def __str__(self): |
| return ''.join([str(self._tracing_event), |
| '[%s,%s]' % (self.start_msec, self.end_msec)]) |
| |
| def Matches(self, category, name): |
| """Match tracing events. |
| |
| Args: |
| category: a tracing category (event['cat']). |
| name: the tracing event name (event['name']). |
| |
| Returns: |
| True if the event matches and False otherwise. |
| """ |
| if name != self.name: |
| return False |
| categories = self.category.split(',') |
| return category in categories |
| |
| def IsIndexable(self): |
| """True iff the event can be indexed by time.""" |
| return self._synthetic or self.type not in [ |
| 'I', 'P', 'c', 'C', |
| 'n', 'T', 'p', # TODO(mattcary): ?? instant types of async events. |
| 'O', # TODO(mattcary): ?? object snapshot |
| 'M' # Metadata |
| ] |
| |
| def IsComplete(self): |
| return self.type == 'X' |
| |
| def Synthesize(self): |
| """Expand into synthetic events. |
| |
| Returns: |
| A list of events, possibly some synthetic, whose start times are all |
| interesting for purposes of indexing. If the event is not indexable the |
| set may be empty. |
| """ |
| if not self.IsIndexable(): |
| return [] |
| if self.IsComplete(): |
| # Tracing event timestamps are microseconds! |
| return [self, Event({'ts': self.end_msec * 1000}, synthetic=True)] |
| return [self] |
| |
| def SetClose(self, closing): |
| """Close a spanning event. |
| |
| Args: |
| closing: The closing event. |
| |
| Raises: |
| devtools_monitor.DevToolsConnectionException if closing can't property |
| close this event. |
| """ |
| if self.type != self.CLOSING_EVENTS.get(closing.type): |
| raise devtools_monitor.DevToolsConnectionException( |
| 'Bad closing: %s --> %s' % (self, closing)) |
| if self.type in ['b', 'S'] and ( |
| self.tracing_event['cat'] != closing.tracing_event['cat'] or |
| self.id != closing.id): |
| raise devtools_monitor.DevToolsConnectionException( |
| 'Bad async closing: %s --> %s' % (self, closing)) |
| self.end_msec = closing.start_msec |
| if 'args' in closing.tracing_event: |
| self.tracing_event.setdefault( |
| 'args', {}).update(closing.tracing_event['args']) |
| |
| def ToJsonDict(self): |
| return self._tracing_event |
| |
| @classmethod |
| def FromJsonDict(cls, json_dict): |
| return Event(json_dict) |
| |
| |
| class _IntervalTree(object): |
| """Simple interval tree. This is not an optimal one, as the split is done with |
| an equal number of events on each side, according to start time. |
| """ |
| _TRESHOLD = 100 |
| def __init__(self, start, end, events): |
| """Builds an interval tree. |
| |
| Args: |
| start: start timestamp of this node, in ms. |
| end: end timestamp covered by this node, in ms. |
| events: Iterable of objects having start_msec and end_msec fields. Has to |
| be sorted by start_msec. |
| """ |
| self.start = start |
| self.end = end |
| self._events = events |
| self._left = self._right = None |
| if len(self._events) > self._TRESHOLD: |
| self._Divide() |
| |
| @classmethod |
| def FromEvents(cls, events): |
| """Returns an IntervalTree instance from a list of events.""" |
| filtered_events = [e for e in events |
| if e.start_msec is not None and e.end_msec is not None] |
| filtered_events.sort(key=operator.attrgetter('start_msec')) |
| start = min(event.start_msec for event in filtered_events) |
| end = max(event.end_msec for event in filtered_events) |
| return _IntervalTree(start, end, filtered_events) |
| |
| def OverlappingEvents(self, start, end): |
| """Returns a set of events overlapping with [start, end).""" |
| if min(end, self.end) - max(start, self.start) <= 0: |
| return set() |
| elif self._IsLeaf(): |
| result = set() |
| for event in self._events: |
| if self._Overlaps(event, start, end): |
| result.add(event) |
| return result |
| else: |
| return (self._left.OverlappingEvents(start, end) |
| | self._right.OverlappingEvents(start, end)) |
| |
| def EventsAt(self, timestamp): |
| result = set() |
| if self._IsLeaf(): |
| for event in self._events: |
| if event.start_msec <= timestamp < event.end_msec: |
| result.add(event) |
| else: |
| if self._left.start <= timestamp < self._left.end: |
| result |= self._left.EventsAt(timestamp) |
| if self._right.start <= timestamp < self._right.end: |
| result |= self._right.EventsAt(timestamp) |
| return result |
| |
| def GetEvents(self): |
| return self._events |
| |
| def _Divide(self): |
| middle = len(self._events) / 2 |
| left_events = self._events[:middle] |
| right_events = self._events[middle:] |
| left_end = max(e.end_msec for e in left_events) |
| right_start = min(e.start_msec for e in right_events) |
| self._left = _IntervalTree(self.start, left_end, left_events) |
| self._right = _IntervalTree(right_start, self.end, right_events) |
| |
| def _IsLeaf(self): |
| return self._left is None |
| |
| @classmethod |
| def _Overlaps(cls, event, start, end): |
| return (min(end, event.end_msec) - max(start, event.start_msec) > 0 |
| or start <= event.start_msec < end) # For instant events. |