appmap 0.48.2 → 0.51.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +3 -332
- data/appmap.gemspec +3 -0
- data/exe/appmap-agent-setup +47 -0
- data/lib/appmap.rb +74 -7
- data/lib/appmap/command/init.rb +42 -0
- data/lib/appmap/config.rb +104 -33
- data/lib/appmap/handler/rails/template.rb +19 -5
- data/lib/appmap/minitest.rb +8 -2
- data/lib/appmap/railtie.rb +7 -0
- data/lib/appmap/rspec.rb +8 -2
- data/lib/appmap/service/guesser.rb +26 -0
- data/lib/appmap/trace.rb +4 -2
- data/lib/appmap/util.rb +21 -0
- data/lib/appmap/version.rb +4 -1
- data/spec/abstract_controller_base_spec.rb +57 -18
- data/spec/config_spec.rb +21 -0
- data/spec/fixtures/rails5_users_app/config/application.rb +0 -8
- data/spec/fixtures/rails5_users_app/spec/rails_helper.rb +0 -2
- data/spec/fixtures/rails6_users_app/config/application.rb +0 -8
- data/spec/fixtures/rails6_users_app/spec/rails_helper.rb +0 -2
- data/spec/hook_spec.rb +2 -2
- data/spec/record_net_http_spec.rb +1 -1
- data/test/cli_test.rb +37 -0
- metadata +9 -6
- data/spec/fixtures/rails5_users_app/config/initializers/record_button.rb +0 -3
- data/spec/fixtures/rails6_users_app/config/initializers/record_button.rb +0 -3
data/lib/appmap.rb
CHANGED
@@ -27,7 +27,7 @@ module AppMap
|
|
27
27
|
# Gets the configuration. If there is no configuration, the default
|
28
28
|
# configuration is initialized.
|
29
29
|
def configuration
|
30
|
-
@configuration ||=
|
30
|
+
@configuration ||= initialize_configuration
|
31
31
|
end
|
32
32
|
|
33
33
|
# Sets the configuration. This is only expected to happen once per
|
@@ -38,19 +38,34 @@ module AppMap
|
|
38
38
|
@configuration = config
|
39
39
|
end
|
40
40
|
|
41
|
-
|
41
|
+
def default_config_file_path
|
42
|
+
ENV['APPMAP_CONFIG_FILE'] || 'appmap.yml'
|
43
|
+
end
|
44
|
+
|
45
|
+
# Configures AppMap for recording. Default behavior is to configure from
|
46
|
+
# APPMAP_CONFIG_FILE, or 'appmap.yml'. If no config file is available, a
|
47
|
+
# configuration will be automatically generated and used - and the user is prompted
|
48
|
+
# to create the config file.
|
49
|
+
#
|
42
50
|
# This method also activates the code hooks which record function calls as trace events.
|
43
51
|
# Call this function before the program code is loaded by the Ruby VM, otherwise
|
44
52
|
# the load events won't be seen and the hooks won't activate.
|
45
|
-
def
|
46
|
-
|
47
|
-
warn "Configuring AppMap from path #{config_file_path}"
|
53
|
+
def initialize_configuration(config_file_path = default_config_file_path)
|
54
|
+
startup_message "Configuring AppMap from path #{config_file_path}"
|
48
55
|
Config.load_from_file(config_file_path).tap do |configuration|
|
49
56
|
self.configuration = configuration
|
50
57
|
Hook.new(configuration).enable
|
51
58
|
end
|
52
59
|
end
|
53
60
|
|
61
|
+
def info(msg)
|
62
|
+
if defined?(::Rails) && defined?(::Rails.logger)
|
63
|
+
::Rails.logger.info msg
|
64
|
+
else
|
65
|
+
warn msg
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
54
69
|
# Used to start tracing, stop tracing, and record events.
|
55
70
|
def tracing
|
56
71
|
@tracing ||= Trace::Tracing.new
|
@@ -94,8 +109,60 @@ module AppMap
|
|
94
109
|
@metadata ||= Metadata.detect.freeze
|
95
110
|
@metadata.deep_dup
|
96
111
|
end
|
112
|
+
|
113
|
+
def startup_message(msg)
|
114
|
+
if defined?(::Rails) && defined?(::Rails.logger) && ::Rails.logger
|
115
|
+
::Rails.logger.debug msg
|
116
|
+
elsif ENV['DEBUG'] == 'true'
|
117
|
+
warn msg
|
118
|
+
end
|
119
|
+
end
|
97
120
|
end
|
98
121
|
end
|
99
122
|
|
100
|
-
|
101
|
-
|
123
|
+
lambda do
|
124
|
+
Initializer = Struct.new(:class_name, :module_name, :gem_module_name)
|
125
|
+
|
126
|
+
INITIALIZERS = {
|
127
|
+
'Rails::Railtie' => Initializer.new('AppMap::Railtie', 'appmap/railtie', 'railtie'),
|
128
|
+
'RSpec' => Initializer.new('AppMap::RSpec', 'appmap/rspec', 'rspec-core'),
|
129
|
+
'Minitest::Unit::TestCase' => Initializer.new('AppMap::Minitest', 'appmap/minitest', 'minitest')
|
130
|
+
}
|
131
|
+
|
132
|
+
TracePoint.new(:class) do |tp|
|
133
|
+
cls_name = tp.self.name
|
134
|
+
initializers = INITIALIZERS.delete(cls_name)
|
135
|
+
if initializers
|
136
|
+
initializers = [ initializers ] unless initializers.is_a?(Array)
|
137
|
+
next if Object.const_defined?(initializers.first.class_name)
|
138
|
+
|
139
|
+
gem_module_name = initializers.first.gem_module_name
|
140
|
+
|
141
|
+
AppMap.startup_message AppMap::Util.color(<<~LOAD_MSG, :magenta)
|
142
|
+
When 'appmap' was loaded, '#{gem_module_name}' had not been loaded yet. Now '#{gem_module_name}' has
|
143
|
+
just been loaded, so the following AppMap modules will be automatically required:
|
144
|
+
|
145
|
+
#{initializers.map(&:module_name).join("\n")}
|
146
|
+
|
147
|
+
To suppress this message, ensure '#{gem_module_name}' appears before 'appmap' in your Gemfile.
|
148
|
+
LOAD_MSG
|
149
|
+
initializers.each do |init|
|
150
|
+
require init.module_name
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end.enable
|
154
|
+
|
155
|
+
if defined?(::Rails::Railtie)
|
156
|
+
require 'appmap/railtie'
|
157
|
+
end
|
158
|
+
|
159
|
+
if defined?(::RSpec)
|
160
|
+
require 'appmap/rspec'
|
161
|
+
end
|
162
|
+
|
163
|
+
if defined?(::Minitest)
|
164
|
+
require 'appmap/minitest'
|
165
|
+
end
|
166
|
+
end.call
|
167
|
+
|
168
|
+
AppMap.initialize_configuration if ENV['APPMAP'] == 'true'
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
require 'appmap/service/guesser'
|
5
|
+
require 'appmap/util'
|
6
|
+
|
7
|
+
module AppMap
|
8
|
+
module Command
|
9
|
+
InitStruct = Struct.new(:config_file)
|
10
|
+
|
11
|
+
class Init < InitStruct
|
12
|
+
def perform
|
13
|
+
if File.exist?(config_file)
|
14
|
+
puts AppMap::Util.color(%(The AppMap config file #{config_file} already exists.), :magenta)
|
15
|
+
return
|
16
|
+
end
|
17
|
+
|
18
|
+
ensure_directory_exists
|
19
|
+
|
20
|
+
config = {
|
21
|
+
'name' => Service::Guesser.guess_name,
|
22
|
+
'packages' => Service::Guesser.guess_paths.map { |path| { 'path' => path } }
|
23
|
+
}
|
24
|
+
content = YAML.dump(config).gsub("---\n", '')
|
25
|
+
|
26
|
+
File.write(config_file, content)
|
27
|
+
puts AppMap::Util.color(
|
28
|
+
%(The following AppMap config file #{config_file} has been created:),
|
29
|
+
:green
|
30
|
+
)
|
31
|
+
puts content
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def ensure_directory_exists
|
37
|
+
dirname = File.dirname(config_file)
|
38
|
+
FileUtils.mkdir_p(dirname) unless File.directory?(dirname)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
data/lib/appmap/config.rb
CHANGED
@@ -1,7 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'yaml'
|
3
4
|
require 'appmap/handler/net_http'
|
4
5
|
require 'appmap/handler/rails/template'
|
6
|
+
require 'appmap/service/guesser'
|
5
7
|
|
6
8
|
module AppMap
|
7
9
|
class Config
|
@@ -167,8 +169,10 @@ module AppMap
|
|
167
169
|
),
|
168
170
|
package_hooks('actionpack',
|
169
171
|
[
|
170
|
-
method_hook('ActionDispatch::Request::Session', %i[
|
171
|
-
method_hook('ActionDispatch::
|
172
|
+
method_hook('ActionDispatch::Request::Session', %i[[] dig values fetch], %w[http.session.read]),
|
173
|
+
method_hook('ActionDispatch::Request::Session', %i[destroy[]= clear update delete merge], %w[http.session.write]),
|
174
|
+
method_hook('ActionDispatch::Cookies::CookieJar', %i[[]= clear update delete recycle], %w[http.session.read]),
|
175
|
+
method_hook('ActionDispatch::Cookies::CookieJar', %i[[]= clear update delete recycle], %w[http.session.write]),
|
172
176
|
method_hook('ActionDispatch::Cookies::EncryptedCookieJar', %i[[]= clear update delete recycle], %w[http.cookie crypto.encrypt])
|
173
177
|
],
|
174
178
|
package_name: 'action_dispatch'
|
@@ -213,15 +217,22 @@ module AppMap
|
|
213
217
|
# This is happening: Method send_command not found on Net::IMAP
|
214
218
|
# 'Net::IMAP' => TargetMethods.new(:send_command, Package.build_from_path('net/imap', package_name: 'net/imap', labels: %w[protocol.email.imap])),
|
215
219
|
# 'Marshal' => TargetMethods.new(%i[dump load], Package.build_from_path('marshal', labels: %w[format.marshal])),
|
216
|
-
'Psych' =>
|
217
|
-
|
218
|
-
|
220
|
+
'Psych' => [
|
221
|
+
TargetMethods.new(%i[load load_stream parse parse_stream], Package.build_from_path('yaml', package_name: 'psych', labels: %w[format.yaml.parse])),
|
222
|
+
TargetMethods.new(%i[dump dump_stream], Package.build_from_path('yaml', package_name: 'psych', labels: %w[format.yaml.generate])),
|
223
|
+
],
|
224
|
+
'JSON::Ext::Parser' => TargetMethods.new(:parse, Package.build_from_path('json', package_name: 'json', labels: %w[format.json.parse])),
|
225
|
+
'JSON::Ext::Generator::State' => TargetMethods.new(:generate, Package.build_from_path('json', package_name: 'json', labels: %w[format.json.generate])),
|
219
226
|
}.freeze
|
220
227
|
|
221
|
-
attr_reader :name, :packages, :exclude, :hooked_methods, :builtin_hooks
|
228
|
+
attr_reader :name, :appmap_dir, :packages, :exclude, :hooked_methods, :builtin_hooks
|
222
229
|
|
223
|
-
def initialize(name,
|
230
|
+
def initialize(name,
|
231
|
+
packages: [],
|
232
|
+
exclude: [],
|
233
|
+
functions: [])
|
224
234
|
@name = name
|
235
|
+
@appmap_dir = AppMap::DEFAULT_APPMAP_DIR
|
225
236
|
@packages = packages
|
226
237
|
@hook_paths = Set.new(packages.map(&:path))
|
227
238
|
@exclude = exclude
|
@@ -248,38 +259,98 @@ module AppMap
|
|
248
259
|
class << self
|
249
260
|
# Loads configuration data from a file, specified by the file name.
|
250
261
|
def load_from_file(config_file_name)
|
251
|
-
|
252
|
-
|
262
|
+
logo = lambda do
|
263
|
+
Util.color(<<~LOGO, :magenta)
|
264
|
+
___ __ ___
|
265
|
+
/ _ | ___ ___ / |/ /__ ____
|
266
|
+
/ __ |/ _ \\/ _ \\/ /|_/ / _ `/ _ \\
|
267
|
+
/_/ |_/ .__/ .__/_/ /_/\\_,_/ .__/
|
268
|
+
/_/ /_/ /_/
|
269
|
+
LOGO
|
270
|
+
end
|
271
|
+
|
272
|
+
config_present = true if File.exists?(config_file_name)
|
273
|
+
|
274
|
+
config_data = if config_present
|
275
|
+
YAML.safe_load(::File.read(config_file_name))
|
276
|
+
else
|
277
|
+
warn logo.()
|
278
|
+
warn ''
|
279
|
+
warn Util.color(%Q|NOTICE: The AppMap config file #{config_file_name} was not found!|, :magenta, bold: true)
|
280
|
+
warn ''
|
281
|
+
warn Util.color(<<~MISSING_FILE_MSG, :magenta)
|
282
|
+
AppMap uses this file to customize its behavior. For example, you can use
|
283
|
+
the 'packages' setting to indicate which local file paths and dependency
|
284
|
+
gems you want to include in the AppMap. Since you haven't provided specific
|
285
|
+
settings, the appmap gem will try and guess some reasonable defaults.
|
286
|
+
To suppress this message, create the file:
|
287
|
+
|
288
|
+
#{Pathname.new(config_file_name).expand_path}
|
289
|
+
|
290
|
+
Here are the default settings that will be used in the meantime. You can
|
291
|
+
copy and paste this example to start your appmap.yml.
|
292
|
+
MISSING_FILE_MSG
|
293
|
+
{}
|
294
|
+
end
|
295
|
+
load(config_data).tap do |config|
|
296
|
+
config_yaml = {
|
297
|
+
'name' => config.name,
|
298
|
+
'packages' => config.packages.select{|p| p.path}.map do |pkg|
|
299
|
+
{ 'path' => pkg.path }
|
300
|
+
end,
|
301
|
+
'exclude' => []
|
302
|
+
}.compact
|
303
|
+
unless config_present
|
304
|
+
warn Util.color(YAML.dump(config_yaml), :magenta)
|
305
|
+
warn logo.()
|
306
|
+
end
|
307
|
+
end
|
253
308
|
end
|
254
309
|
|
255
310
|
# Loads configuration from a Hash.
|
256
311
|
def load(config_data)
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
312
|
+
name = config_data['name'] || Service::Guesser.guess_name
|
313
|
+
config_params = {
|
314
|
+
exclude: config_data['exclude']
|
315
|
+
}.compact
|
316
|
+
|
317
|
+
if config_data['functions']
|
318
|
+
config_params[:functions] = config_data['functions'].map do |function_data|
|
319
|
+
package = function_data['package']
|
320
|
+
cls = function_data['class']
|
321
|
+
functions = function_data['function'] || function_data['functions']
|
322
|
+
raise %q(AppMap config 'function' element should specify 'package', 'class' and 'function' or 'functions') unless package && cls && functions
|
323
|
+
|
324
|
+
functions = Array(functions).map(&:to_sym)
|
325
|
+
labels = function_data['label'] || function_data['labels']
|
326
|
+
labels = Array(labels).map(&:to_s) if labels
|
327
|
+
Function.new(package, cls, labels, functions)
|
328
|
+
end
|
266
329
|
end
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
330
|
+
|
331
|
+
config_params[:packages] = \
|
332
|
+
if config_data['packages']
|
333
|
+
config_data['packages'].map do |package|
|
334
|
+
gem = package['gem']
|
335
|
+
path = package['path']
|
336
|
+
raise %q(AppMap config 'package' element should specify 'gem' or 'path', not both) if gem && path
|
337
|
+
|
338
|
+
if gem
|
339
|
+
shallow = package['shallow']
|
340
|
+
# shallow is true by default for gems
|
341
|
+
shallow = true if shallow.nil?
|
342
|
+
Package.build_from_gem(gem, exclude: package['exclude'] || [], shallow: shallow)
|
343
|
+
else
|
344
|
+
Package.build_from_path(path, exclude: package['exclude'] || [], shallow: package['shallow'])
|
345
|
+
end
|
346
|
+
end.compact
|
277
347
|
else
|
278
|
-
|
348
|
+
Array(Service::Guesser.guess_paths).map do |path|
|
349
|
+
Package.build_from_path(path)
|
350
|
+
end
|
279
351
|
end
|
280
|
-
|
281
|
-
|
282
|
-
Config.new config_data['name'], packages, exclude: exclude, functions: functions
|
352
|
+
|
353
|
+
Config.new name, config_params
|
283
354
|
end
|
284
355
|
end
|
285
356
|
|
@@ -289,7 +360,7 @@ module AppMap
|
|
289
360
|
packages: packages.map(&:to_h),
|
290
361
|
functions: @functions.map(&:to_h),
|
291
362
|
exclude: exclude
|
292
|
-
}
|
363
|
+
}.compact
|
293
364
|
end
|
294
365
|
|
295
366
|
# Determines if methods defined in a file path should possibly be hooked.
|
@@ -14,16 +14,30 @@ module AppMap
|
|
14
14
|
# The class name is generated from the template path. The package name is
|
15
15
|
# 'app/views', and the method name is 'render'. The source location of the method
|
16
16
|
# is, of course, the path to the view template.
|
17
|
-
TemplateMethod
|
18
|
-
private_instance_methods :path
|
17
|
+
class TemplateMethod
|
19
18
|
attr_reader :class_name
|
20
|
-
|
19
|
+
|
20
|
+
attr_reader :path
|
21
|
+
private_instance_methods :path
|
22
|
+
|
21
23
|
def initialize(path)
|
22
|
-
|
24
|
+
@path = path
|
23
25
|
|
24
26
|
@class_name = path.parameterize.underscore
|
25
27
|
end
|
26
|
-
|
28
|
+
|
29
|
+
def id
|
30
|
+
[ package, path, name ]
|
31
|
+
end
|
32
|
+
|
33
|
+
def hash
|
34
|
+
id.hash
|
35
|
+
end
|
36
|
+
|
37
|
+
def eql?(other)
|
38
|
+
other.is_a?(TemplateMethod) && id.eql?(other.id)
|
39
|
+
end
|
40
|
+
|
27
41
|
def package
|
28
42
|
'app/views'
|
29
43
|
end
|
data/lib/appmap/minitest.rb
CHANGED
@@ -54,15 +54,21 @@ module AppMap
|
|
54
54
|
|
55
55
|
@recordings_by_test = {}
|
56
56
|
@event_methods = Set.new
|
57
|
+
@recording_count = 0
|
57
58
|
|
58
59
|
class << self
|
59
60
|
def init
|
60
|
-
warn 'Configuring AppMap recorder for Minitest'
|
61
|
-
|
62
61
|
FileUtils.mkdir_p APPMAP_OUTPUT_DIR
|
63
62
|
end
|
64
63
|
|
64
|
+
def first_recording?
|
65
|
+
@recording_count == 0
|
66
|
+
end
|
67
|
+
|
65
68
|
def begin_test(test, name)
|
69
|
+
AppMap.info 'Configuring AppMap recorder for Minitest' if first_recording?
|
70
|
+
@recording_count += 1
|
71
|
+
|
66
72
|
@recordings_by_test[test.object_id] = Recording.new(test, name)
|
67
73
|
end
|
68
74
|
|
data/lib/appmap/railtie.rb
CHANGED
@@ -3,6 +3,13 @@
|
|
3
3
|
module AppMap
|
4
4
|
# Railtie connects the AppMap recorder to Rails-specific features.
|
5
5
|
class Railtie < ::Rails::Railtie
|
6
|
+
initializer 'appmap.remote_recording' do
|
7
|
+
require 'appmap/middleware/remote_recording'
|
8
|
+
Rails.application.config.middleware.insert_after \
|
9
|
+
Rails::Rack::Logger,
|
10
|
+
AppMap::Middleware::RemoteRecording
|
11
|
+
end
|
12
|
+
|
6
13
|
# appmap.subscribe subscribes to ActiveSupport Notifications so that they can be recorded as
|
7
14
|
# AppMap events.
|
8
15
|
initializer 'appmap.subscribe' do |_| # params: app
|
data/lib/appmap/rspec.rb
CHANGED
@@ -139,15 +139,21 @@ module AppMap
|
|
139
139
|
|
140
140
|
@recordings_by_example = {}
|
141
141
|
@event_methods = Set.new
|
142
|
+
@recording_count = 0
|
142
143
|
|
143
144
|
class << self
|
144
145
|
def init
|
145
|
-
warn 'Configuring AppMap recorder for RSpec'
|
146
|
-
|
147
146
|
FileUtils.mkdir_p APPMAP_OUTPUT_DIR
|
148
147
|
end
|
149
148
|
|
149
|
+
def first_recording?
|
150
|
+
@recording_count == 0
|
151
|
+
end
|
152
|
+
|
150
153
|
def begin_spec(example)
|
154
|
+
AppMap.info 'Configuring AppMap recorder for RSpec' if first_recording?
|
155
|
+
@recording_count += 1
|
156
|
+
|
151
157
|
@recordings_by_example[example.object_id] = Recording.new(example)
|
152
158
|
end
|
153
159
|
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AppMap
|
4
|
+
module Service
|
5
|
+
class Guesser
|
6
|
+
POSSIBLE_PATHS = %w[app/controllers app/models lib]
|
7
|
+
class << self
|
8
|
+
def guess_name
|
9
|
+
reponame = lambda do
|
10
|
+
next unless File.directory?('.git')
|
11
|
+
|
12
|
+
repo_name = `git config --get remote.origin.url`.strip
|
13
|
+
repo_name.split('/').last.split('.').first unless repo_name == ''
|
14
|
+
end
|
15
|
+
dirname = -> { Dir.pwd.split('/').last }
|
16
|
+
|
17
|
+
reponame.() || dirname.()
|
18
|
+
end
|
19
|
+
|
20
|
+
def guess_paths
|
21
|
+
POSSIBLE_PATHS.select { |path| File.directory?(path) }
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|