appmap 0.33.0 → 0.34.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -6,23 +6,21 @@ module AppMap
6
6
  class Hook
7
7
  LOG = (ENV['DEBUG'] == 'true')
8
8
 
9
+ @unbound_method_arity = ::UnboundMethod.instance_method(:arity)
10
+ @method_arity = ::Method.instance_method(:arity)
11
+
9
12
  class << self
13
+ def lock_builtins
14
+ return if @builtins_hooked
15
+
16
+ @builtins_hooked = true
17
+ end
18
+
10
19
  # Return the class, separator ('.' or '#'), and method name for
11
20
  # the given method.
12
21
  def qualify_method_name(method)
13
22
  if method.owner.singleton_class?
14
- # Singleton class names can take two forms:
15
- # #<Class:Foo> or
16
- # #<Class:#<Bar:0x0123ABC>>. Retrieve the name of
17
- # the class from the string.
18
- #
19
- # (There really isn't a better way to do this. The
20
- # singleton's reference to the class it was created
21
- # from is stored in an instance variable named
22
- # '__attached__'. It doesn't have the '@' prefix, so
23
- # it's internal only, and not accessible from user
24
- # code.)
25
- class_name = /#<Class:((#<(?<cls>.*?):)|((?<cls>.*?)>))/.match(method.owner.to_s)['cls']
23
+ class_name = singleton_method_owner_name(method)
26
24
  [ class_name, '.', method.name ]
27
25
  else
28
26
  [ method.owner.name, '#', method.name ]
@@ -39,6 +37,8 @@ module AppMap
39
37
  def enable &block
40
38
  require 'appmap/hook/method'
41
39
 
40
+ hook_builtins
41
+
42
42
  tp = TracePoint.new(:end) do |trace_point|
43
43
  cls = trace_point.self
44
44
 
@@ -47,12 +47,10 @@ module AppMap
47
47
 
48
48
  hook = lambda do |hook_cls|
49
49
  lambda do |method_id|
50
- next if method_id.to_s =~ /_hooked_by_appmap$/
51
-
52
50
  method = hook_cls.public_instance_method(method_id)
53
51
  hook_method = Hook::Method.new(hook_cls, method)
54
52
 
55
- warn "AppMap: Examining #{hook_method.method_display_name}" if LOG
53
+ warn "AppMap: Examining #{hook_cls} #{method.name}" if LOG
56
54
 
57
55
  disasm = RubyVM::InstructionSequence.disasm(method)
58
56
  # Skip methods that have no instruction sequence, as they are obviously trivial.
@@ -63,7 +61,7 @@ module AppMap
63
61
  next if /\AAppMap[:\.]/.match?(hook_method.method_display_name)
64
62
 
65
63
  next unless \
66
- config.always_hook?(hook_method.defined_class, method.name) ||
64
+ config.always_hook?(hook_cls, method.name) ||
67
65
  config.included_by_location?(method)
68
66
 
69
67
  hook_method.activate
@@ -76,5 +74,35 @@ module AppMap
76
74
 
77
75
  tp.enable(&block)
78
76
  end
77
+
78
+ def hook_builtins
79
+ return unless self.class.lock_builtins
80
+
81
+ class_from_string = lambda do |fq_class|
82
+ fq_class.split('::').inject(Object) do |mod, class_name|
83
+ mod.const_get(class_name)
84
+ end
85
+ end
86
+
87
+ Config::BUILTIN_METHODS.each do |class_name, hook|
88
+ require hook.package.package_name if hook.package.package_name
89
+ Array(hook.method_names).each do |method_name|
90
+ method_name = method_name.to_sym
91
+ cls = class_from_string.(class_name)
92
+ method = \
93
+ begin
94
+ cls.instance_method(method_name)
95
+ rescue NameError
96
+ cls.method(method_name) rescue nil
97
+ end
98
+
99
+ if method
100
+ Hook::Method.new(cls, method).activate
101
+ else
102
+ warn "Method #{method_name} not found on #{cls.name}"
103
+ end
104
+ end
105
+ end
106
+ end
79
107
  end
80
108
  end
@@ -1,63 +1,91 @@
1
1
  module AppMap
2
2
  class Hook
3
3
  class Method
4
- attr_reader :hook_class, :hook_method, :defined_class, :method_display_name
4
+ attr_reader :hook_class, :hook_method
5
+
6
+ # +method_display_name+ may be nil if name resolution gets
7
+ # deferred until runtime (e.g. for a singleton method on an
8
+ # embedded Struct).
9
+ attr_reader :method_display_name
5
10
 
6
11
  HOOK_DISABLE_KEY = 'AppMap::Hook.disable'
7
12
  private_constant :HOOK_DISABLE_KEY
8
13
 
14
+ # Grab the definition of Time.now here, to avoid interfering
15
+ # with the method we're hooking.
16
+ TIME_NOW = Time.method(:now)
17
+ private_constant :TIME_NOW
18
+
9
19
  def initialize(hook_class, hook_method)
10
20
  @hook_class = hook_class
11
21
  @hook_method = hook_method
22
+
23
+ # Get the class for the method, if it's known.
12
24
  @defined_class, method_symbol = Hook.qualify_method_name(@hook_method)
13
- @method_display_name = [@defined_class, method_symbol, @hook_method.name].join
25
+ @method_display_name = [@defined_class, method_symbol, @hook_method.name].join if @defined_class
14
26
  end
15
27
 
16
28
  def activate
17
- warn "AppMap: Hooking #{method_display_name}" if Hook::LOG
29
+ if Hook::LOG
30
+ msg = if method_display_name
31
+ "#{method_display_name}"
32
+ else
33
+ "#{hook_method.name} (class resolution deferred)"
34
+ end
35
+ warn "AppMap: Hooking " + msg
36
+ end
18
37
 
38
+ defined_class = @defined_class
19
39
  hook_method = self.hook_method
20
40
  before_hook = self.method(:before_hook)
21
41
  after_hook = self.method(:after_hook)
22
42
  with_disabled_hook = self.method(:with_disabled_hook)
23
43
 
24
- hook_class.define_method hook_method.name do |*args, &block|
25
- instance_method = hook_method.bind(self).to_proc
44
+ hook_method_def = nil
45
+ hook_class.instance_eval do
46
+ hook_method_def = Proc.new do |*args, &block|
47
+ instance_method = hook_method.bind(self).to_proc
26
48
 
27
- hook_disabled = Thread.current[HOOK_DISABLE_KEY]
28
- enabled = true if !hook_disabled && AppMap.tracing.enabled?
29
- return instance_method.call(*args, &block) unless enabled
49
+ # We may not have gotten the class for the method during
50
+ # initialization (e.g. for a singleton method on an embedded
51
+ # struct), so make sure we have it now.
52
+ defined_class,_ = Hook.qualify_method_name(hook_method) unless defined_class
30
53
 
31
- call_event, start_time = with_disabled_hook.() do
32
- before_hook.(self, args)
33
- end
34
- return_value = nil
35
- exception = nil
36
- begin
37
- return_value = instance_method.(*args, &block)
38
- rescue
39
- exception = $ERROR_INFO
40
- raise
41
- ensure
42
- with_disabled_hook.() do
43
- after_hook.(call_event, start_time, return_value, exception)
54
+ hook_disabled = Thread.current[HOOK_DISABLE_KEY]
55
+ enabled = true if !hook_disabled && AppMap.tracing.enabled?
56
+ return instance_method.call(*args, &block) unless enabled
57
+
58
+ call_event, start_time = with_disabled_hook.() do
59
+ before_hook.(self, defined_class, args)
60
+ end
61
+ return_value = nil
62
+ exception = nil
63
+ begin
64
+ return_value = instance_method.(*args, &block)
65
+ rescue
66
+ exception = $ERROR_INFO
67
+ raise
68
+ ensure
69
+ with_disabled_hook.() do
70
+ after_hook.(call_event, start_time, return_value, exception)
71
+ end
44
72
  end
45
73
  end
46
74
  end
75
+ hook_class.define_method_with_arity(hook_method.name, hook_method.arity, hook_method_def)
47
76
  end
48
-
49
77
  protected
50
78
 
51
- def before_hook(receiver, args)
79
+ def before_hook(receiver, defined_class, args)
52
80
  require 'appmap/event'
53
81
  call_event = AppMap::Event::MethodCall.build_from_invocation(defined_class, hook_method, receiver, args)
54
82
  AppMap.tracing.record_event call_event, defined_class: defined_class, method: hook_method
55
- [ call_event, Time.now ]
83
+ [ call_event, TIME_NOW.call ]
56
84
  end
57
85
 
58
86
  def after_hook(call_event, start_time, return_value, exception)
59
87
  require 'appmap/event'
60
- elapsed = Time.now - start_time
88
+ elapsed = TIME_NOW.call - start_time
61
89
  return_event = \
62
90
  AppMap::Event::MethodReturn.build_from_invocation call_event.id, elapsed, return_value, exception
63
91
  AppMap.tracing.record_event return_event
@@ -73,20 +73,15 @@ module AppMap
73
73
 
74
74
  class ActiveRecordExaminer
75
75
  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
76
+ ActiveRecord::Base.connection.try(:database_version) ||\
77
+ warn("Unable to determine database version for #{database_type.inspect}")
84
78
  end
85
79
 
86
80
  def database_type
87
- return :postgres if ActiveRecord::Base.connection.respond_to?(:postgresql_version)
81
+ type = ActiveRecord::Base.connection.adapter_name.downcase.to_sym
82
+ type = :postgres if type == :postgresql
88
83
 
89
- ActiveRecord::Base.connection.adapter_name.downcase.to_sym
84
+ type
90
85
  end
91
86
 
92
87
  def execute_query(sql)
@@ -154,7 +154,7 @@ module AppMap
154
154
  end
155
155
 
156
156
  labels = labels.map(&:to_s).map(&:strip).reject(&:blank?).map(&:downcase).uniq
157
- description.reject!(&:nil?).reject(&:blank?)
157
+ description.reject!(&:nil?).reject!(&:blank?)
158
158
  default_description = description.last
159
159
  description.reverse!
160
160
 
@@ -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.33.0'
6
+ VERSION = '0.34.5'
7
7
 
8
8
  APPMAP_FORMAT_VERSION = '1.2'
9
9
  end
@@ -20,4 +20,8 @@ class InstanceMethod
20
20
  def say_block(&block)
21
21
  yield
22
22
  end
23
+
24
+ def say_the_time
25
+ Time.now.to_s
26
+ end
23
27
  end
@@ -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,12 +22,12 @@ 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)
29
29
  AppMap.configuration = nil
30
- package = AppMap::Package.new(file, nil, [])
30
+ package = AppMap::Config::Package.new(file)
31
31
  config = AppMap::Config.new('hook_spec', [ package ])
32
32
  AppMap.configuration = config
33
33
  tracer = nil
@@ -50,8 +50,9 @@ 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)
54
- expect(Diffy::Diff.new(events, events_yaml).to_s).to eq('')
53
+ events = collect_events(tracer).to_yaml
54
+
55
+ expect(Diffy::Diff.new(events_yaml, events).to_s).to eq('')
55
56
 
56
57
  [ config, tracer ]
57
58
  end
@@ -99,7 +100,7 @@ describe 'AppMap class Hooking', docker: false do
99
100
  InstanceMethod.new.say_default
100
101
  end
101
102
  class_map = AppMap.class_map(tracer.event_methods).to_yaml
102
- expect(Diffy::Diff.new(class_map, <<~YAML).to_s).to eq('')
103
+ expect(Diffy::Diff.new(<<~YAML, class_map).to_s).to eq('')
103
104
  ---
104
105
  - :name: spec/fixtures/hook/instance_method.rb
105
106
  :type: package
@@ -341,7 +342,7 @@ describe 'AppMap class Hooking', docker: false do
341
342
  :defined_class: SingletonMethod
342
343
  :method_id: added_method
343
344
  :path: spec/fixtures/hook/singleton_method.rb
344
- :lineno: 44
345
+ :lineno: 21
345
346
  :static: false
346
347
  :parameters: []
347
348
  :receiver:
@@ -349,10 +350,10 @@ describe 'AppMap class Hooking', docker: false do
349
350
  :value: Singleton Method fixture
350
351
  - :id: 2
351
352
  :event: :call
352
- :defined_class: AddMethod
353
+ :defined_class: SingletonMethod::AddMethod
353
354
  :method_id: _added_method
354
355
  :path: spec/fixtures/hook/singleton_method.rb
355
- :lineno: 50
356
+ :lineno: 27
356
357
  :static: false
357
358
  :parameters: []
358
359
  :receiver:
@@ -394,10 +395,44 @@ describe 'AppMap class Hooking', docker: false do
394
395
  load 'spec/fixtures/hook/singleton_method.rb'
395
396
  setup = -> { SingletonMethod.new_with_instance_method }
396
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
+
397
405
  expect(s.say_instance_defined).to eq('defined for an instance')
398
406
  end
399
407
  end
400
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
+
401
436
  it 'Reports exceptions' do
402
437
  events_yaml = <<~YAML
403
438
  ---
@@ -465,7 +500,7 @@ describe 'AppMap class Hooking', docker: false do
465
500
  :event: :call
466
501
  :defined_class: ActiveSupport::SecurityUtils
467
502
  :method_id: secure_compare
468
- :path: gems/activesupport-6.0.3.2/lib/active_support/security_utils.rb
503
+ :path: lib/active_support/security_utils.rb
469
504
  :lineno: 26
470
505
  :static: true
471
506
  :parameters:
@@ -481,12 +516,52 @@ describe 'AppMap class Hooking', docker: false do
481
516
  :class: Module
482
517
  :value: ActiveSupport::SecurityUtils
483
518
  - :id: 3
519
+ :event: :call
520
+ :defined_class: Digest::Instance
521
+ :method_id: digest
522
+ :path: Digest::Instance#digest
523
+ :static: false
524
+ :parameters:
525
+ - :name: arg
526
+ :class: String
527
+ :value: string
528
+ :kind: :rest
529
+ :receiver:
530
+ :class: Digest::SHA256
531
+ :value: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
532
+ - :id: 4
533
+ :event: :return
534
+ :parent_id: 3
535
+ :return_value:
536
+ :class: String
537
+ :value: "G2__)__qc____X____3_].\\x02y__.___/_"
538
+ - :id: 5
539
+ :event: :call
540
+ :defined_class: Digest::Instance
541
+ :method_id: digest
542
+ :path: Digest::Instance#digest
543
+ :static: false
544
+ :parameters:
545
+ - :name: arg
546
+ :class: String
547
+ :value: string
548
+ :kind: :rest
549
+ :receiver:
550
+ :class: Digest::SHA256
551
+ :value: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
552
+ - :id: 6
553
+ :event: :return
554
+ :parent_id: 5
555
+ :return_value:
556
+ :class: String
557
+ :value: "G2__)__qc____X____3_].\\x02y__.___/_"
558
+ - :id: 7
484
559
  :event: :return
485
560
  :parent_id: 2
486
561
  :return_value:
487
562
  :class: TrueClass
488
563
  :value: 'true'
489
- - :id: 4
564
+ - :id: 8
490
565
  :event: :return
491
566
  :parent_id: 1
492
567
  :return_value:
@@ -523,22 +598,82 @@ describe 'AppMap class Hooking', docker: false do
523
598
  :children:
524
599
  - :name: secure_compare
525
600
  :type: function
526
- :location: gems/activesupport-6.0.3.2/lib/active_support/security_utils.rb:26
601
+ :location: lib/active_support/security_utils.rb:26
527
602
  :static: true
528
603
  :labels:
529
604
  - security
605
+ - crypto
606
+ - :name: openssl
607
+ :type: package
608
+ :children:
609
+ - :name: Digest
610
+ :type: class
611
+ :children:
612
+ - :name: Instance
613
+ :type: class
614
+ :children:
615
+ - :name: digest
616
+ :type: function
617
+ :location: Digest::Instance#digest
618
+ :static: false
619
+ :labels:
620
+ - security
621
+ - crypto
530
622
  YAML
531
623
 
532
624
  config, tracer = invoke_test_file 'spec/fixtures/hook/compare.rb' do
533
625
  expect(Compare.compare('string', 'string')).to be_truthy
534
626
  end
535
- cm = AppMap::ClassMap.build_from_methods(config, tracer.event_methods)
627
+ cm = AppMap::Util.sanitize_paths(AppMap::ClassMap.build_from_methods(config, tracer.event_methods))
536
628
  entry = cm[1][:children][0][:children][0][:children][0]
537
629
  # Sanity check, make sure we got the right one
538
630
  expect(entry[:name]).to eq('secure_compare')
539
631
  spec = Gem::Specification.find_by_name('activesupport')
540
632
  entry[:location].gsub!(spec.base_dir + '/', '')
541
- expect(Diffy::Diff.new(cm.to_yaml, classmap_yaml).to_s).to eq('')
633
+ expect(Diffy::Diff.new(classmap_yaml, cm.to_yaml).to_s).to eq('')
634
+ end
635
+ end
636
+
637
+ it "doesn't cause expectations on Time.now to fail" do
638
+ events_yaml = <<~YAML
639
+ ---
640
+ - :id: 1
641
+ :event: :call
642
+ :defined_class: InstanceMethod
643
+ :method_id: say_the_time
644
+ :path: spec/fixtures/hook/instance_method.rb
645
+ :lineno: 24
646
+ :static: false
647
+ :parameters: []
648
+ :receiver:
649
+ :class: InstanceMethod
650
+ :value: Instance Method fixture
651
+ - :id: 2
652
+ :event: :return
653
+ :parent_id: 1
654
+ :return_value:
655
+ :class: String
656
+ :value: '2020-01-01 00:00:00 +0000'
657
+ YAML
658
+ test_hook_behavior 'spec/fixtures/hook/instance_method.rb', events_yaml do
659
+ require 'timecop'
660
+ begin
661
+ tz = ENV['TZ']
662
+ ENV['TZ'] = 'UTC'
663
+ Timecop.freeze(Time.utc('2020-01-01')) do
664
+ expect(Time).to receive(:now).exactly(3).times.and_call_original
665
+ expect(InstanceMethod.new.say_the_time).to be
666
+ end
667
+ ensure
668
+ ENV['TZ'] = tz
669
+ end
670
+ end
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)
542
677
  end
543
678
  end
544
679
  end