blob: 61e5092947fe15c509b451edd2cdd00a40077404 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2016 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
Copybara854996b2021-09-07 19:36:02 +00004
5"""A simple profiler object to track how time is spent on a request.
6
7The profiler is called from application code at the begining and
8end of each major phase and subphase of processing. The profiler
9object keeps track of how much time was spent on each phase or subphase.
10
11This class is useful when developers need to understand where
12server-side time is being spent. It includes durations in
13milliseconds, and a simple bar chart on the HTML page.
14
15On-page debugging and performance info is useful because it makes it easier
16to explore performance interactively.
17"""
18
19from __future__ import division
20from __future__ import print_function
21from __future__ import absolute_import
22
23import datetime
24import logging
25import random
26import re
27import threading
28import time
29
30from infra_libs import ts_mon
31
32from contextlib import contextmanager
33
34from google.appengine.api import app_identity
35
36PHASE_TIME = ts_mon.CumulativeDistributionMetric(
37 'monorail/servlet/phase_time',
38 'Time spent in profiler phases, in ms',
39 [ts_mon.StringField('phase')])
40
41# trace_service requires names less than 128 bytes
42# https://cloud.google.com/trace/docs/reference/v1/rest/v1/projects.traces#Trace
43MAX_PHASE_NAME_LENGTH = 128
44
45
46class Profiler(object):
47 """Object to record and help display request processing profiling info.
48
49 The Profiler class holds a list of phase objects, which can hold additional
50 phase objects (which are subphases). Each phase or subphase represents some
51 meaningful part of this application's HTTP request processing.
52 """
53
54 _COLORS = ['900', '090', '009', '360', '306', '036',
55 '630', '630', '063', '333']
56
57 def __init__(self, opt_trace_context=None, opt_trace_service=None):
58 """Each request processing profile begins with an empty list of phases."""
59 self.top_phase = _Phase('overall profile', -1, None)
60 self.current_phase = self.top_phase
61 self.next_color = 0
62 self.original_thread_id = threading.current_thread().ident
63 self.trace_context = opt_trace_context
64 self.trace_service = opt_trace_service
65 self.project_id = app_identity.get_application_id()
66
67 def StartPhase(self, name='unspecified phase'):
68 """Begin a (sub)phase by pushing a new phase onto a stack."""
69 if self.original_thread_id != threading.current_thread().ident:
70 return # We only profile the main thread.
71 color = self._COLORS[self.next_color % len(self._COLORS)]
72 self.next_color += 1
73 self.current_phase = _Phase(name, color, self.current_phase)
74
75 def EndPhase(self):
76 """End a (sub)phase by poping the phase stack."""
77 if self.original_thread_id != threading.current_thread().ident:
78 return # We only profile the main thread.
79 self.current_phase = self.current_phase.End()
80
81 @contextmanager
82 def Phase(self, name='unspecified phase'):
83 """Context manager to automatically begin and end (sub)phases."""
84 self.StartPhase(name)
85 try:
86 yield
87 finally:
88 self.EndPhase()
89
90 def LogStats(self):
91 """Log sufficiently-long phases and subphases, for debugging purposes."""
92 self.top_phase.End()
93 lines = ['Stats:']
94 self.top_phase.AccumulateStatLines(self.top_phase.elapsed_seconds, lines)
95 logging.info('\n'.join(lines))
96
97 def ReportTrace(self):
98 """Send a profile trace to Google Cloud Tracing."""
99 self.top_phase.End()
100 spans = self.top_phase.SpanJson()
101 if not self.trace_service or not self.trace_context:
102 logging.info('would have sent trace: %s', spans)
103 return
104
105 # Format of trace_context: 'TRACE_ID/SPAN_ID;o=TRACE_TRUE'
106 # (from https://cloud.google.com/trace/docs/troubleshooting#force-trace)
107 # TODO(crbug/monorail:7086): Respect the o=TRACE_TRUE part.
108 # Note: on Appngine it seems ';o=1' is omitted rather than set to 0.
109 trace_id, root_span_id = self.trace_context.split(';')[0].split('/')
110 for s in spans:
111 # TODO(crbug/monorail:7087): Consider setting `parentSpanId` to
112 # `root_span_id` for the children of `top_phase`.
113 if not 'parentSpanId' in s:
114 s['parentSpanId'] = root_span_id
115 traces_body = {
116 'projectId': self.project_id,
117 'traceId': trace_id,
118 'spans': spans,
119 }
120 body = {
121 'traces': [traces_body]
122 }
123 # TODO(crbug/monorail:7088): Do this async so it doesn't delay the response.
124 request = self.trace_service.projects().patchTraces(
125 projectId=self.project_id, body=body)
126 _res = request.execute()
127
128
129class _Phase(object):
130 """A _Phase instance represents a period of time during request processing."""
131
132 def __init__(self, name, color, parent):
133 """Initialize a (sub)phase with the given name and current system clock."""
134 self.start = time.time()
135 self.name = name[:MAX_PHASE_NAME_LENGTH]
136 self.color = color
137 self.subphases = []
138 self.elapsed_seconds = None
139 self.ms = 'in_progress' # shown if the phase never records a finish.
140 self.uncategorized_ms = None
141 self.parent = parent
142 if self.parent is not None:
143 self.parent._RegisterSubphase(self)
144
145 self.id = str(random.getrandbits(64))
146
147
148 def _RegisterSubphase(self, subphase):
149 """Add a subphase to this phase."""
150 self.subphases.append(subphase)
151
152 def End(self):
153 """Record the time between the start and end of this (sub)phase."""
154 self.elapsed_seconds = time.time() - self.start
155 self.ms = int(self.elapsed_seconds * 1000)
156 for sub in self.subphases:
157 if sub.elapsed_seconds is None:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100158 logging.warning('issue3182: subphase is %r', sub and sub.name)
Copybara854996b2021-09-07 19:36:02 +0000159 categorized = sum(sub.elapsed_seconds or 0.0 for sub in self.subphases)
160 self.uncategorized_ms = int((self.elapsed_seconds - categorized) * 1000)
161 return self.parent
162
163 def AccumulateStatLines(self, total_seconds, lines, indent=''):
164 # Only phases that took longer than 30ms are interesting.
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100165 if self.ms == 'in_progress' or self.ms <= 30:
Copybara854996b2021-09-07 19:36:02 +0000166 return
167
168 percent = self.elapsed_seconds // total_seconds * 100
169 lines.append('%s%5d ms (%2d%%): %s' % (indent, self.ms, percent, self.name))
170
171 # Remove IDs etc to reduce the phase name cardinality for ts_mon.
172 normalized_phase = re.sub('[0-9]+', '', self.name)
173 PHASE_TIME.add(self.ms, {'phase': normalized_phase})
174
175 for subphase in self.subphases:
176 subphase.AccumulateStatLines(total_seconds, lines, indent=indent + ' ')
177
178 def SpanJson(self):
179 """Return a json representation of this phase as a GCP Cloud Trace object.
180 """
181 endTime = self.start + self.elapsed_seconds
182
183 span = {
184 'kind': 'RPC_SERVER',
185 'name': self.name,
186 'spanId': self.id,
187 'startTime':
188 datetime.datetime.fromtimestamp(self.start).isoformat() + 'Z',
189 'endTime': datetime.datetime.fromtimestamp(endTime).isoformat() + 'Z',
190 }
191
192 if self.parent is not None and self.parent.id is not None:
193 span['parentSpanId'] = self.parent.id
194
195 spans = [span]
196 for s in self.subphases:
197 spans.extend(s.SpanJson())
198
199 return spans