From 599d4ceeed944a48e69081957d12a653f7d5c669 Mon Sep 17 00:00:00 2001 From: Jim Jones Date: Sun, 12 Nov 2023 00:21:27 -0600 Subject: [PATCH] Adding a TracePoint instrument implementation. --- .idea/.gitignore | 8 - .idea/modules.xml | 2 +- .idea/vcs.xml | 2 +- Gemfile.lock | 16 +- lib/callstacking/rails/engine.rb | 5 +- .../rails/helpers/instrument_helper.rb | 4 +- lib/callstacking/rails/instrument.rb | 180 ------------------ lib/callstacking/rails/instrument/base.rb | 21 ++ .../method_override_instrumentor.rb | 170 +++++++++++++++++ .../instrument/trace_point_instrumentor.rb | 28 +++ .../method_override_instrumentor_test.rb} | 8 +- 11 files changed, 238 insertions(+), 206 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 lib/callstacking/rails/instrument.rb create mode 100644 lib/callstacking/rails/instrument/base.rb create mode 100644 lib/callstacking/rails/instrument/method_override_instrumentor.rb create mode 100644 lib/callstacking/rails/instrument/trace_point_instrumentor.rb rename test/callstacking/rails/{instrument_test.rb => instrument/method_override_instrumentor_test.rb} (89%) diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/modules.xml b/.idea/modules.xml index 3678173..79928b3 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,7 +2,7 @@ - + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 94a25f7..35eb1dd 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index c72f791..ef3dd8e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - callstacking-rails (0.1.36) + callstacking-rails (0.1.37) faraday (>= 1.10.3) faraday-follow_redirects rails (>= 4) @@ -85,8 +85,8 @@ GEM faraday-follow_redirects (0.3.0) faraday (>= 1, < 3) faraday-net_http (3.0.2) - globalid (1.1.0) - activesupport (>= 5.0) + globalid (1.2.1) + activesupport (>= 6.1) i18n (1.12.0) concurrent-ruby (~> 1.0) loofah (2.20.0) @@ -99,13 +99,13 @@ GEM net-smtp marcel (1.0.2) method_source (1.0.0) - mini_mime (1.1.2) + mini_mime (1.1.5) minitest (5.18.0) minitest-silence (0.2.4) minitest (~> 5.12) mocha (2.0.2) ruby2_keywords (>= 0.0.5) - net-imap (0.3.6) + net-imap (0.3.7) date net-protocol net-pop (0.1.2) @@ -158,13 +158,13 @@ GEM sprockets (>= 3.0.0) sqlite3 (1.6.2-arm64-darwin) thor (1.2.2) - timeout (0.3.2) + timeout (0.4.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - websocket-driver (0.7.5) + websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.6.8) + zeitwerk (2.6.11) PLATFORMS arm64-darwin-21 diff --git a/lib/callstacking/rails/engine.rb b/lib/callstacking/rails/engine.rb index 9bf71b4..128a037 100644 --- a/lib/callstacking/rails/engine.rb +++ b/lib/callstacking/rails/engine.rb @@ -2,7 +2,8 @@ require "active_support/cache" require "callstacking/rails/env" require "callstacking/rails/trace" -require "callstacking/rails/instrument" +require "callstacking/rails/instrument/method_override_instrumentor" +require "callstacking/rails/instrument/trace_point_instrumentor" require 'callstacking/rails/spans' require "callstacking/rails/setup" require "callstacking/rails/settings" @@ -28,7 +29,7 @@ class Engine < ::Rails::Engine @@spans||={} @@traces||={} @@lock||=Mutex.new - @@instrumenter||=Instrument.new + @@instrumenter||=Callstacking::Rails::Instrument::MethodOverrideInstrumentor.new @@settings||=Callstacking::Rails::Settings.new initializer "engine_name.assets.precompile" do |app| diff --git a/lib/callstacking/rails/helpers/instrument_helper.rb b/lib/callstacking/rails/helpers/instrument_helper.rb index 6e74c65..b019ec6 100644 --- a/lib/callstacking/rails/helpers/instrument_helper.rb +++ b/lib/callstacking/rails/helpers/instrument_helper.rb @@ -5,12 +5,12 @@ module InstrumentHelper extend ActiveSupport::Concern def callstacking_setup exception = nil - @last_callstacking_sample = TIme.utc.now + @last_callstacking_sample = Time.now.utc Callstacking::Rails::Engine.start_tracing(self) yield rescue Exception => e - @last_callstacking_exception = Time.utc.now + @last_callstacking_exception = Time.now.utc exception = e raise e ensure diff --git a/lib/callstacking/rails/instrument.rb b/lib/callstacking/rails/instrument.rb deleted file mode 100644 index fc24241..0000000 --- a/lib/callstacking/rails/instrument.rb +++ /dev/null @@ -1,180 +0,0 @@ -require 'rails' - -# https://stackoverflow.com/q/52932516 -module Callstacking - module Rails - class Instrument - attr_accessor :spans - attr_reader :settings, :span_modules - - def initialize - @spans = {} - @span_modules = Set.new - @settings = Callstacking::Rails::Settings.new - end - - def instrument_method(klass, method_name, application_level: true) - method_path = (klass.instance_method(method_name).source_location.first rescue nil) || - (klass.method(method_name).source_location.first rescue nil) - - # method was not defined in Ruby (i.e. native) - return if method_path.nil? - - # Application level method definitions - return if application_level && !(method_path =~ /#{::Rails.root.to_s}/) - - return if method_path =~ /initializer/i - - tmp_module = find_or_initialize_module(klass) - - return if tmp_module.nil? || - tmp_module.instance_methods.include?(method_name) || - tmp_module.singleton_methods.include?(method_name) - - new_method = nil - if RUBY_VERSION < "2.7.8" - new_method = tmp_module.define_method(method_name) do |*args, &block| - settings = tmp_module.instance_variable_get(:@settings) - return super(*args, &block) if settings.disabled? - - method_name = __method__ - - path = method(__method__).super_method.source_location&.first || '' - line_no = method(__method__).super_method.source_location&.last || '' - - p, l = caller.find { |c| c.to_s =~ /#{::Rails.root.to_s}/}&.split(':') - - spans = tmp_module.instance_variable_get(:@spans) - span = spans[Thread.current.object_id] - klass = tmp_module.instance_variable_get(:@klass) - - arguments = Callstacking::Rails::Instrument.arguments_for(method(__method__).super_method, args) - - span.call_entry(klass, method_name, arguments, p || path, l || line_no) - return_val = super(*args, &block) - span.call_return(klass, method_name, p || path, l || line_no, return_val) - - return_val - end - new_method.ruby2_keywords if new_method.respond_to?(:ruby2_keywords) - else - new_method = tmp_module.define_method(method_name) do |*args, **kwargs, &block| - settings = tmp_module.instance_variable_get(:@settings) - return super(*args, **kwargs, &block) if settings.disabled? - - method_name = __method__ - - path = method(__method__).super_method.source_location&.first || '' - line_no = method(__method__).super_method.source_location&.last || '' - - p, l = caller.find { |c| c.to_s =~ /#{::Rails.root.to_s}/}&.split(':') - - spans = tmp_module.instance_variable_get(:@spans) - span = spans[Thread.current.object_id] - klass = tmp_module.instance_variable_get(:@klass) - - arguments = Callstacking::Rails::Instrument.arguments_for(method(__method__).super_method, args) - - span.call_entry(klass, method_name, arguments, p || path, l || line_no) - return_val = super(*args, **kwargs, &block) - span.call_return(klass, method_name, p || path, l || line_no, return_val) - - return_val - end - - end - - new_method - end - - def enable!(klasses) - Array.wrap(klasses).each do |klass| - instrument_klass(klass, application_level: true) - end - end - - def disable!(modules = span_modules) - modules.each do |mod| - mod.instance_methods.each do |method_name| - mod.remove_method(method_name) - end - end - - reset! - end - - def instrumentation_required? - span_modules.empty? - end - - def reset! - span_modules.clear - end - - def instrument_klass(klass, application_level: true) - relevant_methods = all_methods(klass) - filtered - relevant_methods.each { |method| instrument_method(klass, method, application_level: application_level) } - end - - def self.arguments_for(m, args) - param_names = m.parameters&.map(&:last) - return {} if param_names.nil? - - h = param_names.map.with_index do |param, index| - next if [:&, :*, :**].include?(param) - [param, args[index].inspect] - end.compact.to_h - - filter = ::Rails.application.config.filter_parameters - f = ActiveSupport::ParameterFilter.new filter - f.filter h - end - - def add_span(span) - spans[Thread.current.object_id] ||= span - end - - private - def find_or_initialize_module(klass) - name = klass&.name rescue nil - return if name.nil? - - module_name = "#{klass.name.gsub('::', '')}Span" - module_index = klass.ancestors.map(&:to_s).index(module_name) - - unless module_index - # Development class reload - - # ancestors are reset but module definition remains - new_module = Object.const_get(module_name) rescue nil - new_module||=Object.const_set(module_name, Module.new) - span_modules << new_module - - new_module.instance_variable_set("@klass", klass) - new_module.instance_variable_set("@spans", spans) - new_module.instance_variable_set("@settings", settings) - - klass.prepend new_module - klass.singleton_class.prepend new_module if klass.class == Module - - return find_or_initialize_module(klass) - end - - span_modules << klass.ancestors[module_index] - klass.ancestors[module_index] - end - - def all_methods(klass) - (klass.instance_methods + - klass.private_instance_methods(false) + - klass.protected_instance_methods(false) + - klass.methods + - klass.singleton_methods).uniq - end - - def filtered - @filtered ||= (Object.instance_methods + Object.private_instance_methods + - Object.protected_instance_methods + Object.methods(false)).uniq - end - end - end -end diff --git a/lib/callstacking/rails/instrument/base.rb b/lib/callstacking/rails/instrument/base.rb new file mode 100644 index 0000000..c241931 --- /dev/null +++ b/lib/callstacking/rails/instrument/base.rb @@ -0,0 +1,21 @@ +module Callstacking + module Rails + module Instrument + class Base + def self.arguments_for(m, args) + param_names = m.parameters&.map(&:last) + return {} if param_names.nil? + + h = param_names.map.with_index do |param, index| + next if [:&, :*, :**].include?(param) + [param, args[index].inspect] + end.compact.to_h + + filter = ::Rails.application.config.filter_parameters + f = ActiveSupport::ParameterFilter.new filter + f.filter h + end + end + end + end +end diff --git a/lib/callstacking/rails/instrument/method_override_instrumentor.rb b/lib/callstacking/rails/instrument/method_override_instrumentor.rb new file mode 100644 index 0000000..f0e4e42 --- /dev/null +++ b/lib/callstacking/rails/instrument/method_override_instrumentor.rb @@ -0,0 +1,170 @@ +require 'rails' +require 'callstacking/rails/instrument/base' + +# https://stackoverflow.com/q/52932516 +module Callstacking + module Rails + module Instrument + class MethodOverrideInstrumentor < Base + attr_accessor :spans + attr_reader :settings, :span_modules + + def initialize + @spans = {} + @span_modules = Set.new + @settings = Callstacking::Rails::Settings.new + end + + def instrument_method(klass, method_name, application_level: true) + method_path = (klass.instance_method(method_name).source_location.first rescue nil) || + (klass.method(method_name).source_location.first rescue nil) + + # method was not defined in Ruby (i.e. native) + return if method_path.nil? + + # Application level method definitions + return if application_level && !(method_path =~ /#{::Rails.root.to_s}/) + + return if method_path =~ /initializer/i + + tmp_module = find_or_initialize_module(klass) + + return if tmp_module.nil? || + tmp_module.instance_methods.include?(method_name) || + tmp_module.singleton_methods.include?(method_name) + + new_method = nil + if RUBY_VERSION < "2.7.8" + new_method = tmp_module.define_method(method_name) do |*args, &block| + settings = tmp_module.instance_variable_get(:@settings) + return super(*args, &block) if settings.disabled? + + method_name = __method__ + + path = method(__method__).super_method.source_location&.first || '' + line_no = method(__method__).super_method.source_location&.last || '' + + p, l = caller.find { |c| c.to_s =~ /#{::Rails.root.to_s}/ }&.split(':') + + spans = tmp_module.instance_variable_get(:@spans) + span = spans[Thread.current.object_id] + klass = tmp_module.instance_variable_get(:@klass) + + arguments = Callstacking::Rails::Instrument::Base.arguments_for(method(__method__).super_method, args) + + span.call_entry(klass, method_name, arguments, p || path, l || line_no) + return_val = super(*args, &block) + span.call_return(klass, method_name, p || path, l || line_no, return_val) + + return_val + end + new_method.ruby2_keywords if new_method.respond_to?(:ruby2_keywords) + else + new_method = tmp_module.define_method(method_name) do |*args, **kwargs, &block| + settings = tmp_module.instance_variable_get(:@settings) + return super(*args, **kwargs, &block) if settings.disabled? + + method_name = __method__ + + path = method(__method__).super_method.source_location&.first || '' + line_no = method(__method__).super_method.source_location&.last || '' + + p, l = caller.find { |c| c.to_s =~ /#{::Rails.root.to_s}/ }&.split(':') + + spans = tmp_module.instance_variable_get(:@spans) + span = spans[Thread.current.object_id] + klass = tmp_module.instance_variable_get(:@klass) + + arguments = Callstacking::Rails::Instrument::Base.arguments_for(method(__method__).super_method, args) + + span.call_entry(klass, method_name, arguments, p || path, l || line_no) + return_val = super(*args, **kwargs, &block) + span.call_return(klass, method_name, p || path, l || line_no, return_val) + + return_val + end + + end + + new_method + end + + def enable!(klasses) + Array.wrap(klasses).each do |klass| + instrument_klass(klass, application_level: true) + end + end + + def disable!(modules = span_modules) + modules.each do |mod| + mod.instance_methods.each do |method_name| + mod.remove_method(method_name) + end + end + + reset! + end + + def instrumentation_required? + span_modules.empty? + end + + def reset! + span_modules.clear + end + + def instrument_klass(klass, application_level: true) + relevant_methods = all_methods(klass) - filtered + relevant_methods.each { |method| instrument_method(klass, method, application_level: application_level) } + end + + def add_span(span) + spans[Thread.current.object_id] ||= span + end + + private + + def find_or_initialize_module(klass) + name = klass&.name rescue nil + return if name.nil? + + module_name = "#{klass.name.gsub('::', '')}Span" + module_index = klass.ancestors.map(&:to_s).index(module_name) + + unless module_index + # Development class reload - + # ancestors are reset but module definition remains + new_module = Object.const_get(module_name) rescue nil + new_module ||= Object.const_set(module_name, Module.new) + span_modules << new_module + + new_module.instance_variable_set("@klass", klass) + new_module.instance_variable_set("@spans", spans) + new_module.instance_variable_set("@settings", settings) + + klass.prepend new_module + klass.singleton_class.prepend new_module if klass.class == Module + + return find_or_initialize_module(klass) + end + + span_modules << klass.ancestors[module_index] + klass.ancestors[module_index] + end + + def all_methods(klass) + (klass.instance_methods + + klass.private_instance_methods(false) + + klass.protected_instance_methods(false) + + klass.methods + + klass.singleton_methods).uniq + end + + def filtered + @filtered ||= (Object.instance_methods + Object.private_instance_methods + + Object.protected_instance_methods + Object.methods(false)).uniq + end + end + end + end +end diff --git a/lib/callstacking/rails/instrument/trace_point_instrumentor.rb b/lib/callstacking/rails/instrument/trace_point_instrumentor.rb new file mode 100644 index 0000000..642c6ea --- /dev/null +++ b/lib/callstacking/rails/instrument/trace_point_instrumentor.rb @@ -0,0 +1,28 @@ +require 'rails' +require 'callstacking/rails/instrument/base' + +# https://stackoverflow.com/q/52932516 +module Callstacking + module Rails + module Instrument + class TracePointInstrumentor < Base + attr_accessor :spans + attr_reader :settings, :span_modules + + def initialize + @spans = {} + @settings = Callstacking::Rails::Settings.new + end + + def enable! + end + + def disable! + reset! + end + + private + end + end + end +end diff --git a/test/callstacking/rails/instrument_test.rb b/test/callstacking/rails/instrument/method_override_instrumentor_test.rb similarity index 89% rename from test/callstacking/rails/instrument_test.rb rename to test/callstacking/rails/instrument/method_override_instrumentor_test.rb index 6a67c9c..986f7b2 100644 --- a/test/callstacking/rails/instrument_test.rb +++ b/test/callstacking/rails/instrument/method_override_instrumentor_test.rb @@ -6,13 +6,13 @@ module Callstacking module Rails - class InstrumentTest < Minitest::Test + class MethodOverrideInstrumentorTest < Minitest::Test TEST_MODULES = [:SalutationSpan, :ApplicationControllerSpan] def setup @spans = Callstacking::Rails::Spans.new @trace = Callstacking::Rails::Trace.new(@spans) - @subject = Callstacking::Rails::Instrument.new + @subject = Callstacking::Rails::Instrument::MethodOverrideInstrumentor.new @settings = Callstacking::Rails::Settings.new @subject.add_span(@spans) @@ -40,7 +40,7 @@ def test_instrument_klass assert_equal 2, ::SalutationSpan.instance_methods(false).size assert_equal true, Salutation.ancestors.include?(::SalutationSpan) - assert_match /instrument.rb/, Salutation.instance_method(:hello).source_location.first + assert_match /method_override_instrumentor.rb/, Salutation.instance_method(:hello).source_location.first Trace.any_instance.expects(:create_call_entry) Trace.any_instance.expects(:create_call_return) @@ -75,7 +75,7 @@ def test_enable_disable assert_equal true, Salutation.ancestors.include?(::SalutationSpan) assert_equal true, module_and_method_exist?('SalutationSpan', :hello) - assert_match /instrument.rb/, Salutation.instance_method(:hello).source_location.first + assert_match /method_override_instrumentor.rb/, Salutation.instance_method(:hello).source_location.first assert_equal 2, ::SalutationSpan.instance_methods(false).size @subject.disable!