From cae0b60ee64856f78cc1ee7c2a7f24b2f6a43442 Mon Sep 17 00:00:00 2001 From: Jack Rothrock Date: Tue, 22 Sep 2026 19:33:55 +0000 Subject: [PATCH 1/3] Fix Grape endpoint naming under Grape 4 (#634) Grape 4 moved endpoint route metadata (method/path/api) off the public `options` Hash and onto the protected `Endpoint#config` object (Grape::Endpoint::Options). Reading the old `options[:method]`, `options[:for]`, and `options[:path]` keys under Grape 4 silently returns nil, so every request was named "Grape/Unknown" instead of the real endpoint name. Extract the name-building logic (duplicated between the alias-method and prepend instrumentation paths) into a shared GrapeEndpointNaming.name_for helper that reads from `config` when available (Grape 4) and falls back to the old `options` reads otherwise (Grape 3), preserving the existing name format and rescue-to-"Grape/Unknown" fallback behavior. Co-Authored-By: Claude Sonnet 5 --- lib/scout_apm/instruments/grape.rb | 54 +++++++++++++++++------------ test/unit/instruments/grape_test.rb | 25 +++++++++++++ 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/lib/scout_apm/instruments/grape.rb b/lib/scout_apm/instruments/grape.rb index efa52695..0fcfcf34 100644 --- a/lib/scout_apm/instruments/grape.rb +++ b/lib/scout_apm/instruments/grape.rb @@ -51,6 +51,36 @@ def endpoint_owns_run? end end + module GrapeEndpointNaming + # Grape >= 4 moved endpoint route metadata off the public `options` + # Hash and onto `Endpoint#config` (a Grape::Endpoint::Options Data + # object). Support both so the instrument works across Grape 3 and 4. + def self.name_for(endpoint) + # `config` is a protected reader on Grape::Endpoint, so a public + # respond_to? misses it: check with `true` and read via `send`. + if endpoint.respond_to?(:config, true) && endpoint.send(:config).respond_to?(:http_methods) + config = endpoint.send(:config) + method = config.http_methods && config.http_methods.first + api = config.api || (endpoint.respond_to?(:api) ? endpoint.api : nil) + path = config.path && config.path.first + else + method = endpoint.options[:method] && endpoint.options[:method].first + api = endpoint.options[:for] + path = endpoint.options[:path] && endpoint.options[:path].first + end + + ["Grape", + method, + api.to_s, + endpoint.namespace.sub(%r{\A/}, ''), # removing leading slashes + path, + ].compact.map { |n| n.to_s }.join("/") + rescue => e + ScoutApm::Agent.instance.context.logger.info("Error getting Grape Endpoint Name. Error: #{e.message}. Options: #{endpoint.options.inspect}") + "Grape/Unknown" + end + end + module GrapeEndpointInstruments def run_with_scout_instruments(*args) request = ::Grape::Request.new(env || args.first) @@ -64,17 +94,7 @@ def run_with_scout_instruments(*args) req.set_headers(request.headers) - begin - name = ["Grape", - self.options[:method].first, - self.options[:for].to_s, - self.namespace.sub(%r{\A/}, ''), # removing leading slashes - self.options[:path].first, - ].compact.map{ |n| n.to_s }.join("/") - rescue => e - ScoutApm::Agent.instance.context.logger.info("Error getting Grape Endpoint Name. Error: #{e.message}. Options: #{self.options.inspect}") - name = "Grape/Unknown" - end + name = GrapeEndpointNaming.name_for(self) req.start_layer( ScoutApm::Layer.new("Controller", name) ) begin @@ -101,17 +121,7 @@ def run(*args) req.set_headers(request.headers) - begin - name = ["Grape", - self.options[:method].first, - self.options[:for].to_s, - self.namespace.sub(%r{\A/}, ''), # removing leading slashes - self.options[:path].first, - ].compact.map{ |n| n.to_s }.join("/") - rescue => e - ScoutApm::Agent.instance.context.logger.info("Error getting Grape Endpoint Name. Error: #{e.message}. Options: #{self.options.inspect}") - name = "Grape/Unknown" - end + name = GrapeEndpointNaming.name_for(self) req.start_layer( ScoutApm::Layer.new("Controller", name) ) begin diff --git a/test/unit/instruments/grape_test.rb b/test/unit/instruments/grape_test.rb index b7cacb57..272f2f7b 100644 --- a/test/unit/instruments/grape_test.rb +++ b/test/unit/instruments/grape_test.rb @@ -53,6 +53,31 @@ def test_endpoint_runs_without_infinite_recursion assert_includes body_content, 'hello' end + # Grape >= 4 moved endpoint route metadata (method/path/api) off the + # public `options` Hash and onto the protected `Endpoint#config` object. + # Reading the old `options[...]` keys under Grape 4 silently returns nil, + # so every request gets named "Grape/Unknown" instead of the real + # endpoint name. + def test_endpoint_name_reflects_the_route + captured_names = [] + original_new = ScoutApm::Layer.method(:new) + ScoutApm::Layer.define_singleton_method(:new) do |type, name, *rest| + captured_names << name if type == "Controller" + original_new.call(type, name, *rest) + end + + begin + status, _headers, _body = HelloAPI.call(Rack::MockRequest.env_for('/hello')) + ensure + ScoutApm::Layer.define_singleton_method(:new, original_new) + end + + assert_equal 200, status + refute_empty captured_names + refute_equal "Grape/Unknown", captured_names.first + assert_equal "Grape/GET/#{HelloAPI}//hello", captured_names.first + end + # Either instrumentation method is valid: alias_method is the default, # prepend is used when configured (`use_prepend`/`prepend_instruments`) # or when the instrument falls back to it for safety. From 9591993d7f8410b7c8c0044943be93ef4f3b60ef Mon Sep 17 00:00:00 2001 From: Jack Rothrock Date: Tue, 22 Sep 2026 19:34:41 +0000 Subject: [PATCH 2/3] Ignore local gems/vendor bundle install path Testing the Grape 4 fix required a bundle install with a local --path for gems/instruments-ruby4.gemfile (no writable system gem dir in the sandbox); ignore the resulting vendor directory so it never gets picked up as repo content. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6bed729c..b6c01326 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ lib/*.bundle lib/*.so log/scout_apm.log gems/*.lock +gems/vendor/ From f860cb6cca6ab38427477383b662dfb1a9e7e0c1 Mon Sep 17 00:00:00 2001 From: Jack Rothrock Date: Tue, 22 Sep 2026 16:53:49 -0600 Subject: [PATCH 3/3] Pin json < 3 in instruments gemfile to keep Grape 3 CI green json 3.0 (2026-09) removed the quirks_mode keyword that Grape 3's ActiveSupport JSON formatter still passes, breaking the Ruby 3.0 / gems/instruments.gemfile matrix cell on any JSON-rendering request. Pin to the maintained 2.x line until that cell moves off Grape 3. --- gems/instruments.gemfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gems/instruments.gemfile b/gems/instruments.gemfile index fa20eff5..7e0124de 100644 --- a/gems/instruments.gemfile +++ b/gems/instruments.gemfile @@ -8,3 +8,7 @@ gem 'actionpack' gem 'actionview' gem 'httpx' gem 'grape' +# json 3.0 (released 2026-09) dropped the `quirks_mode` keyword that Grape 3's +# ActiveSupport JSON formatter still passes, so any Grape 3 JSON request raises +# ArgumentError. Pin to the 2.x line until this matrix moves off Grape 3. +gem 'json', '< 3'