Skip to content

Commit 8fc5cb1

Browse files
committed
Merge remote-tracking branch 'upstream/main' into chore/standardrb
* upstream/main: Sidekiq middleware (#53) Methods for explicit start and stop flamegraph recording (#52)
2 parents e91509f + 28e8573 commit 8fc5cb1

9 files changed

Lines changed: 523 additions & 9 deletions

File tree

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@ source "https://rubygems.org"
55
# Specify your gem's dependencies in singed.gemspec
66
gemspec
77

8+
gem "activejob"
89
gem "rake", "~> 13.0"
10+
gem "sidekiq"
911
gem "standard"

Gemfile.lock

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,51 @@ PATH
77
GEM
88
remote: https://rubygems.org/
99
specs:
10+
activejob (8.1.2)
11+
activesupport (= 8.1.2)
12+
globalid (>= 0.3.6)
13+
activesupport (8.1.2)
14+
base64
15+
bigdecimal
16+
concurrent-ruby (~> 1.0, >= 1.3.1)
17+
connection_pool (>= 2.2.5)
18+
drb
19+
i18n (>= 1.6, < 2)
20+
json
21+
logger (>= 1.4.2)
22+
minitest (>= 5.1)
23+
securerandom (>= 0.3)
24+
tzinfo (~> 2.0, >= 2.0.5)
25+
uri (>= 0.13.1)
1026
ast (2.4.3)
27+
base64 (0.3.0)
28+
bigdecimal (4.0.1)
29+
concurrent-ruby (1.3.6)
30+
connection_pool (3.0.2)
1131
diff-lcs (1.5.0)
12-
json (2.19.0)
32+
drb (2.2.3)
33+
globalid (1.3.0)
34+
activesupport (>= 6.1)
35+
i18n (1.14.8)
36+
concurrent-ruby (~> 1.0)
37+
json (2.19.1)
1338
language_server-protocol (3.17.0.5)
1439
lint_roller (1.1.0)
40+
logger (1.7.0)
41+
minitest (6.0.2)
42+
drb (~> 2.0)
43+
prism (~> 1.5)
1544
parallel (1.27.0)
1645
parser (3.3.10.2)
1746
ast (~> 2.4.1)
1847
racc
1948
prism (1.9.0)
2049
racc (1.8.1)
50+
rack (3.2.5)
2151
rainbow (3.1.1)
2252
rake (13.0.6)
53+
redis-client (0.26.4)
54+
connection_pool
2355
regexp_parser (2.11.3)
2456
rspec (3.12.0)
2557
rspec-core (~> 3.12.0)
@@ -53,6 +85,13 @@ GEM
5385
rubocop (>= 1.75.0, < 2.0)
5486
rubocop-ast (>= 1.47.1, < 2.0)
5587
ruby-progressbar (1.13.0)
88+
securerandom (0.4.1)
89+
sidekiq (8.1.1)
90+
connection_pool (>= 3.0.0)
91+
json (>= 2.16.0)
92+
logger (>= 1.7.0)
93+
rack (>= 3.2.0)
94+
redis-client (>= 0.26.0)
5695
stackprof (0.2.17)
5796
standard (1.54.0)
5897
language_server-protocol (~> 3.17.0.2)
@@ -66,16 +105,21 @@ GEM
66105
standard-performance (1.9.0)
67106
lint_roller (~> 1.1)
68107
rubocop-performance (~> 1.26.0)
108+
tzinfo (2.0.6)
109+
concurrent-ruby (~> 1.0)
69110
unicode-display_width (3.2.0)
70111
unicode-emoji (~> 4.1)
71112
unicode-emoji (4.2.0)
113+
uri (1.1.1)
72114

73115
PLATFORMS
74116
ruby
75117

76118
DEPENDENCIES
119+
activejob
77120
rake (~> 13.0)
78121
rspec
122+
sidekiq
79123
singed!
80124
standard
81125

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ flamegraph(open: false) {
4747
}
4848
```
4949

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+
5069
### RSpec
5170

5271
If you are using RSpec, you can use the `flamegraph` metadata to capture it for you.
@@ -90,6 +109,38 @@ PROTIP: use Chrome Developer Tools to record network activity, and copy requests
90109

91110
This can also be enabled to always run by setting `SINGED_MIDDLEWARE_ALWAYS_CAPTURE=1` in the environment.
92111

112+
### Sidekiq
113+
114+
If you are using Sidekiq, you can use the `Singed::Sidekiq::ServerMiddleware` to capture flamegraphs for you.
115+
116+
```ruby
117+
require "singed/sidekiq"
118+
119+
Sidekiq.configure_server do |config|
120+
config.server_middleware do |chain|
121+
chain.add Singed::Sidekiq::ServerMiddleware
122+
end
123+
end
124+
```
125+
126+
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.
127+
128+
To capture flamegraphs for a specific job, you can set the `x-singed` key in the job payload to `true`.
129+
130+
```ruby
131+
MyJob.set("x-singed" => true).perform_async
132+
```
133+
134+
Or define a `capture_flamegraph?` method on the job class:
135+
136+
```ruby
137+
class MyJob
138+
def self.capture_flamegraph?(payload)
139+
payload["flamegraph"]
140+
end
141+
end
142+
```
143+
93144
### Command Line
94145

95146
There is a `singed` command line you can use that will record a flamegraph from the entirety of a command run:

lib/singed.rb

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ module Singed
88

99
# Where should flamegraphs be saved?
1010
def output_directory=(directory)
11-
@output_directory = Pathname.new(directory)
11+
@output_directory = directory && Pathname.new(directory)
1212
end
1313

1414
def self.output_directory
@@ -45,6 +45,28 @@ def filter_line(line)
4545
line
4646
end
4747

48+
def start(label = nil, ignore_gc: false, interval: 1000)
49+
return unless enabled?
50+
return if profiling?
51+
52+
@current_flamegraph = Flamegraph.new(label: label, ignore_gc: ignore_gc, interval: interval)
53+
@current_flamegraph.tap(&:start)
54+
end
55+
56+
def stop
57+
return nil unless profiling?
58+
59+
flamegraph = @current_flamegraph
60+
@current_flamegraph = nil
61+
flamegraph.stop
62+
flamegraph.save
63+
flamegraph
64+
end
65+
66+
def profiling?
67+
@current_flamegraph&.started? || false
68+
end
69+
4870
autoload :Flamegraph, "singed/flamegraph"
4971
autoload :Report, "singed/report"
5072
autoload :RackMiddleware, "singed/rack_middleware"

lib/singed/flamegraph.rb

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,31 @@ def initialize(label: nil, ignore_gc: false, interval: 1000, filename: nil)
2323
end
2424

2525
def record
26-
return yield unless Singed.enabled?
27-
return yield if filename.exist? # file existing means its been captured already
26+
start
27+
yield
28+
ensure
29+
stop
30+
end
2831

29-
result = nil
30-
@profile = StackProf.run(mode: :wall, raw: true, ignore_gc: @ignore_gc, interval: @interval) do
31-
result = yield
32-
end
33-
result
32+
def start
33+
return false unless Singed.enabled?
34+
return false if filename.exist? # file existing means its been captured already
35+
return false if started?
36+
37+
StackProf.start(mode: :wall, raw: true, ignore_gc: @ignore_gc, interval: @interval)
38+
@started = true
39+
end
40+
41+
def stop
42+
return nil unless started?
43+
44+
@started = false
45+
StackProf.stop
46+
@profile = StackProf.results
47+
end
48+
49+
def started?
50+
!!@started
3451
end
3552

3653
def save

lib/singed/sidekiq.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# frozen_string_literal: true
2+
3+
module Singed
4+
module Sidekiq
5+
class ServerMiddleware
6+
include ::Sidekiq::ServerMiddleware
7+
8+
def call(job_instance, job_payload, queue, &block)
9+
return block.call unless capture_flamegraph?(job_instance, job_payload)
10+
11+
flamegraph(flamegraph_label(job_instance, job_payload), &block)
12+
end
13+
14+
private
15+
16+
TRUTHY_STRINGS = %w[true 1 yes].freeze
17+
18+
def capture_flamegraph?(job_instance, job_payload)
19+
return TRUTHY_STRINGS.include?(job_payload["x-singed"].to_s) if job_payload.key?("x-singed")
20+
21+
job_class = job_class(job_instance, job_payload)
22+
return false unless job_class
23+
return job_class.capture_flamegraph?(job_payload) if job_class.respond_to?(:capture_flamegraph?)
24+
25+
TRUTHY_STRINGS.include?(ENV.fetch("SINGED_MIDDLEWARE_ALWAYS_CAPTURE", "false"))
26+
end
27+
28+
def flamegraph_label(job_instance, job_payload)
29+
[job_class(job_instance, job_payload), job_payload["jid"]].compact.join("--")
30+
end
31+
32+
def job_class(job_instance, job_payload)
33+
job_class = job_payload.fetch("wrapped", job_instance) # ActiveJob
34+
return job_class if job_class.is_a?(Class)
35+
return job_class.class if job_class.is_a?(::Sidekiq::Job)
36+
return job_class.constantize if job_class.respond_to?(:constantize)
37+
38+
Object.const_get(job_class.to_s)
39+
rescue NameError
40+
nil
41+
end
42+
end
43+
end
44+
end

0 commit comments

Comments
 (0)