profile-tools 0.1.0 → 0.2.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/CHANGELOG.md +22 -0
- data/README.md +207 -2
- data/UPGRADING.md +98 -0
- data/lib/profile-tools.rb +97 -107
- data/lib/profile_tools/collector.rb +48 -73
- data/lib/profile_tools/error.rb +6 -0
- data/lib/profile_tools/log_subscriber.rb +24 -10
- data/lib/profile_tools/method_name.rb +51 -0
- data/lib/profile_tools/method_stats.rb +99 -0
- data/lib/profile_tools/method_wrapper.rb +95 -0
- data/lib/profile_tools/middleware.rb +21 -0
- data/lib/profile_tools/profiler.rb +34 -41
- data/lib/profile_tools/railtie.rb +47 -0
- data/lib/profile_tools/unknown_method_error.rb +6 -0
- data/lib/profile_tools/version.rb +6 -0
- metadata +44 -22
- data/.gitignore +0 -52
- data/.rubocop.yml +0 -14
- data/.ruby-gemset +0 -1
- data/.ruby-version +0 -1
- data/Gemfile +0 -15
- data/Gemfile.lock +0 -67
- data/profile-tools.gemspec +0 -13
- data/script/console +0 -16
- data/script/test +0 -53
|
@@ -1,97 +1,72 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
#
|
|
3
|
+
module ProfileTools
|
|
4
|
+
# Measures calls and adds them up per method for one profiling run (a request, a job, a block).
|
|
5
|
+
#
|
|
6
|
+
# Allocations are read from GC.stat(:total_allocated_objects), which only goes up, so the
|
|
7
|
+
# counts stay exact when garbage collection runs mid-call. The counter is process-wide:
|
|
8
|
+
# objects allocated by other threads at the same time are counted too.
|
|
9
|
+
#
|
|
10
|
+
# Nothing is allocated between the start and end readings, so a profiled method nested
|
|
11
|
+
# inside another doesn't add to the outer method's count.
|
|
7
12
|
class Collector
|
|
8
|
-
|
|
9
|
-
|
|
13
|
+
# @return [Hash{String => MethodStats}] stats for every method seen by this collector
|
|
14
|
+
attr_reader :stats
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
# Creates the stats for the given methods up front. A method seen for the first time
|
|
17
|
+
# inside another method's call allocates its {MethodStats}, which that outer call counts.
|
|
18
|
+
#
|
|
19
|
+
# @param method_names [Array<String>] display names of the profiled methods
|
|
20
|
+
def initialize(method_names = [])
|
|
21
|
+
@stats = {}
|
|
14
22
|
@sort_order = 0
|
|
23
|
+
method_names.each { |method| stats_for(method) }
|
|
15
24
|
end
|
|
16
25
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
# Runs the block and adds its time, allocations and garbage collection to the method's totals.
|
|
27
|
+
# A call made while the same method is already running is counted but not measured again.
|
|
28
|
+
#
|
|
29
|
+
# @param method [String] display name of the method
|
|
30
|
+
# @yield the code to measure
|
|
31
|
+
# @return [Object] the block's result
|
|
32
|
+
def instrument(method, &)
|
|
33
|
+
stats = stats_for(method)
|
|
34
|
+
recursive = stats.running?
|
|
35
|
+
stats.enter(@sort_order += 1)
|
|
36
|
+
begin
|
|
37
|
+
recursive ? yield : measure(stats, &)
|
|
38
|
+
ensure
|
|
39
|
+
stats.leave
|
|
40
|
+
end
|
|
26
41
|
end
|
|
27
42
|
|
|
43
|
+
# @return [Array<MethodStats>] the methods that were called, in the order they were first called
|
|
28
44
|
def called_methods
|
|
29
|
-
@
|
|
30
|
-
.values
|
|
31
|
-
.reject { |info| info[:calls].zero? }
|
|
32
|
-
.sort { |a, b| a[:sort_order] <=> b[:sort_order] }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def instrument(method)
|
|
36
|
-
current_collection_calls = @total_collection_calls
|
|
37
|
-
result = nil
|
|
38
|
-
duration = nil
|
|
39
|
-
@methods[method][:sort_order] ||= (@sort_order += 1)
|
|
40
|
-
count_objects = count_objects_around do
|
|
41
|
-
started_at = now
|
|
42
|
-
result = yield
|
|
43
|
-
duration = now - started_at
|
|
44
|
-
end
|
|
45
|
-
add(
|
|
46
|
-
method,
|
|
47
|
-
duration * 1000.0,
|
|
48
|
-
count_objects,
|
|
49
|
-
@total_collection_calls - current_collection_calls
|
|
50
|
-
)
|
|
51
|
-
result
|
|
45
|
+
@stats.values.select(&:called?).sort_by(&:sort_order)
|
|
52
46
|
end
|
|
53
47
|
|
|
54
48
|
private
|
|
55
49
|
|
|
56
|
-
def
|
|
57
|
-
@
|
|
58
|
-
@methods[method][:calls] += 1
|
|
59
|
-
@methods[method][:duration] += duration
|
|
60
|
-
@methods[method][:num_collection_calls] = num_collection_calls
|
|
61
|
-
add_object_changes(@methods[method][:count_objects], count_object_changes)
|
|
62
|
-
adjust_count_objects(@methods[method][:count_objects], num_collection_calls)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def add_object_changes(current_objects, new_objects)
|
|
66
|
-
new_objects.each do |name, cnt|
|
|
67
|
-
current_objects[name] += cnt
|
|
68
|
-
end
|
|
69
|
-
current_objects
|
|
50
|
+
def stats_for(method)
|
|
51
|
+
@stats[method] ||= MethodStats.new(method)
|
|
70
52
|
end
|
|
71
53
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
54
|
+
def measure(stats)
|
|
55
|
+
started_at = now
|
|
56
|
+
allocations = allocated_objects
|
|
57
|
+
gc_count = GC.count
|
|
58
|
+
gc_time = GC.stat(:time)
|
|
59
|
+
yield
|
|
60
|
+
ensure
|
|
61
|
+
stats.add(now - started_at, allocated_objects - allocations, GC.count - gc_count, GC.stat(:time) - gc_time)
|
|
78
62
|
end
|
|
79
63
|
|
|
80
64
|
def now
|
|
81
|
-
|
|
65
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
82
66
|
end
|
|
83
67
|
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
new_objects[name] -= starting_objects[name]
|
|
87
|
-
new_objects[name] -= 1 if name == :T_HASH
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def count_objects_around
|
|
92
|
-
starting_objects = ObjectSpace.count_objects
|
|
93
|
-
yield
|
|
94
|
-
count_objects_changes(starting_objects, ObjectSpace.count_objects)
|
|
68
|
+
def allocated_objects
|
|
69
|
+
GC.stat(:total_allocated_objects)
|
|
95
70
|
end
|
|
96
71
|
end
|
|
97
72
|
end
|
|
@@ -1,22 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require 'active_support/log_subscriber'
|
|
5
|
+
|
|
6
|
+
module ProfileTools
|
|
7
|
+
# Writes one log line per called method when a profiling run finishes.
|
|
8
|
+
#
|
|
9
|
+
# ProfileTools::LogSubscriber.attach_to :profile_tools
|
|
10
|
+
#
|
|
11
|
+
# The {Railtie} attaches it for you. Lines are logged at info level to
|
|
12
|
+
# ActiveSupport::LogSubscriber.logger (Rails.logger in a Rails app).
|
|
5
13
|
class LogSubscriber < ActiveSupport::LogSubscriber
|
|
14
|
+
# Logs the stats of every method called during the run.
|
|
15
|
+
#
|
|
16
|
+
# @param event [ActiveSupport::Notifications::Event] carries the run's collector in its payload
|
|
17
|
+
# @return [void]
|
|
6
18
|
def profile(event)
|
|
7
|
-
event.payload[:collector].called_methods.each do |
|
|
8
|
-
|
|
9
|
-
count_objects = display_count_objects(info[:count_objects])
|
|
10
|
-
logger.info "method #{info[:method]} took #{duration}ms, called #{info[:calls]}, objects: #{count_objects}"
|
|
19
|
+
event.payload[:collector].called_methods.each do |stats|
|
|
20
|
+
info { format_stats(stats) }
|
|
11
21
|
end
|
|
12
22
|
end
|
|
23
|
+
subscribe_log_level :profile, :info
|
|
13
24
|
|
|
14
25
|
private
|
|
15
26
|
|
|
16
|
-
def
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
def format_stats(stats)
|
|
28
|
+
"[ProfileTools] #{stats.method}: #{pluralize(stats.calls, 'call')}, #{stats.duration.round(3)}ms, " \
|
|
29
|
+
"#{pluralize(stats.allocations, 'allocation')}, #{pluralize(stats.gc_count, 'GC run')} (#{stats.gc_time}ms)"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def pluralize(count, word)
|
|
33
|
+
"#{count} #{count == 1 ? word : "#{word}s"}"
|
|
20
34
|
end
|
|
21
35
|
end
|
|
22
36
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProfileTools
|
|
4
|
+
# A method to profile, named the way Ruby docs name methods:
|
|
5
|
+
# "User#save" for an instance method, "User.find" for a class method.
|
|
6
|
+
class MethodName
|
|
7
|
+
# Splits "Class#method" or "Class.method". The class part may be namespaced ("Admin::User").
|
|
8
|
+
PATTERN = /\A(?<class_name>[A-Z]\w*(?:::[A-Z]\w*)*)(?<separator>[#.])(?<method_name>.+)\z/
|
|
9
|
+
|
|
10
|
+
# @return [String] the full name, such as "User#save"
|
|
11
|
+
attr_reader :name
|
|
12
|
+
|
|
13
|
+
# @return [String] the class or module name, such as "User"
|
|
14
|
+
attr_reader :class_name
|
|
15
|
+
|
|
16
|
+
# @return [Symbol] the method name, such as :save
|
|
17
|
+
attr_reader :method_name
|
|
18
|
+
|
|
19
|
+
# @param name [String, Symbol] "Class#method" or "Class.method"
|
|
20
|
+
# @raise [Error] if the name isn't in either form
|
|
21
|
+
def initialize(name)
|
|
22
|
+
match = PATTERN.match(name.to_s)
|
|
23
|
+
raise Error, "#{name.inspect} is not a method name, expected Class#method or Class.method" unless match
|
|
24
|
+
|
|
25
|
+
@name = name.to_s.freeze
|
|
26
|
+
@class_name = match[:class_name]
|
|
27
|
+
@method_name = match[:method_name].to_sym
|
|
28
|
+
@class_method = match[:separator] == '.'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] true for a class method ("User.find")
|
|
32
|
+
def class_method?
|
|
33
|
+
@class_method
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The class or module the method is defined on: the singleton class for a class method.
|
|
37
|
+
# Looking it up loads the constant, so this autoloads app classes.
|
|
38
|
+
#
|
|
39
|
+
# @return [Module]
|
|
40
|
+
# @raise [NameError] if the class doesn't exist
|
|
41
|
+
def owner
|
|
42
|
+
constant = Object.const_get(class_name)
|
|
43
|
+
class_method? ? constant.singleton_class : constant
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [String] the full name
|
|
47
|
+
def to_s
|
|
48
|
+
name
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProfileTools
|
|
4
|
+
# Totals for one profiled method within a single {Collector}.
|
|
5
|
+
#
|
|
6
|
+
# Duration, allocations and GC numbers include everything the method calls.
|
|
7
|
+
# Recursive calls are counted in {#calls}, but only the outermost call is
|
|
8
|
+
# measured, so a recursive method isn't counted twice.
|
|
9
|
+
class MethodStats
|
|
10
|
+
# @return [String] the method's display name, such as "User#save" or "User.find"
|
|
11
|
+
attr_reader :method
|
|
12
|
+
|
|
13
|
+
# @return [Integer] how many times the method was called
|
|
14
|
+
attr_reader :calls
|
|
15
|
+
|
|
16
|
+
# @return [Float] total time spent in the method, in milliseconds
|
|
17
|
+
attr_reader :duration
|
|
18
|
+
|
|
19
|
+
# @return [Integer] objects allocated while the method ran
|
|
20
|
+
attr_reader :allocations
|
|
21
|
+
|
|
22
|
+
# @return [Integer] garbage collections that ran while the method ran
|
|
23
|
+
attr_reader :gc_count
|
|
24
|
+
|
|
25
|
+
# @return [Integer] time spent in garbage collection while the method ran, in milliseconds
|
|
26
|
+
attr_reader :gc_time
|
|
27
|
+
|
|
28
|
+
# @return [Integer, nil] order in which the method was first called, nil until it is called
|
|
29
|
+
attr_reader :sort_order
|
|
30
|
+
|
|
31
|
+
# @param method [String] display name of the method
|
|
32
|
+
def initialize(method)
|
|
33
|
+
@method = method
|
|
34
|
+
@calls = 0
|
|
35
|
+
@duration = 0.0
|
|
36
|
+
@allocations = 0
|
|
37
|
+
@gc_count = 0
|
|
38
|
+
@gc_time = 0
|
|
39
|
+
@sort_order = nil
|
|
40
|
+
@depth = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Boolean] true once the method has been called
|
|
44
|
+
def called?
|
|
45
|
+
@calls.positive?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Boolean] true while a call to the method is in progress, so a nested call is recursion
|
|
49
|
+
def running?
|
|
50
|
+
@depth.positive?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Records the start of a call.
|
|
54
|
+
#
|
|
55
|
+
# @api private
|
|
56
|
+
# @param sort_order [Integer] position to use if this is the method's first call
|
|
57
|
+
# @return [void]
|
|
58
|
+
def enter(sort_order)
|
|
59
|
+
@sort_order ||= sort_order
|
|
60
|
+
@calls += 1
|
|
61
|
+
@depth += 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Records the end of a call.
|
|
65
|
+
#
|
|
66
|
+
# @api private
|
|
67
|
+
# @return [void]
|
|
68
|
+
def leave
|
|
69
|
+
@depth -= 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Adds one measured call to the totals.
|
|
73
|
+
#
|
|
74
|
+
# @api private
|
|
75
|
+
# @param duration [Float] milliseconds
|
|
76
|
+
# @param allocations [Integer] objects allocated
|
|
77
|
+
# @param gc_count [Integer] garbage collections
|
|
78
|
+
# @param gc_time [Integer] milliseconds spent in garbage collection
|
|
79
|
+
# @return [void]
|
|
80
|
+
def add(duration, allocations, gc_count, gc_time)
|
|
81
|
+
@duration += duration
|
|
82
|
+
@allocations += allocations
|
|
83
|
+
@gc_count += gc_count
|
|
84
|
+
@gc_time += gc_time
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Hash] the totals as a hash
|
|
88
|
+
def to_h
|
|
89
|
+
{
|
|
90
|
+
method: method,
|
|
91
|
+
calls: calls,
|
|
92
|
+
duration: duration,
|
|
93
|
+
allocations: allocations,
|
|
94
|
+
gc_count: gc_count,
|
|
95
|
+
gc_time: gc_time
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProfileTools
|
|
4
|
+
# Adds and removes the profiling wrapper around a method.
|
|
5
|
+
#
|
|
6
|
+
# Wrappers live in a module prepended to the method's class (one per class, reused),
|
|
7
|
+
# so the original method is never renamed. Each wrapper forwards its arguments with
|
|
8
|
+
# `...` and calls `super`, which keeps positional, keyword and block arguments intact
|
|
9
|
+
# and allocates nothing per call.
|
|
10
|
+
class MethodWrapper
|
|
11
|
+
# Method names that can't be written as `def name(...)`, wrapped with define_method instead.
|
|
12
|
+
DEF_NAME = %r{\A(?:[A-Za-z_]\w*[?!=]?|\[\]=?|[-+]@|[-+*/%<>!~^&|`]|\*\*|<=>|===?|=~|!=|!~|<<|>>|<=|>=)\z}
|
|
13
|
+
|
|
14
|
+
# Marks the modules this class prepends, so they're found again and shown clearly in ancestors.
|
|
15
|
+
class WrapperModule < Module
|
|
16
|
+
# @return [String]
|
|
17
|
+
def inspect
|
|
18
|
+
'ProfileTools::MethodWrapper::WrapperModule'
|
|
19
|
+
end
|
|
20
|
+
alias to_s inspect
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param method_name [MethodName] the method to wrap
|
|
24
|
+
def initialize(method_name)
|
|
25
|
+
@method_name = method_name
|
|
26
|
+
@owner = method_name.owner
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Wraps the method. Its visibility (public, protected or private) is kept.
|
|
30
|
+
#
|
|
31
|
+
# @return [void]
|
|
32
|
+
# @raise [UnknownMethodError] if the class doesn't define the method
|
|
33
|
+
def wrap
|
|
34
|
+
visibility = method_visibility
|
|
35
|
+
raise UnknownMethodError, "#{@method_name} is not defined" unless visibility
|
|
36
|
+
|
|
37
|
+
define_wrapper(wrapper_module)
|
|
38
|
+
wrapper_module.send(visibility, name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Removes the wrapper added by {#wrap}. The original method is untouched, so calls go
|
|
42
|
+
# straight to it again.
|
|
43
|
+
#
|
|
44
|
+
# @return [void]
|
|
45
|
+
def unwrap
|
|
46
|
+
existing_wrapper_module.send(:remove_method, name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def name
|
|
52
|
+
@method_name.method_name
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def method_visibility
|
|
56
|
+
if @owner.public_method_defined?(name) then :public
|
|
57
|
+
elsif @owner.protected_method_defined?(name) then :protected
|
|
58
|
+
elsif @owner.private_method_defined?(name) then :private
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def wrapper_module
|
|
63
|
+
existing_wrapper_module || WrapperModule.new.tap { |module_| @owner.prepend(module_) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Only modules prepended directly to the owner come before it in its ancestors.
|
|
67
|
+
def existing_wrapper_module
|
|
68
|
+
@owner.ancestors.take_while { |ancestor| ancestor != @owner }.grep(WrapperModule).first
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def define_wrapper(module_)
|
|
72
|
+
if DEF_NAME.match?(name.to_s)
|
|
73
|
+
module_.module_eval(def_source, __FILE__, __LINE__)
|
|
74
|
+
else
|
|
75
|
+
define_method_wrapper(module_)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# `"...".freeze` compiles to a single frozen string, so passing the display name allocates nothing.
|
|
80
|
+
def def_source
|
|
81
|
+
<<~RUBY
|
|
82
|
+
def #{name}(...)
|
|
83
|
+
::ProfileTools.profiler.instrument(#{@method_name.name.dump}.freeze) { super(...) }
|
|
84
|
+
end
|
|
85
|
+
RUBY
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def define_method_wrapper(module_)
|
|
89
|
+
display_name = @method_name.name
|
|
90
|
+
module_.send(:define_method, name) do |*args, **kwargs, &block|
|
|
91
|
+
::ProfileTools.profiler.instrument(display_name) { super(*args, **kwargs, &block) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProfileTools
|
|
4
|
+
# Rack middleware that makes each request one profiling run, so the profiled methods it calls
|
|
5
|
+
# are reported together, under a "GET /path" line for the whole request.
|
|
6
|
+
#
|
|
7
|
+
# The {Railtie} adds it when a config file is present. Without it, every outermost call to a
|
|
8
|
+
# profiled method is reported on its own.
|
|
9
|
+
class Middleware
|
|
10
|
+
# @param app [#call] the next Rack app
|
|
11
|
+
def initialize(app)
|
|
12
|
+
@app = app
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param env [Hash] the Rack environment
|
|
16
|
+
# @return [Array] the Rack response
|
|
17
|
+
def call(env)
|
|
18
|
+
ProfileTools.instrument("#{env['REQUEST_METHOD']} #{env['PATH_INFO']}") { @app.call(env) }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -1,59 +1,52 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require 'active_support/notifications'
|
|
5
|
+
|
|
6
|
+
module ProfileTools
|
|
7
|
+
# Tracks the profiling run on the current thread (fiber-local, through Thread.current).
|
|
8
|
+
#
|
|
9
|
+
# The outermost {#instrument} call starts a run: it creates a {Collector} and publishes it in
|
|
10
|
+
# a "profile.profile_tools" ActiveSupport notification once the block finishes. Calls made
|
|
11
|
+
# during the run add to that collector.
|
|
5
12
|
class Profiler
|
|
13
|
+
# Name used by {ProfileTools.instrument} when none is given
|
|
14
|
+
DEFAULT_NAME = 'ProfileTools.instrument'
|
|
15
|
+
|
|
16
|
+
# @return [Collector, nil] the collector of the current run, or of the last finished run
|
|
6
17
|
attr_reader :collector
|
|
7
18
|
|
|
8
19
|
def initialize
|
|
9
|
-
@
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def instrument(class_and_method_name = 'ProfileTools::Profiler#instrument')
|
|
13
|
-
result = nil
|
|
14
|
-
if increment_call_depth == 1
|
|
15
|
-
@collector = new_collector
|
|
16
|
-
@collector.init_method(class_and_method_name)
|
|
17
|
-
instrument_with_notifications(class_and_method_name) do
|
|
18
|
-
result = yield
|
|
19
|
-
end
|
|
20
|
-
else
|
|
21
|
-
instrument_with_collector(class_and_method_name) do
|
|
22
|
-
result = yield
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
decrement_call_depth
|
|
26
|
-
result
|
|
20
|
+
@collector = nil
|
|
21
|
+
@running = false
|
|
27
22
|
end
|
|
28
23
|
|
|
29
|
-
|
|
24
|
+
# Measures the block as the named method. Starts a new run if none is in progress.
|
|
25
|
+
#
|
|
26
|
+
# @param name [String] display name to record the block under
|
|
27
|
+
# @yield the code to measure
|
|
28
|
+
# @return [Object] the block's result
|
|
29
|
+
def instrument(name = DEFAULT_NAME, &)
|
|
30
|
+
return @collector.instrument(name, &) if running?
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
ActiveSupport::Notifications.instrument(EVENT, collector: @collector) do
|
|
33
|
-
instrument_with_collector(class_and_method_name) do
|
|
34
|
-
yield
|
|
35
|
-
end
|
|
36
|
-
end
|
|
32
|
+
run(name, &)
|
|
37
33
|
end
|
|
38
34
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def increment_call_depth
|
|
46
|
-
@call_depth += 1
|
|
35
|
+
# @return [Boolean] true while a run is in progress
|
|
36
|
+
def running?
|
|
37
|
+
@running
|
|
47
38
|
end
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
@call_depth -= 1
|
|
51
|
-
end
|
|
40
|
+
private
|
|
52
41
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
|
|
42
|
+
def run(name, &)
|
|
43
|
+
@running = true
|
|
44
|
+
@collector = Collector.new(ProfileTools.profiled_methods)
|
|
45
|
+
ActiveSupport::Notifications.instrument(EVENT, name: name, collector: @collector) do
|
|
46
|
+
@collector.instrument(name, &)
|
|
56
47
|
end
|
|
48
|
+
ensure
|
|
49
|
+
@running = false
|
|
57
50
|
end
|
|
58
51
|
end
|
|
59
52
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/railtie'
|
|
4
|
+
|
|
5
|
+
module ProfileTools
|
|
6
|
+
# Turns profiling on in a Rails app when a config file is present, with no code changes:
|
|
7
|
+
# put the file in place, restart, read the log, remove the file, restart.
|
|
8
|
+
#
|
|
9
|
+
# The file is config/profile_tools.yml, or the path in the PROFILE_TOOLS_CONFIG environment
|
|
10
|
+
# variable. When it exists, the Railtie adds {Middleware} after Rails::Rack::Logger (so log
|
|
11
|
+
# lines keep the request's tags), attaches {LogSubscriber}, and once the app has booted
|
|
12
|
+
# (and eager loaded) wraps the methods the file lists.
|
|
13
|
+
class Railtie < Rails::Railtie
|
|
14
|
+
initializer('profile_tools.middleware') { |app| ProfileTools::Railtie.setup(app) }
|
|
15
|
+
config.after_initialize { |app| ProfileTools::Railtie.profile(app) }
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# @param app [Rails::Application]
|
|
19
|
+
# @return [Pathname] where the config file is looked for
|
|
20
|
+
def config_path(app)
|
|
21
|
+
Pathname.new(ENV.fetch('PROFILE_TOOLS_CONFIG') { app.root.join('config/profile_tools.yml') })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Adds the middleware and log subscriber if the config file exists.
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
# @param app [Rails::Application]
|
|
28
|
+
# @return [void]
|
|
29
|
+
def setup(app)
|
|
30
|
+
return unless config_path(app).exist?
|
|
31
|
+
|
|
32
|
+
app.config.middleware.insert_after Rails::Rack::Logger, ProfileTools::Middleware
|
|
33
|
+
ProfileTools::LogSubscriber.attach_to :profile_tools
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Profiles the methods in the config file if it exists.
|
|
37
|
+
#
|
|
38
|
+
# @api private
|
|
39
|
+
# @param app [Rails::Application]
|
|
40
|
+
# @return [void]
|
|
41
|
+
def profile(app)
|
|
42
|
+
path = config_path(app)
|
|
43
|
+
ProfileTools.load(path) if path.exist?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|