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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ae5015ceb06bdcb4a4ee0149083fa5449bd6db4c508ab552a77f07a24c540aa
4
- data.tar.gz: 533ff18bba4acbc7b19ee0c7875d5ae1beb97623eddab13c64b7eae5fae267c9
3
+ metadata.gz: edd7e0459f79298b925de483b4b6a1363f2fa2a071b93d6a954ae939ff65ea3c
4
+ data.tar.gz: 99c4141ad809f9a54dfd477ab8097c87fdb41531e1b3db24b658c1759ca0ca3d
5
5
  SHA512:
6
- metadata.gz: 61e8d7689b0a1e23cc81b75a892910b53641bf3bdc8ace5d8956a62962941ec8ede1bf6dd30b2f1bd3d56d4faa5b37d9b9855dafa006e864cba36e07af9f9c52
7
- data.tar.gz: 3b482b7351174cfd923be2df3fe8d04678cd2140a99141b4c0fb0c0d72cd5ad3736ce5b98b696d192b4575b7c7744df52a09981020542967f90783e11324e8cd
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,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "singed/cli"
4
5
  if Singed::CLI.chdir_rails_root
@@ -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
- attr_accessor :argv, :filename, :opts
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
- FileUtils.mkdir_p Singed.output_directory
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 = lambda do
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
- unless filename.exist?
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: 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
- system(env, *sudo_args, exception: true)
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
- def self.included(base)
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
- def flamegraph(target_action, ignore_gc: false, interval: 1000)
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: ignore_gc, interval: interval, &action)
17
+ controller.flamegraph(ignore_gc:, interval:, profiler:, &action)
12
18
  end
13
19
  end
14
20
  end
@@ -1,8 +1,26 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
1
4
  module Singed
2
5
  class Flamegraph
3
- attr_accessor :profile, :filename
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
- def initialize(label: nil, ignore_gc: false, interval: 1000, filename: nil)
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
- @filename = filename
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
- @ignore_gc = ignore_gc
19
- @interval = interval
20
- @time = Time.now # rubocop:disable Rails/TimeZone
21
- @filename = self.class.generate_filename(label: label, time: @time)
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
- def record
26
- return yield unless Singed.enabled?
27
- return yield if filename.exist? # file existing means its been captured already
52
+ #: [Result] () { () -> Result } -> Result
53
+ def record(&_block)
54
+ start
55
+ yield
56
+ ensure
57
+ stop
58
+ end
28
59
 
29
- result = nil
30
- @profile = StackProf.run(mode: :wall, raw: true, ignore_gc: @ignore_gc, interval: @interval) do
31
- result = yield
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
- report = Singed::Report.new(@profile)
42
- report.filter!
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
- system open_command
113
+ Singed::Speedscope.open(@filename)
49
114
  end
50
115
 
116
+ #: () -> String
51
117
  def open_command
52
- @open_command ||= "npx speedscope #{@filename}"
118
+ Singed::Speedscope.open_command(@filename)
53
119
  end
54
120
 
55
- def self.generate_filename(label: nil, time: Time.now) # rubocop:disable Rails/TimeZone
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
- file = Singed.output_directory.join("#{basename_parts.join("-")}.json")
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
@@ -1,14 +1,24 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
1
4
  module Kernel
2
- def flamegraph(label = nil, open: true, ignore_gc: false, interval: 1000, io: $stdout, &)
3
- fg = Singed::Flamegraph.new(label: label, ignore_gc: ignore_gc, interval: interval)
4
- result = fg.record(&)
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
- status, headers, body = if capture_flamegraph?(env)
13
- flamegraph do
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