Skip to content

Commit f8c291b

Browse files
committed
Squashed stopwatch.py master:stopwatch.py cc34eae71
url: https://github.com/baishancloud/stopwatch ref: master sub-dir: stopwatch.py localtag: refs/tags/_gitsubrepo/stopwatch.py cc34eae71df5ded7b67b396aa690c9ea8dffa274 git-subrepo-dir: stopwatch.py git-subrepo-hash: cc34eae71df5ded7b67b396aa690c9ea8dffa274
1 parent fc7575c commit f8c291b

File tree

1 file changed

+385
-0
lines changed

1 file changed

+385
-0
lines changed

stopwatch.py

+385
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
"""StopWatch - library for adding timers and tags in your code for performance monitoring
2+
https://github.com/dropbox/stopwatch
3+
4+
StopWatch operates on a notion of "spans" which represent scopes of code for which we
5+
want to measure timing. Spans can be nested and placed inside loops for aggregation.
6+
7+
For example:
8+
```
9+
with sw.timer('root'):
10+
for i in range(50):
11+
with sw.timer('inner_task'):
12+
do_inner_task(i)
13+
```
14+
15+
StopWatch requires a root scope which upon completion signifies the end of the round
16+
of measurements. On a server, you might use a single request as your root scope.
17+
18+
StopWatch produces two kinds of reports.
19+
1) Aggregated (see _reported_values).
20+
2) Non-aggregated or "tracing" (see _reported_traces)
21+
22+
Aggregated reports have one value for each timer name. Great for averaging across
23+
various root scopes / requests. The above example would have 2 reports.
24+
25+
Tracing reports keep one trace every time we enter a scope. The above example would
26+
have 51 reports. This is great for digging into a data for a particular request,
27+
or constructing waterfall graphs.
28+
"""
29+
30+
from __future__ import absolute_import
31+
from __future__ import division
32+
from __future__ import print_function
33+
34+
import collections
35+
import contextlib
36+
import random as insecure_random
37+
import time
38+
39+
TraceAnnotation = collections.namedtuple('TraceKeyValueAnnotation', ['key', 'value', 'time'])
40+
AggregatedReport = collections.namedtuple('AggregatedReport',
41+
['aggregated_values', 'root_timer_data'])
42+
43+
class TimerData(object):
44+
"""
45+
Simple object that wraps all data needed for a single timer span.
46+
The StopWatch object maintains a stack of these timers.
47+
"""
48+
49+
__slots__ = (
50+
'span_id',
51+
'name',
52+
'start_time',
53+
'end_time',
54+
'trace_annotations',
55+
'parent_span_id',
56+
'log_name',
57+
)
58+
59+
def __init__(self, name, start_time, parent_name):
60+
# Generate new span id.
61+
self.span_id = '%032x' % insecure_random.getrandbits(128)
62+
self.name = name
63+
self.start_time = start_time
64+
self.end_time = None # Gets filled in later
65+
self.trace_annotations = []
66+
self.parent_span_id = None # Gets filled in at the end
67+
68+
if parent_name:
69+
self.log_name = parent_name + '#' + name
70+
else:
71+
self.log_name = name
72+
73+
def __repr__(self):
74+
return ('name=%r, span_id=%r start_time=%r end_time=%r annotations=%r, parent_span_id=%r,'
75+
'log_name=%r') % (
76+
self.name,
77+
self.span_id,
78+
self.start_time,
79+
self.end_time,
80+
self.trace_annotations,
81+
self.parent_span_id,
82+
self.log_name,
83+
)
84+
85+
def format_report(aggregated_report):
86+
"""returns a pretty printed string of reported values"""
87+
values = aggregated_report.aggregated_values
88+
root_tr_data = aggregated_report.root_timer_data
89+
90+
# fetch all values only for main stopwatch, ignore all the tags
91+
log_names = sorted(
92+
log_name for log_name in values if "+" not in log_name
93+
)
94+
if not log_names:
95+
return
96+
97+
root = log_names[0]
98+
root_time_ms, root_count, bucket = values[root]
99+
buf = [
100+
"************************",
101+
"*** StopWatch Report ***",
102+
"************************",
103+
"%s %.3fms (%.f%%)" % (root.ljust(20), root_time_ms / root_count, 100),
104+
]
105+
for log_name in log_names[1:]:
106+
delta_ms, count, bucket = values[log_name]
107+
depth = log_name[len(root):].count("#")
108+
short_name = log_name[log_name.rfind("#") + 1:]
109+
bucket_name = bucket.name if bucket else ""
110+
111+
buf.append("%s%s %s %4d %.3fms (%.f%%)" % (
112+
" " * depth, bucket_name.ljust(12),
113+
short_name.ljust(20),
114+
count,
115+
delta_ms,
116+
delta_ms / root_time_ms * 100.0,
117+
))
118+
119+
annotations = sorted(ann.key for ann in root_tr_data.trace_annotations)
120+
buf.append("Annotations: %s" % (', '.join(annotations)))
121+
return "\n".join(buf)
122+
123+
def default_export_tracing(reported_traces):
124+
"""Default implementation of non-aggregated trace logging"""
125+
pass
126+
127+
def default_export_aggregated_timers(aggregated_report):
128+
"""Default implementation of aggregated timer logging"""
129+
pass
130+
131+
class StopWatch(object):
132+
"""StopWatch - main class for storing timer stack and exposing timer functions/contextmanagers
133+
to the rest of the code"""
134+
135+
def __init__(self,
136+
strict_assert=True,
137+
export_tracing_func=None,
138+
export_aggregated_timers_func=None,
139+
max_tracing_spans_for_path=1000,
140+
min_tracing_milliseconds=3,
141+
time_func=None):
142+
"""
143+
Arguments:
144+
strict_assert: If True, assert on callsite misuse
145+
146+
export_tracing_func: Function to log tracing data when stack empties
147+
148+
export_aggregated_timers_func: Function to log timers when stack empties
149+
150+
max_tracing_spans_for_path:
151+
The maximum number of spans to be logged per root scope for
152+
each unique path, so we make sure we aren't too excessive on the
153+
non-aggregated tracing report
154+
155+
min_tracing_milliseconds:
156+
To reduce the large number of trivial spans, don't trace
157+
anything that takes less then this amount of time.
158+
159+
time_func:
160+
Function which returns the current time in seconds. Defaults to time.time
161+
"""
162+
163+
self._timer_stack = []
164+
self._strict_assert = strict_assert
165+
self._export_tracing_func = export_tracing_func or default_export_tracing
166+
self._export_aggregated_timers_func = (
167+
export_aggregated_timers_func or default_export_aggregated_timers
168+
)
169+
self._time_func = time_func or time.time
170+
self.MAX_REQUEST_TRACING_SPANS_FOR_PATH = max_tracing_spans_for_path
171+
self.TRACING_MIN_NUM_MILLISECONDS = min_tracing_milliseconds
172+
self._last_trace_report = None
173+
self._last_aggregated_report = None
174+
175+
self._reset()
176+
177+
def _reset(self):
178+
"""Reset internal timer stack when stack is cleared"""
179+
if self._timer_stack:
180+
assert not self._strict_assert, \
181+
"StopWatch reset() but stack not empty: %r" % (self._timer_stack,)
182+
self._reported_values = {}
183+
self._reported_traces = []
184+
self._root_annotations = []
185+
self._slow_annotations = {}
186+
187+
# Dictionary of span names that have been cancelled in the current
188+
# context. Used to ensure that a cancelled span is not redundantly ended as well.
189+
self._cancelled_spans = {}
190+
191+
################
192+
# Public methods
193+
################
194+
@contextlib.contextmanager
195+
def sampling_timer(self, name, p, *n, **kwargs):
196+
"""Context manager that will time the context with probability p."""
197+
if p > insecure_random.uniform(0.0, 1.0):
198+
with self.timer(name, *n, **kwargs):
199+
yield
200+
else:
201+
yield
202+
203+
@contextlib.contextmanager
204+
def timer(self, name, bucket=None, start_time=None, end_time=None):
205+
"""Context manager to wrap a stopwatch span"""
206+
self.start(name, start_time=start_time)
207+
try:
208+
yield
209+
except Exception as e:
210+
self.add_annotation('Exception', type(e).__name__, event_time=end_time)
211+
raise
212+
finally:
213+
if name in self._cancelled_spans:
214+
del self._cancelled_spans[name]
215+
else:
216+
self.end(name, end_time=end_time, bucket=bucket)
217+
218+
def start(self, name, start_time=None):
219+
"""Begin a stopwatch span
220+
Arguments:
221+
name:
222+
Name of the span to start
223+
start_time:
224+
Time (s) at which the scope began if set. (if not, use the current time)
225+
"""
226+
if start_time is None:
227+
start_time = self._time_func()
228+
self._timer_stack.append(TimerData(
229+
name=name,
230+
start_time=start_time,
231+
parent_name=self._timer_stack[-1].log_name if self._timer_stack else None
232+
))
233+
234+
def end(self, name, end_time=None, bucket=None):
235+
"""End a stopwatch span (must match latest started span)
236+
Arguments:
237+
name:
238+
Name of the scope that's completed. Must match the latest start()
239+
end_time:
240+
Time (s) at which the scope completed if set (if not use the current time)
241+
bucket:
242+
optional enum.Enum value describing bucket for additional reporting.
243+
For example, you might bucket all database queries together to see
244+
overall how much time is spent in databases.
245+
"""
246+
247+
if not end_time:
248+
end_time = self._time_func()
249+
250+
tr_data = self._pop_stack(name)
251+
tr_data.end_time = end_time
252+
log_name = tr_data.log_name
253+
254+
# Aggregate into a single bucket per concatenated log name. This makes sure that code like
255+
# the following code stopwatches as expected.
256+
#
257+
# with StopWatch.timer('cool_loop_time'):
258+
# for x in cool_loop:
259+
# cool_stuff(x)
260+
tr_delta_ms = max((tr_data.end_time - tr_data.start_time) * 1000.0, 0.001)
261+
if log_name in self._reported_values:
262+
self._reported_values[log_name][0] += tr_delta_ms
263+
self._reported_values[log_name][1] += 1
264+
else:
265+
self._reported_values[log_name] = [tr_delta_ms, 1, bucket]
266+
267+
# go through slow tags and add them as tags if enough time has passed
268+
if not self._timer_stack:
269+
tr_data.trace_annotations.extend(self._root_annotations)
270+
271+
threshold_s = tr_delta_ms / 1000.0
272+
for slowtag, timelimit in self._slow_annotations.items():
273+
if timelimit <= threshold_s:
274+
tr_data.trace_annotations.append(
275+
TraceAnnotation(slowtag, '1', tr_data.end_time)
276+
)
277+
278+
if self._should_trace_timer(log_name, tr_delta_ms):
279+
tr_data.parent_span_id = self._timer_stack[-1].span_id if self._timer_stack else None
280+
self._reported_traces.append(tr_data)
281+
282+
# report stopwatch values once the final 'end' call has been made
283+
if not self._timer_stack:
284+
agg_report = AggregatedReport(self._reported_values, tr_data)
285+
# Stash information internally
286+
self._last_trace_report = self._reported_traces
287+
self._last_aggregated_report = agg_report
288+
# Hit callbacks
289+
self._export_tracing_func(reported_traces=self._reported_traces)
290+
self._export_aggregated_timers_func(aggregated_report=agg_report)
291+
292+
self._reset() # Clear out stats to prevent duplicate reporting
293+
294+
def cancel(self, name):
295+
"""Cancels a stopwatch span (must match latest started span).
296+
This span will not show up in any reports.
297+
Arguments:
298+
name:
299+
Name of the scope that's being cancelled. Must match the latest start().
300+
"""
301+
self._pop_stack(name, end_type='cancel')
302+
self._cancelled_spans[name] = 1
303+
304+
def add_annotation(self, key, value='1', event_time=None):
305+
"""Add an annotation to the root scope. Note that we don't do this directly
306+
in order to support this case:
307+
308+
if x > 1000:
309+
sw.add_annotation('big_work')
310+
with sw.timer('root'):
311+
do_work(x)
312+
313+
"""
314+
if event_time is None:
315+
event_time = self._time_func()
316+
self._root_annotations.append(
317+
TraceAnnotation(key, value, event_time)
318+
)
319+
320+
def add_span_annotation(self, key, value='1', event_time=None):
321+
"""Add an annotation to the current scope"""
322+
if event_time is None:
323+
event_time = self._time_func()
324+
self._timer_stack[-1].trace_annotations.append(
325+
TraceAnnotation(key, value, event_time)
326+
)
327+
328+
def add_slow_annotation(self, tag, timelimit):
329+
"""add annotation that will only be used if root scope takes longer than
330+
timelimit amount of seconds
331+
Arguments:
332+
tag: String tag name for the slowtag
333+
timelimit: Lower bound for the root scope after which tag is applied
334+
"""
335+
self._slow_annotations[tag] = timelimit
336+
337+
def get_last_trace_report(self):
338+
"""Returns the last trace report from when the last root_scope completed"""
339+
return self._last_trace_report
340+
341+
def get_last_aggregated_report(self):
342+
"""Returns the last aggregated report and tags as a 2-tuple"""
343+
return self._last_aggregated_report
344+
345+
def format_last_report(self):
346+
"""Return formatted report from the last aggregated report. Simply calls
347+
format_last_report() on the get_last_aggregated_report()"""
348+
return format_report(self._last_aggregated_report)
349+
350+
#################
351+
# Private methods
352+
#################
353+
354+
def _pop_stack(self, name, end_type='end'):
355+
"""Remove elements off the top of the timer stack until the element with name `name` is found.
356+
Return that element."""
357+
if not self._timer_stack:
358+
assert not self._strict_assert, \
359+
"StopWatch %s called but stack is empty: %s" % (end_type, name,)
360+
return
361+
362+
tr_data = self._timer_stack.pop()
363+
assert (not self._strict_assert) or (tr_data.name == name), \
364+
"StopWatch %s: %s, does not match latest start: %s" % (end_type, name, tr_data.name)
365+
366+
# if the top element on stack doesn't match "name", need to pop off things from the stack
367+
# till it matches to maximally negate the possible inconsistencies
368+
while name != tr_data.name and self._timer_stack:
369+
tr_data = self._timer_stack.pop()
370+
return tr_data
371+
372+
def _should_trace_timer(self, log_name, delta_ms):
373+
"""
374+
Helper method to determine if we should log the message or not.
375+
"""
376+
if delta_ms < self.TRACING_MIN_NUM_MILLISECONDS:
377+
return False
378+
379+
# Check if we have logged too many spans with the same full path already.
380+
# If yes, we should stop logging so we don't overload. E.g. if someone
381+
# is making for loop with 50k stopwatches, we will log only the first
382+
# MAX_REQUEST_TRACING_SPANS_FOR_PATH spans.
383+
384+
return bool(log_name not in self._reported_values or
385+
self._reported_values[log_name][1] <= self.MAX_REQUEST_TRACING_SPANS_FOR_PATH)

0 commit comments

Comments
 (0)