appmap 0.34.1 → 0.35.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,7 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AppMap
2
4
  class Hook
3
5
  class Method
4
- attr_reader :hook_class, :hook_method, :defined_class, :method_display_name
6
+ attr_reader :hook_package, :hook_class, :hook_method
7
+
8
+ # +method_display_name+ may be nil if name resolution gets
9
+ # deferred until runtime (e.g. for a singleton method on an
10
+ # embedded Struct).
11
+ attr_reader :method_display_name
5
12
 
6
13
  HOOK_DISABLE_KEY = 'AppMap::Hook.disable'
7
14
  private_constant :HOOK_DISABLE_KEY
@@ -10,57 +17,77 @@ module AppMap
10
17
  # with the method we're hooking.
11
18
  TIME_NOW = Time.method(:now)
12
19
  private_constant :TIME_NOW
13
-
14
- def initialize(hook_class, hook_method)
20
+
21
+ def initialize(hook_package, hook_class, hook_method)
22
+ @hook_package = hook_package
15
23
  @hook_class = hook_class
16
24
  @hook_method = hook_method
25
+
26
+ # Get the class for the method, if it's known.
17
27
  @defined_class, method_symbol = Hook.qualify_method_name(@hook_method)
18
- @method_display_name = [@defined_class, method_symbol, @hook_method.name].join
28
+ @method_display_name = [@defined_class, method_symbol, @hook_method.name].join if @defined_class
19
29
  end
20
30
 
21
31
  def activate
22
- warn "AppMap: Hooking #{method_display_name}" if Hook::LOG
32
+ if Hook::LOG
33
+ msg = if method_display_name
34
+ "#{method_display_name}"
35
+ else
36
+ "#{hook_method.name} (class resolution deferred)"
37
+ end
38
+ warn "AppMap: Hooking " + msg
39
+ end
23
40
 
41
+ defined_class = @defined_class
24
42
  hook_method = self.hook_method
25
43
  before_hook = self.method(:before_hook)
26
44
  after_hook = self.method(:after_hook)
27
45
  with_disabled_hook = self.method(:with_disabled_hook)
28
46
 
29
- hook_class.define_method hook_method.name do |*args, &block|
30
- instance_method = hook_method.bind(self).to_proc
47
+ hook_method_def = nil
48
+ hook_class.instance_eval do
49
+ hook_method_def = Proc.new do |*args, &block|
50
+ instance_method = hook_method.bind(self).to_proc
31
51
 
32
- hook_disabled = Thread.current[HOOK_DISABLE_KEY]
33
- enabled = true if !hook_disabled && AppMap.tracing.enabled?
34
- return instance_method.call(*args, &block) unless enabled
52
+ # We may not have gotten the class for the method during
53
+ # initialization (e.g. for a singleton method on an embedded
54
+ # struct), so make sure we have it now.
55
+ defined_class,_ = Hook.qualify_method_name(hook_method) unless defined_class
35
56
 
36
- call_event, start_time = with_disabled_hook.() do
37
- before_hook.(self, args)
38
- end
39
- return_value = nil
40
- exception = nil
41
- begin
42
- return_value = instance_method.(*args, &block)
43
- rescue
44
- exception = $ERROR_INFO
45
- raise
46
- ensure
47
- with_disabled_hook.() do
48
- after_hook.(call_event, start_time, return_value, exception)
57
+ hook_disabled = Thread.current[HOOK_DISABLE_KEY]
58
+ enabled = true if !hook_disabled && AppMap.tracing.enabled?
59
+ return instance_method.call(*args, &block) unless enabled
60
+
61
+ call_event, start_time = with_disabled_hook.() do
62
+ before_hook.(self, defined_class, args)
63
+ end
64
+ return_value = nil
65
+ exception = nil
66
+ begin
67
+ return_value = instance_method.(*args, &block)
68
+ rescue
69
+ exception = $ERROR_INFO
70
+ raise
71
+ ensure
72
+ with_disabled_hook.() do
73
+ after_hook.(self, call_event, start_time, return_value, exception)
74
+ end
49
75
  end
50
76
  end
51
77
  end
78
+ hook_class.define_method_with_arity(hook_method.name, hook_method.arity, hook_method_def)
52
79
  end
53
80
 
54
81
  protected
55
82
 
56
- def before_hook(receiver, args)
83
+ def before_hook(receiver, defined_class, args)
57
84
  require 'appmap/event'
58
85
  call_event = AppMap::Event::MethodCall.build_from_invocation(defined_class, hook_method, receiver, args)
59
- AppMap.tracing.record_event call_event, defined_class: defined_class, method: hook_method
86
+ AppMap.tracing.record_event call_event, package: hook_package, defined_class: defined_class, method: hook_method
60
87
  [ call_event, TIME_NOW.call ]
61
88
  end
62
89
 
63
- def after_hook(call_event, start_time, return_value, exception)
90
+ def after_hook(receiver, call_event, start_time, return_value, exception)
64
91
  require 'appmap/event'
65
92
  elapsed = TIME_NOW.call - start_time
66
93
  return_event = \
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'appmap/event'
4
+ require 'appmap/hook'
5
+
6
+ module AppMap
7
+ module Rails
8
+ module RequestHandler
9
+ class HTTPServerRequest < AppMap::Event::MethodEvent
10
+ attr_accessor :request_method, :path_info, :params
11
+
12
+ def initialize(request)
13
+ super AppMap::Event.next_id_counter, :call, Thread.current.object_id
14
+
15
+ @request_method = request.request_method
16
+ @path_info = request.path_info.split('?')[0]
17
+ @params = ActionDispatch::Http::ParameterFilter.new(::Rails.application.config.filter_parameters).filter(request.params)
18
+ end
19
+
20
+ def to_h
21
+ super.tap do |h|
22
+ h[:http_server_request] = {
23
+ request_method: request_method,
24
+ path_info: path_info
25
+ }
26
+
27
+ h[:message] = params.keys.map do |key|
28
+ val = params[key]
29
+ {
30
+ name: key,
31
+ class: val.class.name,
32
+ value: self.class.display_string(val),
33
+ object_id: val.__id__
34
+ }
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ class HTTPServerResponse < AppMap::Event::MethodReturnIgnoreValue
41
+ attr_accessor :status, :mime_type
42
+
43
+ def initialize(response, parent_id, elapsed)
44
+ super AppMap::Event.next_id_counter, :return, Thread.current.object_id
45
+
46
+ self.status = response.status
47
+ self.mime_type = response.headers['Content-Type']
48
+ self.parent_id = parent_id
49
+ self.elapsed = elapsed
50
+ end
51
+
52
+ def to_h
53
+ super.tap do |h|
54
+ h[:http_server_response] = {
55
+ status: status,
56
+ mime_type: mime_type
57
+ }.compact
58
+ end
59
+ end
60
+ end
61
+
62
+ class HookMethod < AppMap::Hook::Method
63
+ def initialize
64
+ # ActionController::Instrumentation has issued start_processing.action_controller and
65
+ # process_action.action_controller since Rails 3. Therefore it's a stable place to hook
66
+ # the request. Rails controller notifications can't be used directly because they don't
67
+ # provide response headers, and we want the Content-Type.
68
+ super(nil, ActionController::Instrumentation, ActionController::Instrumentation.instance_method(:process_action))
69
+ end
70
+
71
+ protected
72
+
73
+ def before_hook(receiver, defined_class, _) # args
74
+ call_event = HTTPServerRequest.new(receiver.request)
75
+ # http_server_request events are i/o and do not require a package name.
76
+ AppMap.tracing.record_event call_event, defined_class: defined_class, method: hook_method
77
+ [ call_event, TIME_NOW.call ]
78
+ end
79
+
80
+ def after_hook(receiver, call_event, start_time, _, _) # return_value, exception
81
+ elapsed = TIME_NOW.call - start_time
82
+ return_event = HTTPServerResponse.new receiver.response, call_event.id, elapsed
83
+ AppMap.tracing.record_event return_event
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -72,21 +72,24 @@ module AppMap
72
72
  end
73
73
 
74
74
  class ActiveRecordExaminer
75
+ @@db_version_warning_issued = {}
76
+
77
+ def issue_warning
78
+ db_type = database_type
79
+ return if @@db_version_warning_issued[db_type]
80
+ warn("AppMap: Unable to determine database version for #{db_type.inspect}")
81
+ @@db_version_warning_issued[db_type] = true
82
+ end
83
+
75
84
  def server_version
76
- case database_type
77
- when :postgres
78
- ActiveRecord::Base.connection.postgresql_version
79
- when :sqlite
80
- ActiveRecord::Base.connection.database_version.to_s
81
- else
82
- warn "Unable to determine database version for #{database_type.inspect}"
83
- end
85
+ ActiveRecord::Base.connection.try(:database_version) || issue_warning
84
86
  end
85
87
 
86
88
  def database_type
87
- return :postgres if ActiveRecord::Base.connection.respond_to?(:postgresql_version)
89
+ type = ActiveRecord::Base.connection.adapter_name.downcase.to_sym
90
+ type = :postgres if type == :postgresql
88
91
 
89
- ActiveRecord::Base.connection.adapter_name.downcase.to_sym
92
+ type
90
93
  end
91
94
 
92
95
  def execute_query(sql)
@@ -13,13 +13,11 @@ module AppMap
13
13
  # AppMap events.
14
14
  initializer 'appmap.subscribe', after: 'appmap.init' do |_| # params: app
15
15
  require 'appmap/rails/sql_handler'
16
- require 'appmap/rails/action_handler'
16
+ require 'appmap/rails/request_handler'
17
17
  ActiveSupport::Notifications.subscribe 'sql.sequel', AppMap::Rails::SQLHandler.new
18
18
  ActiveSupport::Notifications.subscribe 'sql.active_record', AppMap::Rails::SQLHandler.new
19
- ActiveSupport::Notifications.subscribe \
20
- 'start_processing.action_controller', AppMap::Rails::ActionHandler::HTTPServerRequest.new
21
- ActiveSupport::Notifications.subscribe \
22
- 'process_action.action_controller', AppMap::Rails::ActionHandler::HTTPServerResponse.new
19
+
20
+ AppMap::Rails::RequestHandler::HookMethod.new.activate
23
21
  end
24
22
 
25
23
  # appmap.trace begins recording an AppMap trace and writes it to appmap.json.
@@ -124,8 +124,18 @@ module AppMap
124
124
  def initialize(example)
125
125
  super
126
126
 
127
+ webdriver_port = lambda do
128
+ return unless defined?(page) && page&.driver
129
+
130
+ # This is the ugliest thing ever but I don't want to lose it.
131
+ # All the WebDriver calls are getting app-mapped and it's really unclear
132
+ # what they are.
133
+ page.driver.options[:http_client].instance_variable_get('@server_url').port
134
+ end
135
+
127
136
  warn "Starting recording of example #{example}" if AppMap::RSpec::LOG
128
137
  @trace = AppMap.tracing.trace
138
+ @webdriver_port = webdriver_port.()
129
139
  end
130
140
 
131
141
  def finish
@@ -3,9 +3,10 @@
3
3
  module AppMap
4
4
  module Trace
5
5
  class ScopedMethod < SimpleDelegator
6
- attr_reader :defined_class, :static
7
-
8
- def initialize(defined_class, method, static)
6
+ attr_reader :package, :defined_class, :static
7
+
8
+ def initialize(package, defined_class, method, static)
9
+ @package = package
9
10
  @defined_class = defined_class
10
11
  @static = static
11
12
  super(method)
@@ -32,9 +33,9 @@ module AppMap
32
33
  @tracing.any?(&:enabled?)
33
34
  end
34
35
 
35
- def record_event(event, defined_class: nil, method: nil)
36
+ def record_event(event, package: nil, defined_class: nil, method: nil)
36
37
  @tracing.each do |tracer|
37
- tracer.record_event(event, defined_class: defined_class, method: method)
38
+ tracer.record_event(event, package: package, defined_class: defined_class, method: method)
38
39
  end
39
40
  end
40
41
 
@@ -71,11 +72,12 @@ module AppMap
71
72
  # Record a program execution event.
72
73
  #
73
74
  # The event should be one of the MethodEvent subclasses.
74
- def record_event(event, defined_class: nil, method: nil)
75
+ def record_event(event, package: nil, defined_class: nil, method: nil)
75
76
  return unless @enabled
76
77
 
77
78
  @events << event
78
- @methods << Trace::ScopedMethod.new(defined_class, method, event.static) if (defined_class && method && event.event == :call)
79
+ @methods << Trace::ScopedMethod.new(package, defined_class, method, event.static) \
80
+ if package && defined_class && method && (event.event == :call)
79
81
  end
80
82
 
81
83
  # Gets a unique list of the methods that were invoked by the program.
@@ -36,6 +36,23 @@ module AppMap
36
36
  [ fname, extension ].join
37
37
  end
38
38
 
39
+ # sanitize_paths removes ephemeral values from objects with
40
+ # embedded paths (e.g. an event or a classmap), making events
41
+ # easier to compare across runs.
42
+ def sanitize_paths(h)
43
+ require 'hashie'
44
+ h.extend(Hashie::Extensions::DeepLocate)
45
+ keys = %i(path location)
46
+ h.deep_locate ->(k,v,o) {
47
+ next unless keys.include?(k)
48
+
49
+ fix = ->(v) {v.gsub(%r{#{Gem.dir}/gems/.*(?=lib)}, '')}
50
+ keys.each {|k| o[k] = fix.(o[k]) if o[k] }
51
+ }
52
+
53
+ h
54
+ end
55
+
39
56
  # sanitize_event removes ephemeral values from an event, making
40
57
  # events easier to compare across runs.
41
58
  def sanitize_event(event, &block)
@@ -49,7 +66,7 @@ module AppMap
49
66
 
50
67
  case event[:event]
51
68
  when :call
52
- event[:path] = event[:path].gsub(Gem.dir + '/', '')
69
+ sanitize_paths(event)
53
70
  end
54
71
 
55
72
  event
@@ -3,7 +3,7 @@
3
3
  module AppMap
4
4
  URL = 'https://github.com/applandinc/appmap-ruby'
5
5
 
6
- VERSION = '0.34.1'
6
+ VERSION = '0.35.1'
7
7
 
8
8
  APPMAP_FORMAT_VERSION = '1.2'
9
9
  end
@@ -39,7 +39,7 @@ describe 'AbstractControllerBase' do
39
39
  expect(appmap).to include(<<-SERVER_REQUEST.strip)
40
40
  http_server_request:
41
41
  request_method: POST
42
- path_info: "/api/users?login=alice&password=foobar
42
+ path_info: "/api/users"
43
43
  SERVER_REQUEST
44
44
  end
45
45
  it 'Properly captures method parameters in the appmap' do
@@ -45,6 +45,12 @@ describe 'AbstractControllerBase' do
45
45
  request_method: POST
46
46
  path_info: "/api/users"
47
47
  SERVER_REQUEST
48
+
49
+ expect(appmap).to include(<<-SERVER_RESPONSE.strip)
50
+ http_server_response:
51
+ status: 201
52
+ mime_type: application/json; charset=utf-8
53
+ SERVER_RESPONSE
48
54
  end
49
55
 
50
56
  it 'properly captures method parameters in the appmap' do
@@ -15,6 +15,20 @@ class SingletonMethod
15
15
  'defined with self class scope'
16
16
  end
17
17
 
18
+ module AddMethod
19
+ def self.included(base)
20
+ base.module_eval do
21
+ define_method "added_method" do
22
+ _added_method
23
+ end
24
+ end
25
+ end
26
+
27
+ def _added_method
28
+ 'defined by including a module'
29
+ end
30
+ end
31
+
18
32
  # When called, do_include calls +include+ to bring in the module
19
33
  # AddMethod. AddMethod defines a new instance method, which gets
20
34
  # added to the singleton class of SingletonMethod.
@@ -32,23 +46,18 @@ class SingletonMethod
32
46
  end
33
47
  end
34
48
  end
35
-
36
- def to_s
37
- 'Singleton Method fixture'
38
- end
39
- end
40
49
 
41
- module AddMethod
42
- def self.included(base)
43
- base.module_eval do
44
- define_method "added_method" do
45
- _added_method
50
+ STRUCT_TEST = Struct.new(:attr) do
51
+ class << self
52
+ def say_struct_singleton
53
+ 'singleton for a struct'
46
54
  end
47
55
  end
48
56
  end
49
57
 
50
- def _added_method
51
- 'defined by including a module'
58
+ def to_s
59
+ 'Singleton Method fixture'
52
60
  end
53
61
  end
54
62
 
63
+
@@ -22,7 +22,7 @@ describe 'AppMap class Hooking', docker: false do
22
22
  while tracer.event?
23
23
  events << tracer.next_event.to_h
24
24
  end
25
- end.map(&AppMap::Util.method(:sanitize_event)).to_yaml
25
+ end.map(&AppMap::Util.method(:sanitize_event))
26
26
  end
27
27
 
28
28
  def invoke_test_file(file, setup: nil, &block)
@@ -50,7 +50,7 @@ describe 'AppMap class Hooking', docker: false do
50
50
  def test_hook_behavior(file, events_yaml, setup: nil, &block)
51
51
  config, tracer = invoke_test_file(file, setup: setup, &block)
52
52
 
53
- events = collect_events(tracer)
53
+ events = collect_events(tracer).to_yaml
54
54
 
55
55
  expect(Diffy::Diff.new(events_yaml, events).to_s).to eq('')
56
56
 
@@ -342,7 +342,7 @@ describe 'AppMap class Hooking', docker: false do
342
342
  :defined_class: SingletonMethod
343
343
  :method_id: added_method
344
344
  :path: spec/fixtures/hook/singleton_method.rb
345
- :lineno: 44
345
+ :lineno: 21
346
346
  :static: false
347
347
  :parameters: []
348
348
  :receiver:
@@ -350,10 +350,10 @@ describe 'AppMap class Hooking', docker: false do
350
350
  :value: Singleton Method fixture
351
351
  - :id: 2
352
352
  :event: :call
353
- :defined_class: AddMethod
353
+ :defined_class: SingletonMethod::AddMethod
354
354
  :method_id: _added_method
355
355
  :path: spec/fixtures/hook/singleton_method.rb
356
- :lineno: 50
356
+ :lineno: 27
357
357
  :static: false
358
358
  :parameters: []
359
359
  :receiver:
@@ -395,10 +395,44 @@ describe 'AppMap class Hooking', docker: false do
395
395
  load 'spec/fixtures/hook/singleton_method.rb'
396
396
  setup = -> { SingletonMethod.new_with_instance_method }
397
397
  test_hook_behavior 'spec/fixtures/hook/singleton_method.rb', events_yaml, setup: setup do |s|
398
+ # Make sure we're testing the right thing
399
+ say_instance_defined = s.method(:say_instance_defined)
400
+ expect(say_instance_defined.owner.to_s).to start_with('#<Class:#<SingletonMethod:')
401
+
402
+ # Verify the native extension works as expected
403
+ expect(AppMap::Hook.singleton_method_owner_name(say_instance_defined)).to eq('SingletonMethod')
404
+
398
405
  expect(s.say_instance_defined).to eq('defined for an instance')
399
406
  end
400
407
  end
401
408
 
409
+ it 'hooks a singleton method on an embedded struct' do
410
+ events_yaml = <<~YAML
411
+ ---
412
+ - :id: 1
413
+ :event: :call
414
+ :defined_class: SingletonMethod::STRUCT_TEST
415
+ :method_id: say_struct_singleton
416
+ :path: spec/fixtures/hook/singleton_method.rb
417
+ :lineno: 52
418
+ :static: true
419
+ :parameters: []
420
+ :receiver:
421
+ :class: Class
422
+ :value: SingletonMethod::STRUCT_TEST
423
+ - :id: 2
424
+ :event: :return
425
+ :parent_id: 1
426
+ :return_value:
427
+ :class: String
428
+ :value: singleton for a struct
429
+ YAML
430
+
431
+ test_hook_behavior 'spec/fixtures/hook/singleton_method.rb', events_yaml do
432
+ expect(SingletonMethod::STRUCT_TEST.say_struct_singleton).to eq('singleton for a struct')
433
+ end
434
+ end
435
+
402
436
  it 'Reports exceptions' do
403
437
  events_yaml = <<~YAML
404
438
  ---
@@ -466,7 +500,7 @@ describe 'AppMap class Hooking', docker: false do
466
500
  :event: :call
467
501
  :defined_class: ActiveSupport::SecurityUtils
468
502
  :method_id: secure_compare
469
- :path: gems/activesupport-6.0.3.2/lib/active_support/security_utils.rb
503
+ :path: lib/active_support/security_utils.rb
470
504
  :lineno: 26
471
505
  :static: true
472
506
  :parameters:
@@ -564,7 +598,7 @@ describe 'AppMap class Hooking', docker: false do
564
598
  :children:
565
599
  - :name: secure_compare
566
600
  :type: function
567
- :location: gems/activesupport-6.0.3.2/lib/active_support/security_utils.rb:26
601
+ :location: lib/active_support/security_utils.rb:26
568
602
  :static: true
569
603
  :labels:
570
604
  - security
@@ -590,7 +624,7 @@ describe 'AppMap class Hooking', docker: false do
590
624
  config, tracer = invoke_test_file 'spec/fixtures/hook/compare.rb' do
591
625
  expect(Compare.compare('string', 'string')).to be_truthy
592
626
  end
593
- cm = AppMap::ClassMap.build_from_methods(config, tracer.event_methods)
627
+ cm = AppMap::Util.sanitize_paths(AppMap::ClassMap.build_from_methods(tracer.event_methods))
594
628
  entry = cm[1][:children][0][:children][0][:children][0]
595
629
  # Sanity check, make sure we got the right one
596
630
  expect(entry[:name]).to eq('secure_compare')
@@ -635,4 +669,11 @@ describe 'AppMap class Hooking', docker: false do
635
669
  end
636
670
  end
637
671
  end
672
+
673
+ it "preserves the arity of hooked methods" do
674
+ invoke_test_file 'spec/fixtures/hook/instance_method.rb' do
675
+ expect(InstanceMethod.instance_method(:say_echo).arity).to be(1)
676
+ expect(InstanceMethod.new.method(:say_echo).arity).to be(1)
677
+ end
678
+ end
638
679
  end