appmap 0.33.0 → 0.34.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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