diff --git a/CHANGELOG.md b/CHANGELOG.md index 5222112df3..c21eaef636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,11 @@ ## dev -Version of the agent introduces improved error tracking functionality by associating a transaction id with each error, and uses more reliable network timeout logic. +Version of the agent adds [Roda](https://roda.jeremyevans.net/) instrumentation, introduces improved error tracking functionality by associating a transaction id with each error, and uses more reliable network timeout logic. + +- **Feature: Add Roda instrumentation** + + [Roda](https://roda.jeremyevans.net/) is a now an instrumented framework. The agent currently supports Roda versions 3.19.0+. [PR#2144](https://github.com/newrelic/newrelic-ruby-agent/pull/2144) - **Feature: Improved error tracking transaction linking** diff --git a/lib/new_relic/agent/configuration/default_source.rb b/lib/new_relic/agent/configuration/default_source.rb index d3d31c5327..8009a9492a 100644 --- a/lib/new_relic/agent/configuration/default_source.rb +++ b/lib/new_relic/agent/configuration/default_source.rb @@ -115,6 +115,7 @@ def self.framework :rails_notifications end when defined?(::Sinatra) && defined?(::Sinatra::Base) then :sinatra + when defined?(::Roda) then :roda when defined?(::NewRelic::IA) then :external else :ruby end @@ -1227,6 +1228,13 @@ def self.enforce_fallback(allowed_values: nil, fallback: nil) :allowed_from_server => false, :description => 'If `true`, disables [Sidekiq instrumentation](/docs/agents/ruby-agent/background-jobs/sidekiq-instrumentation).' }, + :disable_roda_auto_middleware => { + :default => false, + :public => true, + :type => Boolean, + :allowed_from_server => false, + :description => 'If `true`, disables agent middleware for Roda. This middleware is responsible for advanced feature support such as [page load timing](/docs/browser/new-relic-browser/getting-started/new-relic-browser) and [error collection](/docs/apm/applications-menu/events/view-apm-error-analytics).' + }, :disable_sinatra_auto_middleware => { :default => false, :public => true, @@ -1564,6 +1572,14 @@ def self.enforce_fallback(allowed_values: nil, fallback: nil) :allowed_from_server => false, :description => 'Controls auto-instrumentation of resque at start up. May be one of: `auto`, `prepend`, `chain`, `disabled`.' }, + :'instrumentation.roda' => { + :default => 'auto', + :public => true, + :type => String, + :dynamic_name => true, + :allowed_from_server => false, + :description => 'Controls auto-instrumentation of Roda at start up. May be one of: `auto`, `prepend`, `chain`, `disabled`.' + }, :'instrumentation.sinatra' => { :default => 'auto', :public => true, diff --git a/lib/new_relic/agent/instrumentation/controller_instrumentation.rb b/lib/new_relic/agent/instrumentation/controller_instrumentation.rb index 069967d4bc..d767bc00c9 100644 --- a/lib/new_relic/agent/instrumentation/controller_instrumentation.rb +++ b/lib/new_relic/agent/instrumentation/controller_instrumentation.rb @@ -243,6 +243,7 @@ def self.prefix_for_category(txn, category = nil) when :background then ::NewRelic::Agent::Transaction::TASK_PREFIX when :rack then ::NewRelic::Agent::Transaction::RACK_PREFIX when :uri then ::NewRelic::Agent::Transaction::CONTROLLER_PREFIX + when :roda then ::NewRelic::Agent::Transaction::RODA_PREFIX when :sinatra then ::NewRelic::Agent::Transaction::SINATRA_PREFIX when :middleware then ::NewRelic::Agent::Transaction::MIDDLEWARE_PREFIX when :grape then ::NewRelic::Agent::Transaction::GRAPE_PREFIX diff --git a/lib/new_relic/agent/instrumentation/roda.rb b/lib/new_relic/agent/instrumentation/roda.rb new file mode 100644 index 0000000000..f55553572d --- /dev/null +++ b/lib/new_relic/agent/instrumentation/roda.rb @@ -0,0 +1,33 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +require_relative 'roda/instrumentation' + +DependencyDetection.defer do + named :roda + + depends_on do + defined?(Roda) && + Gem::Version.new(Roda::RodaVersion) >= Gem::Version.new('3.19.0') && + Roda::RodaPlugins::Base::ClassMethods.private_method_defined?(:build_rack_app) && + Roda::RodaPlugins::Base::InstanceMethods.method_defined?(:_roda_handle_main_route) + end + + executes do + require_relative '../../rack/agent_hooks' + require_relative '../../rack/browser_monitoring' + + NewRelic::Agent.logger.info('Installing Roda instrumentation') + + if use_prepend? + require_relative 'roda/prepend' + prepend_instrument Roda.singleton_class, NewRelic::Agent::Instrumentation::Roda::Build::Prepend + prepend_instrument Roda, NewRelic::Agent::Instrumentation::Roda::Prepend + else + require_relative 'roda/chain' + chain_instrument NewRelic::Agent::Instrumentation::Roda::Build::Chain + chain_instrument NewRelic::Agent::Instrumentation::Roda::Chain + end + end +end diff --git a/lib/new_relic/agent/instrumentation/roda/chain.rb b/lib/new_relic/agent/instrumentation/roda/chain.rb new file mode 100644 index 0000000000..e96b28a079 --- /dev/null +++ b/lib/new_relic/agent/instrumentation/roda/chain.rb @@ -0,0 +1,43 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +module NewRelic::Agent::Instrumentation + module Roda + module Chain + def self.instrument! + ::Roda.class_eval do + include ::NewRelic::Agent::Instrumentation::Roda::Tracer + + alias_method(:_roda_handle_main_route_without_tracing, :_roda_handle_main_route) + + def _roda_handle_main_route(*args) + _roda_handle_main_route_with_tracing(*args) do + _roda_handle_main_route_without_tracing(*args) + end + end + end + end + end + + module Build + module Chain + def self.instrument! + ::Roda.class_eval do + include ::NewRelic::Agent::Instrumentation::Roda::Tracer + + class << self + alias_method(:build_rack_app_without_tracing, :build_rack_app) + + def build_rack_app + build_rack_app_with_tracing do + build_rack_app_without_tracing + end + end + end + end + end + end + end + end +end diff --git a/lib/new_relic/agent/instrumentation/roda/instrumentation.rb b/lib/new_relic/agent/instrumentation/roda/instrumentation.rb new file mode 100644 index 0000000000..07adb10236 --- /dev/null +++ b/lib/new_relic/agent/instrumentation/roda/instrumentation.rb @@ -0,0 +1,52 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +module NewRelic::Agent::Instrumentation + module Roda + module Tracer + include ::NewRelic::Agent::Instrumentation::ControllerInstrumentation + + def self.included(clazz) + clazz.extend(self) + end + + def newrelic_middlewares + middlewares = [NewRelic::Rack::BrowserMonitoring] + if NewRelic::Rack::AgentHooks.needed? + middlewares << NewRelic::Rack::AgentHooks + end + middlewares + end + + def build_rack_app_with_tracing + unless NewRelic::Agent.config[:disable_roda_auto_middleware] + newrelic_middlewares.each do |middleware_class| + self.use middleware_class + end + end + yield + end + + # Roda makes use of Rack, so we can get params from the request object + def rack_request_params + begin + @_request.params + rescue => e + NewRelic::Agent.logger.debug('Failed to get params from Rack request.', e) + NewRelic::EMPTY_HASH + end + end + + def _roda_handle_main_route_with_tracing(*args) + perform_action_with_newrelic_trace( + category: :roda, + name: TransactionNamer.transaction_name(request), + params: ::NewRelic::Agent::ParameterFiltering::apply_filters(request.env, rack_request_params) + ) do + yield + end + end + end + end +end diff --git a/lib/new_relic/agent/instrumentation/roda/prepend.rb b/lib/new_relic/agent/instrumentation/roda/prepend.rb new file mode 100644 index 0000000000..0da5f44af2 --- /dev/null +++ b/lib/new_relic/agent/instrumentation/roda/prepend.rb @@ -0,0 +1,24 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +module NewRelic::Agent::Instrumentation + module Roda + module Prepend + include ::NewRelic::Agent::Instrumentation::Roda::Tracer + + def _roda_handle_main_route(*args) + _roda_handle_main_route_with_tracing(*args) { super } + end + end + + module Build + module Prepend + include ::NewRelic::Agent::Instrumentation::Roda::Tracer + def build_rack_app + build_rack_app_with_tracing { super } + end + end + end + end +end diff --git a/lib/new_relic/agent/instrumentation/roda/roda_transaction_namer.rb b/lib/new_relic/agent/instrumentation/roda/roda_transaction_namer.rb new file mode 100644 index 0000000000..2985a2a6f0 --- /dev/null +++ b/lib/new_relic/agent/instrumentation/roda/roda_transaction_namer.rb @@ -0,0 +1,30 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +module NewRelic + module Agent + module Instrumentation + module Roda + module TransactionNamer + extend self + + ROOT = '/'.freeze + REGEX_MULTIPLE_SLASHES = %r{^[/^\A]*(.*?)[/$?\z]*$}.freeze + + def transaction_name(request) + path = request.path || ::NewRelic::Agent::UNKNOWN_METRIC + name = path.gsub(REGEX_MULTIPLE_SLASHES, '\1') # remove any rogue slashes + name = ROOT if name.empty? + name = "#{request.request_method} #{name}" if request.respond_to?(:request_method) + + name + rescue => e + ::NewRelic::Agent.logger.debug("#{e.class} : #{e.message} - Error encountered trying to identify Roda transaction name") + ::NewRelic::Agent::UNKNOWN_METRIC + end + end + end + end + end +end diff --git a/lib/new_relic/agent/transaction.rb b/lib/new_relic/agent/transaction.rb index 32f183fba2..7c4654c550 100644 --- a/lib/new_relic/agent/transaction.rb +++ b/lib/new_relic/agent/transaction.rb @@ -31,11 +31,12 @@ class Transaction RAKE_PREFIX = "#{OTHER_TRANSACTION_PREFIX}Rake/" MESSAGE_PREFIX = "#{OTHER_TRANSACTION_PREFIX}Message/" RACK_PREFIX = "#{CONTROLLER_PREFIX}Rack/" + RODA_PREFIX = "#{CONTROLLER_PREFIX}Roda/" SINATRA_PREFIX = "#{CONTROLLER_PREFIX}Sinatra/" GRAPE_PREFIX = "#{CONTROLLER_PREFIX}Grape/" ACTION_CABLE_PREFIX = "#{CONTROLLER_PREFIX}ActionCable/" - WEB_TRANSACTION_CATEGORIES = [:web, :controller, :uri, :rack, :sinatra, :grape, :middleware, :action_cable].freeze + WEB_TRANSACTION_CATEGORIES = %i[action_cable controller grape middleware rack roda sinatra web uri].freeze MIDDLEWARE_SUMMARY_METRICS = ['Middleware/all'].freeze WEB_SUMMARY_METRIC = 'HttpDispatcher' diff --git a/lib/new_relic/control/frameworks/roda.rb b/lib/new_relic/control/frameworks/roda.rb new file mode 100644 index 0000000000..09a6010786 --- /dev/null +++ b/lib/new_relic/control/frameworks/roda.rb @@ -0,0 +1,20 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +require 'new_relic/control/frameworks/ruby' +module NewRelic + class Control + module Frameworks + # Contains basic control logic for Roda + class Roda < NewRelic::Control::Frameworks::Ruby + protected + + def install_shim + super + ::Roda.class_eval { include NewRelic::Agent::Instrumentation::ControllerInstrumentation::Shim } + end + end + end + end +end diff --git a/test/multiverse/lib/multiverse/runner.rb b/test/multiverse/lib/multiverse/runner.rb index d67b079c86..b4b1b1be56 100644 --- a/test/multiverse/lib/multiverse/runner.rb +++ b/test/multiverse/lib/multiverse/runner.rb @@ -103,7 +103,7 @@ def execute_suites(filter, opts) 'background_2' => ['rake'], 'database' => %w[elasticsearch mongo redis sequel], 'rails' => %w[active_record active_record_pg rails rails_prepend activemerchant], - 'frameworks' => %w[sinatra padrino grape], + 'frameworks' => %w[grape padrino roda sinatra], 'httpclients' => %w[curb excon httpclient], 'httpclients_2' => %w[typhoeus net_http httprb], 'infinite_tracing' => ['infinite_tracing'], diff --git a/test/multiverse/suites/roda/Envfile b/test/multiverse/suites/roda/Envfile new file mode 100644 index 0000000000..5feccbaecb --- /dev/null +++ b/test/multiverse/suites/roda/Envfile @@ -0,0 +1,20 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +instrumentation_methods :chain, :prepend + +RODA_VERSIONS = [ + [nil, 2.4], + ['3.19.0', 2.4] +] + +def gem_list(roda_version = nil) + <<~RB + gem 'roda'#{roda_version} + gem 'rack' + gem 'rack-test', '>= 0.8.0', :require => 'rack/test' + RB +end + +create_gemfiles(RODA_VERSIONS) diff --git a/test/multiverse/suites/roda/config/newrelic.yml b/test/multiverse/suites/roda/config/newrelic.yml new file mode 100644 index 0000000000..df405d6a86 --- /dev/null +++ b/test/multiverse/suites/roda/config/newrelic.yml @@ -0,0 +1,19 @@ +--- +development: + error_collector: + enabled: true + apdex_t: 0.5 + monitor_mode: true + license_key: bootstrap_newrelic_admin_license_key_000 + instrumentation: + roda: <%= $instrumentation_method %> + app_name: test + log_level: debug + host: 127.0.0.1 + api_host: 127.0.0.1 + transaction_trace: + record_sql: obfuscated + enabled: true + stack_trace_threshold: 0.5 + transaction_threshold: 1.0 + capture_params: false diff --git a/test/multiverse/suites/roda/roda_instrumentation_test.rb b/test/multiverse/suites/roda/roda_instrumentation_test.rb new file mode 100644 index 0000000000..8bc078d467 --- /dev/null +++ b/test/multiverse/suites/roda/roda_instrumentation_test.rb @@ -0,0 +1,135 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +require_relative '../../../../lib/new_relic/agent/instrumentation/roda/instrumentation' +require_relative '../../../../lib/new_relic/agent/instrumentation/roda/roda_transaction_namer' + +class RodaTestApp < Roda + plugin :error_handler do |e| + 'Oh No!' + end + + route do |r| + # GET / request + r.root do + r.redirect('home') + end + + r.on('home') do + 'home page' + end + + # /hello branch + r.on('hello') do + # GET /hello/:name request + r.get(':name') do |name| + "Hello #{name}!" + end + end + + r.on('error') do + raise 'boom' + end + end +end + +class RodaNoMiddleware < Roda; end + +class RodaInstrumentationTest < Minitest::Test + include Rack::Test::Methods + include MultiverseHelpers + + setup_and_teardown_agent + + def app + RodaTestApp + end + + def test_http_verb_request_no_request_method + fake_request = Struct.new('FakeRequest', :path).new + name = NewRelic::Agent::Instrumentation::Roda::TransactionNamer.transaction_name(fake_request) + + assert_equal ::NewRelic::Agent::UNKNOWN_METRIC, name + end + + def test_request_is_recorded + get('/home') + txn = harvest_transaction_events![1][0] + + assert_equal 'Controller/Roda/RodaTestApp/GET home', txn[0]['name'] + assert_equal 200, txn[2][:'http.statusCode'] + end + + def test_500_response_status + get('/error') + errors = harvest_error_traces! + txn = harvest_transaction_events! + + assert_equal 500, txn[1][0][2][:"http.statusCode"] + assert_equal 'Oh No!', last_response.body + assert_equal 1, errors.size + end + + def test_404_response_status + get('/nothing') + errors = harvest_error_traces! + txn = harvest_transaction_events! + + assert_equal 404, txn[1][0][2][:"http.statusCode"] + assert_equal 0, errors.size + end + + def test_empty_route_name_and_response_status + get('') + errors = harvest_error_traces! + txn = harvest_transaction_events![1][0] + + assert_equal 'Controller/Roda/RodaTestApp/GET /', txn[0]['name'] + assert_equal 302, txn[2][:'http.statusCode'] + end + + def test_roda_auto_middleware_disabled + with_config(:disable_roda_auto_middleware => true) do + RodaNoMiddleware.build_rack_app_with_tracing {} + + assert_truthy NewRelic::Agent::Agent::config[:disable_roda_auto_middleware] + end + end + + def test_roda_instrumentation_works_if_middleware_disabled + with_config(:disable_middleware_instrumentation => true) do + get('/home') + txn = harvest_transaction_events![1][0] + + assert_equal 'Controller/Roda/RodaTestApp/GET home', txn[0]['name'] + end + end + + def test_transaction_name_error + NewRelic::Agent.stub(:logger, NewRelic::Agent::MemoryLogger.new) do + # pass in {} to produce an error, because {} doesn't support #path and + # confirm that the desired error handling took place + result = NewRelic::Agent::Instrumentation::Roda::TransactionNamer.transaction_name({}) + + assert_equal NewRelic::Agent::UNKNOWN_METRIC, result + assert_logged(/NoMethodError.*Error encountered trying to identify Roda transaction name/) + end + end + + def test_rack_request_params_error + NewRelic::Agent.stub(:logger, NewRelic::Agent::MemoryLogger.new) do + # Unit-syle test calling rack_request_params directly. No Rack request exists, + # so @_request.params should fail. + app.rack_request_params + + assert_logged(/Failed to get params from Rack request./) + end + end + + def assert_logged(expected) + found = NewRelic::Agent.logger.messages.flatten.any? { |m| m.match?(expected) } + + assert(found, "Didn't see log message: '#{expected}'") + end +end diff --git a/test/multiverse/suites/roda_agent_disabled/Envfile b/test/multiverse/suites/roda_agent_disabled/Envfile new file mode 100644 index 0000000000..5feccbaecb --- /dev/null +++ b/test/multiverse/suites/roda_agent_disabled/Envfile @@ -0,0 +1,20 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +instrumentation_methods :chain, :prepend + +RODA_VERSIONS = [ + [nil, 2.4], + ['3.19.0', 2.4] +] + +def gem_list(roda_version = nil) + <<~RB + gem 'roda'#{roda_version} + gem 'rack' + gem 'rack-test', '>= 0.8.0', :require => 'rack/test' + RB +end + +create_gemfiles(RODA_VERSIONS) diff --git a/test/multiverse/suites/roda_agent_disabled/config/newrelic.yml b/test/multiverse/suites/roda_agent_disabled/config/newrelic.yml new file mode 100644 index 0000000000..477bb05fe6 --- /dev/null +++ b/test/multiverse/suites/roda_agent_disabled/config/newrelic.yml @@ -0,0 +1,20 @@ +--- +development: + error_collector: + enabled: true + apdex_t: 0.5 + agent_enabled: false + monitor_mode: false + license_key: bootstrap_newrelic_admin_license_key_000 + ca_bundle_path: ../../../config/test.cert.crt + app_name: test + host: localhost + api_host: localhost + port: <%= $collector && $collector.port %> + transaction_tracer: + record_sql: obfuscated + enabled: true + stack_trace_threshold: 0.5 + transaction_threshold: 1.0 + capture_params: false + disable_serialization: false diff --git a/test/multiverse/suites/roda_agent_disabled/shim_test.rb b/test/multiverse/suites/roda_agent_disabled/shim_test.rb new file mode 100644 index 0000000000..765521e27e --- /dev/null +++ b/test/multiverse/suites/roda_agent_disabled/shim_test.rb @@ -0,0 +1,24 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +require 'roda' + +class TestRodaApp < Roda; end + +class RodaAgentDisabledTestCase < Minitest::Test + def assert_shims_defined + # class method shim + assert_respond_to TestRodaApp, :newrelic_ignore, 'Class method newrelic_ignore not defined' + assert_respond_to TestRodaApp, :newrelic_ignore_apdex, 'Class method newrelic_ignore_apdex not defined' + assert_respond_to TestRodaApp, :newrelic_ignore_enduser, 'Class method newrelic_ignore_enduser not defined' + + # instance method shims + assert_includes(TestRodaApp.instance_methods, :perform_action_with_newrelic_trace, 'Instance method perform_action_with_newrelic_trace not defined') + end + + # Agent disabled via config/newrelic.yml + def test_shims_exist_when_agent_enabled_false + assert_shims_defined + end +end