scout_apm 6.2.1 → 6.3.1

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: c667c4b7279ecdc5107a30bb50ac2a42eda6403a257f6bf2a05a6452c1143f18
4
- data.tar.gz: d5183ae2f3fb7fbdfabae56616543f99503f59bb65b154e6709ea278f42166d3
3
+ metadata.gz: 97692f03252aa5c2af4a4f87092742319b9088dace2f3dbcb4c48af766dfb848
4
+ data.tar.gz: 30bc39d151abec5d26723b2de0626319cf80685548a0f68da48074e283c72515
5
5
  SHA512:
6
- metadata.gz: bdc045802aa3d9cc37c147f6fd5a866e4bc3a3a49de480711595b3ca5b6dd04c314c2085bd229ccaebc0dca3125a7463f2bf0a53552adbb2caef8b0a09102de2
7
- data.tar.gz: 07a522e00f9d86d4d03a57c5e8965a9fd724104881429c8acddf603c0a0ad8829d098f998b3e551a6a0751ab1ba60af0f2c077bab1c3528d988138d2f4c2268e
6
+ metadata.gz: 0c6a06a5e08a3a753a7110568f44d9c9c6b0163aeeabf3be281a2de9df6a2357f14515de7419c9dedd79e8f7e8266dca67cbf58b3dbac2e6efb54f283ba4d433
7
+ data.tar.gz: 13bacea25583bf396a3e4d0edee2e957a756296a3c4fcf6083a5a42b5e0bb2e57c90a9751dd6b04f7d86f0315d8c30222ba4d3d0c9c679aa2d73eb1eb38b2eb3
data/.gitignore CHANGED
@@ -19,3 +19,4 @@ lib/*.bundle
19
19
  lib/*.so
20
20
  log/scout_apm.log
21
21
  gems/*.lock
22
+ gems/vendor/
data/CHANGELOG.markdown CHANGED
@@ -1,5 +1,13 @@
1
1
  # Pending
2
2
 
3
+ # 6.3.1
4
+
5
+ - Capture route names for Grape 4 (#635)
6
+
7
+ # 6.3.0
8
+
9
+ - Fix span leak on `ActionController::Live` streaming requests (#631)
10
+
3
11
  # 6.2.1
4
12
 
5
13
  - Fix `SystemStackError` with `grape >= 3.3.0` by falling back to `prepend` instrumentation when Grape owns `Endpoint#run` itself (#624)
@@ -10,4 +10,5 @@ gem 'moped'
10
10
  gem 'actionpack'
11
11
  gem 'actionview'
12
12
  gem 'httpx'
13
+ gem 'webmock'
13
14
  gem 'grape'
@@ -7,4 +7,9 @@ gem 'moped'
7
7
  gem 'actionpack'
8
8
  gem 'actionview'
9
9
  gem 'httpx'
10
+ gem 'webmock'
10
11
  gem 'grape'
12
+ # json 3.0 (released 2026-09) dropped the `quirks_mode` keyword that Grape 3's
13
+ # ActiveSupport JSON formatter still passes, so any Grape 3 JSON request raises
14
+ # ArgumentError. Pin to the 2.x line until this matrix moves off Grape 3.
15
+ gem 'json', '< 3'
@@ -1,3 +1,4 @@
1
1
  eval_gemfile("../Gemfile")
2
2
 
3
3
  gem 'typhoeus'
4
+ gem 'webmock'
@@ -51,6 +51,36 @@ module ScoutApm
51
51
  end
52
52
  end
53
53
 
54
+ module GrapeEndpointNaming
55
+ # Grape >= 4 moved endpoint route metadata off the public `options`
56
+ # Hash and onto `Endpoint#config` (a Grape::Endpoint::Options Data
57
+ # object). Support both so the instrument works across Grape 3 and 4.
58
+ def self.name_for(endpoint)
59
+ # `config` is a protected reader on Grape::Endpoint, so a public
60
+ # respond_to? misses it: check with `true` and read via `send`.
61
+ if endpoint.respond_to?(:config, true) && endpoint.send(:config).respond_to?(:http_methods)
62
+ config = endpoint.send(:config)
63
+ method = config.http_methods && config.http_methods.first
64
+ api = config.api || (endpoint.respond_to?(:api) ? endpoint.api : nil)
65
+ path = config.path && config.path.first
66
+ else
67
+ method = endpoint.options[:method] && endpoint.options[:method].first
68
+ api = endpoint.options[:for]
69
+ path = endpoint.options[:path] && endpoint.options[:path].first
70
+ end
71
+
72
+ ["Grape",
73
+ method,
74
+ api.to_s,
75
+ endpoint.namespace.sub(%r{\A/}, ''), # removing leading slashes
76
+ path,
77
+ ].compact.map { |n| n.to_s }.join("/")
78
+ rescue => e
79
+ ScoutApm::Agent.instance.context.logger.info("Error getting Grape Endpoint Name. Error: #{e.message}. Options: #{endpoint.options.inspect}")
80
+ "Grape/Unknown"
81
+ end
82
+ end
83
+
54
84
  module GrapeEndpointInstruments
55
85
  def run_with_scout_instruments(*args)
56
86
  request = ::Grape::Request.new(env || args.first)
@@ -64,17 +94,7 @@ module ScoutApm
64
94
 
65
95
  req.set_headers(request.headers)
66
96
 
67
- begin
68
- name = ["Grape",
69
- self.options[:method].first,
70
- self.options[:for].to_s,
71
- self.namespace.sub(%r{\A/}, ''), # removing leading slashes
72
- self.options[:path].first,
73
- ].compact.map{ |n| n.to_s }.join("/")
74
- rescue => e
75
- ScoutApm::Agent.instance.context.logger.info("Error getting Grape Endpoint Name. Error: #{e.message}. Options: #{self.options.inspect}")
76
- name = "Grape/Unknown"
77
- end
97
+ name = GrapeEndpointNaming.name_for(self)
78
98
 
79
99
  req.start_layer( ScoutApm::Layer.new("Controller", name) )
80
100
  begin
@@ -101,17 +121,7 @@ module ScoutApm
101
121
 
102
122
  req.set_headers(request.headers)
103
123
 
104
- begin
105
- name = ["Grape",
106
- self.options[:method].first,
107
- self.options[:for].to_s,
108
- self.namespace.sub(%r{\A/}, ''), # removing leading slashes
109
- self.options[:path].first,
110
- ].compact.map{ |n| n.to_s }.join("/")
111
- rescue => e
112
- ScoutApm::Agent.instance.context.logger.info("Error getting Grape Endpoint Name. Error: #{e.message}. Options: #{self.options.inspect}")
113
- name = "Grape/Unknown"
114
- end
124
+ name = GrapeEndpointNaming.name_for(self)
115
125
 
116
126
  req.start_layer( ScoutApm::Layer.new("Controller", name) )
117
127
  begin
@@ -7,11 +7,23 @@ module ScoutApm
7
7
  find || create
8
8
  end
9
9
 
10
- # Get the current Thread local, and detecting, and not returning a stale request
10
+ # Get the current Thread local, detecting and not returning a stale request,
11
+ # nor a request that belongs to a different thread.
11
12
  def self.find
12
13
  req = Thread.current[:scout_request]
14
+ return nil unless req
13
15
 
14
- if req && (req.stopping? || req.recorded?)
16
+ # ActionController::Live runs the controller action in a child thread and
17
+ # copies the parent thread's raw Thread.current locals (including
18
+ # :scout_request) into it. Without this check the parent (Rack) thread and
19
+ # the child (streaming) thread would share and concurrently mutate a single
20
+ # TrackedRequest -- racing on @layers/@stopping -- which corrupts the layer
21
+ # stack, drops the transaction, and leaks layers across requests. If the
22
+ # resident request was created on a different thread, treat it as absent so
23
+ # lookup creates a fresh one owned by this thread.
24
+ return nil if req.creating_thread_id != Thread.current.object_id
25
+
26
+ if req.stopping? || req.recorded?
15
27
  nil
16
28
  else
17
29
  req
@@ -74,9 +74,20 @@ module ScoutApm
74
74
  @recorder = agent_context.recorder
75
75
  @real_request = false
76
76
  @transaction_id = ScoutApm::Utils::TransactionId.new.to_s
77
+ # The thread this request was created on. ActionController::Live copies the
78
+ # raw Thread.current locals (including :scout_request) into its streaming
79
+ # child thread, which would otherwise cause two threads to share and race
80
+ # on one TrackedRequest. RequestManager uses this to detect an inherited
81
+ # request and give the child its own instead. See RequestManager.find.
82
+ @creating_thread_id = Thread.current.object_id
77
83
  ignore_request! if @recorder.nil?
78
84
  end
79
85
 
86
+ # The object_id of the Thread that created this request. Used to detect a
87
+ # TrackedRequest that was inherited by a different thread (e.g. an
88
+ # ActionController::Live streaming thread) rather than created for it.
89
+ attr_reader :creating_thread_id
90
+
80
91
  def layer_count
81
92
  @layers.size
82
93
  end
@@ -368,14 +379,21 @@ module ScoutApm
368
379
  end
369
380
  end
370
381
 
371
- # This request is a job transaction iff it has a 'Job' layer
372
- # Use this only during recording
382
+ # True iff this request has a 'Job' layer. Only reliable during recording --
383
+ # see #web? for why.
373
384
  def job?
374
385
  layer_finder.job != nil
375
386
  end
376
387
 
377
- # This request is a web transaction iff it has a 'Controller' layer
378
- # Use this only during recording
388
+ # True iff this request has a 'Controller' layer.
389
+ #
390
+ # Only reliable during recording; returns false mid-request even for a real
391
+ # web request. #layer_finder walks root_layer's child *tree*, but layers are
392
+ # attached to that tree only in #stop_layer (via add_child), not in
393
+ # #start_layer. Controller/Job are top-level spans, so they stop last --
394
+ # meaning web?/job? only resolve as the request unwinds into recording.
395
+ # (Negative results aren't memoized, so an early false doesn't poison a
396
+ # later check.)
379
397
  def web?
380
398
  layer_finder.controller != nil
381
399
  end
@@ -1,3 +1,3 @@
1
1
  module ScoutApm
2
- VERSION = "6.2.1"
2
+ VERSION = "6.3.1"
3
3
  end
@@ -53,6 +53,31 @@ if (ENV["SCOUT_TEST_FEATURES"] || "").include?("instruments")
53
53
  assert_includes body_content, 'hello'
54
54
  end
55
55
 
56
+ # Grape >= 4 moved endpoint route metadata (method/path/api) off the
57
+ # public `options` Hash and onto the protected `Endpoint#config` object.
58
+ # Reading the old `options[...]` keys under Grape 4 silently returns nil,
59
+ # so every request gets named "Grape/Unknown" instead of the real
60
+ # endpoint name.
61
+ def test_endpoint_name_reflects_the_route
62
+ captured_names = []
63
+ original_new = ScoutApm::Layer.method(:new)
64
+ ScoutApm::Layer.define_singleton_method(:new) do |type, name, *rest|
65
+ captured_names << name if type == "Controller"
66
+ original_new.call(type, name, *rest)
67
+ end
68
+
69
+ begin
70
+ status, _headers, _body = HelloAPI.call(Rack::MockRequest.env_for('/hello'))
71
+ ensure
72
+ ScoutApm::Layer.define_singleton_method(:new, original_new)
73
+ end
74
+
75
+ assert_equal 200, status
76
+ refute_empty captured_names
77
+ refute_equal "Grape/Unknown", captured_names.first
78
+ assert_equal "Grape/GET/#{HelloAPI}//hello", captured_names.first
79
+ end
80
+
56
81
  # Either instrumentation method is valid: alias_method is the default,
57
82
  # prepend is used when configured (`use_prepend`/`prepend_instruments`)
58
83
  # or when the instrument falls back to it for safety.
@@ -4,17 +4,34 @@ if (ENV["SCOUT_TEST_FEATURES"] || "").include?("instruments")
4
4
  require 'scout_apm/instruments/httpx'
5
5
 
6
6
  require 'httpx'
7
+ require 'webmock'
7
8
 
8
9
  class HTTPXTest < Minitest::Test
10
+ include WebMock::API
11
+
9
12
  def setup
13
+ super # clears Thread.current[:scout_request] so a stale request from a
14
+ # prior test can't swallow this test's layers
15
+ WebMock.enable!
16
+ WebMock.disable_net_connect!
17
+
10
18
  @context = ScoutApm::AgentContext.new
11
19
  @recorder = FakeRecorder.new
12
20
  ScoutApm::Agent.instance.context.recorder = @recorder
13
21
  ScoutApm::Instruments::HTTPX.new(@context).install(prepend: false)
14
22
  end
15
23
 
24
+ def teardown
25
+ WebMock.reset!
26
+ WebMock.allow_net_connect!
27
+ WebMock.disable!
28
+ super
29
+ end
16
30
 
17
31
  def test_httpx
32
+ stub_request(:get, /news\.ycombinator\.com/).to_return(status: 200, body: "")
33
+ stub_request(:get, /google\.com/).to_return(status: 200, body: "")
34
+
18
35
  responses = HTTPX.get(
19
36
  "https://news.ycombinator.com/news",
20
37
  "https://news.ycombinator.com/news?p=2",
@@ -27,11 +44,18 @@ if (ENV["SCOUT_TEST_FEATURES"] || "").include?("instruments")
27
44
  end
28
45
 
29
46
  def test_httpx_post_request
47
+ stub_request(:post, /httpbin\.org\/post/).to_return(status: 200, body: "")
48
+
30
49
  HTTPX.post("https://httpbin.org/post", json: { test: "data" })
31
50
  assert_recorded(@recorder, "HTTP", "POST", "httpbin.org/post")
32
51
  end
33
52
 
34
53
  def test_instruments_httpx_error_handling
54
+ # An error response still flows through Session#request, so the layer is
55
+ # started and stopped, and the request is recorded exactly once.
56
+ stub_request(:get, /thisshouldnotexistatall12345\.com/)
57
+ .to_return(status: 500, body: "")
58
+
35
59
  begin
36
60
  HTTPX.get("https://thisshouldnotexistatall12345.com")
37
61
  rescue
@@ -41,6 +65,10 @@ if (ENV["SCOUT_TEST_FEATURES"] || "").include?("instruments")
41
65
  end
42
66
 
43
67
  def test_httpx_request_retry
68
+ # A timed-out request is still dispatched through Session#request, so the
69
+ # layer is recorded even though the response errors.
70
+ stub_request(:get, /httpbin\.org\/delay/).to_timeout
71
+
44
72
  begin
45
73
  HTTPX.with(timeout: { connect_timeout: 0.25, request_timeout: 0.25 })
46
74
  .get("https://httpbin.org/delay/5")
@@ -50,14 +78,17 @@ if (ENV["SCOUT_TEST_FEATURES"] || "").include?("instruments")
50
78
  end
51
79
 
52
80
  def test_multiple_plugins
81
+ stub_request(:get, /news\.ycombinator\.com/).to_return(status: 200, body: "")
82
+ stub_request(:get, "http://httpbin.org/redirect/2")
83
+ .to_return(status: 302, headers: { "Location" => "http://httpbin.org/redirect/1" })
84
+ stub_request(:get, "http://httpbin.org/redirect/1")
85
+ .to_return(status: 302, headers: { "Location" => "http://httpbin.org/get" })
86
+ stub_request(:get, "http://httpbin.org/get").to_return(status: 200, body: "")
87
+
53
88
  session = HTTPX.plugin(:persistent).plugin(:follow_redirects)
54
89
 
55
90
  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
91
+ session.get("http://httpbin.org/redirect/2")
61
92
 
62
93
  assert_equal 2, @recorder.requests.length, "Expected 2 requests to be recorded"
63
94
  end
@@ -4,15 +4,31 @@ if (ENV["SCOUT_TEST_FEATURES"] || "").include?("typhoeus")
4
4
  require 'scout_apm/instruments/typhoeus'
5
5
 
6
6
  require 'typhoeus'
7
+ require 'webmock'
7
8
 
8
9
  class TyphoeusTest < Minitest::Test
10
+ include WebMock::API
11
+
9
12
  def setup
13
+ super # clears Thread.current[:scout_request] so a stale request from a
14
+ # prior test can't swallow this test's layers
15
+ WebMock.enable!
16
+ WebMock.disable_net_connect!
17
+ stub_request(:any, /example\.com/).to_return(status: 200, body: "")
18
+
10
19
  @context = ScoutApm::AgentContext.new
11
20
  @recorder = FakeRecorder.new
12
21
  ScoutApm::Agent.instance.context.recorder = @recorder
13
22
  ScoutApm::Instruments::Typhoeus.new(@context).install(prepend: false)
14
23
  end
15
24
 
25
+ def teardown
26
+ WebMock.reset!
27
+ WebMock.allow_net_connect!
28
+ WebMock.disable!
29
+ super
30
+ end
31
+
16
32
  def test_instruments_typhoeus_hydra
17
33
  hydra = Typhoeus::Hydra.new
18
34
  2.times.map{ hydra.queue(Typhoeus::Request.new("example.com", followlocation: true)) }
@@ -39,4 +55,4 @@ if (ENV["SCOUT_TEST_FEATURES"] || "").include?("typhoeus")
39
55
  assert_equal desc, req.root_layer.desc if !desc.nil?
40
56
  end
41
57
  end
42
- end
58
+ end
@@ -0,0 +1,70 @@
1
+ require 'test_helper'
2
+
3
+ # Tests for RequestManager's thread-local handling, in particular that a
4
+ # TrackedRequest created on one thread is NOT shared with another thread.
5
+ #
6
+ # ActionController::Live runs the controller action in a child thread and copies
7
+ # the parent (Rack) thread's raw Thread.current locals -- including
8
+ # :scout_request -- into it. Without protection both threads would share and
9
+ # concurrently mutate a single TrackedRequest, racing on @layers/@stopping,
10
+ # which drops the transaction and leaks layers. RequestManager must give the
11
+ # inheriting thread its own request instead.
12
+ class RequestManagerTest < Minitest::Test
13
+ def teardown
14
+ # Never let a test leak the thread-local into sibling tests.
15
+ Thread.current[:scout_request] = nil
16
+ end
17
+
18
+ def test_lookup_reuses_the_request_on_the_same_thread
19
+ first = ScoutApm::RequestManager.lookup
20
+ second = ScoutApm::RequestManager.lookup
21
+
22
+ assert_same first, second,
23
+ "lookup should return the same TrackedRequest on repeated calls within one thread"
24
+ end
25
+
26
+ def test_lookup_in_a_child_thread_that_inherited_the_local_gets_its_own_request
27
+ parent_request = ScoutApm::RequestManager.lookup
28
+ inherited = Thread.current[:scout_request]
29
+
30
+ child_request = nil
31
+ child_local_before = nil
32
+
33
+ t = Thread.new do
34
+ # Simulate ActionController::Live copying the parent's raw thread local
35
+ # into the streaming child thread.
36
+ Thread.current[:scout_request] = inherited
37
+ child_local_before = Thread.current[:scout_request]
38
+
39
+ child_request = ScoutApm::RequestManager.lookup
40
+ end
41
+ t.join
42
+
43
+ # The child observed the inherited (parent's) request as its raw local...
44
+ assert_same parent_request, child_local_before
45
+
46
+ # ...but lookup must NOT hand it the parent's request to mutate.
47
+ refute_nil child_request
48
+ refute_same parent_request, child_request,
49
+ "a thread that inherited another thread's :scout_request must get its own TrackedRequest"
50
+
51
+ # And the parent's request must be left untouched (still the parent's).
52
+ assert_same parent_request, Thread.current[:scout_request]
53
+ end
54
+
55
+ def test_find_treats_a_foreign_thread_request_as_absent
56
+ # A request that reports it was created on a different thread should not be
57
+ # returned by find (so lookup falls through to create).
58
+ foreign = ScoutApm::TrackedRequest.new(ScoutApm::Agent.instance.context, ScoutApm::FakeStore.new)
59
+ foreign.stubs(:creating_thread_id).returns(Thread.current.object_id + 1)
60
+ Thread.current[:scout_request] = foreign
61
+
62
+ assert_nil ScoutApm::RequestManager.find,
63
+ "find should treat a request created on another thread as absent"
64
+ end
65
+
66
+ def test_find_returns_a_same_thread_request
67
+ own = ScoutApm::RequestManager.lookup
68
+ assert_same own, ScoutApm::RequestManager.find
69
+ end
70
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scout_apm
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.2.1
4
+ version: 6.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Derek Haynes
@@ -486,6 +486,7 @@ files:
486
486
  - test/unit/remote/route_test.rb
487
487
  - test/unit/remote/server_test.rb
488
488
  - test/unit/request_histograms_test.rb
489
+ - test/unit/request_manager_test.rb
489
490
  - test/unit/sampling_test.rb
490
491
  - test/unit/scored_item_set_test.rb
491
492
  - test/unit/serializers/payload_serializer_test.rb
@@ -522,7 +523,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
522
523
  - !ruby/object:Gem::Version
523
524
  version: '0'
524
525
  requirements: []
525
- rubygems_version: 4.0.16
526
+ rubygems_version: 4.0.20
526
527
  specification_version: 4
527
528
  summary: Ruby application performance monitoring
528
529
  test_files: []