appmap 0.27.0 → 0.33.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -3
  3. data/CHANGELOG.md +37 -0
  4. data/README.md +170 -29
  5. data/Rakefile +1 -1
  6. data/appmap.gemspec +1 -0
  7. data/exe/appmap +3 -1
  8. data/lib/appmap.rb +54 -38
  9. data/lib/appmap/algorithm/stats.rb +2 -1
  10. data/lib/appmap/class_map.rb +21 -28
  11. data/lib/appmap/command/record.rb +2 -61
  12. data/lib/appmap/config.rb +89 -0
  13. data/lib/appmap/cucumber.rb +89 -0
  14. data/lib/appmap/event.rb +28 -19
  15. data/lib/appmap/hook.rb +56 -128
  16. data/lib/appmap/hook/method.rb +78 -0
  17. data/lib/appmap/metadata.rb +62 -0
  18. data/lib/appmap/middleware/remote_recording.rb +2 -6
  19. data/lib/appmap/minitest.rb +141 -0
  20. data/lib/appmap/open.rb +57 -0
  21. data/lib/appmap/rails/action_handler.rb +7 -7
  22. data/lib/appmap/rails/sql_handler.rb +10 -8
  23. data/lib/appmap/railtie.rb +2 -2
  24. data/lib/appmap/record.rb +27 -0
  25. data/lib/appmap/rspec.rb +9 -37
  26. data/lib/appmap/trace.rb +18 -10
  27. data/lib/appmap/util.rb +59 -0
  28. data/lib/appmap/version.rb +1 -1
  29. data/package-lock.json +3 -3
  30. data/spec/abstract_controller4_base_spec.rb +1 -1
  31. data/spec/abstract_controller_base_spec.rb +9 -2
  32. data/spec/config_spec.rb +3 -3
  33. data/spec/fixtures/hook/compare.rb +7 -0
  34. data/spec/fixtures/hook/singleton_method.rb +54 -0
  35. data/spec/fixtures/rails_users_app/Gemfile +1 -0
  36. data/spec/fixtures/rails_users_app/features/api_users.feature +13 -0
  37. data/spec/fixtures/rails_users_app/features/support/env.rb +4 -0
  38. data/spec/fixtures/rails_users_app/features/support/hooks.rb +11 -0
  39. data/spec/fixtures/rails_users_app/features/support/steps.rb +18 -0
  40. data/spec/hook_spec.rb +228 -53
  41. data/spec/open_spec.rb +19 -0
  42. data/spec/rails_spec_helper.rb +2 -0
  43. data/spec/record_sql_rails_pg_spec.rb +56 -33
  44. data/spec/rspec_feature_metadata_spec.rb +2 -0
  45. data/spec/spec_helper.rb +4 -0
  46. data/spec/util_spec.rb +21 -0
  47. data/test/cli_test.rb +4 -4
  48. data/test/cucumber_test.rb +72 -0
  49. data/test/fixtures/cucumber4_recorder/Gemfile +5 -0
  50. data/test/fixtures/cucumber4_recorder/appmap.yml +3 -0
  51. data/test/fixtures/cucumber4_recorder/features/say_hello.feature +5 -0
  52. data/test/fixtures/cucumber4_recorder/features/support/env.rb +5 -0
  53. data/test/fixtures/cucumber4_recorder/features/support/hooks.rb +11 -0
  54. data/test/fixtures/cucumber4_recorder/features/support/steps.rb +9 -0
  55. data/test/fixtures/cucumber4_recorder/lib/hello.rb +7 -0
  56. data/test/fixtures/cucumber_recorder/Gemfile +5 -0
  57. data/test/fixtures/cucumber_recorder/appmap.yml +3 -0
  58. data/test/fixtures/cucumber_recorder/features/say_hello.feature +5 -0
  59. data/test/fixtures/cucumber_recorder/features/support/env.rb +5 -0
  60. data/test/fixtures/cucumber_recorder/features/support/hooks.rb +11 -0
  61. data/test/fixtures/cucumber_recorder/features/support/steps.rb +9 -0
  62. data/test/fixtures/cucumber_recorder/lib/hello.rb +7 -0
  63. data/test/fixtures/minitest_recorder/Gemfile +5 -0
  64. data/test/fixtures/minitest_recorder/appmap.yml +3 -0
  65. data/test/fixtures/minitest_recorder/lib/hello.rb +5 -0
  66. data/test/fixtures/minitest_recorder/test/hello_test.rb +12 -0
  67. data/test/fixtures/process_recorder/appmap.yml +3 -0
  68. data/test/fixtures/process_recorder/hello.rb +9 -0
  69. data/test/minitest_test.rb +38 -0
  70. data/test/record_process_test.rb +35 -0
  71. data/test/test_helper.rb +1 -0
  72. metadata +55 -3
  73. data/spec/fixtures/hook/class_method.rb +0 -17
@@ -4,149 +4,77 @@ 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
7
+ LOG = (ENV['DEBUG'] == 'true')
8
+
9
+ class << self
10
+ # Return the class, separator ('.' or '#'), and method name for
11
+ # the given method.
12
+ def qualify_method_name(method)
13
+ 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']
26
+ [ class_name, '.', method.name ]
27
+ else
28
+ [ method.owner.name, '#', method.name ]
29
+ end
15
30
  end
16
31
  end
17
32
 
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
33
+ attr_reader :config
34
+ def initialize(config)
35
+ @config = config
36
+ end
25
37
 
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
33
- end
38
+ # Observe class loading and hook all methods which match the config.
39
+ def enable &block
40
+ require 'appmap/hook/method'
34
41
 
35
- def initialize(name, packages = [])
36
- super name, packages || []
37
- end
42
+ tp = TracePoint.new(:end) do |trace_point|
43
+ cls = trace_point.self
38
44
 
39
- def to_h
40
- {
41
- name: name,
42
- packages: packages.map(&:to_h)
43
- }
44
- end
45
- end
45
+ instance_methods = cls.public_instance_methods(false)
46
+ class_methods = cls.singleton_class.public_instance_methods(false) - instance_methods
46
47
 
47
- HOOK_DISABLE_KEY = 'AppMap::Hook.disable'
48
+ hook = lambda do |hook_cls|
49
+ lambda do |method_id|
50
+ next if method_id.to_s =~ /_hooked_by_appmap$/
48
51
 
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
52
+ method = hook_cls.public_instance_method(method_id)
53
+ hook_method = Hook::Method.new(hook_cls, method)
58
54
 
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
55
+ warn "AppMap: Examining #{hook_method.method_display_name}" if LOG
65
56
 
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
57
+ disasm = RubyVM::InstructionSequence.disasm(method)
58
+ # Skip methods that have no instruction sequence, as they are obviously trivial.
59
+ next unless disasm
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
82
- end
83
- end
61
+ # Don't try and trace the AppMap methods or there will be
62
+ # a stack overflow in the defined hook method.
63
+ next if /\AAppMap[:\.]/.match?(hook_method.method_display_name)
84
64
 
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
- warn "AppMap: Hooking #{defined_class}#{method_symbol}#{method.name}" if LOG
119
-
120
- cls.define_method method_id do |*args, &block|
121
- base_method = method.bind(self).to_proc
122
-
123
- hook_disabled = Thread.current[HOOK_DISABLE_KEY]
124
- enabled = true if !hook_disabled && AppMap.tracing.enabled?
125
- return base_method.call(*args, &block) unless enabled
126
-
127
- call_event, start_time = with_disabled_hook.call do
128
- before_hook.call(defined_class, method, self, args)
129
- end
130
- return_value = nil
131
- exception = nil
132
- begin
133
- return_value = base_method.call(*args, &block)
134
- rescue
135
- exception = $ERROR_INFO
136
- raise
137
- ensure
138
- with_disabled_hook.call do
139
- after_hook.call(call_event, defined_class, method, start_time, return_value, exception)
140
- end
141
- end
142
- end
143
- end
144
- end
65
+ next unless \
66
+ config.always_hook?(hook_method.defined_class, method.name) ||
67
+ config.included_by_location?(method)
145
68
 
146
- instance_methods.each(&hook_method.call(cls))
147
- class_methods.each(&hook_method.call(cls.singleton_class))
69
+ hook_method.activate
70
+ end
148
71
  end
72
+
73
+ instance_methods.each(&hook.(cls))
74
+ class_methods.each(&hook.(cls.singleton_class))
149
75
  end
76
+
77
+ tp.enable(&block)
150
78
  end
151
79
  end
152
80
  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
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AppMap
4
+ module Metadata
5
+ class << self
6
+ def detect
7
+ {
8
+ app: AppMap.configuration.name,
9
+ language: {
10
+ name: 'ruby',
11
+ engine: RUBY_ENGINE,
12
+ version: RUBY_VERSION
13
+ },
14
+ client: {
15
+ name: 'appmap',
16
+ url: AppMap::URL,
17
+ version: AppMap::VERSION
18
+ }
19
+ }.tap do |m|
20
+ if defined?(::Rails) && defined?(::Rails.version)
21
+ m[:frameworks] ||= []
22
+ m[:frameworks] << {
23
+ name: 'rails',
24
+ version: ::Rails.version
25
+ }
26
+ end
27
+ m[:git] = git_metadata if git_available
28
+ end
29
+ end
30
+
31
+ protected
32
+
33
+ def git_available
34
+ @git_available = system('git status 2>&1 > /dev/null') if @git_available.nil?
35
+ end
36
+
37
+ def git_metadata
38
+ git_repo = `git config --get remote.origin.url`.strip
39
+ git_branch = `git rev-parse --abbrev-ref HEAD`.strip
40
+ git_sha = `git rev-parse HEAD`.strip
41
+ git_status = `git status -s`.split("\n").map(&:strip)
42
+ git_last_annotated_tag = `git describe --abbrev=0 2>/dev/null`.strip
43
+ git_last_annotated_tag = nil if git_last_annotated_tag.blank?
44
+ git_last_tag = `git describe --abbrev=0 --tags 2>/dev/null`.strip
45
+ git_last_tag = nil if git_last_tag.blank?
46
+ git_commits_since_last_annotated_tag = `git describe`.strip =~ /-(\d+)-(\w+)$/[1] rescue 0 if git_last_annotated_tag
47
+ git_commits_since_last_tag = `git describe --tags`.strip =~ /-(\d+)-(\w+)$/[1] rescue 0 if git_last_tag
48
+
49
+ {
50
+ repository: git_repo,
51
+ branch: git_branch,
52
+ commit: git_sha,
53
+ status: git_status,
54
+ git_last_annotated_tag: git_last_annotated_tag,
55
+ git_last_tag: git_last_tag,
56
+ git_commits_since_last_annotated_tag: git_commits_since_last_annotated_tag,
57
+ git_commits_since_last_tag: git_commits_since_last_tag
58
+ }
59
+ end
60
+ end
61
+ end
62
+ end
@@ -5,12 +5,9 @@ module AppMap
5
5
  # RemoteRecording adds `/_appmap/record` routes to control recordings via HTTP requests
6
6
  class RemoteRecording
7
7
  def initialize(app)
8
- require 'appmap/command/record'
9
8
  require 'json'
10
9
 
11
10
  @app = app
12
- @config = AppMap.configure
13
- AppMap::Hook.hook(@config)
14
11
  end
15
12
 
16
13
  def event_loop
@@ -63,15 +60,14 @@ module AppMap
63
60
  @events.delete_if(&is_control_command_event)
64
61
  @events.delete_if(&is_return_from_control_command_event)
65
62
 
66
- require 'appmap/command/record'
67
- metadata = AppMap::Command::Record.detect_metadata
63
+ metadata = AppMap.detect_metadata
68
64
  metadata[:recorder] = {
69
65
  name: 'remote_recording'
70
66
  }
71
67
 
72
68
  response = JSON.generate \
73
69
  version: AppMap::APPMAP_FORMAT_VERSION,
74
- classMap: AppMap.class_map(@config, tracer.event_methods),
70
+ classMap: AppMap.class_map(tracer.event_methods),
75
71
  metadata: metadata,
76
72
  events: @events
77
73
 
@@ -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