scout_apm 5.7.1 → 6.0.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +2 -0
  3. data/CHANGELOG.markdown +21 -1
  4. data/README.markdown +20 -8
  5. data/gems/instruments.gemfile +1 -0
  6. data/lib/scout_apm/auto_instrument/instruction_sequence.rb +2 -1
  7. data/lib/scout_apm/auto_instrument/parser.rb +150 -2
  8. data/lib/scout_apm/auto_instrument/prism.rb +357 -0
  9. data/lib/scout_apm/auto_instrument/rails.rb +9 -155
  10. data/lib/scout_apm/auto_instrument/requirements.rb +11 -0
  11. data/lib/scout_apm/background_job_integrations/delayed_job.rb +25 -1
  12. data/lib/scout_apm/background_job_integrations/faktory.rb +7 -1
  13. data/lib/scout_apm/background_job_integrations/good_job.rb +7 -1
  14. data/lib/scout_apm/background_job_integrations/legacy_sneakers.rb +7 -1
  15. data/lib/scout_apm/background_job_integrations/que.rb +7 -1
  16. data/lib/scout_apm/background_job_integrations/shoryuken.rb +7 -1
  17. data/lib/scout_apm/background_job_integrations/sidekiq.rb +89 -1
  18. data/lib/scout_apm/background_job_integrations/sneakers.rb +7 -1
  19. data/lib/scout_apm/background_job_integrations/solid_queue.rb +19 -1
  20. data/lib/scout_apm/config.rb +32 -7
  21. data/lib/scout_apm/context.rb +3 -1
  22. data/lib/scout_apm/error_service/error_record.rb +5 -1
  23. data/lib/scout_apm/instrument_manager.rb +2 -0
  24. data/lib/scout_apm/instruments/http_client.rb +10 -0
  25. data/lib/scout_apm/instruments/httpx.rb +119 -0
  26. data/lib/scout_apm/instruments/opensearch.rb +131 -0
  27. data/lib/scout_apm/limited_layer.rb +5 -2
  28. data/lib/scout_apm/logger.rb +1 -1
  29. data/lib/scout_apm/sampling.rb +25 -13
  30. data/lib/scout_apm/server_integrations/puma.rb +21 -4
  31. data/lib/scout_apm/version.rb +1 -1
  32. data/lib/scout_apm.rb +9 -4
  33. data/test/unit/auto_instrument/controller-ast.prism.txt +1015 -0
  34. data/test/unit/auto_instrument/controller-instrumented.rb +36 -11
  35. data/test/unit/auto_instrument/controller.rb +25 -0
  36. data/test/unit/auto_instrument/hash_shorthand_controller-instrumented.rb +28 -10
  37. data/test/unit/auto_instrument/hash_shorthand_controller.rb +19 -1
  38. data/test/unit/auto_instrument_test.rb +7 -1
  39. data/test/unit/background_job_integrations/faktory_test.rb +109 -0
  40. data/test/unit/background_job_integrations/shoryuken_test.rb +81 -0
  41. data/test/unit/background_job_integrations/sidekiq_test.rb +38 -0
  42. data/test/unit/config_test.rb +14 -0
  43. data/test/unit/error_service/error_buffer_test.rb +32 -0
  44. data/test/unit/error_test.rb +3 -3
  45. data/test/unit/ignored_uris_test.rb +7 -0
  46. data/test/unit/instruments/http_client_test.rb +0 -2
  47. data/test/unit/instruments/httpx_test.rb +78 -0
  48. data/test/unit/limited_layer_test.rb +4 -4
  49. data/test/unit/sampling_test.rb +10 -10
  50. metadata +10 -3
  51. data/lib/scout_apm/utils/time.rb +0 -12
  52. /data/test/unit/auto_instrument/{controller-ast.txt → controller-ast.parser.txt} +0 -0
@@ -10,40 +10,65 @@ class ClientsController < ApplicationController
10
10
  end
11
11
  end
12
12
 
13
+ def new
14
+ ::ScoutApm::AutoInstrument("super do |something|...",["ROOT/test/unit/auto_instrument/controller.rb:14:in `new'"]){super do |something|
15
+ @client = Client.new
16
+ end}
17
+ end
18
+
13
19
  def create
14
- @client = ::ScoutApm::AutoInstrument("Client.new(params[:client])",["ROOT/test/unit/auto_instrument/controller.rb:14:in `create'"]){Client.new(params[:client])}
15
- if ::ScoutApm::AutoInstrument("@client.save",["ROOT/test/unit/auto_instrument/controller.rb:15:in `create'"]){@client.save}
16
- ::ScoutApm::AutoInstrument("redirect_to @client",["ROOT/test/unit/auto_instrument/controller.rb:16:in `create'"]){redirect_to @client}
20
+ @client = ::ScoutApm::AutoInstrument("Client.new(params[:client])",["ROOT/test/unit/auto_instrument/controller.rb:20:in `create'"]){Client.new(params[:client])}
21
+ if ::ScoutApm::AutoInstrument("@client.save",["ROOT/test/unit/auto_instrument/controller.rb:21:in `create'"]){@client.save}
22
+ ::ScoutApm::AutoInstrument("redirect_to @client",["ROOT/test/unit/auto_instrument/controller.rb:22:in `create'"]){redirect_to @client}
17
23
  else
18
24
  # This line overrides the default rendering behavior, which
19
25
  # would have been to render the "create" view.
20
- ::ScoutApm::AutoInstrument("render \"new\"",["ROOT/test/unit/auto_instrument/controller.rb:20:in `create'"]){render "new"}
26
+ ::ScoutApm::AutoInstrument("render \"new\"",["ROOT/test/unit/auto_instrument/controller.rb:26:in `create'"]){render "new"}
21
27
  end
22
28
  end
23
29
 
24
30
  def edit
25
- @client = ::ScoutApm::AutoInstrument("Client.new(params[:client])",["ROOT/test/unit/auto_instrument/controller.rb:25:in `edit'"]){Client.new(params[:client])}
31
+ @client = ::ScoutApm::AutoInstrument("Client.new(params[:client])",["ROOT/test/unit/auto_instrument/controller.rb:31:in `edit'"]){Client.new(params[:client])}
26
32
 
27
- if ::ScoutApm::AutoInstrument("request.post?",["ROOT/test/unit/auto_instrument/controller.rb:27:in `edit'"]){request.post?}
28
- ::ScoutApm::AutoInstrument("@client.transaction do...",["ROOT/test/unit/auto_instrument/controller.rb:28:in `edit'"]){@client.transaction do
33
+ if ::ScoutApm::AutoInstrument("request.post?",["ROOT/test/unit/auto_instrument/controller.rb:33:in `edit'"]){request.post?}
34
+ ::ScoutApm::AutoInstrument("@client.transaction do...",["ROOT/test/unit/auto_instrument/controller.rb:34:in `edit'"]){@client.transaction do
29
35
  @client.update_attributes(params[:client])
30
36
  end}
31
37
  end
32
38
  end
33
39
 
34
40
  def data
35
- @clients = ::ScoutApm::AutoInstrument("Client.all",["ROOT/test/unit/auto_instrument/controller.rb:35:in `data'"]){Client.all}
41
+ @clients = ::ScoutApm::AutoInstrument("Client.all",["ROOT/test/unit/auto_instrument/controller.rb:41:in `data'"]){Client.all}
36
42
 
37
- formatter = ::ScoutApm::AutoInstrument("proc do |row|...",["ROOT/test/unit/auto_instrument/controller.rb:37:in `data'"]){proc do |row|
43
+ formatter = ::ScoutApm::AutoInstrument("proc do |row|...",["ROOT/test/unit/auto_instrument/controller.rb:43:in `data'"]){proc do |row|
38
44
  row.to_json
39
45
  end}
40
46
 
41
- ::ScoutApm::AutoInstrument("respond_with @clients.each(&formatter).join(\"\\n\"), :content_type => 'application/json; boundary=NL'",["ROOT/test/unit/auto_instrument/controller.rb:41:in `data'"]){respond_with @clients.each(&formatter).join("\n"), :content_type => 'application/json; boundary=NL'}
47
+ ::ScoutApm::AutoInstrument("respond_with @clients.each(&formatter).join(\"\\n\"), :content_type => 'application/json; boundary=NL'",["ROOT/test/unit/auto_instrument/controller.rb:47:in `data'"]){respond_with @clients.each(&formatter).join("\n"), :content_type => 'application/json; boundary=NL'}
42
48
  end
43
49
 
44
50
  def things
45
51
  x = {}
46
52
  x[:this] ||= 'foo'
47
- x[:that] &&= ::ScoutApm::AutoInstrument("'foo'.size",["ROOT/test/unit/auto_instrument/controller.rb:47:in `things'"]){'foo'.size}
53
+ x[:that] &&= ::ScoutApm::AutoInstrument("'foo'.size",["ROOT/test/unit/auto_instrument/controller.rb:53:in `things'"]){'foo'.size}
54
+ end
55
+
56
+ def do_something
57
+ ::ScoutApm::AutoInstrument("wrap_call(\"123\",...",["ROOT/test/unit/auto_instrument/controller.rb:57:in `do_something'"]){wrap_call("123",
58
+ something: -> { puts "Do something" }
59
+ ) do
60
+
61
+ raw_data = '{ "key": "123" }'
62
+
63
+ payload = begin
64
+ Marshal.load(raw_data)
65
+ rescue
66
+ puts 'Failed with bad/unhelpful error message'
67
+ end
68
+ end}
69
+ end
70
+
71
+ def wrap_call(*args, **kwargs)
72
+ yield
48
73
  end
49
74
  end
@@ -10,6 +10,12 @@ class ClientsController < ApplicationController
10
10
  end
11
11
  end
12
12
 
13
+ def new
14
+ super do |something|
15
+ @client = Client.new
16
+ end
17
+ end
18
+
13
19
  def create
14
20
  @client = Client.new(params[:client])
15
21
  if @client.save
@@ -46,4 +52,23 @@ class ClientsController < ApplicationController
46
52
  x[:this] ||= 'foo'
47
53
  x[:that] &&= 'foo'.size
48
54
  end
55
+
56
+ def do_something
57
+ wrap_call("123",
58
+ something: -> { puts "Do something" }
59
+ ) do
60
+
61
+ raw_data = '{ "key": "123" }'
62
+
63
+ payload = begin
64
+ Marshal.load(raw_data)
65
+ rescue
66
+ puts 'Failed with bad/unhelpful error message'
67
+ end
68
+ end
69
+ end
70
+
71
+ def wrap_call(*args, **kwargs)
72
+ yield
73
+ end
49
74
  end
@@ -1,28 +1,46 @@
1
1
 
2
2
  class HashShorthandController < ApplicationController
3
3
  def hash
4
+ ::ScoutApm::AutoInstrument("THREAD.current[:ternary_check] = true",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:4:in `hash'"]){THREAD.current[:ternary_check] = true}
4
5
  json = {
5
6
  static: "static",
6
- shorthand: ::ScoutApm::AutoInstrument("shorthand:",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:6:in `hash'"]){shorthand},
7
- longhand: ::ScoutApm::AutoInstrument("longhand: longhand",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:7:in `hash'"]){longhand},
8
- longhand_different_key: ::ScoutApm::AutoInstrument("longhand",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:8:in `hash'"]){longhand},
9
- hash_rocket: ::ScoutApm::AutoInstrument(":hash_rocket => hash_rocket",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:9:in `hash'"]){hash_rocket},
10
- :hash_rocket_different_key => ::ScoutApm::AutoInstrument("hash_rocket",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:10:in `hash'"]){hash_rocket},
11
- non_nil_receiver: ::ScoutApm::AutoInstrument("non_nil_receiver.value",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:11:in `hash'"]){non_nil_receiver.value},
7
+ shorthand: ::ScoutApm::AutoInstrument("shorthand:",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:7:in `hash'"]){shorthand},
8
+ longhand: ::ScoutApm::AutoInstrument("longhand: longhand",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:8:in `hash'"]){longhand},
9
+ longhand_different_key: ::ScoutApm::AutoInstrument("longhand",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:9:in `hash'"]){longhand},
10
+ hash_rocket: ::ScoutApm::AutoInstrument(":hash_rocket => hash_rocket",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:10:in `hash'"]){hash_rocket},
11
+ :hash_rocket_different_key => ::ScoutApm::AutoInstrument("hash_rocket",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:11:in `hash'"]){hash_rocket},
12
+ non_nil_receiver: ::ScoutApm::AutoInstrument("non_nil_receiver.value",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:12:in `hash'"]){non_nil_receiver.value},
12
13
  nested: {
13
- shorthand: ::ScoutApm::AutoInstrument("shorthand:",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:13:in `hash'"]){shorthand},
14
+ shorthand: ::ScoutApm::AutoInstrument("shorthand:",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:14:in `hash'"]){shorthand},
14
15
  },
15
- nested_call: ::ScoutApm::AutoInstrument("nested_call(params[\"timestamp\"])",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:15:in `hash'"]){nested_call(params["timestamp"])}
16
+ nested_call: ::ScoutApm::AutoInstrument("nested_call(params[\"timestamp\"])",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:16:in `hash'"]){nested_call(params["timestamp"])},
17
+ nested_with_ternaries: {
18
+ truthy: ::ScoutApm::AutoInstrument("THREAD.current[:ternary_check] == true",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:18:in `hash'"]){THREAD.current[:ternary_check] == true} ? 1 : 0,
19
+ falsy: ::ScoutApm::AutoInstrument("THREAD.current[:ternary_check] == false",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:19:in `hash'"]){THREAD.current[:ternary_check] == false} ? 1 : 0,
20
+ },
21
+ ternary: ::ScoutApm::AutoInstrument("ternary",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:21:in `hash'"]){ternary} ? ::ScoutApm::AutoInstrument("ternary",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:21:in `hash'"]){ternary} : nil,
16
22
  }
17
- ::ScoutApm::AutoInstrument("render json:",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:17:in `hash'"]){render json:}
23
+ ::ScoutApm::AutoInstrument("render json:",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:23:in `hash'"]){render json:}
18
24
  end
19
25
 
20
26
  private
21
27
 
28
+ def simple_method
29
+ "simple"
30
+ end
31
+
32
+ def inner_method
33
+ "inner"
34
+ end
35
+
22
36
  def nested_call(noop)
23
37
  noop
24
38
  end
25
39
 
40
+ def ternary
41
+ true
42
+ end
43
+
26
44
  def shorthand
27
45
  "shorthand"
28
46
  end
@@ -36,6 +54,6 @@ class HashShorthandController < ApplicationController
36
54
  end
37
55
 
38
56
  def non_nil_receiver
39
- ::ScoutApm::AutoInstrument("OpenStruct.new(value: \"value\")",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:39:in `non_nil_receiver'"]){OpenStruct.new(value: "value")}
57
+ ::ScoutApm::AutoInstrument("OpenStruct.new(value: \"value\")",["ROOT/test/unit/auto_instrument/hash_shorthand_controller.rb:57:in `non_nil_receiver'"]){OpenStruct.new(value: "value")}
40
58
  end
41
59
  end
@@ -1,6 +1,7 @@
1
1
 
2
2
  class HashShorthandController < ApplicationController
3
3
  def hash
4
+ THREAD.current[:ternary_check] = true
4
5
  json = {
5
6
  static: "static",
6
7
  shorthand:,
@@ -12,17 +13,34 @@ class HashShorthandController < ApplicationController
12
13
  nested: {
13
14
  shorthand:,
14
15
  },
15
- nested_call: nested_call(params["timestamp"])
16
+ nested_call: nested_call(params["timestamp"]),
17
+ nested_with_ternaries: {
18
+ truthy: THREAD.current[:ternary_check] == true ? 1 : 0,
19
+ falsy: THREAD.current[:ternary_check] == false ? 1 : 0,
20
+ },
21
+ ternary: ternary ? ternary : nil,
16
22
  }
17
23
  render json:
18
24
  end
19
25
 
20
26
  private
21
27
 
28
+ def simple_method
29
+ "simple"
30
+ end
31
+
32
+ def inner_method
33
+ "inner"
34
+ end
35
+
22
36
  def nested_call(noop)
23
37
  noop
24
38
  end
25
39
 
40
+ def ternary
41
+ true
42
+ end
43
+
26
44
  def shorthand
27
45
  "shorthand"
28
46
  end
@@ -1,3 +1,8 @@
1
+ begin
2
+ require 'prism'
3
+ rescue LoadError
4
+ end
5
+
1
6
  require 'test_helper'
2
7
 
3
8
  require 'scout_apm/auto_instrument'
@@ -21,7 +26,8 @@ class AutoInstrumentTest < Minitest::Test
21
26
  # test controller.rb file, which will be different on different environments.
22
27
  # This normalizes backtraces across environments.
23
28
  def normalize_backtrace(string)
24
- string.gsub(ROOT, "ROOT")
29
+ # Keep tests simple and just use UTF-8 encoding.
30
+ result = string.gsub(ROOT, "ROOT").force_encoding("UTF-8")
25
31
  end
26
32
 
27
33
  # Use this to automatically update the test fixtures.
@@ -0,0 +1,109 @@
1
+ require 'test_helper'
2
+ require 'scout_apm/background_job_integrations/faktory'
3
+
4
+ class FaktoryTest < Minitest::Test
5
+ FaktoryMiddleware = ScoutApm::BackgroundJobIntegrations::FaktoryMiddleware
6
+
7
+ def test_middleware_call_job_exception_with_error_monitoring
8
+ # Test that error buffer is called on exception
9
+ fake_request = mock
10
+ fake_request.expects(:annotate_request)
11
+ fake_request.expects(:start_layer).twice
12
+ fake_request.expects(:stop_layer).twice
13
+ fake_request.expects(:error!)
14
+
15
+ fake_context = mock
16
+ fake_error_buffer = mock
17
+ fake_context.expects(:error_buffer).returns(fake_error_buffer)
18
+
19
+ expected_env = {
20
+ :custom_controller => "TestJob",
21
+ :custom_action => "critical"
22
+ }
23
+ fake_error_buffer.expects(:capture).with(kind_of(RuntimeError), expected_env)
24
+
25
+ ScoutApm::RequestManager.stubs(:lookup).returns(fake_request)
26
+ ScoutApm::Agent.instance.expects(:context).returns(fake_context)
27
+
28
+ worker_instance = mock
29
+ job = {
30
+ "queue" => "critical",
31
+ "jobtype" => "TestJob",
32
+ "enqueued_at" => Time.now.iso8601
33
+ }
34
+
35
+ assert_raises RuntimeError do
36
+ FaktoryMiddleware.new.call(worker_instance, job) do
37
+ raise RuntimeError, "Job failed"
38
+ end
39
+ end
40
+ end
41
+
42
+ def test_middleware_call_activejob_wrapper
43
+ # Test ActiveJob job class extraction
44
+ fake_request = mock
45
+ fake_request.expects(:annotate_request)
46
+ fake_request.expects(:start_layer).twice
47
+ fake_request.expects(:stop_layer).twice
48
+ fake_request.expects(:error!)
49
+
50
+ fake_context = mock
51
+ fake_error_buffer = mock
52
+ fake_context.expects(:error_buffer).returns(fake_error_buffer)
53
+
54
+ expected_env = {
55
+ :custom_controller => "MyRealJob", # Should extract from custom.wrapped
56
+ :custom_action => "default"
57
+ }
58
+ fake_error_buffer.expects(:capture).with(kind_of(RuntimeError), expected_env)
59
+
60
+ ScoutApm::RequestManager.stubs(:lookup).returns(fake_request)
61
+ ScoutApm::Agent.instance.expects(:context).returns(fake_context)
62
+
63
+ # ActiveJob wrapper scenario
64
+ worker_instance = mock
65
+ job = {
66
+ "queue" => "default",
67
+ "jobtype" => "ActiveJob::QueueAdapters::FaktoryAdapter::JobWrapper",
68
+ "custom" => { "wrapped" => "MyRealJob" },
69
+ "created_at" => Time.now.iso8601
70
+ }
71
+
72
+ assert_raises RuntimeError do
73
+ FaktoryMiddleware.new.call(worker_instance, job) do
74
+ raise RuntimeError, "ActiveJob failed"
75
+ end
76
+ end
77
+ end
78
+
79
+ def test_middleware_call_missing_queue_fallback
80
+ # Test behavior when queue is missing
81
+ fake_request = mock
82
+ fake_request.expects(:annotate_request)
83
+ fake_request.expects(:start_layer).twice
84
+ fake_request.expects(:stop_layer).twice
85
+ fake_request.expects(:error!)
86
+
87
+ fake_context = mock
88
+ fake_error_buffer = mock
89
+ fake_context.expects(:error_buffer).returns(fake_error_buffer)
90
+
91
+ expected_env = {
92
+ :custom_controller => "UnknownJob", # Fallback when jobtype missing
93
+ :custom_action => nil # No queue
94
+ }
95
+ fake_error_buffer.expects(:capture).with(kind_of(RuntimeError), expected_env)
96
+
97
+ ScoutApm::RequestManager.stubs(:lookup).returns(fake_request)
98
+ ScoutApm::Agent.instance.expects(:context).returns(fake_context)
99
+
100
+ worker_instance = mock
101
+ job = {} # Empty job data
102
+
103
+ assert_raises RuntimeError do
104
+ FaktoryMiddleware.new.call(worker_instance, job) do
105
+ raise RuntimeError, "Job failed"
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,81 @@
1
+ require 'test_helper'
2
+ require 'scout_apm/background_job_integrations/shoryuken'
3
+
4
+ class ShoryukenTest < Minitest::Test
5
+ ShoryukenIntegration = ScoutApm::BackgroundJobIntegrations::Shoryuken
6
+ ShoryukenMiddleware = ScoutApm::BackgroundJobIntegrations::ShoryukenMiddleware
7
+
8
+ def test_middleware_call_job_exception_with_error_monitoring
9
+ # Test that error buffer is called on exception
10
+ fake_request = mock
11
+ fake_request.expects(:annotate_request)
12
+ fake_request.expects(:start_layer).twice
13
+ fake_request.expects(:stop_layer).twice
14
+ fake_request.expects(:error!)
15
+
16
+ fake_context = mock
17
+ fake_error_buffer = mock
18
+ fake_context.expects(:error_buffer).returns(fake_error_buffer)
19
+
20
+ expected_env = {
21
+ :custom_controller => "TestWorker",
22
+ :custom_action => "test-queue"
23
+ }
24
+ fake_error_buffer.expects(:capture).with(kind_of(RuntimeError), expected_env)
25
+
26
+ ScoutApm::RequestManager.stubs(:lookup).returns(fake_request)
27
+ ScoutApm::Agent.instance.expects(:context).returns(fake_context)
28
+
29
+ worker_instance = mock
30
+ mock_class = mock
31
+ mock_class.expects(:to_s).twice.returns("TestWorker")
32
+ worker_instance.expects(:class).twice.returns(mock_class)
33
+ queue = "test-queue"
34
+ msg = mock
35
+ msg.expects(:attributes).returns({'SentTimestamp' => '1534873927868'})
36
+ body = {}
37
+
38
+ assert_raises RuntimeError do
39
+ ShoryukenMiddleware.new.call(worker_instance, queue, msg, body) do
40
+ raise RuntimeError, "Job failed"
41
+ end
42
+ end
43
+ end
44
+
45
+ def test_middleware_call_activejob_wrapper
46
+ # Test ActiveJob job class extraction
47
+ fake_request = mock
48
+ fake_request.expects(:annotate_request)
49
+ fake_request.expects(:start_layer).twice
50
+ fake_request.expects(:stop_layer).twice
51
+ fake_request.expects(:error!)
52
+
53
+ fake_context = mock
54
+ fake_error_buffer = mock
55
+ fake_context.expects(:error_buffer).returns(fake_error_buffer)
56
+
57
+ expected_env = {
58
+ :custom_controller => "MyRealJob", # Should extract from body
59
+ :custom_action => "priority-queue"
60
+ }
61
+ fake_error_buffer.expects(:capture).with(kind_of(RuntimeError), expected_env)
62
+
63
+ ScoutApm::RequestManager.stubs(:lookup).returns(fake_request)
64
+ ScoutApm::Agent.instance.expects(:context).returns(fake_context)
65
+
66
+ worker_instance = mock
67
+ mock_class = mock
68
+ mock_class.expects(:to_s).returns("ActiveJob::QueueAdapters::ShoryukenAdapter::JobWrapper")
69
+ worker_instance.expects(:class).returns(mock_class)
70
+ queue = "priority-queue"
71
+ msg = mock
72
+ msg.expects(:attributes).returns({'SentTimestamp' => '1534873927868'})
73
+ body = { "job_class" => "MyRealJob" }
74
+
75
+ assert_raises RuntimeError do
76
+ ShoryukenMiddleware.new.call(worker_instance, queue, msg, body) do
77
+ raise RuntimeError, "ActiveJob failed"
78
+ end
79
+ end
80
+ end
81
+ end
@@ -125,4 +125,42 @@ class SidekiqTest < Minitest::Test
125
125
  msg = {}
126
126
  assert_equal 0, SidekiqMiddleware.new.latency(msg, 200)
127
127
  end
128
+
129
+ ########################################
130
+ # Job Arguments Capture
131
+ ########################################
132
+ def test_capture_job_args_when_enabled_with_valid_job
133
+ test_job_class = Class.new do
134
+ def self.name
135
+ 'TestJobWithArgs'
136
+ end
137
+
138
+ def perform(user_id, email, filter_param, dict_val)
139
+ end
140
+ end
141
+
142
+ Object.const_set('TestJobWithArgs', test_job_class)
143
+
144
+ SidekiqMiddleware.any_instance.stubs(:capture_job_args?).returns(true)
145
+ SidekiqMiddleware.any_instance.stubs(:filtered_params_config).returns(["filter_param"])
146
+
147
+ fake_request = mock
148
+ fake_request.expects(:annotate_request)
149
+ fake_request.expects(:start_layer).twice
150
+ fake_request.expects(:stop_layer).twice
151
+
152
+ ScoutApm::RequestManager.stubs(:lookup).returns(fake_request)
153
+ ScoutApm::Context.expects(:add).with({ user_id: 123, email: 'test@example.com', filter_param: '[FILTERED]', dict_val: '[UNSUPPORTED TYPE]' })
154
+
155
+ msg = {
156
+ 'class' => 'TestJobWithArgs',
157
+ 'args' => [{ 'arguments' => [123, 'test@example.com', 'secret', {a: 1}] }]
158
+ }
159
+
160
+ block_called = false
161
+ SidekiqMiddleware.new.call(nil, msg, "defaultqueue") { block_called = true }
162
+ assert block_called
163
+
164
+ Object.send(:remove_const, 'TestJobWithArgs')
165
+ end
128
166
  end
@@ -92,6 +92,20 @@ class ConfigTest < Minitest::Test
92
92
  assert_nil coercion.coerce(nil)
93
93
  end
94
94
 
95
+ def test_sample_rate_coercion
96
+ coercion = ScoutApm::Config::SampleRateCoercion.new
97
+ assert_in_delta 1.0, coercion.coerce("1")
98
+ assert_in_delta 0.015, coercion.coerce("1.5")
99
+ assert_in_delta 1.0, coercion.coerce(1)
100
+ assert_in_delta 0.015, coercion.coerce(1.5)
101
+ assert_in_delta 0.0, coercion.coerce("0")
102
+ assert_in_delta 0.0, coercion.coerce(0)
103
+ assert_in_delta 0.0, coercion.coerce("")
104
+ assert_in_delta 0.0, coercion.coerce(nil)
105
+ assert_in_delta 0.5, coercion.coerce("0.5")
106
+ assert_in_delta 0, coercion.coerce("-2.5")
107
+ end
108
+
95
109
  def test_any_keys_found
96
110
  ENV.stubs(:has_key?).returns(nil)
97
111
 
@@ -25,6 +25,7 @@ class ErrorBufferTest < Minitest::Test
25
25
 
26
26
  exception = exceptions[0]
27
27
  expected_env_keys = [
28
+ "REQUEST_METHOD",
28
29
  "ANOTHER_HEADER",
29
30
  "HTTP_X_FORWARDED_FOR",
30
31
  "HTTP_USER_AGENT",
@@ -67,4 +68,35 @@ class ErrorBufferTest < Minitest::Test
67
68
  def ex(msg="Whoops")
68
69
  FakeError.new(msg)
69
70
  end
71
+
72
+ def test_user_context_is_flattened_with_user_prefix
73
+ config = make_fake_config(
74
+ 'errors_enabled' => true,
75
+ 'errors_env_capture' => %w()
76
+ )
77
+ test_context = ScoutApm::AgentContext.new().tap { |c| c.config = config }
78
+
79
+ ScoutApm::Agent.instance.stub(:context, test_context) do
80
+ eb = ScoutApm::ErrorService::ErrorBuffer.new(test_context)
81
+
82
+ # Set user context using add_user
83
+ ScoutApm::Context.add_user(id: 2)
84
+ # Set extra context using add
85
+ ScoutApm::Context.add(organization: 3)
86
+
87
+ eb.capture(ex, env)
88
+ exceptions = eb.instance_variable_get(:@error_records)
89
+
90
+ assert_equal 1, exceptions.length
91
+ exception = exceptions[0]
92
+
93
+ # User context should be flattened with "user_" prefix
94
+ assert_equal 2, exception.context["user_id"]
95
+ # Extra context should remain as-is
96
+ assert_equal 3, exception.context[:organization]
97
+
98
+ # Should NOT have nested :user key
99
+ refute exception.context.key?(:user)
100
+ end
101
+ end
70
102
  end
@@ -55,7 +55,7 @@ class ErrorTest < Minitest::Test
55
55
 
56
56
  assert_equal "No context or env", exceptions[3].message
57
57
  assert_equal "AnotherError", exceptions[3].exception_class
58
- assert_equal assert_empty_context, exceptions[3].context
58
+ assert_equal assert_default_context, exceptions[3].context
59
59
 
60
60
  assert_equal "Whoops", exceptions[4].message
61
61
  assert_equal "ErrorTest::FakeError", exceptions[4].exception_class
@@ -84,8 +84,8 @@ class ErrorTest < Minitest::Test
84
84
  }
85
85
  end
86
86
 
87
- def assert_empty_context
88
- {user: {}}
87
+ def assert_default_context
88
+ {transaction_id: ScoutApm::RequestManager.lookup.transaction_id}
89
89
  end
90
90
 
91
91
  def ex(msg="Whoops")
@@ -19,4 +19,11 @@ class IgnoredUrlsTest < Minitest::Test
19
19
  assert_equal false, i.ignore?("/users/2/health")
20
20
  assert_equal true, i.ignore?("/admin/dashboard")
21
21
  end
22
+
23
+ def test_ignores_prefix_regex
24
+ i = ScoutApm::IgnoredUris.new(["/slow/\\d+/notifications", "/health"])
25
+ assert_equal true, i.ignore?("/slow/123/notifications")
26
+ assert_equal false, i.ignore?("/slow/abcd/notifications")
27
+ assert_equal true, i.ignore?("/health")
28
+ end
22
29
  end
@@ -3,8 +3,6 @@ if (ENV["SCOUT_TEST_FEATURES"] || "").include?("instruments")
3
3
 
4
4
  require 'scout_apm/instruments/http_client'
5
5
 
6
- require 'httpclient'
7
-
8
6
  class HttpClientTest < Minitest::Test
9
7
  def setup
10
8
  @context = ScoutApm::AgentContext.new
@@ -0,0 +1,78 @@
1
+ if (ENV["SCOUT_TEST_FEATURES"] || "").include?("instruments")
2
+ require 'test_helper'
3
+
4
+ require 'scout_apm/instruments/httpx'
5
+
6
+ require 'httpx'
7
+
8
+ class HTTPXTest < Minitest::Test
9
+ def setup
10
+ @context = ScoutApm::AgentContext.new
11
+ @recorder = FakeRecorder.new
12
+ ScoutApm::Agent.instance.context.recorder = @recorder
13
+ ScoutApm::Instruments::HTTPX.new(@context).install(prepend: false)
14
+ end
15
+
16
+
17
+ def test_httpx
18
+ responses = HTTPX.get(
19
+ "https://news.ycombinator.com/news",
20
+ "https://news.ycombinator.com/news?p=2",
21
+ "https://google.com/q=me"
22
+ )
23
+
24
+ assert_equal 1, @recorder.requests.length
25
+
26
+ assert_recorded(@recorder, "HTTP", "GET", "3 requests")
27
+ end
28
+
29
+ def test_httpx_post_request
30
+ HTTPX.post("https://httpbin.org/post", json: { test: "data" })
31
+ assert_recorded(@recorder, "HTTP", "POST", "httpbin.org/post")
32
+ end
33
+
34
+ def test_instruments_httpx_error_handling
35
+ begin
36
+ HTTPX.get("https://thisshouldnotexistatall12345.com")
37
+ rescue
38
+ end
39
+
40
+ assert_equal 1, @recorder.requests.length
41
+ end
42
+
43
+ def test_httpx_request_retry
44
+ begin
45
+ HTTPX.with(timeout: { connect_timeout: 0.25, request_timeout: 0.25 })
46
+ .get("https://httpbin.org/delay/5")
47
+ rescue
48
+ end
49
+ assert_equal 1, @recorder.requests.length
50
+ end
51
+
52
+ def test_multiple_plugins
53
+ session = HTTPX.plugin(:persistent).plugin(:follow_redirects)
54
+
55
+ session.get("https://news.ycombinator.com/news")
56
+ begin
57
+ session.get("http://httpbin.org/redirect/2")
58
+ rescue
59
+ skip "httpbin.org not available"
60
+ end
61
+
62
+ assert_equal 2, @recorder.requests.length, "Expected 2 requests to be recorded"
63
+ end
64
+
65
+ private
66
+
67
+ def assert_recorded(recorder, type, name, desc = nil)
68
+ req = recorder.requests.first
69
+ assert req, "recorder recorded no layers"
70
+ assert_equal type, req.root_layer.type
71
+ assert_equal name, req.root_layer.name
72
+ if !desc.nil?
73
+ assert req.root_layer.desc.include?(desc),
74
+ "Expected description to include '#{desc}', got '#{req.root_layer.desc}'"
75
+ end
76
+ end
77
+ end
78
+ end