Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ lib/*.bundle
lib/*.so
log/scout_apm.log
gems/*.lock
gems/vendor/
4 changes: 4 additions & 0 deletions gems/instruments.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
54 changes: 32 additions & 22 deletions lib/scout_apm/instruments/grape.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions test/unit/instruments/grape_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading