diff --git a/app.rb b/app.rb index f57bb0b09..6db21442a 100644 --- a/app.rb +++ b/app.rb @@ -183,6 +183,9 @@ # Initialize the app require_relative 'init' +# Custom method tracers (must load after init so traced methods exist) +require_relative 'config/newrelic_method_tracers' + # Enter console mode if settings.environment == :console require 'rack/test' diff --git a/config/newrelic_method_tracers.rb b/config/newrelic_method_tracers.rb new file mode 100644 index 000000000..81638162a --- /dev/null +++ b/config/newrelic_method_tracers.rb @@ -0,0 +1,65 @@ +# Custom New Relic method tracers for hot request paths. +# +# High-traffic transactions (GET /search, GET .../classes/{cls}/tree) +# spend most of their time in application code that the agent reports as +# a single opaque "Sinatra::Application#GET (unknown)" segment. These +# tracers split that block into named Custom/* child segments so +# transaction traces and the breakdown table show where the time goes. +# +# To instrument another endpoint, add its module/class and methods to the +# registry below; test/helpers/test_newrelic_method_tracers.rb consumes +# the same registry, so new entries are covered automatically. +# +# Must be loaded after init.rb so all traced methods are already defined. +# When the agent is disabled (development/test), the tracers are no-ops. +# +# Kill switch: set NEWRELIC_CUSTOM_TRACERS=off (and restart) to skip +# registration entirely, e.g. to rule instrumentation out while +# debugging. Requires a process restart to take effect either way. +module NewRelicMethodTracers + # Instance methods, keyed by module/class name. Methods inherited from + # concerns or mixins (e.g. the tree methods, defined in an + # ontologies_linked_data concern) can be traced here without touching + # the defining repo. + INSTANCE_METHODS = { + # GET /search pipeline + 'Sinatra::Helpers::SearchHelper' => %i[process_search get_term_search_query + add_matched_fields filter_attrs_by_language], + # App-wide: ontology access list load + response serialization entry + 'Sinatra::Helpers::ApplicationHelper' => %i[restricted_ontologies reply], + # GET /ontologies/{ontology}/classes/{cls}/tree pipeline + 'LinkedData::Models::Class' => %i[tree tree_sorted paths_to_root] + }.freeze + + # Class (singleton) methods, keyed by class name. + CLASS_METHODS = { + # Hypermedia links + JSON generation for every API response + 'LinkedData::Serializer' => %i[build_response serialize], + # Solr query execution incl. Ruby-side response parsing (the HTTP + # call itself already appears as an External segment) + 'LinkedData::Models::Class' => %i[search] + }.freeze + + def self.register + INSTANCE_METHODS.each do |const_name, methods| + install(Object.const_get(const_name), const_name, methods) + end + + CLASS_METHODS.each do |const_name, methods| + install(Object.const_get(const_name).singleton_class, const_name, methods) + end + end + + def self.install(target, const_name, methods) + target.include(::NewRelic::Agent::MethodTracer) + + methods.each do |method_name| + target.send(:add_method_tracer, method_name, "Custom/#{const_name}/#{method_name}") + end + end +end + +unless ENV['NEWRELIC_CUSTOM_TRACERS'] == 'off' + require 'new_relic/agent/method_tracer' + NewRelicMethodTracers.register +end diff --git a/helpers/search_helper.rb b/helpers/search_helper.rb index c7420eca2..81c59c0e1 100644 --- a/helpers/search_helper.rb +++ b/helpers/search_helper.rb @@ -560,6 +560,12 @@ def process_search(params = nil) params.delete('query') text = params["q"] + # The agent strips query strings from request.uri, so record whether + # this search is ontology-scoped as a queryable transaction attribute + if defined?(::NewRelic::Agent) + ::NewRelic::Agent.add_custom_attributes(search_scoped: !params[ONTOLOGIES_PARAM].to_s.empty?) + end + query = get_term_search_query(text, params) # puts "Edismax query: #{query}, params: #{params}" set_page_params(params) diff --git a/test/helpers/test_newrelic_method_tracers.rb b/test/helpers/test_newrelic_method_tracers.rb new file mode 100644 index 000000000..aab0ed4f1 --- /dev/null +++ b/test/helpers/test_newrelic_method_tracers.rb @@ -0,0 +1,45 @@ +require_relative '../test_case_helpers' + +# Guards the custom New Relic instrumentation registered by +# config/newrelic_method_tracers.rb. If a traced method is renamed or +# moved, add_method_tracer logs a warning and silently no-ops, so without +# these assertions the instrumentation could vanish unnoticed while the +# rest of the suite stays green. +# +# The tests iterate the registry itself, so entries added to +# NewRelicMethodTracers::INSTANCE_METHODS / CLASS_METHODS are covered +# automatically. +class TestNewRelicMethodTracers < TestCaseHelpers + + def test_instance_method_tracers_registered + NewRelicMethodTracers::INSTANCE_METHODS.each do |const_name, methods| + mod = Object.const_get(const_name) + methods.each do |method_name| + assert_wrapped_by_newrelic(mod.instance_method(method_name), + "#{const_name}##{method_name}") + end + end + end + + def test_class_method_tracers_registered + NewRelicMethodTracers::CLASS_METHODS.each do |const_name, methods| + singleton = Object.const_get(const_name).singleton_class + methods.each do |method_name| + assert_wrapped_by_newrelic(singleton.instance_method(method_name), + "#{const_name}.#{method_name}") + end + end + end + + private + + # The tracer wrapper is defined inside the newrelic_rpm gem, so the + # wrapped method's source_location must point there. This distinguishes + # a genuine tracer from the untraced original (defined in this repo or + # its gems) and from any other module that happens to prepend. + def assert_wrapped_by_newrelic(unbound_method, label) + source_file = unbound_method.source_location&.first.to_s + assert_match(/newrelic/, source_file, + "#{label} is not wrapped by a newrelic_rpm method tracer (source: #{source_file})") + end +end