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.
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Timify
4
+ VERSION = "1.0.0"
5
+ end