appsignal 3.0.0.beta.1 → 3.0.0.rc.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -5,56 +5,34 @@ if defined?(Appsignal)
5
5
  end
6
6
 
7
7
  class Object
8
- if Appsignal::System.ruby_2_7_or_newer?
9
- def self.appsignal_instrument_class_method(method_name, options = {})
10
- singleton_class.send \
11
- :alias_method, "appsignal_uninstrumented_#{method_name}", method_name
12
- singleton_class.send(:define_method, method_name) do |*args, **kwargs, &block|
13
- name = options.fetch(:name) do
14
- "#{method_name}.class_method.#{appsignal_reverse_class_name}.other"
15
- end
16
- Appsignal.instrument name do
17
- send "appsignal_uninstrumented_#{method_name}", *args, **kwargs, &block
18
- end
8
+ def self.appsignal_instrument_class_method(method_name, options = {})
9
+ singleton_class.send \
10
+ :alias_method, "appsignal_uninstrumented_#{method_name}", method_name
11
+ singleton_class.send(:define_method, method_name) do |*args, &block|
12
+ name = options.fetch(:name) do
13
+ "#{method_name}.class_method.#{appsignal_reverse_class_name}.other"
19
14
  end
20
- end
21
-
22
- def self.appsignal_instrument_method(method_name, options = {})
23
- alias_method "appsignal_uninstrumented_#{method_name}", method_name
24
- define_method method_name do |*args, **kwargs, &block|
25
- name = options.fetch(:name) do
26
- "#{method_name}.#{appsignal_reverse_class_name}.other"
27
- end
28
- Appsignal.instrument name do
29
- send "appsignal_uninstrumented_#{method_name}", *args, **kwargs, &block
30
- end
15
+ Appsignal.instrument name do
16
+ send "appsignal_uninstrumented_#{method_name}", *args, &block
31
17
  end
32
18
  end
33
- else
34
- def self.appsignal_instrument_class_method(method_name, options = {})
35
- singleton_class.send \
36
- :alias_method, "appsignal_uninstrumented_#{method_name}", method_name
37
- singleton_class.send(:define_method, method_name) do |*args, &block|
38
- name = options.fetch(:name) do
39
- "#{method_name}.class_method.#{appsignal_reverse_class_name}.other"
40
- end
41
- Appsignal.instrument name do
42
- send "appsignal_uninstrumented_#{method_name}", *args, &block
43
- end
44
- end
19
+
20
+ if singleton_class.respond_to?(:ruby2_keywords, true) # rubocop:disable Style/GuardClause
21
+ singleton_class.send(:ruby2_keywords, method_name)
45
22
  end
23
+ end
46
24
 
47
- def self.appsignal_instrument_method(method_name, options = {})
48
- alias_method "appsignal_uninstrumented_#{method_name}", method_name
49
- define_method method_name do |*args, &block|
50
- name = options.fetch(:name) do
51
- "#{method_name}.#{appsignal_reverse_class_name}.other"
52
- end
53
- Appsignal.instrument name do
54
- send "appsignal_uninstrumented_#{method_name}", *args, &block
55
- end
25
+ def self.appsignal_instrument_method(method_name, options = {})
26
+ alias_method "appsignal_uninstrumented_#{method_name}", method_name
27
+ define_method method_name do |*args, &block|
28
+ name = options.fetch(:name) do
29
+ "#{method_name}.#{appsignal_reverse_class_name}.other"
30
+ end
31
+ Appsignal.instrument name do
32
+ send "appsignal_uninstrumented_#{method_name}", *args, &block
56
33
  end
57
34
  end
35
+ ruby2_keywords method_name if respond_to?(:ruby2_keywords, true)
58
36
  end
59
37
 
60
38
  def self.appsignal_reverse_class_name
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Appsignal
6
+ module Integrations
7
+ # Error handler for Sidekiq to report errors from jobs and internal Sidekiq
8
+ # errors.
9
+ #
10
+ # @api private
11
+ class SidekiqErrorHandler
12
+ def call(exception, sidekiq_context)
13
+ transaction = Appsignal::Transaction.current
14
+
15
+ if transaction.nil_transaction?
16
+ # Sidekiq error outside of the middleware scope.
17
+ # Can be a job JSON parse error or some other error happening in
18
+ # Sidekiq.
19
+ transaction = Appsignal::Transaction.create(
20
+ SecureRandom.uuid, # Newly generated job id
21
+ Appsignal::Transaction::BACKGROUND_JOB,
22
+ Appsignal::Transaction::GenericRequest.new({})
23
+ )
24
+ transaction.set_action_if_nil("SidekiqInternal")
25
+ transaction.set_metadata("sidekiq_error", sidekiq_context[:context])
26
+ transaction.params = { :jobstr => sidekiq_context[:jobstr] }
27
+ end
28
+
29
+ transaction.set_error(exception)
30
+ Appsignal::Transaction.complete_current!
31
+ end
32
+ end
33
+
34
+ # @api private
35
+ class SidekiqMiddleware # rubocop:disable Metrics/ClassLength
36
+ include Appsignal::Hooks::Helpers
37
+
38
+ EXCLUDED_JOB_KEYS = %w[
39
+ args backtrace class created_at enqueued_at error_backtrace error_class
40
+ error_message failed_at jid retried_at retry wrapped
41
+ ].freeze
42
+
43
+ def call(_worker, item, _queue)
44
+ job_status = nil
45
+ transaction = Appsignal::Transaction.create(
46
+ item["jid"],
47
+ Appsignal::Transaction::BACKGROUND_JOB,
48
+ Appsignal::Transaction::GenericRequest.new(
49
+ :queue_start => item["enqueued_at"]
50
+ )
51
+ )
52
+
53
+ Appsignal.instrument "perform_job.sidekiq" do
54
+ yield
55
+ end
56
+ rescue Exception => exception # rubocop:disable Lint/RescueException
57
+ job_status = :failed
58
+ raise exception
59
+ ensure
60
+ if transaction
61
+ transaction.set_action_if_nil(formatted_action_name(item))
62
+
63
+ params = filtered_arguments(item)
64
+ transaction.params = params if params
65
+
66
+ formatted_metadata(item).each do |key, value|
67
+ transaction.set_metadata key, value
68
+ end
69
+ transaction.set_http_or_background_queue_start
70
+ Appsignal::Transaction.complete_current! unless exception
71
+
72
+ queue = item["queue"] || "unknown"
73
+ if job_status
74
+ increment_counter "queue_job_count", 1,
75
+ :queue => queue,
76
+ :status => job_status
77
+ end
78
+ increment_counter "queue_job_count", 1,
79
+ :queue => queue,
80
+ :status => :processed
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def increment_counter(key, value, tags = {})
87
+ Appsignal.increment_counter "sidekiq_#{key}", value, tags
88
+ end
89
+
90
+ def formatted_action_name(job)
91
+ sidekiq_action_name = parse_action_name(job)
92
+ return unless sidekiq_action_name
93
+
94
+ complete_action = sidekiq_action_name =~ /\.|#/
95
+ return sidekiq_action_name if complete_action
96
+
97
+ "#{sidekiq_action_name}#perform"
98
+ end
99
+
100
+ def filtered_arguments(job)
101
+ arguments = parse_arguments(job)
102
+ return unless arguments
103
+
104
+ Appsignal::Utils::HashSanitizer.sanitize(
105
+ arguments,
106
+ Appsignal.config[:filter_parameters]
107
+ )
108
+ end
109
+
110
+ def formatted_metadata(item)
111
+ {}.tap do |hash|
112
+ (item || {}).each do |key, value|
113
+ next if EXCLUDED_JOB_KEYS.include?(key)
114
+
115
+ hash[key] = truncate(string_or_inspect(value))
116
+ end
117
+ end
118
+ end
119
+
120
+ # Based on: https://github.com/mperham/sidekiq/blob/63ee43353bd3b753beb0233f64865e658abeb1c3/lib/sidekiq/api.rb#L316-L334
121
+ def parse_action_name(job)
122
+ args = job.fetch("args", [])
123
+ job_class = job["class"]
124
+ case job_class
125
+ when "Sidekiq::Extensions::DelayedModel"
126
+ safe_load(args[0], job_class) do |target, method, _|
127
+ "#{target.class}##{method}"
128
+ end
129
+ when /\ASidekiq::Extensions::Delayed/
130
+ safe_load(args[0], job_class) do |target, method, _|
131
+ "#{target}.#{method}"
132
+ end
133
+ else
134
+ job_class
135
+ end
136
+ end
137
+
138
+ # Based on: https://github.com/mperham/sidekiq/blob/63ee43353bd3b753beb0233f64865e658abeb1c3/lib/sidekiq/api.rb#L336-L358
139
+ def parse_arguments(job)
140
+ args = job.fetch("args", [])
141
+ case job["class"]
142
+ when /\ASidekiq::Extensions::Delayed/
143
+ safe_load(args[0], args) do |_, _, arg|
144
+ arg
145
+ end
146
+ when "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper"
147
+ nil # Set in the ActiveJob integration
148
+ else
149
+ # Sidekiq Enterprise argument encryption.
150
+ # More information: https://github.com/mperham/sidekiq/wiki/Ent-Encryption
151
+ if job["encrypt".freeze]
152
+ # No point in showing 150+ bytes of random garbage
153
+ args[-1] = "[encrypted data]".freeze
154
+ end
155
+ args
156
+ end
157
+ end
158
+
159
+ # Based on: https://github.com/mperham/sidekiq/blob/63ee43353bd3b753beb0233f64865e658abeb1c3/lib/sidekiq/api.rb#L403-L412
160
+ def safe_load(content, default)
161
+ yield(*YAML.load(content))
162
+ rescue => error
163
+ # Sidekiq issue #1761: in dev mode, it's possible to have jobs enqueued
164
+ # which haven't been loaded into memory yet so the YAML can't be
165
+ # loaded.
166
+ Appsignal.logger.warn "Unable to load YAML: #{error.message}"
167
+ default
168
+ end
169
+ end
170
+ end
171
+ end
@@ -123,6 +123,12 @@ module Appsignal
123
123
  def start
124
124
  stop
125
125
  @thread = Thread.new do
126
+ # Advise multi-threaded app servers to ignore this thread
127
+ # for the purposes of fork safety warnings
128
+ if Thread.current.respond_to?(:thread_variable_set)
129
+ Thread.current.thread_variable_set(:fork_safe, true)
130
+ end
131
+
126
132
  sleep initial_wait_time
127
133
  initialize_probes
128
134
  loop do
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Appsignal
4
- VERSION = "3.0.0.beta.1".freeze
4
+ VERSION = "3.0.0.rc.1".freeze
5
5
  end
@@ -2,6 +2,8 @@ describe Appsignal::Hooks::ActionCableHook do
2
2
  if DependencyHelper.action_cable_present?
3
3
  context "with ActionCable" do
4
4
  require "action_cable/engine"
5
+ # Require test helper to test with ConnectionStub
6
+ require "action_cable/channel/test_case" if DependencyHelper.rails6_present?
5
7
 
6
8
  describe ".dependencies_present?" do
7
9
  subject { described_class.new.dependencies_present? }
@@ -262,6 +264,49 @@ describe Appsignal::Hooks::ActionCableHook do
262
264
  )
263
265
  end
264
266
  end
267
+
268
+ if DependencyHelper.rails6_present?
269
+ context "with ConnectionStub" do
270
+ let(:connection) { ActionCable::Channel::ConnectionStub.new }
271
+ let(:transaction_id) { "Stubbed transaction id" }
272
+ before do
273
+ # Stub future (private AppSignal) transaction id generated by the hook.
274
+ expect(SecureRandom).to receive(:uuid).and_return(transaction_id)
275
+ end
276
+
277
+ it "does not fail on missing `#env` method on `ConnectionStub`" do
278
+ instance.subscribe_to_channel
279
+
280
+ expect(subject).to include(
281
+ "action" => "MyChannel#subscribed",
282
+ "error" => nil,
283
+ "id" => transaction_id,
284
+ "namespace" => Appsignal::Transaction::ACTION_CABLE,
285
+ "metadata" => {
286
+ "method" => "websocket",
287
+ "path" => "" # No path as the ConnectionStub doesn't have the real request env
288
+ }
289
+ )
290
+ expect(subject["events"].first).to include(
291
+ "allocation_count" => kind_of(Integer),
292
+ "body" => "",
293
+ "body_format" => Appsignal::EventFormatter::DEFAULT,
294
+ "child_allocation_count" => kind_of(Integer),
295
+ "child_duration" => kind_of(Float),
296
+ "child_gc_duration" => kind_of(Float),
297
+ "count" => 1,
298
+ "gc_duration" => kind_of(Float),
299
+ "start" => kind_of(Float),
300
+ "duration" => kind_of(Float),
301
+ "name" => "subscribed.action_cable",
302
+ "title" => ""
303
+ )
304
+ expect(subject["sample_data"]).to include(
305
+ "params" => { "internal" => "true" }
306
+ )
307
+ end
308
+ end
309
+ end
265
310
  end
266
311
 
267
312
  describe "unsubscribe callback" do
@@ -349,6 +394,49 @@ describe Appsignal::Hooks::ActionCableHook do
349
394
  )
350
395
  end
351
396
  end
397
+
398
+ if DependencyHelper.rails6_present?
399
+ context "with ConnectionStub" do
400
+ let(:connection) { ActionCable::Channel::ConnectionStub.new }
401
+ let(:transaction_id) { "Stubbed transaction id" }
402
+ before do
403
+ # Stub future (private AppSignal) transaction id generated by the hook.
404
+ expect(SecureRandom).to receive(:uuid).and_return(transaction_id)
405
+ end
406
+
407
+ it "does not fail on missing `#env` method on `ConnectionStub`" do
408
+ instance.unsubscribe_from_channel
409
+
410
+ expect(subject).to include(
411
+ "action" => "MyChannel#unsubscribed",
412
+ "error" => nil,
413
+ "id" => transaction_id,
414
+ "namespace" => Appsignal::Transaction::ACTION_CABLE,
415
+ "metadata" => {
416
+ "method" => "websocket",
417
+ "path" => "" # No path as the ConnectionStub doesn't have the real request env
418
+ }
419
+ )
420
+ expect(subject["events"].first).to include(
421
+ "allocation_count" => kind_of(Integer),
422
+ "body" => "",
423
+ "body_format" => Appsignal::EventFormatter::DEFAULT,
424
+ "child_allocation_count" => kind_of(Integer),
425
+ "child_duration" => kind_of(Float),
426
+ "child_gc_duration" => kind_of(Float),
427
+ "count" => 1,
428
+ "gc_duration" => kind_of(Float),
429
+ "start" => kind_of(Float),
430
+ "duration" => kind_of(Float),
431
+ "name" => "unsubscribed.action_cable",
432
+ "title" => ""
433
+ )
434
+ expect(subject["sample_data"]).to include(
435
+ "params" => { "internal" => "true" }
436
+ )
437
+ end
438
+ end
439
+ end
352
440
  end
353
441
  end
354
442
  end
@@ -16,14 +16,31 @@ describe Appsignal::Hooks::SidekiqHook do
16
16
  end
17
17
 
18
18
  describe "#install" do
19
- class SidekiqMiddlewareMock < Set
20
- def exists?(middleware)
21
- include?(middleware)
19
+ class SidekiqMiddlewareMockWithPrepend < Array
20
+ alias add <<
21
+ alias exists? include?
22
+
23
+ unless method_defined? :prepend
24
+ def prepend(middleware) # For Ruby < 2.5
25
+ insert(0, middleware)
26
+ end
22
27
  end
23
28
  end
29
+
30
+ class SidekiqMiddlewareMockWithoutPrepend < Array
31
+ alias add <<
32
+ alias exists? include?
33
+
34
+ undef_method :prepend if method_defined? :prepend # For Ruby >= 2.5
35
+ end
36
+
24
37
  module SidekiqMock
38
+ def self.middleware_mock=(mock)
39
+ @middlewares = mock.new
40
+ end
41
+
25
42
  def self.middlewares
26
- @middlewares ||= SidekiqMiddlewareMock.new
43
+ @middlewares
27
44
  end
28
45
 
29
46
  def self.configure_server
@@ -34,479 +51,64 @@ describe Appsignal::Hooks::SidekiqHook do
34
51
  yield middlewares if block_given?
35
52
  middlewares
36
53
  end
37
- end
38
-
39
- before do
40
- Appsignal.config = project_fixture_config
41
- stub_const "Sidekiq", SidekiqMock
42
- end
43
-
44
- it "adds the AppSignal SidekiqPlugin to the Sidekiq middleware chain" do
45
- described_class.new.install
46
-
47
- expect(Sidekiq.server_middleware.exists?(Appsignal::Hooks::SidekiqPlugin)).to be(true)
48
- end
49
- end
50
- end
51
-
52
- describe Appsignal::Hooks::SidekiqPlugin, :with_yaml_parse_error => false do
53
- class DelayedTestClass; end
54
-
55
- let(:namespace) { Appsignal::Transaction::BACKGROUND_JOB }
56
- let(:worker) { anything }
57
- let(:queue) { anything }
58
- let(:given_args) do
59
- [
60
- "foo",
61
- {
62
- :foo => "Foo",
63
- :bar => "Bar",
64
- "baz" => { 1 => :foo }
65
- }
66
- ]
67
- end
68
- let(:expected_args) do
69
- [
70
- "foo",
71
- {
72
- "foo" => "Foo",
73
- "bar" => "Bar",
74
- "baz" => { "1" => "foo" }
75
- }
76
- ]
77
- end
78
- let(:job_class) { "TestClass" }
79
- let(:jid) { "b4a577edbccf1d805744efa9" }
80
- let(:item) do
81
- {
82
- "jid" => jid,
83
- "class" => job_class,
84
- "retry_count" => 0,
85
- "queue" => "default",
86
- "created_at" => Time.parse("2001-01-01 10:00:00UTC").to_f,
87
- "enqueued_at" => Time.parse("2001-01-01 10:00:00UTC").to_f,
88
- "args" => given_args,
89
- "extra" => "data"
90
- }
91
- end
92
- let(:plugin) { Appsignal::Hooks::SidekiqPlugin.new }
93
- let(:log) { StringIO.new }
94
- before do
95
- start_agent
96
- Appsignal.logger = test_logger(log)
97
- end
98
- around { |example| keep_transactions { example.run } }
99
- after :with_yaml_parse_error => false do
100
- expect(log_contents(log)).to_not contains_log(:warn, "Unable to load YAML")
101
- end
102
-
103
- describe "internal Sidekiq job values" do
104
- it "does not save internal Sidekiq values as metadata on transaction" do
105
- perform_job
106
-
107
- transaction_hash = transaction.to_h
108
- expect(transaction_hash["metadata"].keys)
109
- .to_not include(*Appsignal::Hooks::SidekiqPlugin::EXCLUDED_JOB_KEYS)
110
- end
111
- end
112
-
113
- context "with parameter filtering" do
114
- before do
115
- Appsignal.config = project_fixture_config("production")
116
- Appsignal.config[:filter_parameters] = ["foo"]
117
- end
118
-
119
- it "filters selected arguments" do
120
- perform_job
121
54
 
122
- transaction_hash = transaction.to_h
123
- expect(transaction_hash["sample_data"]).to include(
124
- "params" => [
125
- "foo",
126
- {
127
- "foo" => "[FILTERED]",
128
- "bar" => "Bar",
129
- "baz" => { "1" => "foo" }
130
- }
131
- ]
132
- )
133
- end
134
- end
135
-
136
- context "with encrypted arguments" do
137
- before do
138
- item["encrypt"] = true
139
- item["args"] << "super secret value" # Last argument will be replaced
140
- end
141
-
142
- it "replaces the last argument (the secret bag) with an [encrypted data] string" do
143
- perform_job
144
-
145
- transaction_hash = transaction.to_h
146
- expect(transaction_hash["sample_data"]).to include(
147
- "params" => expected_args << "[encrypted data]"
148
- )
149
- end
150
- end
151
-
152
- context "when using the Sidekiq delayed extension" do
153
- let(:item) do
154
- {
155
- "jid" => jid,
156
- "class" => "Sidekiq::Extensions::DelayedClass",
157
- "queue" => "default",
158
- "args" => [
159
- "---\n- !ruby/class 'DelayedTestClass'\n- :foo_method\n- - :bar: baz\n"
160
- ],
161
- "retry" => true,
162
- "created_at" => Time.parse("2001-01-01 10:00:00UTC").to_f,
163
- "enqueued_at" => Time.parse("2001-01-01 10:00:00UTC").to_f,
164
- "extra" => "data"
165
- }
166
- end
167
-
168
- it "uses the delayed class and method name for the action" do
169
- perform_job
170
-
171
- transaction_hash = transaction.to_h
172
- expect(transaction_hash["action"]).to eq("DelayedTestClass.foo_method")
173
- expect(transaction_hash["sample_data"]).to include(
174
- "params" => ["bar" => "baz"]
175
- )
176
- end
177
-
178
- context "when job arguments is a malformed YAML object", :with_yaml_parse_error => true do
179
- before { item["args"] = [] }
180
-
181
- it "logs a warning and uses the default argument" do
182
- perform_job
183
-
184
- transaction_hash = transaction.to_h
185
- expect(transaction_hash["action"]).to eq("Sidekiq::Extensions::DelayedClass#perform")
186
- expect(transaction_hash["sample_data"]).to include("params" => [])
187
- expect(log_contents(log)).to contains_log(:warn, "Unable to load YAML")
55
+ def self.error_handlers
56
+ @error_handlers ||= []
188
57
  end
189
58
  end
190
- end
191
-
192
- context "when using the Sidekiq ActiveRecord instance delayed extension" do
193
- let(:item) do
194
- {
195
- "jid" => jid,
196
- "class" => "Sidekiq::Extensions::DelayedModel",
197
- "queue" => "default",
198
- "args" => [
199
- "---\n- !ruby/object:DelayedTestClass {}\n- :foo_method\n- - :bar: :baz\n"
200
- ],
201
- "retry" => true,
202
- "created_at" => Time.parse("2001-01-01 10:00:00UTC").to_f,
203
- "enqueued_at" => Time.parse("2001-01-01 10:00:00UTC").to_f,
204
- "extra" => "data"
205
- }
206
- end
207
-
208
- it "uses the delayed class and method name for the action" do
209
- perform_job
210
-
211
- transaction_hash = transaction.to_h
212
- expect(transaction_hash["action"]).to eq("DelayedTestClass#foo_method")
213
- expect(transaction_hash["sample_data"]).to include(
214
- "params" => ["bar" => "baz"]
215
- )
216
- end
217
59
 
218
- context "when job arguments is a malformed YAML object", :with_yaml_parse_error => true do
219
- before { item["args"] = [] }
220
-
221
- it "logs a warning and uses the default argument" do
222
- perform_job
223
-
224
- transaction_hash = transaction.to_h
225
- expect(transaction_hash["action"]).to eq("Sidekiq::Extensions::DelayedModel#perform")
226
- expect(transaction_hash["sample_data"]).to include("params" => [])
227
- expect(log_contents(log)).to contains_log(:warn, "Unable to load YAML")
60
+ def add_middleware(middleware)
61
+ Sidekiq.configure_server do |sidekiq_config|
62
+ sidekiq_config.middlewares.add(middleware)
228
63
  end
229
64
  end
230
- end
231
-
232
- context "with an error" do
233
- let(:error) { ExampleException }
234
65
 
235
- it "creates a transaction and adds the error" do
236
- expect(Appsignal).to receive(:increment_counter)
237
- .with("sidekiq_queue_job_count", 1, :queue => "default", :status => :failed)
238
- expect(Appsignal).to receive(:increment_counter)
239
- .with("sidekiq_queue_job_count", 1, :queue => "default", :status => :processed)
240
-
241
- expect do
242
- perform_job { raise error, "uh oh" }
243
- end.to raise_error(error)
244
-
245
- transaction_hash = transaction.to_h
246
- expect(transaction_hash).to include(
247
- "id" => jid,
248
- "action" => "TestClass#perform",
249
- "error" => {
250
- "name" => "ExampleException",
251
- "message" => "uh oh",
252
- # TODO: backtrace should be an Array of Strings
253
- # https://github.com/appsignal/appsignal-agent/issues/294
254
- "backtrace" => kind_of(String)
255
- },
256
- "metadata" => {
257
- "extra" => "data",
258
- "queue" => "default",
259
- "retry_count" => "0"
260
- },
261
- "namespace" => namespace,
262
- "sample_data" => {
263
- "environment" => {},
264
- "params" => expected_args,
265
- "tags" => {},
266
- "breadcrumbs" => []
267
- }
268
- )
269
- expect_transaction_to_have_sidekiq_event(transaction_hash)
270
- end
271
- end
272
-
273
- context "without an error" do
274
- it "creates a transaction with events" do
275
- expect(Appsignal).to receive(:increment_counter)
276
- .with("sidekiq_queue_job_count", 1, :queue => "default", :status => :processed)
277
-
278
- perform_job
279
-
280
- transaction_hash = transaction.to_h
281
- expect(transaction_hash).to include(
282
- "id" => jid,
283
- "action" => "TestClass#perform",
284
- "error" => nil,
285
- "metadata" => {
286
- "extra" => "data",
287
- "queue" => "default",
288
- "retry_count" => "0"
289
- },
290
- "namespace" => namespace,
291
- "sample_data" => {
292
- "environment" => {},
293
- "params" => expected_args,
294
- "tags" => {},
295
- "breadcrumbs" => []
296
- }
297
- )
298
- # TODO: Not available in transaction.to_h yet.
299
- # https://github.com/appsignal/appsignal-agent/issues/293
300
- expect(transaction.request.env).to eq(
301
- :queue_start => Time.parse("2001-01-01 10:00:00UTC").to_f
302
- )
303
- expect_transaction_to_have_sidekiq_event(transaction_hash)
304
- end
305
- end
306
-
307
- def perform_job
308
- Timecop.freeze(Time.parse("2001-01-01 10:01:00UTC")) do
309
- plugin.call(worker, item, queue) do
310
- yield if block_given?
311
- end
312
- end
313
- end
314
-
315
- def transaction
316
- last_transaction
317
- end
318
-
319
- def expect_transaction_to_have_sidekiq_event(transaction_hash)
320
- events = transaction_hash["events"]
321
- expect(events.count).to eq(1)
322
- expect(events.first).to include(
323
- "name" => "perform_job.sidekiq",
324
- "title" => "",
325
- "count" => 1,
326
- "body" => "",
327
- "body_format" => Appsignal::EventFormatter::DEFAULT
328
- )
329
- end
330
- end
331
-
332
- if DependencyHelper.active_job_present?
333
- require "active_job"
334
- require "action_mailer"
335
- require "sidekiq/testing"
336
-
337
- describe "Sidekiq ActiveJob integration" do
338
- let(:namespace) { Appsignal::Transaction::BACKGROUND_JOB }
339
- let(:time) { Time.parse("2001-01-01 10:00:00UTC") }
340
- let(:log) { StringIO.new }
341
- let(:given_args) do
342
- [
343
- "foo",
344
- {
345
- :foo => "Foo",
346
- "bar" => "Bar",
347
- "baz" => { "1" => "foo" }
348
- }
349
- ]
350
- end
351
- let(:expected_args) do
352
- [
353
- "foo",
354
- {
355
- "_aj_symbol_keys" => ["foo"],
356
- "foo" => "Foo",
357
- "bar" => "Bar",
358
- "baz" => {
359
- "_aj_symbol_keys" => [],
360
- "1" => "foo"
361
- }
362
- }
363
- ]
364
- end
365
- let(:expected_tags) do
366
- {}.tap do |hash|
367
- hash["active_job_id"] = kind_of(String)
368
- if DependencyHelper.rails_version >= Gem::Version.new("5.0.0")
369
- hash["provider_job_id"] = kind_of(String)
370
- end
371
- end
372
- end
373
66
  before do
374
- start_agent
375
- Appsignal.logger = test_logger(log)
376
- ActiveJob::Base.queue_adapter = :sidekiq
377
-
378
- class ActiveJobSidekiqTestJob < ActiveJob::Base
379
- self.queue_adapter = :sidekiq
380
-
381
- def perform(*_args)
382
- end
383
- end
384
-
385
- class ActiveJobSidekiqErrorTestJob < ActiveJob::Base
386
- self.queue_adapter = :sidekiq
387
-
388
- def perform(*_args)
389
- raise "uh oh"
390
- end
391
- end
392
- # Manually add the AppSignal middleware for the Testing environment.
393
- # It doesn't use configured middlewares by default looks like.
394
- # We test somewhere else if the middleware is installed properly.
395
- Sidekiq::Testing.server_middleware do |chain|
396
- chain.add Appsignal::Hooks::SidekiqPlugin
397
- end
398
- end
399
- around do |example|
400
- keep_transactions do
401
- Sidekiq::Testing.fake! do
402
- example.run
403
- end
404
- end
405
- end
406
- after do
407
- Object.send(:remove_const, :ActiveJobSidekiqTestJob)
408
- Object.send(:remove_const, :ActiveJobSidekiqErrorTestJob)
409
- end
410
-
411
- it "reports the transaction from the ActiveJob integration" do
412
- perform_job(ActiveJobSidekiqTestJob, given_args)
413
-
414
- transaction = last_transaction
415
- transaction_hash = transaction.to_h
416
- expect(transaction_hash).to include(
417
- "action" => "ActiveJobSidekiqTestJob#perform",
418
- "error" => nil,
419
- "namespace" => namespace,
420
- "metadata" => hash_including(
421
- "queue" => "default"
422
- ),
423
- "sample_data" => hash_including(
424
- "environment" => {},
425
- "params" => [expected_args],
426
- "tags" => expected_tags.merge("queue" => "default")
427
- )
428
- )
429
- expect(transaction.request.env).to eq(:queue_start => time.to_f)
430
- events = transaction_hash["events"]
431
- .sort_by { |e| e["start"] }
432
- .map { |event| event["name"] }
433
- expect(events)
434
- .to eq(["perform_job.sidekiq", "perform_start.active_job", "perform.active_job"])
67
+ Appsignal.config = project_fixture_config
68
+ stub_const "Sidekiq", SidekiqMock
435
69
  end
436
70
 
437
- context "with error" do
438
- it "reports the error on the transaction from the ActiveRecord integration" do
439
- expect do
440
- perform_job(ActiveJobSidekiqErrorTestJob, given_args)
441
- end.to raise_error(RuntimeError, "uh oh")
442
-
443
- transaction = last_transaction
444
- transaction_hash = transaction.to_h
445
- expect(transaction_hash).to include(
446
- "action" => "ActiveJobSidekiqErrorTestJob#perform",
447
- "error" => {
448
- "name" => "RuntimeError",
449
- "message" => "uh oh",
450
- "backtrace" => kind_of(String)
451
- },
452
- "namespace" => namespace,
453
- "metadata" => hash_including(
454
- "queue" => "default"
455
- ),
456
- "sample_data" => hash_including(
457
- "environment" => {},
458
- "params" => [expected_args],
459
- "tags" => expected_tags.merge("queue" => "default")
460
- )
461
- )
462
- expect(transaction.request.env).to eq(:queue_start => time.to_f)
463
- events = transaction_hash["events"]
464
- .sort_by { |e| e["start"] }
465
- .map { |event| event["name"] }
466
- expect(events)
467
- .to eq(["perform_job.sidekiq", "perform_start.active_job", "perform.active_job"])
468
- end
71
+ it "adds error handler" do
72
+ Sidekiq.middleware_mock = SidekiqMiddlewareMockWithPrepend
73
+ described_class.new.install
74
+ expect(Sidekiq.error_handlers).to include(Appsignal::Integrations::SidekiqErrorHandler)
469
75
  end
470
76
 
471
- context "with ActionMailer" do
472
- include ActionMailerHelpers
77
+ context "when Sidekiq middleware responds to prepend method" do # Sidekiq 3.3.0 and newer
78
+ before { Sidekiq.middleware_mock = SidekiqMiddlewareMockWithPrepend }
473
79
 
474
- before do
475
- class ActionMailerSidekiqTestJob < ActionMailer::Base
476
- def welcome(*args)
477
- end
478
- end
479
- end
80
+ it "adds the AppSignal SidekiqPlugin to the Sidekiq middleware chain in the first position" do
81
+ user_middleware1 = proc {}
82
+ add_middleware(user_middleware1)
83
+ described_class.new.install
84
+ user_middleware2 = proc {}
85
+ add_middleware(user_middleware2)
480
86
 
481
- it "reports ActionMailer data on the transaction" do
482
- perform_mailer(ActionMailerSidekiqTestJob, :welcome, given_args)
483
-
484
- transaction = last_transaction
485
- transaction_hash = transaction.to_h
486
- expect(transaction_hash).to include(
487
- "action" => "ActionMailerSidekiqTestJob#welcome",
488
- "sample_data" => hash_including(
489
- "params" => ["ActionMailerSidekiqTestJob", "welcome", "deliver_now"] + expected_args
490
- )
491
- )
87
+ expect(Sidekiq.server_middleware).to eql([
88
+ Appsignal::Integrations::SidekiqMiddleware, # Prepend makes it the first entry
89
+ user_middleware1,
90
+ user_middleware2
91
+ ])
492
92
  end
493
93
  end
494
94
 
495
- def perform_sidekiq
496
- Timecop.freeze(time) do
497
- yield
498
- # Combined with Sidekiq::Testing.fake! and drain_all we get a
499
- # enqueue_at in the job data.
500
- Sidekiq::Worker.drain_all
501
- end
502
- end
95
+ context "when Sidekiq middleware does not respond to prepend method" do
96
+ before { Sidekiq.middleware_mock = SidekiqMiddlewareMockWithoutPrepend }
503
97
 
504
- def perform_job(job_class, args)
505
- perform_sidekiq { job_class.perform_later(args) }
506
- end
98
+ it "adds the AppSignal SidekiqPlugin to the Sidekiq middleware chain" do
99
+ user_middleware1 = proc {}
100
+ add_middleware(user_middleware1)
101
+ described_class.new.install
102
+ user_middleware2 = proc {}
103
+ add_middleware(user_middleware2)
507
104
 
508
- def perform_mailer(mailer, method, args = nil)
509
- perform_sidekiq { perform_action_mailer(mailer, method, args) }
105
+ # Add middlewares in whatever order they were added
106
+ expect(Sidekiq.server_middleware).to eql([
107
+ user_middleware1,
108
+ Appsignal::Integrations::SidekiqMiddleware,
109
+ user_middleware2
110
+ ])
111
+ end
510
112
  end
511
113
  end
512
114
  end