singed 0.3.0 → 0.4.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/README.md +102 -2
- data/exe/singed +1 -0
- data/lib/singed/backtrace_cleaner_ext.rb +5 -0
- data/lib/singed/cli.rb +86 -17
- data/lib/singed/controller_ext.rb +11 -5
- data/lib/singed/flamegraph.rb +118 -20
- data/lib/singed/kernel_ext.rb +14 -4
- data/lib/singed/rack_middleware.rb +13 -7
- data/lib/singed/railtie.rb +6 -0
- data/lib/singed/report.rb +8 -2
- data/lib/singed/rspec.rb +3 -0
- data/lib/singed/sidekiq.rb +54 -0
- data/lib/singed/speedscope.rb +88 -0
- data/lib/singed/vernier_report.rb +122 -0
- data/lib/singed.rb +61 -6
- data/singed.gemspec +7 -10
- data/vendor/speedscope/LICENSE +21 -0
- data/vendor/speedscope/README +2 -0
- data/vendor/speedscope/SourceCodePro-Regular.ttf-ILST5JV6.woff2 +0 -0
- data/vendor/speedscope/favicon-16x16-V2DMIAZS.js +2 -0
- data/vendor/speedscope/favicon-16x16-VSI62OPJ.png +0 -0
- data/vendor/speedscope/favicon-32x32-3EB2YCUY.png +0 -0
- data/vendor/speedscope/favicon-32x32-THY3JDJL.js +2 -0
- data/vendor/speedscope/favicon-FOKUP5Y5.ico +0 -0
- data/vendor/speedscope/favicon-M34RF7BI.js +2 -0
- data/vendor/speedscope/file-format-schema.json +324 -0
- data/vendor/speedscope/index.html +19 -0
- data/vendor/speedscope/perf-vertx-stacks-01-collapsed-all-ZNUIGAJL.txt +199 -0
- data/vendor/speedscope/release.txt +3 -0
- data/vendor/speedscope/source-code-pro.LICENSE.md +93 -0
- data/vendor/speedscope/speedscope-432XE7GS.js +189 -0
- data/vendor/speedscope/speedscope-GHPHNKXC.css +2 -0
- metadata +24 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: edd7e0459f79298b925de483b4b6a1363f2fa2a071b93d6a954ae939ff65ea3c
|
|
4
|
+
data.tar.gz: 99c4141ad809f9a54dfd477ab8097c87fdb41531e1b3db24b658c1759ca0ca3d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 36cd5b1b55aab972df638d1490c88f01df41aad90759cde60a54beb3ab4f164d0dbc13f7441d9a920027af6870dcc6af4493fa7ecc0374b2b8c26709dc78c04d
|
|
7
|
+
data.tar.gz: 2a65c9be211f547d0c0da221bb9e523f15c1d9530bb60dd3dc190993d7438962d3c9bd2fe783673c8d3eac39816bcddfc9a8ac975d16712b83219b989ca4ab41
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Singed
|
|
2
2
|
|
|
3
|
-
Singed makes it easy to get a flamegraph anywhere in your code base. It wraps profiling your code with [stackprof](https://github.com/tmm1/stackprof) or [rbspy](https://github.com/rbspy/rbspy), and then launching [speedscope](https://github.com/jlfwong/speedscope) to view it.
|
|
3
|
+
Singed makes it easy to get a flamegraph anywhere in your code base. It wraps profiling your code with [stackprof](https://github.com/tmm1/stackprof), [vernier](https://github.com/jhawthorn/vernier) or [rbspy](https://github.com/rbspy/rbspy), and then launching [speedscope](https://github.com/jlfwong/speedscope) to view it.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -47,6 +47,59 @@ flamegraph(open: false) {
|
|
|
47
47
|
}
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
### Explicit start and stop
|
|
51
|
+
|
|
52
|
+
You can also start and stop the flamegraph explicitly:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# config/boot.rb
|
|
56
|
+
require "singed"
|
|
57
|
+
Singed.output_directory ||= Dir.pwd + "/tmp/speedscope"
|
|
58
|
+
Singed.start
|
|
59
|
+
# Let some code to run here...
|
|
60
|
+
# and then stop the flamegraph with e.g. rails runner 'Singed.stop'
|
|
61
|
+
flamegraph = Singed.stop
|
|
62
|
+
# The flamegraph is saved to the output directory
|
|
63
|
+
# Open it with your browser:
|
|
64
|
+
flamegraph.open
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Note that `Singed.start` can't be run multiple times in parallel, instantiate multiple `Singed::Flamegraph` objects instead and call `start` on them.
|
|
68
|
+
|
|
69
|
+
### Vernier
|
|
70
|
+
|
|
71
|
+
Singed profiles with stackprof by default. [Vernier](https://github.com/jhawthorn/vernier) profiles each thread separately instead, so a flamegraph from a multi-threaded app like Puma or Sidekiq isn't a mix of what all its threads were doing. Singed doesn't depend on vernier, so add it (1.5 or newer) to your Gemfile:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
gem "vernier"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Then ask for it when capturing a flamegraph. `Singed.start` and controllers' `flamegraph` take `profiler:` too:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
flamegraph(profiler: :vernier) {
|
|
81
|
+
# your code here
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or make it the default, which the RSpec, controller, Rack and Sidekiq integrations below then use as well:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
Singed.profiler = :vernier
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
That loads vernier straight away, so a missing or outdated gem fails at boot. If vernier is only in some of your Gemfile's groups, set this only in the environments that load them, e.g. in `config/environments/development.rb`.
|
|
92
|
+
|
|
93
|
+
speedscope then gets a profile per thread, and opens on the thread that ran your code. Pick another thread from its title bar, or step through them with `n` and `p`. Vernier keeps sampling threads that are waiting, so their stacks end in `(idle)` while sleeping or waiting on I/O or a lock, and in `(waiting for GVL)` while another thread holds the GVL.
|
|
94
|
+
|
|
95
|
+
Vernier doesn't sample a thread while it's running garbage collection, so `ignore_gc` makes no difference with it. The `singed` command line always uses rbspy.
|
|
96
|
+
|
|
97
|
+
The flamegraph's `profile` is Vernier's own result, which you can also save for [vernier.prof](https://vernier.prof) to show GVL and GC activity alongside the flamegraph:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
Singed.stop.profile.write(out: "tmp/profile.vernier.json.gz")
|
|
101
|
+
```
|
|
102
|
+
|
|
50
103
|
### RSpec
|
|
51
104
|
|
|
52
105
|
If you are using RSpec, you can use the `flamegraph` metadata to capture it for you.
|
|
@@ -78,6 +131,16 @@ end
|
|
|
78
131
|
|
|
79
132
|
This won't catch the entire request though, just once it's been routed to controller and a response has been served (ie no middleware).
|
|
80
133
|
|
|
134
|
+
If Sorbet checks the controller (`# typed: true` or stricter), also include `Singed::ControllerExt` in it. The Railtie already includes it into `ActionController::Base` at runtime, but Sorbet can't see that, so it would check `flamegraph :show` against the block form of `flamegraph` instead:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
class EmployeesController < ApplicationController
|
|
138
|
+
include Singed::ControllerExt
|
|
139
|
+
|
|
140
|
+
flamegraph :show
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
81
144
|
### Rack/Rails requests
|
|
82
145
|
|
|
83
146
|
To capture the whole request, there is a middleware which checks for the `X-Singed` header to be 'true'. With curl, you can do this like:
|
|
@@ -90,6 +153,38 @@ PROTIP: use Chrome Developer Tools to record network activity, and copy requests
|
|
|
90
153
|
|
|
91
154
|
This can also be enabled to always run by setting `SINGED_MIDDLEWARE_ALWAYS_CAPTURE=1` in the environment.
|
|
92
155
|
|
|
156
|
+
### Sidekiq
|
|
157
|
+
|
|
158
|
+
If you are using Sidekiq, you can use the `Singed::Sidekiq::ServerMiddleware` to capture flamegraphs for you.
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
require "singed/sidekiq"
|
|
162
|
+
|
|
163
|
+
Sidekiq.configure_server do |config|
|
|
164
|
+
config.server_middleware do |chain|
|
|
165
|
+
chain.add Singed::Sidekiq::ServerMiddleware
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
To capture flamegraphs for all jobs, you can set the `SINGED_MIDDLEWARE_ALWAYS_CAPTURE` environment variable to `true` the same way as the Rack middleware.
|
|
171
|
+
|
|
172
|
+
To capture flamegraphs for a specific job, you can set the `x-singed` key in the job payload to `true`.
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
MyJob.set("x-singed" => true).perform_async
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Or define a `capture_flamegraph?` method on the job class:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
class MyJob
|
|
182
|
+
def self.capture_flamegraph?(payload)
|
|
183
|
+
payload["flamegraph"]
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
93
188
|
### Command Line
|
|
94
189
|
|
|
95
190
|
There is a `singed` command line you can use that will record a flamegraph from the entirety of a command run:
|
|
@@ -101,14 +196,19 @@ $ bundle exec singed -- bin/rails runner 'Model.all.to_a'
|
|
|
101
196
|
|
|
102
197
|
The flamegraph is opened afterwards.
|
|
103
198
|
|
|
199
|
+
To profile a command that runs until it's stopped, like a server, stop it with Ctrl-C. Or, when `singed` runs in the background, such as from a script, stop it with `kill`'s default SIGTERM. Either way, rbspy stops the command and writes the flamegraph, which `singed` then opens. `singed` ignores a SIGINT sent to it alone, such as by `kill -INT`, because Ctrl-C's reaches rbspy directly.
|
|
200
|
+
|
|
201
|
+
Send SIGTERM to `singed` alone, though, not to its whole process group as `kill -- -<pgid>` and `timeout` without `--foreground` do: sudo passes a SIGTERM straight on to rbspy, which then exits without writing the flamegraph. And rbspy kills only the command itself, so processes the command started may be left running.
|
|
202
|
+
|
|
104
203
|
|
|
105
204
|
## Limitations
|
|
106
205
|
|
|
107
206
|
When using the auto-opening feature, it's assumed that you are have a browser available on the same host you are profiling code.
|
|
108
207
|
|
|
109
|
-
The `open` is expected to be available.
|
|
208
|
+
The `open` command is expected to be available.
|
|
110
209
|
|
|
111
210
|
## Alternatives
|
|
112
211
|
|
|
113
212
|
- using [rbspy](https://rbspy.github.io/) directly
|
|
114
213
|
- using [stackprof](https://github.com/tmm1/stackprof) (a dependency of singed) directly
|
|
214
|
+
- using [vernier](https://github.com/jhawthorn/vernier) directly
|
data/exe/singed
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
1
4
|
module ActiveSupport
|
|
2
5
|
class BacktraceCleaner
|
|
6
|
+
#: (String) -> String
|
|
3
7
|
def filter_line(line)
|
|
4
8
|
filtered_line = line
|
|
5
9
|
@filters.each do |f|
|
|
@@ -9,6 +13,7 @@ module ActiveSupport
|
|
|
9
13
|
filtered_line
|
|
10
14
|
end
|
|
11
15
|
|
|
16
|
+
#: (String) -> bool
|
|
12
17
|
def silence_line?(line)
|
|
13
18
|
@silencers.any? { |s| s.call(line) }
|
|
14
19
|
end
|
data/lib/singed/cli.rb
CHANGED
|
@@ -1,30 +1,46 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
1
4
|
require "shellwords"
|
|
2
5
|
require "tmpdir"
|
|
3
6
|
require "optionparser"
|
|
4
|
-
require "pathname"
|
|
5
7
|
|
|
6
8
|
# NOTE: we defer requiring singed until we run. that lets Rails load it if its in the gemfile, so the railtie has had a chance to run
|
|
7
9
|
|
|
8
10
|
module Singed
|
|
9
11
|
class CLI
|
|
10
|
-
|
|
12
|
+
#: Array[String]
|
|
13
|
+
attr_accessor :argv
|
|
14
|
+
|
|
15
|
+
#: Pathname?
|
|
16
|
+
attr_accessor :filename
|
|
11
17
|
|
|
18
|
+
#: OptionParser
|
|
19
|
+
attr_accessor :opts
|
|
20
|
+
|
|
21
|
+
#: (Array[String]) -> void
|
|
12
22
|
def initialize(argv)
|
|
13
23
|
@argv = argv
|
|
14
|
-
@opts = OptionParser.new
|
|
24
|
+
@opts = OptionParser.new #: OptionParser
|
|
25
|
+
@interrupted = false #: bool
|
|
15
26
|
|
|
16
27
|
parse_argv!
|
|
17
28
|
end
|
|
18
29
|
|
|
30
|
+
#: () -> void
|
|
19
31
|
def parse_argv!
|
|
20
32
|
opts.banner = "Usage: singed [options] <command>"
|
|
21
33
|
|
|
22
34
|
opts.on("-h", "--help", "Show this message") do
|
|
23
|
-
@show_help = true
|
|
35
|
+
@show_help = true #: bool?
|
|
24
36
|
end
|
|
25
37
|
|
|
26
38
|
opts.on("-o", "--output-directory DIRECTORY", "Directory to write flamegraph to") do |directory|
|
|
27
|
-
@output_directory = directory
|
|
39
|
+
@output_directory = directory #: String?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
opts.on("-r", "--rate RATE", Integer, "Sample rate for rbspy") do |rate|
|
|
43
|
+
@rate = rate #: Integer?
|
|
28
44
|
end
|
|
29
45
|
|
|
30
46
|
opts.order(@argv) do |arg|
|
|
@@ -34,7 +50,7 @@ module Singed
|
|
|
34
50
|
|
|
35
51
|
if @argv.empty?
|
|
36
52
|
@show_help = true
|
|
37
|
-
@error_message = "missing command to profile"
|
|
53
|
+
@error_message = "missing command to profile" #: (String | OptionParser::InvalidOption)?
|
|
38
54
|
return
|
|
39
55
|
end
|
|
40
56
|
|
|
@@ -48,6 +64,7 @@ module Singed
|
|
|
48
64
|
end
|
|
49
65
|
end
|
|
50
66
|
|
|
67
|
+
#: () -> void
|
|
51
68
|
def run
|
|
52
69
|
require "singed"
|
|
53
70
|
|
|
@@ -65,20 +82,25 @@ module Singed
|
|
|
65
82
|
|
|
66
83
|
Singed.output_directory = @output_directory if @output_directory
|
|
67
84
|
Singed.output_directory ||= Dir.tmpdir
|
|
68
|
-
|
|
85
|
+
# The ||= above has just set output_directory if it was unset.
|
|
86
|
+
FileUtils.mkdir_p(
|
|
87
|
+
Singed.output_directory #: as !nil
|
|
88
|
+
)
|
|
69
89
|
@filename = Singed::Flamegraph.generate_filename(label: "cli")
|
|
70
90
|
|
|
91
|
+
# nil values are for flags. rbspy uses its default rate unless one was given.
|
|
71
92
|
options = {
|
|
72
93
|
format: "speedscope",
|
|
73
94
|
file: filename.to_s,
|
|
74
|
-
silent: nil
|
|
95
|
+
silent: nil,
|
|
75
96
|
}
|
|
97
|
+
options[:rate] = @rate if @rate
|
|
76
98
|
|
|
77
99
|
rbspy_args = [
|
|
78
100
|
"record",
|
|
79
101
|
*options.map { |k, v| ["--#{k}", v].compact }.flatten,
|
|
80
102
|
"--",
|
|
81
|
-
*argv
|
|
103
|
+
*argv,
|
|
82
104
|
]
|
|
83
105
|
|
|
84
106
|
loop do
|
|
@@ -88,9 +110,9 @@ module Singed
|
|
|
88
110
|
prompt_password
|
|
89
111
|
end
|
|
90
112
|
|
|
91
|
-
rbspy =
|
|
113
|
+
rbspy = -> do
|
|
92
114
|
# don't run things with spring, because it forks and rbspy won't see it
|
|
93
|
-
sudo ["rbspy", *rbspy_args], reason: "Singed needs to run as root, but will drop permissions back to your user.", env: {"DISABLE_SPRING" => "1"}
|
|
115
|
+
sudo ["rbspy", *rbspy_args], reason: "Singed needs to run as root, but will drop permissions back to your user.", env: { "DISABLE_SPRING" => "1" }
|
|
94
116
|
end
|
|
95
117
|
|
|
96
118
|
if defined?(Bundler)
|
|
@@ -101,7 +123,8 @@ module Singed
|
|
|
101
123
|
rbspy.call
|
|
102
124
|
end
|
|
103
125
|
|
|
104
|
-
|
|
126
|
+
# @filename rather than the nilable filename reader: Sorbet knows it holds a Pathname by now.
|
|
127
|
+
unless @filename.exist?
|
|
105
128
|
puts "#{filename} doesn't exist. Maybe rbspy had a failure capturing it? Check the scrollback."
|
|
106
129
|
exit 1
|
|
107
130
|
end
|
|
@@ -112,32 +135,38 @@ module Singed
|
|
|
112
135
|
end
|
|
113
136
|
|
|
114
137
|
# clean the report, similar to how Singed::Report does
|
|
115
|
-
json = JSON.parse(filename.read)
|
|
138
|
+
json = JSON.parse(@filename.read)
|
|
116
139
|
json["shared"]["frames"].each do |frame|
|
|
117
140
|
frame["file"] = Singed.filter_line(frame["file"])
|
|
118
141
|
end
|
|
119
|
-
filename.write(JSON.dump(json))
|
|
142
|
+
@filename.write(JSON.dump(json))
|
|
120
143
|
|
|
121
|
-
flamegraph = Singed::Flamegraph.new(filename:
|
|
144
|
+
flamegraph = Singed::Flamegraph.new(filename:)
|
|
122
145
|
flamegraph.open
|
|
123
146
|
end
|
|
124
147
|
|
|
148
|
+
#: () -> bool
|
|
125
149
|
def password_needed?
|
|
126
150
|
!system("sudo --non-interactive true >/dev/null 2>&1")
|
|
127
151
|
end
|
|
128
152
|
|
|
153
|
+
#: () -> bool?
|
|
129
154
|
def prompt_password
|
|
130
155
|
system("sudo true")
|
|
131
156
|
end
|
|
132
157
|
|
|
158
|
+
#: () -> bool
|
|
133
159
|
def adjust_ownership!
|
|
134
160
|
sudo ["chown", ENV["USER"], filename], reason: "Adjusting ownership of #{filename}, but need root."
|
|
135
161
|
end
|
|
136
162
|
|
|
163
|
+
#: () -> bool?
|
|
137
164
|
def show_help?
|
|
138
165
|
@show_help
|
|
139
166
|
end
|
|
140
167
|
|
|
168
|
+
# Never nil or false: a command that fails raises instead.
|
|
169
|
+
#: (Array[String | Integer | Pathname | nil], reason: String, ?env: Hash[String, String]) -> bool
|
|
141
170
|
def sudo(system_args, reason:, env: {})
|
|
142
171
|
loop do
|
|
143
172
|
break unless password_needed?
|
|
@@ -149,14 +178,54 @@ module Singed
|
|
|
149
178
|
sudo_args = [
|
|
150
179
|
"sudo",
|
|
151
180
|
"--preserve-env",
|
|
152
|
-
*system_args.map(&:to_s)
|
|
181
|
+
*system_args.map(&:to_s),
|
|
153
182
|
]
|
|
154
183
|
|
|
155
184
|
puts "$ #{Shellwords.join(sudo_args)}"
|
|
156
185
|
|
|
157
|
-
|
|
186
|
+
# Sorbet can't check a splat of an array of unknown length: https://srb.help/7019
|
|
187
|
+
process = Process #: as untyped
|
|
188
|
+
pid = process.spawn(env, *sudo_args) #: as Integer
|
|
189
|
+
status = wait_passing_on_signals(pid)
|
|
190
|
+
raise "#{Shellwords.join(sudo_args)} failed (#{status})" unless status.success?
|
|
191
|
+
|
|
192
|
+
true
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Kernel#system would leave the command running when singed is killed. Instead, the first SIGTERM is
|
|
196
|
+
# passed on as SIGINT, which sudo relays and is the only signal rbspy stops cleanly on, killing the
|
|
197
|
+
# command and writing the flamegraph. Nothing more is passed on, because rbspy exits without writing
|
|
198
|
+
# anything when interrupted twice, and Ctrl-C at a terminal already reaches it directly. Later SIGTERMs
|
|
199
|
+
# are ignored until singed exits, so they can't stop it opening the flamegraph either.
|
|
200
|
+
# https://github.com/rbspy/rbspy/blob/v0.53.0/src/main.rs#L164-L211
|
|
201
|
+
#: (Integer) -> Process::Status
|
|
202
|
+
def wait_passing_on_signals(pid)
|
|
203
|
+
previous_int = trap("INT", "IGNORE")
|
|
204
|
+
previous_term = trap("TERM") do
|
|
205
|
+
@interrupted ||= interrupt(pid)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Process.wait2 only returns nil when told not to block.
|
|
209
|
+
waited = Process.wait2(pid) #: as !nil
|
|
210
|
+
waited.last
|
|
211
|
+
ensure
|
|
212
|
+
# trap returns nil for a handler installed outside Ruby, and restoring nil would ignore the signal.
|
|
213
|
+
trap("INT", previous_int || "DEFAULT")
|
|
214
|
+
trap("TERM", previous_term || "DEFAULT") unless @interrupted
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# sudo before 1.9.13 doesn't relay a signal sent from its own process group, which singed shares to keep
|
|
218
|
+
# sudo in the terminal's foreground, so a kill in a group of its own sends it. kill fails while sudo
|
|
219
|
+
# briefly runs entirely as root, as it does starting up, and then a later SIGTERM tries again.
|
|
220
|
+
# https://github.com/sudo-project/sudo/commit/36742deec3041413af9b293706a64530321b96b5
|
|
221
|
+
#: (Integer) -> bool
|
|
222
|
+
def interrupt(pid)
|
|
223
|
+
kill = Process.spawn("kill", "-INT", pid.to_s, pgroup: true, err: File::NULL)
|
|
224
|
+
waited = Process.wait2(kill) #: as !nil
|
|
225
|
+
!!waited.last.success?
|
|
158
226
|
end
|
|
159
227
|
|
|
228
|
+
#: () -> String?
|
|
160
229
|
def self.chdir_rails_root
|
|
161
230
|
original_cwd = Dir.pwd
|
|
162
231
|
|
|
@@ -1,14 +1,20 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "active_support/concern"
|
|
5
|
+
|
|
1
6
|
module Singed
|
|
2
7
|
module ControllerExt
|
|
3
|
-
|
|
4
|
-
base.extend(ClassMethods)
|
|
5
|
-
end
|
|
8
|
+
extend ActiveSupport::Concern
|
|
6
9
|
|
|
10
|
+
# Concern extends the including controller class with this module; controllers define around_action.
|
|
11
|
+
# @requires_ancestor: AbstractController::Callbacks::ClassMethods
|
|
7
12
|
module ClassMethods
|
|
8
13
|
# Define an around_action to generate flamegraph for a controller action.
|
|
9
|
-
|
|
14
|
+
#: (Symbol | String | Array[Symbol | String], ?ignore_gc: bool, ?interval: Integer, ?profiler: Symbol?) -> void
|
|
15
|
+
def flamegraph(target_action, ignore_gc: false, interval: 1000, profiler: nil)
|
|
10
16
|
around_action(only: target_action) do |controller, action|
|
|
11
|
-
controller.flamegraph(ignore_gc
|
|
17
|
+
controller.flamegraph(ignore_gc:, interval:, profiler:, &action)
|
|
12
18
|
end
|
|
13
19
|
end
|
|
14
20
|
end
|
data/lib/singed/flamegraph.rb
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
1
4
|
module Singed
|
|
2
5
|
class Flamegraph
|
|
3
|
-
|
|
6
|
+
PROFILERS = [:stackprof, :vernier].freeze
|
|
7
|
+
# The first with Vernier::Result#stack_table.
|
|
8
|
+
MINIMUM_VERNIER_VERSION = "1.5"
|
|
9
|
+
|
|
10
|
+
# The StackProf.results hash, whose values vary by key, or a Vernier::Result when profiling with Vernier.
|
|
11
|
+
# Not typed as Vernier::Result: apps' Tapioca evaluates this sig even when Vernier isn't loaded.
|
|
12
|
+
#: untyped
|
|
13
|
+
attr_accessor :profile
|
|
14
|
+
|
|
15
|
+
#: Pathname
|
|
16
|
+
attr_accessor :filename
|
|
17
|
+
|
|
18
|
+
# nil when wrapping an existing file.
|
|
19
|
+
#: Symbol?
|
|
20
|
+
attr_reader :profiler
|
|
4
21
|
|
|
5
|
-
|
|
22
|
+
#: (?label: String?, ?ignore_gc: bool, ?interval: Integer, ?profiler: Symbol?, ?filename: Pathname?) -> void
|
|
23
|
+
def initialize(label: nil, ignore_gc: false, interval: 1000, profiler: nil, filename: nil)
|
|
6
24
|
# it's been created elsewhere, ie rbspy
|
|
7
25
|
if filename
|
|
8
26
|
if ignore_gc
|
|
@@ -13,54 +31,134 @@ module Singed
|
|
|
13
31
|
raise ArgumentError, "label not supported when given an existing file"
|
|
14
32
|
end
|
|
15
33
|
|
|
16
|
-
|
|
34
|
+
if profiler
|
|
35
|
+
raise ArgumentError, "profiler not supported when given an existing file"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@filename = filename #: Pathname
|
|
17
39
|
else
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
40
|
+
profiler ||= Singed.profiler
|
|
41
|
+
self.class.load_profiler(profiler)
|
|
42
|
+
|
|
43
|
+
# Nilable because they stay unset when wrapping an existing file, and #start still reads them.
|
|
44
|
+
@profiler = profiler #: Symbol?
|
|
45
|
+
@ignore_gc = ignore_gc #: bool?
|
|
46
|
+
@interval = interval #: Integer?
|
|
47
|
+
@time = Time.now #: Time
|
|
48
|
+
@filename = self.class.generate_filename(label:, time: @time)
|
|
22
49
|
end
|
|
23
50
|
end
|
|
24
51
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
52
|
+
#: [Result] () { () -> Result } -> Result
|
|
53
|
+
def record(&_block)
|
|
54
|
+
start
|
|
55
|
+
yield
|
|
56
|
+
ensure
|
|
57
|
+
stop
|
|
58
|
+
end
|
|
28
59
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
60
|
+
#: () -> bool
|
|
61
|
+
def start
|
|
62
|
+
return false unless Singed.enabled?
|
|
63
|
+
return false if filename.exist? # file existing means its been captured already
|
|
64
|
+
return false if started?
|
|
65
|
+
|
|
66
|
+
if vernier?
|
|
67
|
+
# A collector per flamegraph, rather than Vernier.start_profile, which raises if a profile is already running.
|
|
68
|
+
# There's no ignore_gc to pass: Vernier doesn't sample a thread while it's running GC.
|
|
69
|
+
@collector = Vernier::Collector.new(:wall, interval: @interval) #: untyped
|
|
70
|
+
@collector.start
|
|
71
|
+
else
|
|
72
|
+
StackProf.start(mode: :wall, raw: true, ignore_gc: @ignore_gc, interval: @interval)
|
|
73
|
+
end
|
|
74
|
+
@started = true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
#: () -> untyped
|
|
78
|
+
def stop
|
|
79
|
+
return nil unless started?
|
|
80
|
+
|
|
81
|
+
@started = false #: bool?
|
|
82
|
+
if vernier?
|
|
83
|
+
@profile = @collector.stop
|
|
84
|
+
else
|
|
85
|
+
StackProf.stop
|
|
86
|
+
@profile = StackProf.results
|
|
32
87
|
end
|
|
33
|
-
result
|
|
34
88
|
end
|
|
35
89
|
|
|
90
|
+
#: () -> bool
|
|
91
|
+
def started?
|
|
92
|
+
!!@started
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
#: () -> void
|
|
36
96
|
def save
|
|
37
97
|
if filename.exist?
|
|
38
98
|
raise ArgumentError, "File #{filename} already exists"
|
|
39
99
|
end
|
|
40
100
|
|
|
41
|
-
|
|
42
|
-
|
|
101
|
+
if vernier?
|
|
102
|
+
report = Singed::VernierReport.new(@profile)
|
|
103
|
+
else
|
|
104
|
+
report = Singed::Report.new(@profile)
|
|
105
|
+
report.filter!
|
|
106
|
+
end
|
|
43
107
|
filename.dirname.mkpath
|
|
44
108
|
filename.open("w") { |f| report.print_json(f) }
|
|
45
109
|
end
|
|
46
110
|
|
|
111
|
+
#: () -> bool?
|
|
47
112
|
def open
|
|
48
|
-
|
|
113
|
+
Singed::Speedscope.open(@filename)
|
|
49
114
|
end
|
|
50
115
|
|
|
116
|
+
#: () -> String
|
|
51
117
|
def open_command
|
|
52
|
-
|
|
118
|
+
Singed::Speedscope.open_command(@filename)
|
|
53
119
|
end
|
|
54
120
|
|
|
55
|
-
|
|
121
|
+
#: (?label: String?, ?time: Time) -> Pathname
|
|
122
|
+
def self.generate_filename(label: nil, time: Time.now)
|
|
56
123
|
formatted_time = time.strftime("%Y%m%d%H%M%S-%6N")
|
|
57
124
|
basename_parts = ["speedscope", label, formatted_time].compact
|
|
58
125
|
|
|
59
|
-
|
|
126
|
+
# Callers must set output_directory first (the Railtie and CLI do); unset, this raises NoMethodError.
|
|
127
|
+
file = Singed.output_directory #: as !nil
|
|
128
|
+
.join("#{basename_parts.join('-')}.json")
|
|
60
129
|
# convert to relative directory if it's an absolute path and within the current
|
|
61
130
|
pwd = Pathname.pwd
|
|
62
131
|
file = file.relative_path_from(pwd) if file.absolute? && file.to_s.start_with?(pwd.to_s)
|
|
63
132
|
file
|
|
64
133
|
end
|
|
134
|
+
|
|
135
|
+
# Raises unless Singed supports the profiler. Requires vernier, which Singed doesn't depend on, when it's the one asked for.
|
|
136
|
+
#: (Symbol) -> void
|
|
137
|
+
def self.load_profiler(profiler)
|
|
138
|
+
unless PROFILERS.include?(profiler)
|
|
139
|
+
raise ArgumentError, "Unsupported profiler #{profiler.inspect}, expected one of #{PROFILERS.inspect}"
|
|
140
|
+
end
|
|
141
|
+
return unless profiler == :vernier
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
require "vernier"
|
|
145
|
+
rescue LoadError => e
|
|
146
|
+
# Other paths mean vernier is installed but broken, e.g. its native extension didn't load.
|
|
147
|
+
raise unless e.path == "vernier"
|
|
148
|
+
|
|
149
|
+
raise LoadError, "Profiling with vernier needs the vernier gem in your bundle (#{e.message})"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
if Gem::Version.new(Vernier::VERSION) < Gem::Version.new(MINIMUM_VERNIER_VERSION)
|
|
153
|
+
raise LoadError, "Profiling with vernier needs vernier #{MINIMUM_VERNIER_VERSION} or newer, not #{Vernier::VERSION}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
#: () -> bool
|
|
160
|
+
def vernier?
|
|
161
|
+
@profiler == :vernier
|
|
162
|
+
end
|
|
65
163
|
end
|
|
66
164
|
end
|
data/lib/singed/kernel_ext.rb
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
1
4
|
module Kernel
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
#: [Result] (
|
|
6
|
+
#| ?String?,
|
|
7
|
+
#| ?open: bool,
|
|
8
|
+
#| ?ignore_gc: bool,
|
|
9
|
+
#| ?interval: Integer,
|
|
10
|
+
#| ?profiler: Symbol?,
|
|
11
|
+
#| ?io: IO | StringIO
|
|
12
|
+
#| ) { () -> Result } -> Result
|
|
13
|
+
def flamegraph(label = nil, open: true, ignore_gc: false, interval: 1000, profiler: nil, io: $stdout, &block) # rubocop:disable Metrics/ParameterLists -- all optional keywords
|
|
14
|
+
fg = Singed::Flamegraph.new(label:, ignore_gc:, interval:, profiler:)
|
|
15
|
+
result = fg.record(&block)
|
|
5
16
|
fg.save
|
|
6
17
|
|
|
7
18
|
# avoid a dep on a colorizing gem by doing this ourselves
|
|
8
19
|
bright_red = "\e[91m"
|
|
9
20
|
none = "\e[0m"
|
|
10
21
|
if open
|
|
11
|
-
# use npx, so we don't have to add it as a dependency
|
|
12
22
|
io.puts "🔥📈 #{bright_red}Captured flamegraph, opening with#{none}: #{fg.open_command}"
|
|
13
23
|
fg.open
|
|
14
24
|
else
|
|
@@ -1,35 +1,41 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
1
4
|
# Rack Middleware
|
|
2
5
|
|
|
3
6
|
require "rack"
|
|
4
7
|
|
|
5
8
|
module Singed
|
|
6
9
|
class RackMiddleware
|
|
10
|
+
# Rack apps are duck-typed: any object that responds to call(env).
|
|
11
|
+
#: (untyped) -> void
|
|
7
12
|
def initialize(app)
|
|
8
13
|
@app = app
|
|
9
14
|
end
|
|
10
15
|
|
|
16
|
+
# Returns the wrapped app's Rack response unchanged, so it is as untyped as the app.
|
|
17
|
+
#: (Hash[String, untyped]) -> untyped
|
|
11
18
|
def call(env)
|
|
12
|
-
|
|
13
|
-
flamegraph
|
|
14
|
-
@app.call(env)
|
|
15
|
-
end
|
|
19
|
+
if capture_flamegraph?(env)
|
|
20
|
+
flamegraph { @app.call(env) }
|
|
16
21
|
else
|
|
17
22
|
@app.call(env)
|
|
18
23
|
end
|
|
19
|
-
|
|
20
|
-
[status, headers, body]
|
|
21
24
|
end
|
|
22
25
|
|
|
26
|
+
#: (Hash[String, untyped]) -> bool
|
|
23
27
|
def capture_flamegraph?(env)
|
|
24
28
|
self.class.always_capture? || env["HTTP_X_SINGED"] == "true"
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
TRUTHY_STRINGS = ["true", "1", "yes"].freeze
|
|
28
32
|
|
|
33
|
+
# bool?, not bool: Sorbet can't tell that defined?(@always_capture) means it holds a bool.
|
|
34
|
+
#: () -> bool?
|
|
29
35
|
def self.always_capture?
|
|
30
36
|
return @always_capture if defined?(@always_capture)
|
|
31
37
|
|
|
32
|
-
@always_capture = TRUTHY_STRINGS.include?(ENV.fetch("SINGED_MIDDLEWARE_ALWAYS_CAPTURE", "false"))
|
|
38
|
+
@always_capture = TRUTHY_STRINGS.include?(ENV.fetch("SINGED_MIDDLEWARE_ALWAYS_CAPTURE", "false")) #: bool?
|
|
33
39
|
end
|
|
34
40
|
end
|
|
35
41
|
end
|