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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4dcb344b200080160f3c9549aef70ccb9714527e4fd9594557f9b7ec3d0f6315
|
|
4
|
+
data.tar.gz: 70adfdbcfc6ad337a44ea49e6c71d8861f38e40145d78f36f6ad091204e0e1ba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
2
|
-
|
|
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
|
+
[](https://github.com/dougyouch/profile-tools/actions/workflows/ci.yml)
|
|
8
|
+
[](https://github.com/dougyouch/profile-tools/actions/workflows/ci.yml)
|
|
9
|
+
[](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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
# Stops profiling every method.
|
|
89
|
+
#
|
|
90
|
+
# @return [void]
|
|
91
|
+
def stop_profiling!
|
|
92
|
+
stop_profiling(*profiled_methods)
|
|
93
|
+
end
|
|
85
94
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
101
|
+
# @return [Profiler] the current thread's profiler
|
|
102
|
+
def profiler
|
|
103
|
+
Thread.current[:profile_tools_profiler] ||= Profiler.new
|
|
104
|
+
end
|
|
93
105
|
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
method_name_with_profiling = generate_method_name(method_name.to_s, 'with_profiling')
|
|
115
|
+
private
|
|
99
116
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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' }
|