timify 0.0.5 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yardopts +9 -5
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -21
- data/README.md +302 -187
- data/lib/timify/recording.rb +375 -0
- data/lib/timify/registry.rb +31 -0
- data/lib/timify/report.rb +192 -0
- data/lib/timify/span.rb +71 -0
- data/lib/timify/trace.rb +27 -0
- data/lib/timify/version.rb +5 -0
- data/lib/timify.rb +153 -199
- metadata +21 -14
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Timify
|
|
4
|
+
# Retained samples per bucket. min, max, count, and the sums stay exact
|
|
5
|
+
# after this cap; percentiles then use the retained prefix.
|
|
6
|
+
MAX_SAMPLES = 10_000
|
|
7
|
+
|
|
8
|
+
THREAD_STATE_KEY = :__timify_thread_state__
|
|
9
|
+
|
|
10
|
+
# Pauses or resumes recording.
|
|
11
|
+
# Time spent paused is dropped: the next segment on each thread starts when
|
|
12
|
+
# the timer is turned back on. +:off+ does not erase segments already recorded.
|
|
13
|
+
#
|
|
14
|
+
# @param value [Symbol, String] +:on+ or +:off+
|
|
15
|
+
# @return [Symbol]
|
|
16
|
+
# @raise [ArgumentError] when +value+ is not +:on+ or +:off+
|
|
17
|
+
def status=(value)
|
|
18
|
+
normalized = value.respond_to?(:to_sym) ? value.to_sym : nil
|
|
19
|
+
unless %i[on off].include?(normalized)
|
|
20
|
+
raise ArgumentError, "status must be :on or :off"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
synchronize do
|
|
24
|
+
if @status == :on && normalized == :off
|
|
25
|
+
@pause_started = monotonic
|
|
26
|
+
elsif @status == :off && normalized == :on
|
|
27
|
+
now = monotonic
|
|
28
|
+
@paused_monotonic += now - @pause_started if @pause_started
|
|
29
|
+
@pause_started = nil
|
|
30
|
+
@resume_at = now
|
|
31
|
+
thread_state[:last_mark] = now
|
|
32
|
+
end
|
|
33
|
+
@status = normalized
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Records the time elapsed since the previous mark on this thread.
|
|
38
|
+
# Inside {#measure}, the mark becomes a child span instead of being counted
|
|
39
|
+
# again as part of the parent's self time. The first call on a thread has no
|
|
40
|
+
# range. Later calls record the jump from the previous call site to this one.
|
|
41
|
+
# When {.enabled} is +false+, returns +0.0+ and records nothing.
|
|
42
|
+
#
|
|
43
|
+
# @param label [Object, nil] optional name. Symbols and strings with the
|
|
44
|
+
# same text are grouped together. +nil+ and blank strings are ignored.
|
|
45
|
+
# @return [Float] self time of this segment, or +0.0+ when paused or disabled
|
|
46
|
+
def add(label = nil)
|
|
47
|
+
return 0.0 unless self.class.enabled?
|
|
48
|
+
|
|
49
|
+
location = caller_location
|
|
50
|
+
normalized = normalize_label(label)
|
|
51
|
+
time_spent, events = synchronize do
|
|
52
|
+
paused? ? [0.0, []] : record_add(location, normalized)
|
|
53
|
+
end
|
|
54
|
+
deliver(events)
|
|
55
|
+
time_spent
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Records the time spent inside +block+ and returns the block's value.
|
|
59
|
+
# Time from the previous mark up to this call is stored first, when it is
|
|
60
|
+
# longer than zero, as an unlabeled segment. The block is a span: its
|
|
61
|
+
# inclusive time is the whole block, and its self time is what nested
|
|
62
|
+
# {#measure} and {#add} calls did not take. If the block raises, the span is
|
|
63
|
+
# still recorded and the exception propagates. While paused, or when
|
|
64
|
+
# {.enabled} is +false+, the block runs and nothing is recorded.
|
|
65
|
+
#
|
|
66
|
+
# @param label [Object, nil] same rules as {#add}
|
|
67
|
+
# @yield the work to time
|
|
68
|
+
# @return [Object] the block's return value
|
|
69
|
+
# @raise [ArgumentError] when no block is given
|
|
70
|
+
def measure(label = nil)
|
|
71
|
+
raise ArgumentError, "measure requires a block" unless block_given?
|
|
72
|
+
return yield unless self.class.enabled?
|
|
73
|
+
|
|
74
|
+
location = caller_location
|
|
75
|
+
normalized = normalize_label(label)
|
|
76
|
+
opened = false
|
|
77
|
+
events = []
|
|
78
|
+
synchronize do
|
|
79
|
+
unless paused?
|
|
80
|
+
open_measure(location, normalized)
|
|
81
|
+
opened = true
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
return yield unless opened
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
yield
|
|
88
|
+
ensure
|
|
89
|
+
synchronize { events = close_measure }
|
|
90
|
+
deliver(events)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Registers a callback invoked when a segment is at least +seconds+ long.
|
|
95
|
+
# {#add} compares the segment. {#measure} compares the block's inclusive
|
|
96
|
+
# time. Callbacks run after the timer has recorded the segment, and several
|
|
97
|
+
# callbacks can be registered.
|
|
98
|
+
#
|
|
99
|
+
# @param seconds [Numeric]
|
|
100
|
+
# @yield [event]
|
|
101
|
+
# @yieldparam event [Timify::Event]
|
|
102
|
+
# @return [Timify] self
|
|
103
|
+
# @raise [ArgumentError] when no block is given or +seconds+ is negative
|
|
104
|
+
def on_slow(seconds, &block)
|
|
105
|
+
raise ArgumentError, "on_slow requires a block" unless block
|
|
106
|
+
|
|
107
|
+
threshold = Float(seconds)
|
|
108
|
+
raise ArgumentError, "threshold must be >= 0" if threshold.negative?
|
|
109
|
+
|
|
110
|
+
synchronize { @slow_handlers << [threshold, block] }
|
|
111
|
+
self
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Registers a callback invoked when a child span's inclusive time is at least
|
|
115
|
+
# +ratio+ of the parent's elapsed time so far. Fires for labeled {#measure}
|
|
116
|
+
# and {#add} children, not for root spans or unlabeled gap spans. Several
|
|
117
|
+
# callbacks can be registered. They run after the segment is recorded,
|
|
118
|
+
# outside the timer mutex.
|
|
119
|
+
#
|
|
120
|
+
# @param ratio [Numeric] share from +0+ to +1+ inclusive
|
|
121
|
+
# @yield [event]
|
|
122
|
+
# @yieldparam event [Timify::Event] includes +share+ and +parent_label+
|
|
123
|
+
# @return [Timify] self
|
|
124
|
+
# @raise [ArgumentError] when no block is given or +ratio+ is outside +0..1+
|
|
125
|
+
def on_share(ratio, &block)
|
|
126
|
+
raise ArgumentError, "on_share requires a block" unless block
|
|
127
|
+
|
|
128
|
+
value = Float(ratio)
|
|
129
|
+
unless value >= 0.0 && value <= 1.0
|
|
130
|
+
raise ArgumentError, "ratio must be between 0 and 1"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
synchronize { @share_handlers << [value, block] }
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Clears recorded segments and starts the clock again.
|
|
138
|
+
# Keeps {#name}, {#show}, {#min_time_to_show}, {#output}, {#on_slow}, and
|
|
139
|
+
# {#on_share} callbacks. The timer is left running even if it was paused.
|
|
140
|
+
# Other threads drop their old cursor on the next mark.
|
|
141
|
+
#
|
|
142
|
+
# @return [Timify] self
|
|
143
|
+
def reset
|
|
144
|
+
synchronize do
|
|
145
|
+
@generation += 1
|
|
146
|
+
@status = :on
|
|
147
|
+
@locations = {}
|
|
148
|
+
@labels = {}
|
|
149
|
+
@ranges = {}
|
|
150
|
+
@roots = []
|
|
151
|
+
@total = 0.0
|
|
152
|
+
@max_time_spent = 0.0
|
|
153
|
+
@initial_time = Time.now
|
|
154
|
+
@finished_time = @initial_time
|
|
155
|
+
@paused_monotonic = 0.0
|
|
156
|
+
@pause_started = nil
|
|
157
|
+
@resume_at = nil
|
|
158
|
+
now = monotonic
|
|
159
|
+
@origin_mark = now
|
|
160
|
+
@last_record_mark = now
|
|
161
|
+
thread_table[object_id] = {
|
|
162
|
+
generation: @generation,
|
|
163
|
+
last_mark: now,
|
|
164
|
+
stack: [],
|
|
165
|
+
location_prev: nil
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
self
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def synchronize(&block)
|
|
174
|
+
@mutex.synchronize(&block)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def paused?
|
|
178
|
+
@status == :off
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def monotonic
|
|
182
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def thread_table
|
|
186
|
+
Thread.current[THREAD_STATE_KEY] ||= {}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def thread_state
|
|
190
|
+
state = thread_table[object_id]
|
|
191
|
+
if state.nil? || state[:generation] != @generation
|
|
192
|
+
state = {
|
|
193
|
+
generation: @generation,
|
|
194
|
+
last_mark: monotonic,
|
|
195
|
+
stack: [],
|
|
196
|
+
location_prev: nil
|
|
197
|
+
}
|
|
198
|
+
thread_table[object_id] = state
|
|
199
|
+
end
|
|
200
|
+
state[:last_mark] = @resume_at if @resume_at && state[:last_mark] < @resume_at
|
|
201
|
+
state
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def normalize_label(label)
|
|
205
|
+
return nil if label.nil?
|
|
206
|
+
|
|
207
|
+
text = label.to_s.strip
|
|
208
|
+
text.empty? ? nil : text
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def record_add(location, label)
|
|
212
|
+
state = thread_state
|
|
213
|
+
now = monotonic
|
|
214
|
+
time_spent = (now - state[:last_mark]).to_f
|
|
215
|
+
state[:last_mark] = now
|
|
216
|
+
record_segment(location, label, time_spent, inclusive: time_spent, range: true, state: state)
|
|
217
|
+
parent = state[:stack].last
|
|
218
|
+
attach_span(state, Span.new(label: label, location: location).tap { |span|
|
|
219
|
+
span.inclusive = time_spent
|
|
220
|
+
span.exclusive = time_spent
|
|
221
|
+
})
|
|
222
|
+
events = slow_events(label, location, time_spent, time_spent)
|
|
223
|
+
events.concat(share_events(label, location, time_spent, time_spent, parent, now))
|
|
224
|
+
[time_spent, events]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def open_measure(location, label)
|
|
228
|
+
state = thread_state
|
|
229
|
+
now = monotonic
|
|
230
|
+
gap = (now - state[:last_mark]).to_f
|
|
231
|
+
if gap.positive?
|
|
232
|
+
state[:last_mark] = now
|
|
233
|
+
record_segment(location, nil, gap, inclusive: gap, range: true, state: state)
|
|
234
|
+
attach_span(state, Span.new(label: nil, location: location).tap { |span|
|
|
235
|
+
span.inclusive = gap
|
|
236
|
+
span.exclusive = gap
|
|
237
|
+
})
|
|
238
|
+
end
|
|
239
|
+
state[:stack] << Span.new(label: label, location: location, started_mark: now)
|
|
240
|
+
state[:last_mark] = now
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def close_measure
|
|
244
|
+
state = thread_state
|
|
245
|
+
span = state[:stack].pop
|
|
246
|
+
finished = monotonic
|
|
247
|
+
inclusive = (finished - span.started_mark).to_f
|
|
248
|
+
exclusive = inclusive - span.children.sum(&:inclusive)
|
|
249
|
+
exclusive = 0.0 if exclusive.negative?
|
|
250
|
+
span.inclusive = inclusive
|
|
251
|
+
span.exclusive = exclusive
|
|
252
|
+
state[:last_mark] = finished
|
|
253
|
+
record_segment(span.location, span.label, exclusive, inclusive: inclusive, range: false, state: state)
|
|
254
|
+
state[:location_prev] = span.location
|
|
255
|
+
parent = state[:stack].last
|
|
256
|
+
attach_span(state, span)
|
|
257
|
+
events = slow_events(span.label, span.location, exclusive, inclusive)
|
|
258
|
+
events.concat(share_events(span.label, span.location, exclusive, inclusive, parent, finished))
|
|
259
|
+
events
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def attach_span(state, span)
|
|
263
|
+
if (parent = state[:stack].last)
|
|
264
|
+
parent.children << span
|
|
265
|
+
else
|
|
266
|
+
@roots << span
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def record_segment(location, label, exclusive, inclusive:, range:, state:)
|
|
271
|
+
@total += exclusive
|
|
272
|
+
@finished_time = Time.now
|
|
273
|
+
@last_record_mark = state[:last_mark]
|
|
274
|
+
new_max = exclusive > @max_time_spent
|
|
275
|
+
@max_time_spent = exclusive if new_max
|
|
276
|
+
|
|
277
|
+
update_bucket(@locations, location, exclusive, inclusive)
|
|
278
|
+
update_bucket(@labels, label, exclusive, inclusive) if label
|
|
279
|
+
if range && state[:location_prev]
|
|
280
|
+
update_bucket(@ranges, "#{state[:location_prev]} - #{location}", exclusive, inclusive)
|
|
281
|
+
end
|
|
282
|
+
state[:location_prev] = location if range
|
|
283
|
+
|
|
284
|
+
return if exclusive < @min_time_to_show
|
|
285
|
+
|
|
286
|
+
emit(segment_message(label, new_max, location, location_percent(location), exclusive))
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def update_bucket(buckets, key, exclusive, inclusive)
|
|
290
|
+
bucket = buckets[key] ||= empty_bucket
|
|
291
|
+
bucket[:secs] += exclusive
|
|
292
|
+
bucket[:inclusive] += inclusive
|
|
293
|
+
bucket[:count] += 1
|
|
294
|
+
bucket[:min] = exclusive if bucket[:min].nil? || exclusive < bucket[:min]
|
|
295
|
+
bucket[:max] = exclusive if bucket[:max].nil? || exclusive > bucket[:max]
|
|
296
|
+
if bucket[:samples].length < MAX_SAMPLES
|
|
297
|
+
bucket[:samples] << exclusive
|
|
298
|
+
else
|
|
299
|
+
bucket[:samples_truncated] = true
|
|
300
|
+
end
|
|
301
|
+
bucket
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def empty_bucket
|
|
305
|
+
{
|
|
306
|
+
secs: 0.0,
|
|
307
|
+
inclusive: 0.0,
|
|
308
|
+
count: 0,
|
|
309
|
+
min: nil,
|
|
310
|
+
max: nil,
|
|
311
|
+
samples: [],
|
|
312
|
+
samples_truncated: false
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def location_percent(location)
|
|
317
|
+
return 0 if @total.zero?
|
|
318
|
+
|
|
319
|
+
((@locations[location][:secs] / @total) * 100).round
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def slow_events(label, location, secs, inclusive)
|
|
323
|
+
@slow_handlers.filter_map do |threshold, handler|
|
|
324
|
+
next if inclusive < threshold
|
|
325
|
+
|
|
326
|
+
[handler, Event.new(name: @name, label: label, location: location, secs: secs, inclusive: inclusive)]
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def share_events(label, location, secs, inclusive, parent, now)
|
|
331
|
+
return [] if parent.nil? || label.nil?
|
|
332
|
+
|
|
333
|
+
parent_elapsed = (now - parent.started_mark).to_f
|
|
334
|
+
return [] if parent_elapsed <= 0.0
|
|
335
|
+
|
|
336
|
+
share = inclusive / parent_elapsed
|
|
337
|
+
@share_handlers.filter_map do |ratio, handler|
|
|
338
|
+
next if share < ratio
|
|
339
|
+
|
|
340
|
+
[
|
|
341
|
+
handler,
|
|
342
|
+
Event.new(
|
|
343
|
+
name: @name,
|
|
344
|
+
label: label,
|
|
345
|
+
location: location,
|
|
346
|
+
secs: secs,
|
|
347
|
+
inclusive: inclusive,
|
|
348
|
+
share: share,
|
|
349
|
+
parent_label: parent.label
|
|
350
|
+
)
|
|
351
|
+
]
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def deliver(events)
|
|
356
|
+
events.each { |handler, event| handler.call(event) }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def segment_message(label, new_max, location, percent, time_spent)
|
|
360
|
+
label_text = label ? "<#{label}>" : ""
|
|
361
|
+
max_text = new_max ? "(New Max)" : ""
|
|
362
|
+
"<#{@name}>#{label_text}#{max_text}: #{location} (#{percent}%): " \
|
|
363
|
+
"#{format_secs(@total)}; #{format_secs(time_spent)}"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def emit(message)
|
|
367
|
+
return unless @show
|
|
368
|
+
|
|
369
|
+
if @output.respond_to?(:info) && !@output.respond_to?(:puts)
|
|
370
|
+
@output.info(message)
|
|
371
|
+
else
|
|
372
|
+
@output.puts(message)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Timify
|
|
4
|
+
REGISTRY_MUTEX = Mutex.new
|
|
5
|
+
|
|
6
|
+
# Returns the timer registered under +name+, creating it on first use.
|
|
7
|
+
# Registered timers start with +show: false+ so fetching one does not print.
|
|
8
|
+
#
|
|
9
|
+
# @param name [Object]
|
|
10
|
+
# @return [Timify]
|
|
11
|
+
def self.[](name)
|
|
12
|
+
REGISTRY_MUTEX.synchronize do
|
|
13
|
+
registry[name] ||= new(name, show: false)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Drops every timer created through {.[]}.
|
|
18
|
+
#
|
|
19
|
+
# @return [void]
|
|
20
|
+
def self.clear!
|
|
21
|
+
REGISTRY_MUTEX.synchronize { @registry = {} }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def registry
|
|
28
|
+
@registry ||= {}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Timify
|
|
4
|
+
# Returns the report for every segment recorded so far.
|
|
5
|
+
# Pausing the timer does not hide this report. When {#show} is true, the
|
|
6
|
+
# printable summary is also written to {#output}.
|
|
7
|
+
#
|
|
8
|
+
# +locations+, +labels+, and +ranges+ are ordered by self time, slowest
|
|
9
|
+
# first. Each entry contains:
|
|
10
|
+
# - +secs+ [Float] accumulated self time
|
|
11
|
+
# - +inclusive+ [Float] accumulated inclusive time
|
|
12
|
+
# - +percent+ [Integer] share of {#total}, rounded to the nearest percent
|
|
13
|
+
# - +count+ [Integer] number of segments
|
|
14
|
+
# - +min+ [Float] shortest self time
|
|
15
|
+
# - +max+ [Float] longest self time
|
|
16
|
+
# - +avg+ [Float] +secs / count+
|
|
17
|
+
# - +p50+, +p95+, +p99+ [Float] nearest-rank percentiles of self time
|
|
18
|
+
# - +samples_truncated+ [Boolean] present when the percentile sample was capped
|
|
19
|
+
#
|
|
20
|
+
# +tree+ is the chronological list of top-level spans. Each node has
|
|
21
|
+
# +label+, +location+, +secs+, +inclusive+, and +children+.
|
|
22
|
+
# +grouped_tree+ merges sibling nodes with the same label recursively:
|
|
23
|
+
# +secs+ and +inclusive+ are summed, +count+ is how many spans were merged,
|
|
24
|
+
# and +children+ are grouped the same way. Order of first appearance is
|
|
25
|
+
# preserved. +wall_time+ is monotonic time from the start to the latest mark,
|
|
26
|
+
# minus pauses.
|
|
27
|
+
#
|
|
28
|
+
# @param json [Boolean] when true, return the same report as a JSON string
|
|
29
|
+
# @param group [Boolean] when true, print a +Grouped spans:+ section after
|
|
30
|
+
# the chronological Spans section. +grouped_tree+ is always included in
|
|
31
|
+
# the hash either way.
|
|
32
|
+
# @return [Hash, String]
|
|
33
|
+
def totals(json: false, group: false)
|
|
34
|
+
report = synchronize { build_report(group: group) }
|
|
35
|
+
emit(report[:message])
|
|
36
|
+
|
|
37
|
+
if json
|
|
38
|
+
require "json"
|
|
39
|
+
report.to_json
|
|
40
|
+
else
|
|
41
|
+
report
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def caller_location
|
|
48
|
+
format_location(caller_locations(2, 1).first)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def format_location(loc)
|
|
52
|
+
path = relative_path(loc.absolute_path || loc.path)
|
|
53
|
+
method = loc.base_label
|
|
54
|
+
suffix = method.nil? || method.empty? ? "" : " in #{method}"
|
|
55
|
+
"#{path}:#{loc.lineno}#{suffix}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def relative_path(path)
|
|
59
|
+
return path if path.nil? || path.empty?
|
|
60
|
+
|
|
61
|
+
expanded = File.expand_path(path)
|
|
62
|
+
prefix = "#{Dir.pwd}/"
|
|
63
|
+
expanded.start_with?(prefix) ? expanded.delete_prefix(prefix) : expanded
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_report(group: false)
|
|
67
|
+
wall = @last_record_mark - @origin_mark - @paused_monotonic
|
|
68
|
+
wall = 0.0 if wall.negative?
|
|
69
|
+
tree = @roots.map(&:to_h)
|
|
70
|
+
report = {
|
|
71
|
+
name: @name,
|
|
72
|
+
total_time: @total.to_f,
|
|
73
|
+
wall_time: wall.to_f,
|
|
74
|
+
started: @initial_time,
|
|
75
|
+
finished: @finished_time,
|
|
76
|
+
tree: tree,
|
|
77
|
+
grouped_tree: group_tree(tree),
|
|
78
|
+
locations: report_buckets(@locations),
|
|
79
|
+
labels: report_buckets(@labels),
|
|
80
|
+
ranges: report_buckets(@ranges)
|
|
81
|
+
}
|
|
82
|
+
report[:message] = summary_message(report, group: group)
|
|
83
|
+
report
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def group_tree(nodes)
|
|
87
|
+
order = []
|
|
88
|
+
buckets = {}
|
|
89
|
+
nodes.each do |node|
|
|
90
|
+
key = node[:label]
|
|
91
|
+
unless buckets.key?(key)
|
|
92
|
+
order << key
|
|
93
|
+
buckets[key] = []
|
|
94
|
+
end
|
|
95
|
+
buckets[key] << node
|
|
96
|
+
end
|
|
97
|
+
order.map do |key|
|
|
98
|
+
group = buckets[key]
|
|
99
|
+
first = group.first
|
|
100
|
+
{
|
|
101
|
+
label: first[:label],
|
|
102
|
+
location: first[:location],
|
|
103
|
+
secs: group.sum { |node| node[:secs] },
|
|
104
|
+
inclusive: group.sum { |node| node[:inclusive] },
|
|
105
|
+
count: group.size,
|
|
106
|
+
children: group_tree(group.flat_map { |node| node[:children] })
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def report_buckets(buckets)
|
|
112
|
+
ordered = buckets.sort_by { |key, bucket| [-bucket[:secs], key.to_s] }
|
|
113
|
+
ordered.each_with_object({}) do |(key, bucket), report|
|
|
114
|
+
count = bucket[:count]
|
|
115
|
+
secs = bucket[:secs]
|
|
116
|
+
samples = bucket[:samples].sort
|
|
117
|
+
entry = {
|
|
118
|
+
secs: secs,
|
|
119
|
+
inclusive: bucket[:inclusive],
|
|
120
|
+
percent: @total.zero? ? 0 : (secs * 100 / @total.to_f).round,
|
|
121
|
+
count: count,
|
|
122
|
+
min: bucket[:min] || 0.0,
|
|
123
|
+
max: bucket[:max] || 0.0,
|
|
124
|
+
avg: count.zero? ? 0.0 : secs / count,
|
|
125
|
+
p50: percentile(samples, 50),
|
|
126
|
+
p95: percentile(samples, 95),
|
|
127
|
+
p99: percentile(samples, 99)
|
|
128
|
+
}
|
|
129
|
+
entry[:samples_truncated] = true if bucket[:samples_truncated]
|
|
130
|
+
report[key] = entry
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Nearest-rank percentile. +samples+ must already be sorted.
|
|
135
|
+
def percentile(samples, percent)
|
|
136
|
+
return 0.0 if samples.empty?
|
|
137
|
+
|
|
138
|
+
rank = (percent / 100.0 * samples.length).ceil
|
|
139
|
+
rank = 1 if rank < 1
|
|
140
|
+
samples[rank - 1]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def summary_message(report, group: false)
|
|
144
|
+
message = "\n\nTotal time <#{@name}>:#{format_secs(report[:total_time])} wall #{format_secs(report[:wall_time])}"
|
|
145
|
+
unless report[:tree].empty?
|
|
146
|
+
message += "\nSpans:\n"
|
|
147
|
+
message += tree_text(report[:tree])
|
|
148
|
+
if group && !report[:grouped_tree].empty?
|
|
149
|
+
message += "Grouped spans:\n"
|
|
150
|
+
message += tree_text(report[:grouped_tree])
|
|
151
|
+
end
|
|
152
|
+
message += "Total time by location:\n"
|
|
153
|
+
else
|
|
154
|
+
message += "\nTotal time by location:\n"
|
|
155
|
+
end
|
|
156
|
+
message += bucket_text(report[:locations])
|
|
157
|
+
unless report[:labels].empty?
|
|
158
|
+
message += "\nTotal time by label:\n"
|
|
159
|
+
message += bucket_text(report[:labels])
|
|
160
|
+
end
|
|
161
|
+
unless report[:ranges].empty?
|
|
162
|
+
message += "\nTotal time by range:\n"
|
|
163
|
+
message += bucket_text(report[:ranges])
|
|
164
|
+
end
|
|
165
|
+
"#{message}\n\n"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def tree_text(nodes, depth = 0)
|
|
169
|
+
nodes.map { |node|
|
|
170
|
+
label = node[:label] || "(gap)"
|
|
171
|
+
count = node.key?(:count) ? " ##{node[:count]}" : ""
|
|
172
|
+
line = "\t#{' ' * depth}#{label} inclusive #{format_secs(node[:inclusive])} self #{format_secs(node[:secs])}#{count}\n"
|
|
173
|
+
line + tree_text(node[:children], depth + 1)
|
|
174
|
+
}.join
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def bucket_text(buckets)
|
|
178
|
+
buckets.map { |key, data|
|
|
179
|
+
inclusive = ""
|
|
180
|
+
if (data[:inclusive] - data[:secs]).abs > 0.000_001
|
|
181
|
+
inclusive = " inclusive #{format_secs(data[:inclusive])}"
|
|
182
|
+
end
|
|
183
|
+
"\t#{key}: #{format_secs(data[:secs])} (#{data[:percent]}%) ##{data[:count]} " \
|
|
184
|
+
"min #{format_secs(data[:min])} max #{format_secs(data[:max])} avg #{format_secs(data[:avg])} " \
|
|
185
|
+
"p95 #{format_secs(data[:p95])}#{inclusive}\n"
|
|
186
|
+
}.join
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def format_secs(value)
|
|
190
|
+
value.round(2)
|
|
191
|
+
end
|
|
192
|
+
end
|
data/lib/timify/span.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Timify
|
|
4
|
+
# One timed block or mark in the tree returned by {Timify#totals}.
|
|
5
|
+
class Span
|
|
6
|
+
attr_accessor :label, :location, :inclusive, :exclusive, :children, :started_mark
|
|
7
|
+
|
|
8
|
+
def initialize(label:, location:, started_mark: nil)
|
|
9
|
+
@label = label
|
|
10
|
+
@location = location
|
|
11
|
+
@started_mark = started_mark
|
|
12
|
+
@inclusive = 0.0
|
|
13
|
+
@exclusive = 0.0
|
|
14
|
+
@children = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Hash]
|
|
18
|
+
def to_h
|
|
19
|
+
{
|
|
20
|
+
label: label,
|
|
21
|
+
location: location,
|
|
22
|
+
secs: exclusive,
|
|
23
|
+
inclusive: inclusive,
|
|
24
|
+
children: children.map(&:to_h)
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A segment that crossed an {Timify#on_slow} or {Timify#on_share} threshold.
|
|
30
|
+
class Event
|
|
31
|
+
# @return [Object]
|
|
32
|
+
attr_reader :name
|
|
33
|
+
|
|
34
|
+
# @return [String, nil]
|
|
35
|
+
attr_reader :label
|
|
36
|
+
|
|
37
|
+
# @return [String]
|
|
38
|
+
attr_reader :location
|
|
39
|
+
|
|
40
|
+
# @return [Float] self time of the segment
|
|
41
|
+
attr_reader :secs
|
|
42
|
+
|
|
43
|
+
# @return [Float] inclusive time of the segment
|
|
44
|
+
attr_reader :inclusive
|
|
45
|
+
|
|
46
|
+
# @return [Float, nil] child inclusive / parent elapsed so far; set for
|
|
47
|
+
# {Timify#on_share} events, +nil+ for {Timify#on_slow}
|
|
48
|
+
attr_reader :share
|
|
49
|
+
|
|
50
|
+
# @return [String, nil] label of the parent span; set for {Timify#on_share}
|
|
51
|
+
# events, +nil+ for {Timify#on_slow}
|
|
52
|
+
attr_reader :parent_label
|
|
53
|
+
|
|
54
|
+
# @param name [Object]
|
|
55
|
+
# @param label [String, nil]
|
|
56
|
+
# @param location [String]
|
|
57
|
+
# @param secs [Float]
|
|
58
|
+
# @param inclusive [Float]
|
|
59
|
+
# @param share [Float, nil]
|
|
60
|
+
# @param parent_label [String, nil]
|
|
61
|
+
def initialize(name:, label:, location:, secs:, inclusive:, share: nil, parent_label: nil)
|
|
62
|
+
@name = name
|
|
63
|
+
@label = label
|
|
64
|
+
@location = location
|
|
65
|
+
@secs = secs
|
|
66
|
+
@inclusive = inclusive
|
|
67
|
+
@share = share
|
|
68
|
+
@parent_label = parent_label
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/timify/trace.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Timify
|
|
4
|
+
# Result of {.trace}: the block's return value and the timer used to measure it.
|
|
5
|
+
class Trace
|
|
6
|
+
# @return [Object] the block's return value
|
|
7
|
+
attr_reader :value
|
|
8
|
+
|
|
9
|
+
# @return [Timify] the timer created for the block
|
|
10
|
+
attr_reader :timer
|
|
11
|
+
|
|
12
|
+
# @param value [Object]
|
|
13
|
+
# @param timer [Timify]
|
|
14
|
+
def initialize(value:, timer:)
|
|
15
|
+
@value = value
|
|
16
|
+
@timer = timer
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Delegates to {Timify#totals} on {#timer}.
|
|
20
|
+
#
|
|
21
|
+
# @param options [Hash] keyword arguments accepted by {Timify#totals}
|
|
22
|
+
# @return [Hash, String]
|
|
23
|
+
def totals(**options)
|
|
24
|
+
@timer.totals(**options)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|