Skip to content

Commit 28e8573

Browse files
authored
Sidekiq middleware (#53)
* Sidekiq middleware * Code review fixes
1 parent 09fa531 commit 28e8573

File tree

6 files changed

+381
-1
lines changed

6 files changed

+381
-1
lines changed

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: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,18 +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.2)
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.7.2)
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.18.1)
1338
language_server-protocol (3.17.0.3)
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.24.0)
1645
parser (3.3.1.0)
1746
ast (~> 2.4.1)
1847
racc
48+
prism (1.9.0)
1949
racc (1.7.3)
50+
rack (3.2.5)
2051
rainbow (3.1.1)
2152
rake (13.0.6)
53+
redis-client (0.26.4)
54+
connection_pool
2255
regexp_parser (2.9.0)
2356
rexml (3.4.2)
2457
rspec (3.12.0)
@@ -51,6 +84,13 @@ GEM
5184
rubocop (>= 1.48.1, < 2.0)
5285
rubocop-ast (>= 1.30.0, < 2.0)
5386
ruby-progressbar (1.13.0)
87+
securerandom (0.4.1)
88+
sidekiq (8.1.1)
89+
connection_pool (>= 3.0.0)
90+
json (>= 2.16.0)
91+
logger (>= 1.7.0)
92+
rack (>= 3.2.0)
93+
redis-client (>= 0.26.0)
5494
stackprof (0.2.17)
5595
standard (1.35.1)
5696
language_server-protocol (~> 3.17.0.2)
@@ -64,14 +104,19 @@ GEM
64104
standard-performance (1.3.1)
65105
lint_roller (~> 1.1)
66106
rubocop-performance (~> 1.20.2)
107+
tzinfo (2.0.6)
108+
concurrent-ruby (~> 1.0)
67109
unicode-display_width (2.5.0)
110+
uri (1.1.1)
68111

69112
PLATFORMS
70113
ruby
71114

72115
DEPENDENCIES
116+
activejob
73117
rake (~> 13.0)
74118
rspec
119+
sidekiq
75120
singed!
76121
standard
77122

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,38 @@ PROTIP: use Chrome Developer Tools to record network activity, and copy requests
109109

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

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+
112144
### Command Line
113145

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

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

spec/singed/sidekiq_spec.rb

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
require "sidekiq"
5+
require "active_job"
6+
require "singed/sidekiq"
7+
require_relative "../support/sidekiq"
8+
9+
RSpec.describe Singed::Sidekiq::ServerMiddleware, sidekiq: true do
10+
subject { job_class.set(job_modifiers).perform_async(*job_args) }
11+
12+
let(:job_class) { SidekiqPlainJob }
13+
let(:job_args) { [] }
14+
let(:job_modifiers) { {} }
15+
16+
before do
17+
allow_any_instance_of(described_class).to receive(:flamegraph) { |*, &block| block.call }
18+
allow_any_instance_of(job_class).to receive(:perform).and_call_original
19+
end
20+
21+
context "with plain Sidekiq jobs" do
22+
it "doesn't capture flamegraph by default" do
23+
expect_any_instance_of(described_class).not_to receive(:flamegraph)
24+
expect_any_instance_of(job_class).to receive(:perform)
25+
subject
26+
end
27+
28+
context "when x-singed payload is true" do
29+
let(:job_modifiers) { {"x-singed" => true} }
30+
31+
it "wraps execution in flamegraph when x-singed is true" do
32+
expect_any_instance_of(described_class).to receive(:flamegraph)
33+
expect_any_instance_of(job_class).to receive(:perform)
34+
subject
35+
end
36+
end
37+
end
38+
39+
context "with class-level capture_flamegraph?" do
40+
let(:job_class) { SidekiqFlamegraphJob }
41+
42+
it "doesn't capture when capture_flamegraph? returns false" do
43+
expect_any_instance_of(described_class).not_to receive(:flamegraph)
44+
expect_any_instance_of(job_class).to receive(:perform)
45+
subject
46+
end
47+
48+
context "when payload satisfies capture_flamegraph?" do
49+
let(:job_modifiers) { {"x-flamegraph" => true} }
50+
51+
it "wraps execution in flamegraph when capture_flamegraph? returns true" do
52+
expect_any_instance_of(described_class).to receive(:flamegraph)
53+
expect_any_instance_of(job_class).to receive(:perform)
54+
subject
55+
end
56+
end
57+
end
58+
59+
context "when SINGED_MIDDLEWARE_ALWAYS_CAPTURE env var is set" do
60+
around do |example|
61+
original = ENV["SINGED_MIDDLEWARE_ALWAYS_CAPTURE"]
62+
example.run
63+
ensure
64+
if original.nil?
65+
ENV.delete("SINGED_MIDDLEWARE_ALWAYS_CAPTURE")
66+
else
67+
ENV["SINGED_MIDDLEWARE_ALWAYS_CAPTURE"] = original
68+
end
69+
end
70+
71+
context "when SINGED_MIDDLEWARE_ALWAYS_CAPTURE=true" do
72+
before { ENV["SINGED_MIDDLEWARE_ALWAYS_CAPTURE"] = "true" }
73+
74+
it "wraps execution in flamegraph" do
75+
expect_any_instance_of(described_class).to receive(:flamegraph)
76+
expect_any_instance_of(job_class).to receive(:perform)
77+
subject
78+
end
79+
end
80+
81+
context "when SINGED_MIDDLEWARE_ALWAYS_CAPTURE is false" do
82+
before { ENV["SINGED_MIDDLEWARE_ALWAYS_CAPTURE"] = "false" }
83+
84+
it "doesn't capture flamegraph" do
85+
expect_any_instance_of(described_class).not_to receive(:flamegraph)
86+
expect_any_instance_of(job_class).to receive(:perform)
87+
subject
88+
end
89+
end
90+
end
91+
92+
context "with ActiveJob jobs" do
93+
subject { job_class.set(job_modifiers).perform_later(*job_args) }
94+
95+
context "with plain ActiveJob" do
96+
let(:job_class) { ActiveJobPlainJob }
97+
98+
it "doesn't capture flamegraph by default" do
99+
expect_any_instance_of(described_class).not_to receive(:flamegraph)
100+
expect_any_instance_of(job_class).to receive(:perform)
101+
subject
102+
end
103+
104+
context "with ActiveJob class where capture_flamegraph? is true" do
105+
let(:job_class) { ActiveJobFlamegraphJob }
106+
107+
it "wraps execution in flamegraph when capture_flamegraph? returns true" do
108+
expect_any_instance_of(described_class).to receive(:flamegraph)
109+
expect_any_instance_of(job_class).to receive(:perform)
110+
subject
111+
end
112+
end
113+
114+
context "with ActiveJob class where capture_flamegraph? is false" do
115+
let(:job_class) { ActiveJobNoFlamegraphJob }
116+
117+
it "doesn't capture when capture_flamegraph? returns false" do
118+
expect_any_instance_of(described_class).not_to receive(:flamegraph)
119+
expect_any_instance_of(job_class).to receive(:perform)
120+
subject
121+
end
122+
end
123+
124+
context "when SINGED_MIDDLEWARE_ALWAYS_CAPTURE env var is set" do
125+
around do |example|
126+
original = ENV["SINGED_MIDDLEWARE_ALWAYS_CAPTURE"]
127+
example.run
128+
ensure
129+
if original.nil?
130+
ENV.delete("SINGED_MIDDLEWARE_ALWAYS_CAPTURE")
131+
else
132+
ENV["SINGED_MIDDLEWARE_ALWAYS_CAPTURE"] = original
133+
end
134+
end
135+
136+
context "when SINGED_MIDDLEWARE_ALWAYS_CAPTURE=true" do
137+
before { ENV["SINGED_MIDDLEWARE_ALWAYS_CAPTURE"] = "true" }
138+
139+
it "wraps execution in flamegraph" do
140+
expect_any_instance_of(described_class).to receive(:flamegraph)
141+
expect_any_instance_of(job_class).to receive(:perform)
142+
subject
143+
end
144+
end
145+
146+
context "when SINGED_MIDDLEWARE_ALWAYS_CAPTURE is false" do
147+
before { ENV["SINGED_MIDDLEWARE_ALWAYS_CAPTURE"] = "false" }
148+
149+
it "doesn't capture flamegraph" do
150+
expect_any_instance_of(described_class).not_to receive(:flamegraph)
151+
expect_any_instance_of(job_class).to receive(:perform)
152+
subject
153+
end
154+
end
155+
end
156+
end
157+
end
158+
end

0 commit comments

Comments
 (0)