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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ea7a642f74758aef7887554315fac1c6141058a668f9c42bd94cb90db90d0b8
4
- data.tar.gz: 1d4f88fa199c5c6adf6162d39b221b41ed4fc9679b9d727e99b3c84950e0a3d4
3
+ metadata.gz: 4dcb344b200080160f3c9549aef70ccb9714527e4fd9594557f9b7ec3d0f6315
4
+ data.tar.gz: 70adfdbcfc6ad337a44ea49e6c71d8861f38e40145d78f36f6ad091204e0e1ba
5
5
  SHA512:
6
- metadata.gz: 95b41c270ca78f611e60863f046d0a0141107bc620e2ca93c726ed806dae98b03c0a9143ed0356de794a8c6d047d151a98529d38e293444d38be4f49a4a5af7a
7
- data.tar.gz: 415571c41e649b9548b1e4011245b90a1fd30328c90f5428469d6dea11ee7d00b775e19b8511bb914680187f2fcc8c40f1bad0c61c313bfbcadc65fd700b3ac1
6
+ metadata.gz: c7d3ed40b95aec0b7b336d3d8dbb127e941a3728f1c618dcc8969def3f0a5bc841492f349e543894afe0ceae6a66a441f3ea1f899a674cbc38b635477f12858a
7
+ data.tar.gz: 7cdb0b6bfaa65310ba4150dcc07463e93d43095ddcebfcab0d9bd67c60c7e757d5a75d64e8af64ae92c1885a82af5fab1f8256da16868a1f2d16b959fe81da37
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/dougyouch/profile-tools/compare/v0.1.0...v0.2.0) (2026-10-05)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * ProfileTools is now a module. Use ProfileTools.profile_method('User#save') and ProfileTools.stop_profiling('User.find', ...) instead of the profile_*/remove_profiled_* instance methods. Per-type count_objects is replaced by an exact allocations total plus gc_count and gc_time, the collector exposes stats (MethodStats objects) instead of methods (hashes), and the log format changed. See UPGRADING.md.
9
+ * **gem:** Ruby 3.4 or newer and ActiveSupport 7.1 or newer are now required. Ruby 3.4 is the first version where forwarding arguments with ... allocates nothing, which exact allocation counts depend on.
10
+
11
+ ### Features
12
+
13
+ * count allocations exactly and profile from a yaml file with no code changes ([d9a2fb3](https://github.com/dougyouch/profile-tools/commit/d9a2fb3acc3cd487c0004b60ad526ca220076983))
14
+
15
+
16
+ ### Build System
17
+
18
+ * **gem:** require ruby 3.4 and activesupport 7.1 ([2347ecf](https://github.com/dougyouch/profile-tools/commit/2347ecfea681cde5ca70ddf440015acb5090dcfb))
19
+
20
+ ## 0.1.0 (2019-08-30)
21
+
22
+ * Initial release: profile methods listed in a YAML file, logging call counts, time and object counts per method
data/README.md CHANGED
@@ -1,2 +1,207 @@
1
- # profile-tools
2
- Ruby profiling tools
1
+ # ProfileTools
2
+
3
+ Find the code that allocates the most objects and triggers the most garbage collection, in production, without changing that code.
4
+
5
+ List the methods you suspect in a YAML file and restart. Every request then logs, for each listed method, how many times it was called, how long it took, how many objects it allocated and how many garbage collections ran inside it. Remove the file and restart to turn it off.
6
+
7
+ [![CI](https://github.com/dougyouch/profile-tools/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/dougyouch/profile-tools/actions/workflows/ci.yml)
8
+ [![Coverage](https://raw.githubusercontent.com/dougyouch/profile-tools/badges/coverage.svg)](https://github.com/dougyouch/profile-tools/actions/workflows/ci.yml)
9
+ [![Branch Coverage](https://raw.githubusercontent.com/dougyouch/profile-tools/badges/branches.svg)](https://github.com/dougyouch/profile-tools/actions/workflows/ci.yml)
10
+
11
+ [API reference](https://rubydoc.info/gems/profile-tools) · [Upgrading from 0.1](UPGRADING.md) · [Changelog](CHANGELOG.md) · [Architecture](ARCHITECTURE.md)
12
+
13
+ ```
14
+ [ProfileTools] GET /orders/42: 1 call, 0.507ms, 4874 allocations, 0 GC runs (0ms)
15
+ [ProfileTools] Order.find: 1 call, 0.025ms, 401 allocations, 0 GC runs (0ms)
16
+ [ProfileTools] Order#total: 3 calls, 0.474ms, 4473 allocations, 0 GC runs (0ms)
17
+ [ProfileTools] Order#line_items: 3 calls, 0.394ms, 4473 allocations, 0 GC runs (0ms)
18
+ ```
19
+
20
+ Here `Order#total` allocates nothing itself; all 4473 objects come from `Order#line_items`, called once per `total`.
21
+
22
+ ## Installation
23
+
24
+ Requires Ruby 3.4 or newer and ActiveSupport 7.1 or newer. Add this line to your application's Gemfile:
25
+
26
+ ```ruby
27
+ gem 'profile-tools'
28
+ ```
29
+
30
+ And then execute:
31
+
32
+ ```bash
33
+ $ bundle install
34
+ ```
35
+
36
+ ## Usage with Rails
37
+
38
+ Nothing else to set up. With the gem installed, profiling is off until a config file exists.
39
+
40
+ 1. Create `config/profile_tools.yml` listing the methods to profile. Keys are class names; values are method names. Class methods start with a dot.
41
+
42
+ ```yaml
43
+ Order:
44
+ - total
45
+ - line_items
46
+ - .find
47
+ Admin::ReportBuilder:
48
+ - build
49
+ ```
50
+
51
+ To keep the file out of the app directory, put it anywhere and set `PROFILE_TOOLS_CONFIG=/path/to/profile_tools.yml`.
52
+
53
+ 2. Restart the app server. When the file exists, the gem's Railtie:
54
+ - wraps the listed methods once the app has booted (after eager loading)
55
+ - adds `ProfileTools::Middleware`, so each request is reported together under a `GET /path` line
56
+ - logs the report to `Rails.logger` at info level, tagged like the rest of the request's lines
57
+
58
+ 3. Read the log, then delete the file and restart again.
59
+
60
+ A typo in the file (a class or method that doesn't exist) raises at boot, so check the app starts before leaving it.
61
+
62
+ Background jobs (Sidekiq, Active Job) don't go through the middleware; see [Background jobs](#background-jobs) to get one report per job.
63
+
64
+ ## Usage without Rails
65
+
66
+ Outside Rails, nothing happens automatically: you choose the methods, where reports go, and what counts as one run. It takes three steps.
67
+
68
+ ```ruby
69
+ require 'logger'
70
+ require 'profile-tools'
71
+
72
+ # 1. Send reports somewhere. Attach first: that loads ActiveSupport::LogSubscriber.
73
+ ProfileTools::LogSubscriber.attach_to :profile_tools
74
+ ActiveSupport::LogSubscriber.logger = Logger.new($stdout)
75
+
76
+ # 2. Choose the methods, after the classes are loaded
77
+ ProfileTools.load('profile_tools.yml') # the same YAML format as in Rails, or:
78
+ ProfileTools.profile('Order' => %w[total .find]) # a hash in that shape, or:
79
+ ProfileTools.profile_method('Order#total') # one method at a time
80
+
81
+ # 3. Decide what one report covers
82
+ ProfileTools.instrument('nightly import') { Importer.run }
83
+ ```
84
+
85
+ Without an enclosing `instrument` block (or the middleware below), each outermost call to a profiled method is reported on its own.
86
+
87
+ ### Rack apps (Sinatra, Roda, Hanami, plain Rack)
88
+
89
+ `ProfileTools::Middleware` makes each request one report, named `GET /path`. To keep the drop-in-file workflow, guard the setup in `config.ru` so it only runs when the file is there:
90
+
91
+ ```ruby
92
+ # config.ru
93
+ require_relative 'app'
94
+
95
+ profile_config = ENV.fetch('PROFILE_TOOLS_CONFIG', 'config/profile_tools.yml')
96
+ if File.exist?(profile_config)
97
+ require 'logger'
98
+ require 'profile-tools'
99
+ ProfileTools::LogSubscriber.attach_to :profile_tools
100
+ ActiveSupport::LogSubscriber.logger = Logger.new($stdout)
101
+ ProfileTools.load(profile_config)
102
+ use ProfileTools::Middleware
103
+ end
104
+
105
+ run App
106
+ ```
107
+
108
+ ### Background jobs
109
+
110
+ Jobs don't pass through the Rack middleware, in Rails or anywhere else. Wrap each job in `instrument` to get one report per job. With Sidekiq:
111
+
112
+ ```ruby
113
+ class ProfileToolsSidekiqMiddleware
114
+ include Sidekiq::ServerMiddleware
115
+
116
+ def call(_job_instance, job, _queue, &)
117
+ ProfileTools.instrument(job['class'], &)
118
+ end
119
+ end
120
+
121
+ Sidekiq.configure_server do |config|
122
+ config.server_middleware { |chain| chain.add ProfileToolsSidekiqMiddleware }
123
+ end
124
+ ```
125
+
126
+ ### Scripts and the console
127
+
128
+ ```ruby
129
+ ProfileTools.profile_method('Order#total')
130
+ ProfileTools.instrument('check') { Order.find(42).total }
131
+ ProfileTools.profiler.collector.called_methods.map(&:to_h)
132
+ # => [{method: "check", calls: 1, duration: 0.51, allocations: 4874, gc_count: 0, gc_time: 0}, ...]
133
+ ```
134
+
135
+ `script/console` in this repo starts IRB with logging to stdout already set up.
136
+
137
+ ### Sending the numbers somewhere else
138
+
139
+ Every finished run publishes a `profile.profile_tools` ActiveSupport notification. Subscribe to it instead of (or as well as) attaching the log subscriber:
140
+
141
+ ```ruby
142
+ ActiveSupport::Notifications.subscribe('profile.profile_tools') do |event|
143
+ event.payload[:collector].called_methods.each do |stats|
144
+ StatsD.distribution('profile_tools.allocations', stats.allocations, tags: ["method:#{stats.method}"])
145
+ end
146
+ end
147
+ ```
148
+
149
+ ### Stopping
150
+
151
+ ```ruby
152
+ ProfileTools.stop_profiling('Order#total', 'Order.find')
153
+ ProfileTools.stop_profiling! # every method
154
+ ```
155
+
156
+ ## Reading the numbers
157
+
158
+ Each line covers one method for one request (or `instrument` block):
159
+
160
+ | Field | Meaning |
161
+ |---|---|
162
+ | calls | Times the method was called |
163
+ | ms | Total time inside the method, including everything it called |
164
+ | allocations | Objects allocated inside the method, including everything it called |
165
+ | GC runs (ms) | Garbage collections that ran inside the method, and the time they took |
166
+
167
+ The top line (the request or `instrument` block) is the total for the whole request, so you can see what share of it each method accounts for.
168
+
169
+ ### How accurate it is
170
+
171
+ - **Allocations are exact.** They come from `GC.stat(:total_allocated_objects)`, a counter that only goes up, so garbage collection running mid-call doesn't distort them. The wrapper itself allocates nothing per call, so wrapping a method doesn't add to its caller's count.
172
+ - **Other threads count too.** The counter is process-wide. On a multi-threaded server (Puma with `threads 5, 5`), allocations from other requests running at the same time land in the numbers. For clean numbers, run the profiled box with one thread per process.
173
+ - **The first call can be higher.** Ruby fills method caches on the first call, which allocates. Look at steady-state requests, not the first one after boot.
174
+ - **Recursion is counted once.** A recursive call adds to `calls`, but only the outermost call is measured, so time and allocations aren't double-counted.
175
+ - **Methods with unusual names** (defined with `define_method` and a name that `def` can't write, like `:'my-method'`) fall back to a wrapper that allocates a few objects per call.
176
+
177
+ ### Choosing methods to list
178
+
179
+ Start broad and narrow down. List a few high-level methods (a service object's `call`, a serializer's `as_json`), find the one with the most allocations, then list the methods it calls. To find candidates first, a sampling profiler like [stackprof](https://github.com/tmm1/stackprof) (`mode: :object`) or [vernier](https://github.com/jhawthorn/vernier) is a good start. ProfileTools then gives you exact numbers for the methods they point at, on real production traffic.
180
+
181
+ ## How it works
182
+
183
+ `ProfileTools.profile_method('Order#total')` prepends a module to `Order` that defines:
184
+
185
+ ```ruby
186
+ def total(...)
187
+ ::ProfileTools.profiler.instrument("Order#total".freeze) { super(...) }
188
+ end
189
+ ```
190
+
191
+ `...` passes positional, keyword and block arguments through unchanged, and the method keeps its visibility (public, protected or private). Removing the wrapper deletes that method from the module, and calls go straight to the original again. See [ARCHITECTURE.md](ARCHITECTURE.md) for more.
192
+
193
+ ## Development
194
+
195
+ ```bash
196
+ bundle install
197
+ bundle exec rspec # specs, with line and branch coverage
198
+ bundle exec rubocop # lint
199
+ bundle exec yard stats --list-undoc
200
+ script/console # IRB with the gem loaded and logging to stdout
201
+ ```
202
+
203
+ CI runs RuboCop, a YARD docs check and the specs on Ruby 3.4 and the `.ruby-version` Ruby, and requires 100% line and branch coverage. Releases are automated with release-please from [conventional commits](https://www.conventionalcommits.org/).
204
+
205
+ ## License
206
+
207
+ MIT. See [LICENSE](LICENSE).
data/UPGRADING.md ADDED
@@ -0,0 +1,98 @@
1
+ # Upgrading from 0.1 to 0.2
2
+
3
+ 0.2 is a rewrite of the internals. The YAML file format is unchanged, but most of the Ruby API and all of the reported numbers changed. If you only used `ProfileTools.load` with a YAML file, the main changes are in [Setup](#setup) and [Reported stats](#reported-stats).
4
+
5
+ ## Requirements
6
+
7
+ | | 0.1 | 0.2 |
8
+ |---|---|---|
9
+ | Ruby | any | 3.4 or newer |
10
+ | ActiveSupport | not declared, but required at runtime | `>= 7.1`, declared |
11
+ | concurrent-ruby | required at runtime, not declared | not used |
12
+
13
+ Ruby 3.4 is the minimum because it is the first version where forwarding arguments with `...` allocates nothing. On Ruby 3.3, every call to a profiled method would add 1 or 2 objects to the counts.
14
+
15
+ ## Setup
16
+
17
+ In a Rails app, delete the initializer you used to load the YAML file and attach the log subscriber, then move the file to `config/profile_tools.yml` (or point `PROFILE_TOOLS_CONFIG` at it). The gem's Railtie now loads the file after boot, attaches the log subscriber and adds a middleware that reports each request together. See [Usage with Rails](README.md#usage-with-rails).
18
+
19
+ Outside Rails, the setup is the same as before, plus `ProfileTools::Middleware` if you want one report per request.
20
+
21
+ ## API changes
22
+
23
+ `ProfileTools` is now a module, not a class, so `ProfileTools.new` is gone. Methods are named the same way everywhere: `"Class#method"` for instance methods and `"Class.method"` for class methods.
24
+
25
+ | 0.1 | 0.2 |
26
+ |---|---|
27
+ | `ProfileTools.new.profile_instance_method(:User, :save)` | `ProfileTools.profile_method('User#save')` |
28
+ | `ProfileTools.new.profile_class_method(:User, :find)` | `ProfileTools.profile_method('User.find')` |
29
+ | `ProfileTools.new.remove_profiled_instance_method(:User, :save)` | `ProfileTools.stop_profiling('User#save')` |
30
+ | `ProfileTools.new.remove_profiled_class_method(:User, :find)` | `ProfileTools.stop_profiling('User.find')` |
31
+ | `ProfileTools.stop_profiling(['User#save', 'User.find'])` | `ProfileTools.stop_profiling('User#save', 'User.find')` (takes names, not an array) |
32
+ | `ProfileTools.add_method` / `ProfileTools.delete_method` | removed; use `profile_method` / `stop_profiling` |
33
+ | `ProfileTools.load(path)` / `ProfileTools.profile(hash)` | unchanged |
34
+ | `ProfileTools.stop_profiling!` | unchanged |
35
+ | `ProfileTools.instrument { }` | unchanged, and takes an optional name: `ProfileTools.instrument('import') { }` |
36
+ | | new: `ProfileTools.profiled?('User#save')` |
37
+
38
+ The default name for an `instrument` block changed from `ProfileTools::Profiler#instrument` to `ProfileTools.instrument`.
39
+
40
+ `ProfileTools.profiled_methods` still returns the names, but the array is now frozen.
41
+
42
+ ## Reported stats
43
+
44
+ Per-type object counts are replaced by a single exact allocation count, plus garbage collection numbers.
45
+
46
+ | 0.1 | 0.2 |
47
+ |---|---|
48
+ | `count_objects` (hash of `T_STRING`, `T_HASH`, ... live-object deltas) | `allocations` (total objects allocated) |
49
+ | `num_collection_calls` | removed |
50
+ | | new: `gc_count` and `gc_time` (ms) |
51
+ | `duration` (ms) | unchanged |
52
+ | `calls` | unchanged |
53
+
54
+ The old counts came from `ObjectSpace.count_objects`, which counts live objects. They went wrong (sometimes negative) whenever garbage collection ran during a call, and needed hand-tuned corrections. The new count comes from `GC.stat(:total_allocated_objects)` and is exact. If you need a breakdown by type for one method, use [memory_profiler](https://github.com/SamSaffron/memory_profiler) on it once ProfileTools has pointed you at it.
55
+
56
+ ### Log format
57
+
58
+ ```
59
+ # 0.1
60
+ method User#save took 12.34567ms, called 2, objects: T_STRING: 40, T_HASH: 3
61
+ # 0.2
62
+ [ProfileTools] User#save: 2 calls, 12.346ms, 43 allocations, 0 GC runs (0ms)
63
+ ```
64
+
65
+ Update any log searches or alerts that matched the old format.
66
+
67
+ ### Collector
68
+
69
+ If you read the collector directly (for example in your own notification subscriber):
70
+
71
+ | 0.1 | 0.2 |
72
+ |---|---|
73
+ | `collector.methods` (hash of hashes) | `collector.stats` (hash of `ProfileTools::MethodStats`) |
74
+ | `collector.called_methods` returned hashes | returns `MethodStats` objects; call `to_h` for a hash |
75
+ | `collector.init_method(name)` | `ProfileTools::Collector.new(names)` |
76
+ | `collector.total_collection_calls` | removed |
77
+
78
+ The `profile.profile_tools` notification payload now also carries `:name`, the name of the request or `instrument` block.
79
+
80
+ ## Behavior changes
81
+
82
+ These were bugs in 0.1 and are fixed in 0.2:
83
+
84
+ - **Blocks and keyword arguments are passed through.** The 0.1 wrapper only forwarded positional arguments, so profiling a method that takes a block or keyword arguments broke it.
85
+ - **An exception no longer turns profiling off.** In 0.1, an exception raised inside a profiled method left the thread's profiler stuck mid-run, and that thread never reported again until restart.
86
+ - **Private and protected methods stay private and protected.** 0.1 made them public.
87
+ - **Setters and operators can be profiled** (`name=`, `[]`, `<=>`). In 0.1 they raised a `SyntaxError`.
88
+ - **Profiling a method twice does nothing.** In 0.1 it caused infinite recursion.
89
+ - **Stopping a method that isn't profiled does nothing.** In 0.1 it raised `NameError`.
90
+ - **A method first seen mid-run, or a nested `instrument` call, no longer raises `NoMethodError`.**
91
+ - **Recursive methods are measured once**, at the outermost call, instead of adding up the nested calls.
92
+ - **Methods are wrapped with `Module#prepend`.** The `*_with_profiling` and `*_without_profiling` aliases are gone, so wrapping no longer conflicts with other code that aliases the same method.
93
+
94
+ ## Errors
95
+
96
+ - `ProfileTools::Error` is raised for a name that isn't `Class#method` or `Class.method`, and for a YAML file that isn't a mapping of class names to methods.
97
+ - `ProfileTools::UnknownMethodError` (a subclass of `Error`) is raised for a method the class doesn't define.
98
+ - An unknown class still raises `NameError`.
data/lib/profile-tools.rb CHANGED
@@ -1,135 +1,125 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # ProfileTools is used to instrument specific methods. Provides feedback about method execution
4
- # time and number of objects created.
5
- class ProfileTools
3
+ require 'active_support/lazy_load_hooks'
4
+
5
+ # Profiles chosen methods without changing their code: call counts, time, objects allocated
6
+ # and garbage collection, per method and per request.
7
+ #
8
+ # ProfileTools.profile('User' => ['save', '.find'])
9
+ # ProfileTools.instrument('nightly import') { Importer.run }
10
+ #
11
+ # In a Rails app, list the methods in config/profile_tools.yml and restart; see {Railtie}.
12
+ module ProfileTools
6
13
  autoload :Collector, 'profile_tools/collector'
14
+ autoload :Error, 'profile_tools/error'
7
15
  autoload :LogSubscriber, 'profile_tools/log_subscriber'
16
+ autoload :MethodName, 'profile_tools/method_name'
17
+ autoload :MethodStats, 'profile_tools/method_stats'
18
+ autoload :MethodWrapper, 'profile_tools/method_wrapper'
19
+ autoload :Middleware, 'profile_tools/middleware'
8
20
  autoload :Profiler, 'profile_tools/profiler'
21
+ autoload :UnknownMethodError, 'profile_tools/unknown_method_error'
22
+ autoload :VERSION, 'profile_tools/version'
9
23
 
24
+ # ActiveSupport notification published when a profiling run finishes
10
25
  EVENT = 'profile.profile_tools'
11
26
 
12
- @profiled_methods = []
27
+ # Replaced, never changed in place, so a run that has already read it isn't affected.
28
+ @profiled_methods = [].freeze
29
+
13
30
  class << self
31
+ # @return [Array<String>] the profiled methods, such as ["User#save", "User.find"]
14
32
  attr_reader :profiled_methods
15
- end
16
-
17
- def self.add_method(display_name)
18
- profiled_methods << display_name
19
- end
20
-
21
- def self.delete_method(display_name)
22
- profiled_methods.delete_if { |method| method == display_name }
23
- end
24
33
 
25
- def initialize
26
- ObjectSpace.count_objects
27
- end
28
-
29
- def profile_instance_method(class_name, method_name)
30
- profile_method(Object.const_get(class_name), method_name, "#{class_name}##{method_name}")
31
- end
32
-
33
- def profile_class_method(class_name, method_name)
34
- profile_method(Object.const_get(class_name).singleton_class, method_name, "#{class_name}.#{method_name}")
35
- end
36
-
37
- def remove_profiled_instance_method(class_name, method_name)
38
- remove_profiling(Object.const_get(class_name), method_name, "#{class_name}##{method_name}")
39
- end
40
-
41
- def remove_profiled_class_method(class_name, method_name)
42
- remove_profiling(Object.const_get(class_name).singleton_class, method_name, "#{class_name}.#{method_name}")
43
- end
44
-
45
- def self.load(yaml_file)
46
- require 'yaml'
47
- profile(YAML.load_file(yaml_file))
48
- end
49
-
50
- def self.profile(classes)
51
- profile_tools = new
34
+ # Profiles the methods listed in a YAML file: class names as keys, each with a list of
35
+ # method names. Class methods start with a dot.
36
+ #
37
+ # User:
38
+ # - save
39
+ # - .find
40
+ #
41
+ # @param path [String, Pathname]
42
+ # @return [void]
43
+ # @raise [Error] if the file isn't a mapping of class names to method names
44
+ def load(path)
45
+ require 'yaml'
46
+ config = YAML.safe_load_file(path)
47
+ raise Error, "#{path} must map class names to lists of methods" unless config.is_a?(Hash)
48
+
49
+ profile(config)
50
+ end
52
51
 
53
- classes.each do |class_name, methods|
54
- methods.each do |method_name|
55
- if method_name =~ /\A\./
56
- profile_tools.profile_class_method(class_name, method_name[1, method_name.size])
57
- else
58
- profile_tools.profile_instance_method(class_name, method_name)
59
- end
52
+ # Profiles the methods in a hash shaped like the YAML file in {.load}.
53
+ #
54
+ # @param config [Hash{String => Array<String>, String}] class name => method names (".name" for class methods)
55
+ # @return [void]
56
+ def profile(config)
57
+ config.each do |class_name, methods|
58
+ Array(methods).each { |method| profile_method(full_method_name(class_name, method)) }
60
59
  end
61
60
  end
62
61
 
63
- profile_tools
64
- end
65
-
66
- def self.stop_profiling(methods)
67
- profile_tools = new
62
+ # Starts profiling a method. Profiling one that already is does nothing.
63
+ #
64
+ # @param name [String] "Class#method" or "Class.method"
65
+ # @return [void]
66
+ # @raise [Error] if the name isn't in either form
67
+ # @raise [NameError] if the class doesn't exist
68
+ # @raise [UnknownMethodError] if the class doesn't define the method
69
+ def profile_method(name)
70
+ method_name = MethodName.new(name)
71
+ return if profiled?(method_name.name)
72
+
73
+ MethodWrapper.new(method_name).wrap
74
+ @profiled_methods = [*@profiled_methods, method_name.name].freeze
75
+ end
68
76
 
69
- methods.each do |method|
70
- if method =~ /#/
71
- profile_tools.remove_profiled_instance_method(*method.split('#', 2))
72
- elsif method =~ /\./
73
- profile_tools.remove_profiled_class_method(*method.split('.', 2))
77
+ # Stops profiling the given methods. Names that aren't profiled are ignored.
78
+ #
79
+ # @param names [Array<String>] "Class#method" or "Class.method"
80
+ # @return [void]
81
+ def stop_profiling(*names)
82
+ names.map(&:to_s).select { |name| profiled?(name) }.each do |name|
83
+ MethodWrapper.new(MethodName.new(name)).unwrap
84
+ @profiled_methods = (@profiled_methods - [name]).freeze
74
85
  end
75
86
  end
76
- end
77
-
78
- def self.stop_profiling!
79
- stop_profiling(profiled_methods.dup)
80
- end
81
87
 
82
- def self.profiler
83
- Thread.current[:profile_tools_profiler] ||= Profiler.new
84
- end
88
+ # Stops profiling every method.
89
+ #
90
+ # @return [void]
91
+ def stop_profiling!
92
+ stop_profiling(*profiled_methods)
93
+ end
85
94
 
86
- def self.instrument
87
- profiler.instrument do
88
- yield
95
+ # @param name [String] "Class#method" or "Class.method"
96
+ # @return [Boolean] true if the method is being profiled
97
+ def profiled?(name)
98
+ profiled_methods.include?(name.to_s)
89
99
  end
90
- end
91
100
 
92
- private
101
+ # @return [Profiler] the current thread's profiler
102
+ def profiler
103
+ Thread.current[:profile_tools_profiler] ||= Profiler.new
104
+ end
93
105
 
94
- def profile_method(kls, method_name, display_name)
95
- self.class.add_method(display_name)
106
+ # Measures the block under the given name. Profiled methods it calls are reported with it.
107
+ #
108
+ # @param name [String] name to report the block under
109
+ # @yield the code to measure
110
+ # @return [Object] the block's result
111
+ def instrument(name = Profiler::DEFAULT_NAME, &)
112
+ profiler.instrument(name, &)
113
+ end
96
114
 
97
- method_name_without_profiling = generate_method_name(method_name.to_s, 'without_profiling')
98
- method_name_with_profiling = generate_method_name(method_name.to_s, 'with_profiling')
115
+ private
99
116
 
100
- kls.class_eval(
101
- <<-STR, __FILE__, __LINE__ + 1
102
- def #{method_name_with_profiling}(*args)
103
- ::ProfileTools.profiler.instrument('#{display_name}') do
104
- #{method_name_without_profiling}(*args)
117
+ def full_method_name(class_name, method)
118
+ method = method.to_s
119
+ method.start_with?('.') ? "#{class_name}#{method}" : "#{class_name}##{method}"
120
+ end
105
121
  end
106
122
  end
107
- STR
108
- )
109
123
 
110
- kls.alias_method(method_name_without_profiling, method_name)
111
- kls.alias_method(method_name, method_name_with_profiling)
112
- end
113
-
114
- def generate_method_name(method_name, suffix)
115
- punctuation =
116
- if method_name =~ /(\?|!)$/
117
- $1
118
- end
119
-
120
- method_name = method_name.sub(punctuation, '') if punctuation
121
-
122
- "#{method_name}_#{suffix}#{punctuation}"
123
- end
124
-
125
- def remove_profiling(kls, method_name, display_name)
126
- self.class.delete_method(display_name)
127
-
128
- method_name_without_profiling = generate_method_name(method_name.to_s, 'without_profiling')
129
- method_name_with_profiling = generate_method_name(method_name.to_s, 'with_profiling')
130
-
131
- kls.alias_method(method_name, method_name_without_profiling)
132
- kls.send(:remove_method, method_name_with_profiling)
133
- kls.send(:remove_method, method_name_without_profiling)
134
- end
135
- end
124
+ # Runs when a Rails app class is defined, early enough for the Railtie's initializers to run
125
+ ActiveSupport.on_load(:before_configuration) { require 'profile_tools/railtie' }