appmap 0.28.0 → 0.34.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +54 -2
  4. data/Rakefile +1 -1
  5. data/appmap.gemspec +1 -0
  6. data/lib/appmap.rb +25 -14
  7. data/lib/appmap/algorithm/stats.rb +2 -1
  8. data/lib/appmap/class_map.rb +26 -28
  9. data/lib/appmap/config.rb +115 -0
  10. data/lib/appmap/event.rb +28 -19
  11. data/lib/appmap/hook.rb +88 -129
  12. data/lib/appmap/hook/method.rb +78 -0
  13. data/lib/appmap/metadata.rb +1 -1
  14. data/lib/appmap/minitest.rb +141 -0
  15. data/lib/appmap/open.rb +57 -0
  16. data/lib/appmap/rails/action_handler.rb +7 -7
  17. data/lib/appmap/rails/sql_handler.rb +10 -8
  18. data/lib/appmap/record.rb +27 -0
  19. data/lib/appmap/rspec.rb +2 -2
  20. data/lib/appmap/trace.rb +17 -9
  21. data/lib/appmap/util.rb +19 -0
  22. data/lib/appmap/version.rb +1 -1
  23. data/package-lock.json +3 -3
  24. data/spec/abstract_controller4_base_spec.rb +1 -1
  25. data/spec/abstract_controller_base_spec.rb +9 -2
  26. data/spec/config_spec.rb +3 -3
  27. data/spec/fixtures/hook/compare.rb +7 -0
  28. data/spec/fixtures/hook/singleton_method.rb +54 -0
  29. data/spec/hook_spec.rb +280 -53
  30. data/spec/open_spec.rb +19 -0
  31. data/spec/record_sql_rails_pg_spec.rb +56 -33
  32. data/spec/util_spec.rb +1 -1
  33. data/test/cli_test.rb +14 -4
  34. data/test/fixtures/minitest_recorder/Gemfile +5 -0
  35. data/test/fixtures/minitest_recorder/appmap.yml +3 -0
  36. data/test/fixtures/minitest_recorder/lib/hello.rb +5 -0
  37. data/test/fixtures/minitest_recorder/test/hello_test.rb +12 -0
  38. data/test/fixtures/openssl_recorder/Gemfile +3 -0
  39. data/test/fixtures/openssl_recorder/appmap.yml +3 -0
  40. data/test/fixtures/openssl_recorder/lib/openssl_cert_sign.rb +94 -0
  41. data/test/fixtures/openssl_recorder/lib/openssl_encrypt.rb +34 -0
  42. data/test/fixtures/openssl_recorder/lib/openssl_key_sign.rb +28 -0
  43. data/test/fixtures/process_recorder/appmap.yml +3 -0
  44. data/test/fixtures/process_recorder/hello.rb +9 -0
  45. data/test/minitest_test.rb +38 -0
  46. data/test/openssl_test.rb +203 -0
  47. data/test/record_process_test.rb +35 -0
  48. data/test/test_helper.rb +1 -0
  49. metadata +38 -4
  50. data/spec/fixtures/hook/class_method.rb +0 -17
@@ -15,24 +15,15 @@ module AppMap
15
15
  end
16
16
  end
17
17
 
18
- MethodEventStruct = Struct.new(:id, :event, :defined_class, :method_id, :path, :lineno, :static, :thread_id)
18
+ MethodEventStruct = Struct.new(:id, :event, :thread_id)
19
19
 
20
20
  class MethodEvent < MethodEventStruct
21
21
  LIMIT = 100
22
22
 
23
23
  class << self
24
- def build_from_invocation(me, event_type, defined_class, method)
25
- singleton = method.owner.singleton_class?
26
-
24
+ def build_from_invocation(me, event_type)
27
25
  me.id = AppMap::Event.next_id_counter
28
26
  me.event = event_type
29
- me.defined_class = defined_class
30
- me.method_id = method.name.to_s
31
- path = method.source_location[0]
32
- path = path[Dir.pwd.length + 1..-1] if path.index(Dir.pwd) == 0
33
- me.path = path
34
- me.lineno = method.source_location[1]
35
- me.static = singleton
36
27
  me.thread_id = Thread.current.object_id
37
28
  end
38
29
 
@@ -61,16 +52,25 @@ module AppMap
61
52
  (value_string||'')[0...LIMIT].encode('utf-8', invalid: :replace, undef: :replace, replace: '_')
62
53
  end
63
54
  end
64
-
65
- alias static? static
66
55
  end
67
56
 
68
57
  class MethodCall < MethodEvent
69
- attr_accessor :parameters, :receiver
58
+ attr_accessor :defined_class, :method_id, :path, :lineno, :parameters, :receiver, :static
70
59
 
71
60
  class << self
72
61
  def build_from_invocation(mc = MethodCall.new, defined_class, method, receiver, arguments)
73
62
  mc.tap do
63
+ static = receiver.is_a?(Module)
64
+ mc.defined_class = defined_class
65
+ mc.method_id = method.name.to_s
66
+ if method.source_location
67
+ path = method.source_location[0]
68
+ path = path[Dir.pwd.length + 1..-1] if path.index(Dir.pwd) == 0
69
+ mc.path = path
70
+ mc.lineno = method.source_location[1]
71
+ else
72
+ mc.path = [ defined_class, static ? '.' : '#', method.name ].join
73
+ end
74
74
  mc.parameters = method.parameters.map.with_index do |method_param, idx|
75
75
  param_type, param_name = method_param
76
76
  param_name ||= 'arg'
@@ -88,28 +88,37 @@ module AppMap
88
88
  object_id: receiver.__id__,
89
89
  value: display_string(receiver)
90
90
  }
91
- MethodEvent.build_from_invocation(mc, :call, defined_class, method)
91
+ mc.static = static
92
+ MethodEvent.build_from_invocation(mc, :call)
92
93
  end
93
94
  end
94
95
  end
95
96
 
96
97
  def to_h
97
98
  super.tap do |h|
99
+ h[:defined_class] = defined_class
100
+ h[:method_id] = method_id
101
+ h[:path] = path
102
+ h[:lineno] = lineno
103
+ h[:static] = static
98
104
  h[:parameters] = parameters
99
105
  h[:receiver] = receiver
106
+ h.delete_if { |_, v| v.nil? }
100
107
  end
101
108
  end
109
+
110
+ alias static? static
102
111
  end
103
112
 
104
113
  class MethodReturnIgnoreValue < MethodEvent
105
114
  attr_accessor :parent_id, :elapsed
106
115
 
107
116
  class << self
108
- def build_from_invocation(mr = MethodReturnIgnoreValue.new, defined_class, method, parent_id, elapsed)
117
+ def build_from_invocation(mr = MethodReturnIgnoreValue.new, parent_id, elapsed)
109
118
  mr.tap do |_|
110
119
  mr.parent_id = parent_id
111
120
  mr.elapsed = elapsed
112
- MethodEvent.build_from_invocation(mr, :return, defined_class, method)
121
+ MethodEvent.build_from_invocation(mr, :return)
113
122
  end
114
123
  end
115
124
  end
@@ -126,7 +135,7 @@ module AppMap
126
135
  attr_accessor :return_value, :exceptions
127
136
 
128
137
  class << self
129
- def build_from_invocation(mr = MethodReturn.new, defined_class, method, parent_id, elapsed, return_value, exception)
138
+ def build_from_invocation(mr = MethodReturn.new, parent_id, elapsed, return_value, exception)
130
139
  mr.tap do |_|
131
140
  if return_value
132
141
  mr.return_value = {
@@ -152,7 +161,7 @@ module AppMap
152
161
 
153
162
  mr.exceptions = exceptions
154
163
  end
155
- MethodReturnIgnoreValue.build_from_invocation(mr, defined_class, method, parent_id, elapsed)
164
+ MethodReturnIgnoreValue.build_from_invocation(mr, parent_id, elapsed)
156
165
  end
157
166
  end
158
167
  end
@@ -4,152 +4,111 @@ require 'English'
4
4
 
5
5
  module AppMap
6
6
  class Hook
7
- LOG = false
8
-
9
- Package = Struct.new(:path, :exclude) do
10
- def to_h
11
- {
12
- path: path,
13
- exclude: exclude.blank? ? nil : exclude
14
- }.compact
15
- end
16
- end
7
+ LOG = (ENV['DEBUG'] == 'true')
17
8
 
18
- Config = Struct.new(:name, :packages) do
19
- class << self
20
- # Loads configuration data from a file, specified by the file name.
21
- def load_from_file(config_file_name)
22
- require 'yaml'
23
- load YAML.safe_load(::File.read(config_file_name))
24
- end
9
+ class << self
10
+ def lock_builtins
11
+ return if @builtins_hooked
25
12
 
26
- # Loads configuration from a Hash.
27
- def load(config_data)
28
- packages = (config_data['packages'] || []).map do |package|
29
- Package.new(package['path'], package['exclude'] || [])
30
- end
31
- Config.new config_data['name'], packages
32
- end
13
+ @builtins_hooked = true
33
14
  end
34
15
 
35
- def initialize(name, packages = [])
36
- super name, packages || []
16
+ # Return the class, separator ('.' or '#'), and method name for
17
+ # the given method.
18
+ def qualify_method_name(method)
19
+ if method.owner.singleton_class?
20
+ # Singleton class names can take two forms:
21
+ # #<Class:Foo> or
22
+ # #<Class:#<Bar:0x0123ABC>>. Retrieve the name of
23
+ # the class from the string.
24
+ #
25
+ # (There really isn't a better way to do this. The
26
+ # singleton's reference to the class it was created
27
+ # from is stored in an instance variable named
28
+ # '__attached__'. It doesn't have the '@' prefix, so
29
+ # it's internal only, and not accessible from user
30
+ # code.)
31
+ class_name = /#<Class:((#<(?<cls>.*?):)|((?<cls>.*?)>))/.match(method.owner.to_s)['cls']
32
+ [ class_name, '.', method.name ]
33
+ else
34
+ [ method.owner.name, '#', method.name ]
35
+ end
37
36
  end
37
+ end
38
38
 
39
- def to_h
40
- {
41
- name: name,
42
- packages: packages.map(&:to_h)
43
- }
44
- end
39
+ attr_reader :config
40
+ def initialize(config)
41
+ @config = config
45
42
  end
46
43
 
47
- HOOK_DISABLE_KEY = 'AppMap::Hook.disable'
44
+ # Observe class loading and hook all methods which match the config.
45
+ def enable &block
46
+ require 'appmap/hook/method'
48
47
 
49
- class << self
50
- # Observe class loading and hook all methods which match the config.
51
- def hook(config = AppMap.configure)
52
- package_include_paths = config.packages.map(&:path)
53
- package_exclude_paths = config.packages.map do |pkg|
54
- pkg.exclude.map do |exclude|
55
- File.join(pkg.path, exclude)
56
- end
57
- end.flatten
48
+ hook_builtins
58
49
 
59
- before_hook = lambda do |defined_class, method, receiver, args|
60
- require 'appmap/event'
61
- call_event = AppMap::Event::MethodCall.build_from_invocation(defined_class, method, receiver, args)
62
- AppMap.tracing.record_event call_event, defined_class: defined_class, method: method
63
- [ call_event, Time.now ]
64
- end
50
+ tp = TracePoint.new(:end) do |trace_point|
51
+ cls = trace_point.self
65
52
 
66
- after_hook = lambda do |call_event, defined_class, method, start_time, return_value, exception|
67
- require 'appmap/event'
68
- elapsed = Time.now - start_time
69
- return_event = AppMap::Event::MethodReturn.build_from_invocation \
70
- defined_class, method, call_event.id, elapsed, return_value, exception
71
- AppMap.tracing.record_event return_event
72
- end
53
+ instance_methods = cls.public_instance_methods(false)
54
+ class_methods = cls.singleton_class.public_instance_methods(false) - instance_methods
55
+
56
+ hook = lambda do |hook_cls|
57
+ lambda do |method_id|
58
+ method = hook_cls.public_instance_method(method_id)
59
+ hook_method = Hook::Method.new(hook_cls, method)
73
60
 
74
- with_disabled_hook = lambda do |&fn|
75
- # Don't record functions, such as to_s and inspect, that might be called
76
- # by the fn. Otherwise there can be a stack oveflow.
77
- Thread.current[HOOK_DISABLE_KEY] = true
78
- begin
79
- fn.call
80
- ensure
81
- Thread.current[HOOK_DISABLE_KEY] = false
61
+ warn "AppMap: Examining #{hook_method.method_display_name}" if LOG
62
+
63
+ disasm = RubyVM::InstructionSequence.disasm(method)
64
+ # Skip methods that have no instruction sequence, as they are obviously trivial.
65
+ next unless disasm
66
+
67
+ # Don't try and trace the AppMap methods or there will be
68
+ # a stack overflow in the defined hook method.
69
+ next if /\AAppMap[:\.]/.match?(hook_method.method_display_name)
70
+
71
+ next unless \
72
+ config.always_hook?(hook_method.defined_class, method.name) ||
73
+ config.included_by_location?(method)
74
+
75
+ hook_method.activate
82
76
  end
83
77
  end
84
78
 
85
- TracePoint.trace(:end) do |tp|
86
- cls = tp.self
87
-
88
- instance_methods = cls.public_instance_methods(false)
89
- class_methods = cls.singleton_class.public_instance_methods(false) - instance_methods
90
-
91
- hook_method = lambda do |cls|
92
- lambda do |method_id|
93
- next if method_id.to_s =~ /_hooked_by_appmap$/
94
-
95
- method = cls.public_instance_method(method_id)
96
- location = method.source_location
97
- location_file, = location
98
- next unless location_file
99
-
100
- location_file = location_file[Dir.pwd.length + 1..-1] if location_file.index(Dir.pwd) == 0
101
- match = package_include_paths.find { |p| location_file.index(p) == 0 }
102
- match &&= !package_exclude_paths.find { |p| location_file.index(p) }
103
- next unless match
104
-
105
- disasm = RubyVM::InstructionSequence.disasm(method)
106
- # Skip methods that have no instruction sequence, as they are obviously trivial.
107
- next unless disasm
108
-
109
- defined_class, method_symbol = \
110
- if method.owner.singleton_class?
111
- # Singleton class name is like: #<Class:<(.*)>>
112
- class_name = method.owner.to_s['#<Class:<'.length-1..-2]
113
- [ class_name, '.' ]
114
- else
115
- [ method.owner.name, '#' ]
116
- end
117
-
118
- method_display_name = "#{defined_class}#{method_symbol}#{method.name}"
119
- # Don't try and trace the tracing method or there will be a stack overflow
120
- # in the defined hook method.
121
- next if method_display_name == "AppMap.tracing"
122
-
123
- warn "AppMap: Hooking #{method_display_name}" if LOG
124
-
125
- cls.define_method method_id do |*args, &block|
126
- base_method = method.bind(self).to_proc
127
-
128
- hook_disabled = Thread.current[HOOK_DISABLE_KEY]
129
- enabled = true if !hook_disabled && AppMap.tracing.enabled?
130
- return base_method.call(*args, &block) unless enabled
131
-
132
- call_event, start_time = with_disabled_hook.call do
133
- before_hook.call(defined_class, method, self, args)
134
- end
135
- return_value = nil
136
- exception = nil
137
- begin
138
- return_value = base_method.call(*args, &block)
139
- rescue
140
- exception = $ERROR_INFO
141
- raise
142
- ensure
143
- with_disabled_hook.call do
144
- after_hook.call(call_event, defined_class, method, start_time, return_value, exception)
145
- end
146
- end
147
- end
79
+ instance_methods.each(&hook.(cls))
80
+ class_methods.each(&hook.(cls.singleton_class))
81
+ end
82
+
83
+ tp.enable(&block)
84
+ end
85
+
86
+ def hook_builtins
87
+ return unless self.class.lock_builtins
88
+
89
+ class_from_string = lambda do |fq_class|
90
+ fq_class.split('::').inject(Object) do |mod, class_name|
91
+ mod.const_get(class_name)
92
+ end
93
+ end
94
+
95
+ Config::BUILTIN_METHODS.each do |class_name, hook|
96
+ require hook.package.package_name if hook.package.package_name
97
+ Array(hook.method_names).each do |method_name|
98
+ method_name = method_name.to_sym
99
+ cls = class_from_string.(class_name)
100
+ method = \
101
+ begin
102
+ cls.instance_method(method_name)
103
+ rescue NameError
104
+ cls.method(method_name) rescue nil
148
105
  end
149
- end
150
106
 
151
- instance_methods.each(&hook_method.call(cls))
152
- class_methods.each(&hook_method.call(cls.singleton_class))
107
+ if method
108
+ Hook::Method.new(cls, method).activate
109
+ else
110
+ warn "Method #{method_name} not found on #{cls.name}"
111
+ end
153
112
  end
154
113
  end
155
114
  end
@@ -0,0 +1,78 @@
1
+ module AppMap
2
+ class Hook
3
+ class Method
4
+ attr_reader :hook_class, :hook_method, :defined_class, :method_display_name
5
+
6
+ HOOK_DISABLE_KEY = 'AppMap::Hook.disable'
7
+ private_constant :HOOK_DISABLE_KEY
8
+
9
+ def initialize(hook_class, hook_method)
10
+ @hook_class = hook_class
11
+ @hook_method = hook_method
12
+ @defined_class, method_symbol = Hook.qualify_method_name(@hook_method)
13
+ @method_display_name = [@defined_class, method_symbol, @hook_method.name].join
14
+ end
15
+
16
+ def activate
17
+ warn "AppMap: Hooking #{method_display_name}" if Hook::LOG
18
+
19
+ hook_method = self.hook_method
20
+ before_hook = self.method(:before_hook)
21
+ after_hook = self.method(:after_hook)
22
+ with_disabled_hook = self.method(:with_disabled_hook)
23
+
24
+ hook_class.define_method hook_method.name do |*args, &block|
25
+ instance_method = hook_method.bind(self).to_proc
26
+
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
30
+
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)
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ protected
50
+
51
+ def before_hook(receiver, args)
52
+ require 'appmap/event'
53
+ call_event = AppMap::Event::MethodCall.build_from_invocation(defined_class, hook_method, receiver, args)
54
+ AppMap.tracing.record_event call_event, defined_class: defined_class, method: hook_method
55
+ [ call_event, Time.now ]
56
+ end
57
+
58
+ def after_hook(call_event, start_time, return_value, exception)
59
+ require 'appmap/event'
60
+ elapsed = Time.now - start_time
61
+ return_event = \
62
+ AppMap::Event::MethodReturn.build_from_invocation call_event.id, elapsed, return_value, exception
63
+ AppMap.tracing.record_event return_event
64
+ end
65
+
66
+ def with_disabled_hook(&fn)
67
+ # Don't record functions, such as to_s and inspect, that might be called
68
+ # by the fn. Otherwise there can be a stack overflow.
69
+ Thread.current[HOOK_DISABLE_KEY] = true
70
+ begin
71
+ fn.call
72
+ ensure
73
+ Thread.current[HOOK_DISABLE_KEY] = false
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -17,7 +17,7 @@ module AppMap
17
17
  version: AppMap::VERSION
18
18
  }
19
19
  }.tap do |m|
20
- if defined?(::Rails)
20
+ if defined?(::Rails) && defined?(::Rails.version)
21
21
  m[:frameworks] ||= []
22
22
  m[:frameworks] << {
23
23
  name: 'rails',
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'appmap/util'
4
+
5
+ module AppMap
6
+ # Integration of AppMap with Minitest. When enabled with APPMAP=true, the AppMap tracer will
7
+ # be activated around each test.
8
+ module Minitest
9
+ APPMAP_OUTPUT_DIR = 'tmp/appmap/minitest'
10
+ LOG = false
11
+
12
+ def self.metadata
13
+ AppMap.detect_metadata
14
+ end
15
+
16
+ Recording = Struct.new(:test) do
17
+ def initialize(test)
18
+ super
19
+
20
+ warn "Starting recording of test #{test.class}.#{test.name}" if AppMap::Minitest::LOG
21
+ @trace = AppMap.tracing.trace
22
+ end
23
+
24
+ def finish
25
+ warn "Finishing recording of test #{test.class}.#{test.name}" if AppMap::Minitest::LOG
26
+
27
+ events = []
28
+ AppMap.tracing.delete @trace
29
+
30
+ events << @trace.next_event.to_h while @trace.event?
31
+
32
+ AppMap::Minitest.add_event_methods @trace.event_methods
33
+
34
+ class_map = AppMap.class_map(@trace.event_methods)
35
+
36
+ feature_group = test.class.name.underscore.split('_')[0...-1].join('_').capitalize
37
+ feature_name = test.name.split('_')[1..-1].join(' ')
38
+ scenario_name = [ feature_group, feature_name ].join(' ')
39
+
40
+ AppMap::Minitest.save scenario_name,
41
+ class_map,
42
+ events: events,
43
+ feature_name: feature_name,
44
+ feature_group_name: feature_group
45
+ end
46
+ end
47
+
48
+ @recordings_by_test = {}
49
+ @event_methods = Set.new
50
+
51
+ class << self
52
+ def init
53
+ warn 'Configuring AppMap recorder for Minitest'
54
+
55
+ FileUtils.mkdir_p APPMAP_OUTPUT_DIR
56
+ end
57
+
58
+ def begin_test(test)
59
+ @recordings_by_test[test.object_id] = Recording.new(test)
60
+ end
61
+
62
+ def end_test(test)
63
+ recording = @recordings_by_test.delete(test.object_id)
64
+ return warn "No recording found for #{test}" unless recording
65
+
66
+ recording.finish
67
+ end
68
+
69
+ def config
70
+ @config or raise "AppMap is not configured"
71
+ end
72
+
73
+ def add_event_methods(event_methods)
74
+ @event_methods += event_methods
75
+ end
76
+
77
+ def save(example_name, class_map, events: nil, feature_name: nil, feature_group_name: nil, labels: nil)
78
+ metadata = AppMap::Minitest.metadata.tap do |m|
79
+ m[:name] = example_name
80
+ m[:app] = AppMap.configuration.name
81
+ m[:feature] = feature_name if feature_name
82
+ m[:feature_group] = feature_group_name if feature_group_name
83
+ m[:frameworks] ||= []
84
+ m[:frameworks] << {
85
+ name: 'minitest',
86
+ version: Gem.loaded_specs['minitest']&.version&.to_s
87
+ }
88
+ m[:recorder] = {
89
+ name: 'minitest'
90
+ }
91
+ end
92
+
93
+ appmap = {
94
+ version: AppMap::APPMAP_FORMAT_VERSION,
95
+ metadata: metadata,
96
+ classMap: class_map,
97
+ events: events
98
+ }.compact
99
+ fname = AppMap::Util.scenario_filename(example_name)
100
+
101
+ File.write(File.join(APPMAP_OUTPUT_DIR, fname), JSON.generate(appmap))
102
+ end
103
+
104
+ def print_inventory
105
+ class_map = AppMap.class_map(@event_methods)
106
+ save 'Inventory', class_map, labels: %w[inventory]
107
+ end
108
+
109
+ def enabled?
110
+ ENV['APPMAP'] == 'true'
111
+ end
112
+
113
+ def run
114
+ init
115
+ at_exit do
116
+ print_inventory
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ if AppMap::Minitest.enabled?
124
+ require 'appmap'
125
+ require 'minitest/test'
126
+
127
+ class ::Minitest::Test
128
+ alias run_without_hook run
129
+
130
+ def run
131
+ AppMap::Minitest.begin_test self
132
+ begin
133
+ run_without_hook
134
+ ensure
135
+ AppMap::Minitest.end_test self
136
+ end
137
+ end
138
+ end
139
+
140
+ AppMap::Minitest.run
141
+ end