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/ 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' 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.