appmap 0.45.0 → 0.48.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0db499c7da4baea3f29fb56214a95e18047a07fc0c132ba559f7f6eb7ef9033a
4
- data.tar.gz: 3aabf3bf1a31c39d6844ccf17807a4b7e206783bdf8e373008d3db0d04a854d7
3
+ metadata.gz: 6ce9ca4faa9074d177861610ff47bec4e7b02191e870759a12d1ed9207e9f79a
4
+ data.tar.gz: 6b0157f28774b1d46fdea527c8076846253780040628f3879ec8c18af8c4a60d
5
5
  SHA512:
6
- metadata.gz: a38e51f5c932d9a04b566c2664104e08a56af80fd3277c039603eb719336599bc9764781e1d0835c6c7b702e4e0465587fd4537e292df325c0d9efe4a90c1493
7
- data.tar.gz: 168e2b8c1605cf7b4f8c4d6a5f373b5add4ce1804c4c494f3cc4e3b29da5a08f4e121889bca36e06e1e29aa4c9943c16b76f7868e9331c3a7e5b33af0817f1a0
6
+ metadata.gz: e9d67de222d734c8b650c2c8bb8cef70b371908561dd8df43410f370071cdd1c24b9cc8d4919ccfda59745afefccc29e8af30444f25136aac685384ec079e0ba
7
+ data.tar.gz: 387a7a074b9c56ae423825e0beea405909066528c40ff6d6ce3789843193c6e0c73cafabefd8e091dc9961dd5e2c112f311538e8afdc5bbaf37ca187be2f1606
data/.travis.yml CHANGED
@@ -17,6 +17,16 @@ before_script:
17
17
  cache:
18
18
  bundler: true
19
19
 
20
+ before_install:
21
+ # see https://blog.travis-ci.com/docker-rate-limits
22
+ # and also https://www.docker.com/blog/what-you-need-to-know-about-upcoming-docker-hub-rate-limiting/
23
+ # if we do not use authorized account,
24
+ # the pulls-per-IP quota is shared with other Travis users
25
+ - >
26
+ if [ ! -z "$DOCKERHUB_PASSWORD" ] && [ ! -z "$DOCKERHUB_USERNAME" ]; then
27
+ echo "$DOCKERHUB_PASSWORD" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin ;
28
+ fi
29
+
20
30
 
21
31
  # GEM_ALTERNATIVE_NAME only needed for deployment
22
32
  jobs:
data/CHANGELOG.md CHANGED
@@ -1,3 +1,39 @@
1
+ # [0.48.0](https://github.com/applandinc/appmap-ruby/compare/v0.47.1...v0.48.0) (2021-05-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * Hook the code only when APPMAP=true ([dd9e383](https://github.com/applandinc/appmap-ruby/commit/dd9e383024d1d9205a617d46bd64b90820035533))
7
+ * Remove server process recording from doc and tests ([383ba0a](https://github.com/applandinc/appmap-ruby/commit/383ba0ad444922a0a85409477d11bc7ed06a9160))
8
+
9
+ ## [0.47.1](https://github.com/applandinc/appmap-ruby/compare/v0.47.0...v0.47.1) (2021-05-13)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * Add the proper template function hooks for Rails 6.0.7 ([175f489](https://github.com/applandinc/appmap-ruby/commit/175f489acbaed77ad52a18d805e4b6eeae1abfdb))
15
+
16
+ # [0.47.0](https://github.com/applandinc/appmap-ruby/compare/v0.46.0...v0.47.0) (2021-05-13)
17
+
18
+
19
+ ### Features
20
+
21
+ * Emit swagger-style normalized paths instead of Rails-style ones ([5a93cd7](https://github.com/applandinc/appmap-ruby/commit/5a93cd7096ca195146a84a6733c7d502dbcd0272))
22
+
23
+ # [0.46.0](https://github.com/applandinc/appmap-ruby/compare/v0.45.1...v0.46.0) (2021-05-12)
24
+
25
+
26
+ ### Features
27
+
28
+ * Record view template rendering events and template paths ([973b258](https://github.com/applandinc/appmap-ruby/commit/973b2581b6e2d4e15a1b93331e4e95a88678faae))
29
+
30
+ ## [0.45.1](https://github.com/applandinc/appmap-ruby/compare/v0.45.0...v0.45.1) (2021-05-04)
31
+
32
+
33
+ ### Bug Fixes
34
+
35
+ * Optimize instrumentation and load time ([db4a8ce](https://github.com/applandinc/appmap-ruby/commit/db4a8ceed4103a52caafa46626c66f33fbfeac27))
36
+
1
37
  # [0.45.0](https://github.com/applandinc/appmap-ruby/compare/v0.44.0...v0.45.0) (2021-05-03)
2
38
 
3
39
 
data/README.md CHANGED
@@ -9,11 +9,11 @@
9
9
  - [Minitest](#minitest)
10
10
  - [Cucumber](#cucumber)
11
11
  - [Remote recording](#remote-recording)
12
- - [Server process recording](#server-process-recording)
13
12
  - [AppMap for VSCode](#appmap-for-vscode)
14
13
  - [AppMap Swagger](#appmap-swagger)
15
14
  - [Uploading AppMaps](#uploading-appmaps)
16
15
  - [Development](#development)
16
+ - [Internal architecture](#internal-architecture)
17
17
  - [Running tests](#running-tests)
18
18
  - [Using fixture apps](#using-fixture-apps)
19
19
  - [`test/fixtures`](#testfixtures)
@@ -83,22 +83,6 @@ If you are using Ruby on Rails, require the railtie after Rails is loaded.
83
83
  require 'appmap/railtie' if defined?(AppMap).
84
84
  ```
85
85
 
86
- **application.rb**
87
-
88
- Add this line to *application.rb*, to enable server recording with `APPMAP_RECORD=true`:
89
-
90
- ```ruby
91
- module MyApp
92
- class Application < Rails::Application
93
- ...
94
-
95
- config.appmap.enabled = true if ENV['APPMAP_RECORD']
96
-
97
- ...
98
- end
99
- end
100
- ```
101
-
102
86
  # Configuration
103
87
 
104
88
  When you run your program, the `appmap` gem reads configuration settings from `appmap.yml`. Here's a sample configuration
@@ -325,25 +309,25 @@ if defined?(AppMap)
325
309
  end
326
310
  ```
327
311
 
328
- 2. Download and unpack the [AppLand browser extension](https://github.com/applandinc/appland-browser-extension). Install into Chrome using `chrome://extensions/`. Turn on "Developer Mode" and then load the extension using the "Load unpacked" button.
312
+ 2. (optional) Download and unpack the [AppLand browser extension](https://github.com/applandinc/appland-browser-extension). Install into Chrome using `chrome://extensions/`. Turn on "Developer Mode" and then load the extension using the "Load unpacked" button.
329
313
 
330
- 3. Start your Rails application server. For example:
314
+ 3. Start your Rails application server, with `APPMAP_RECORD=true`. For example:
331
315
 
332
316
  ```sh-session
333
- $ bundle exec rails server
317
+ $ APPMAP_RECORD=true bundle exec rails server
334
318
  ```
335
319
 
336
- 4. Open the AppLand browser extension and push `Start`.
320
+ 4. Start the recording
337
321
 
338
- 5. Use your app. For example, perform a login flow, or run through a manual UI test.
322
+ Option 1: Open the AppLand browser extension and push `Start`.
323
+ Option 2: `curl -XPOST localhost:3000/_appmap/record` (be sure and get the port number right)
339
324
 
340
- 6. Open the AppLand browser extension and push `Stop`. The recording will be transferred to the AppLand website and opened in your browser.
341
-
342
- ## Server process recording
325
+ 5. Use your app. For example, perform a login flow, or run through a manual UI test.
343
326
 
344
- Run your Rails server with `APPMAP_RECORD=true`. When the server exits, an *appmap.json* file will be written to the project directory. This is a great way to start the server, interact with your app as a user (or through it's API), and then view an AppMap of everything that happened.
327
+ 6. Finish the recording.
345
328
 
346
- Be sure and set `WEB_CONCURRENCY=1`, if you are using a webserver that can run multiple processes. You only want there to be one process while you are recording, otherwise they will both try and write *appmap.json* and one of them will clobber the other.
329
+ Option 1: Open the AppLand browser extension and push `Stop`. The recording will be transferred to the AppLand website and opened in your browser.
330
+ Option 2: `curl -XDELETE localhost:3000/_appmap/record > recording.appmap.json` - Saves the recording as a local file.
347
331
 
348
332
 
349
333
  # AppMap for VSCode
@@ -369,6 +353,34 @@ For instructions on uploading, see the documentation of the [AppLand CLI](https:
369
353
  # Development
370
354
  [![Build Status](https://travis-ci.com/applandinc/appmap-ruby.svg?branch=master)](https://travis-ci.com/applandinc/appmap-ruby)
371
355
 
356
+ ## Internal architecture
357
+
358
+ **Configuration**
359
+
360
+ *appmap.yml* is loaded into an `AppMap::Config`.
361
+
362
+ **Hooking**
363
+
364
+ Once configuration is loaded, `AppMap::Hook` is enabled. "Hooking" refers to the process of replacing a method
365
+ with a "hooked" version of the method. The hooked method checks to see if tracing is enabled. If so, it wraps the original
366
+ method with calls that record the parameters and return value.
367
+
368
+ **Builtins**
369
+
370
+ `Hook` begins by iterating over builtin classes and modules defined in the `Config`. Builtins include code
371
+ like `openssl` and `net/http`. This code is not dependent on any external libraries being present, and
372
+ `appmap` cannot guarantee that it will be loaded before builtins. Therefore, it's necessary to require it and
373
+ hook it by looking up the classes and modules as constants in the `Object` namespace.
374
+
375
+ **User code and gems**
376
+
377
+ After hooking builtins, `Hook` attaches a [TracePoint](https://ruby-doc.org/core-2.6/TracePoint.html) to `:begin` events.
378
+ This TracePoint is notified each time a new class or module is being evaluated. When this happens, `Hook` uses the `Config`
379
+ to determine whether any code within the evaluated file is configured for hooking. If so, a `TracePoint` is attached to
380
+ `:end` events. Each `:end` event is fired when a class or module definition is completed. When this happens, the `Hook` enumerates
381
+ the public methods of the class or module, hooking the ones that are targeted by the `Config`. Once the `:end` TracePoint leaves
382
+ the scope of the `:begin`, the `:end` TracePoint is disabled.
383
+
372
384
  ## Running tests
373
385
 
374
386
  Before running tests, configure `local.appmap` to point to your local `appmap-ruby` directory.
data/lib/appmap.rb CHANGED
@@ -9,7 +9,6 @@ end
9
9
 
10
10
  require 'appmap/version'
11
11
  require 'appmap/hook'
12
- require 'appmap/handler/net_http'
13
12
  require 'appmap/config'
14
13
  require 'appmap/trace'
15
14
  require 'appmap/class_map'
@@ -99,4 +98,4 @@ module AppMap
99
98
  end
100
99
 
101
100
  require 'appmap/railtie' if defined?(::Rails::Railtie)
102
- AppMap.initialize unless ENV['APPMAP_INITIALIZE'] == 'false'
101
+ AppMap.initialize if ENV['APPMAP'] == 'true'
@@ -82,16 +82,13 @@ module AppMap
82
82
  protected
83
83
 
84
84
  def add_function(root, method)
85
- package = method.package
86
- static = method.static
87
-
88
85
  object_infos = [
89
86
  {
90
- name: package.name,
87
+ name: method.package,
91
88
  type: 'package'
92
89
  }
93
90
  ]
94
- object_infos += method.defined_class.split('::').map do |name|
91
+ object_infos += method.class_name.split('::').map do |name|
95
92
  {
96
93
  name: name,
97
94
  type: 'class'
@@ -100,7 +97,7 @@ module AppMap
100
97
  function_info = {
101
98
  name: method.name,
102
99
  type: 'function',
103
- static: static
100
+ static: method.static
104
101
  }
105
102
  location = method.source_location
106
103
 
@@ -108,20 +105,15 @@ module AppMap
108
105
  if location
109
106
  location_file, lineno = location
110
107
  location_file = location_file[Dir.pwd.length + 1..-1] if location_file.index(Dir.pwd) == 0
111
- [ location_file, lineno ].join(':')
108
+ [ location_file, lineno ].compact.join(':')
112
109
  else
113
- [ method.defined_class, static ? '.' : '#', method.name ].join
110
+ [ method.class_name, method.static ? '.' : '#', method.name ].join
114
111
  end
115
112
 
116
- comment = begin
117
- method.comment
118
- rescue MethodSource::SourceNotFoundError
119
- nil
120
- end
121
-
113
+ comment = method.comment
122
114
  function_info[:comment] = comment unless comment.blank?
123
115
 
124
- function_info[:labels] = parse_labels(comment) + (package.labels || [])
116
+ function_info[:labels] = parse_labels(comment) + (method.labels || [])
125
117
  object_infos << function_info
126
118
 
127
119
  parent = root
data/lib/appmap/config.rb CHANGED
@@ -1,8 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'appmap/handler/net_http'
4
+ require 'appmap/handler/rails/template'
5
+
3
6
  module AppMap
4
7
  class Config
8
+ # Specifies a code +path+ to be mapped.
9
+ # Options:
10
+ #
11
+ # * +gem+ may indicate a gem name that "owns" the path
12
+ # * +package_name+ can be used to make sure that the code is required so that it can be loaded. This is generally used with
13
+ # builtins, or when the path to be required is not automatically required when bundler requires the gem.
14
+ # * +exclude+ can be used used to exclude sub-paths. Generally not used with +gem+.
15
+ # * +labels+ is used to apply labels to matching code. This is really only useful when the package will be applied to
16
+ # specific functions, via TargetMethods.
17
+ # * +shallow+ indicates shallow mapping, in which only the entrypoint to a gem is recorded.
5
18
  Package = Struct.new(:path, :gem, :package_name, :exclude, :labels, :shallow) do
19
+ # This is for internal use only.
20
+ private_methods :gem
21
+
22
+ # Specifies the class that will convert code events into event objects.
6
23
  attr_writer :handler_class
7
24
 
8
25
  def handler_class
@@ -18,25 +35,36 @@ module AppMap
18
35
  end
19
36
 
20
37
  class << self
38
+ # Builds a package for a path, such as `app/models` in a Rails app. Generally corresponds to a `path:` entry
39
+ # in appmap.yml. Also used for mapping specific methods via TargetMethods.
21
40
  def build_from_path(path, shallow: false, package_name: nil, exclude: [], labels: [])
22
41
  Package.new(path, nil, package_name, exclude, labels, shallow)
23
42
  end
24
43
 
25
- def build_from_gem(gem, shallow: true, package_name: nil, exclude: [], labels: [])
26
- if %w[method_source activesupport].member?(gem)
44
+ # Builds a package for gem. Generally corresponds to a `gem:` entry in appmap.yml. Also used when mapping
45
+ # a builtin.
46
+ def build_from_gem(gem, shallow: true, package_name: nil, exclude: [], labels: [], optional: false, force: false)
47
+ if !force && %w[method_source activesupport].member?(gem)
27
48
  warn "WARNING: #{gem} cannot be AppMapped because it is a dependency of the appmap gem"
28
49
  return
29
50
  end
30
- Package.new(gem_path(gem), gem, package_name, exclude, labels, shallow)
51
+ path = gem_path(gem, optional)
52
+ if path
53
+ Package.new(path, gem, package_name, exclude, labels, shallow)
54
+ else
55
+ warn "#{gem} is not available in the bundle" if AppMap::Hook::LOG
56
+ end
31
57
  end
32
58
 
33
59
  private_class_method :new
34
60
 
35
61
  protected
36
62
 
37
- def gem_path(gem)
38
- gemspec = Gem.loaded_specs[gem] or raise "Gem #{gem.inspect} not found"
39
- gemspec.gem_dir
63
+ def gem_path(gem, optional)
64
+ gemspec = Gem.loaded_specs[gem]
65
+ # This exception will notify a user that their appmap.yml contains non-existent gems.
66
+ raise "Gem #{gem.inspect} not found" unless gemspec || optional
67
+ gemspec ? gemspec.gem_dir : nil
40
68
  end
41
69
  end
42
70
 
@@ -57,7 +85,33 @@ module AppMap
57
85
  end
58
86
  end
59
87
 
60
- Function = Struct.new(:package, :cls, :labels, :function_names) do
88
+ # Identifies specific methods within a package which should be hooked.
89
+ class TargetMethods # :nodoc:
90
+ attr_reader :method_names, :package
91
+
92
+ def initialize(method_names, package)
93
+ @method_names = method_names
94
+ @package = package
95
+ end
96
+
97
+ def include_method?(method_name)
98
+ Array(method_names).include?(method_name)
99
+ end
100
+
101
+ def to_h
102
+ {
103
+ package: package.name,
104
+ method_names: method_names
105
+ }
106
+ end
107
+ end
108
+ private_constant :TargetMethods
109
+
110
+ # Function represents a specific function configured for hooking by the +functions+
111
+ # entry in appmap.yml. When the Config is initialized, each Function is converted into
112
+ # a Package and TargetMethods. It's called a Function rather than a Method, because Function
113
+ # is the AppMap terminology.
114
+ Function = Struct.new(:package, :cls, :labels, :function_names) do # :nodoc:
61
115
  def to_h
62
116
  {
63
117
  package: package,
@@ -67,80 +121,127 @@ module AppMap
67
121
  }.compact
68
122
  end
69
123
  end
124
+ private_constant :Function
70
125
 
71
- class Hook
72
- attr_reader :method_names, :package
126
+ ClassTargetMethods = Struct.new(:cls, :target_methods) # :nodoc:
127
+ private_constant :ClassTargetMethods
73
128
 
74
- def initialize(method_names, package)
75
- @method_names = method_names
76
- @package = package
129
+ MethodHook = Struct.new(:cls, :method_names, :labels) # :nodoc:
130
+ private_constant :MethodHook
131
+
132
+ class << self
133
+ def package_hooks(gem_name, methods, handler_class: nil, package_name: nil)
134
+ Array(methods).map do |method|
135
+ package = Package.build_from_gem(gem_name, package_name: package_name, labels: method.labels, shallow: false, optional: true)
136
+ next unless package
137
+
138
+ package.handler_class = handler_class if handler_class
139
+ ClassTargetMethods.new(method.cls, TargetMethods.new(Array(method.method_names), package))
140
+ end.compact
77
141
  end
78
142
 
79
- def to_h
80
- {
81
- package: package.name,
82
- method_names: method_names
83
- }
143
+ def method_hook(cls, method_names, labels)
144
+ MethodHook.new(cls, method_names, labels)
84
145
  end
85
146
  end
86
147
 
87
- OPENSSL_PACKAGES = ->(labels) { Package.build_from_path('openssl', package_name: 'openssl', labels: labels) }
148
+ # Hook well-known functions. When a function configured here is available in the bundle, it will be hooked with the
149
+ # predefined labels specified here. If any of these hooks are not desired, they can be disabled in the +exclude+ section
150
+ # of appmap.yml.
151
+ METHOD_HOOKS = [
152
+ package_hooks('actionview',
153
+ [
154
+ method_hook('ActionView::Renderer', :render, %w[mvc.view]),
155
+ method_hook('ActionView::TemplateRenderer', :render, %w[mvc.view]),
156
+ method_hook('ActionView::PartialRenderer', :render, %w[mvc.view])
157
+ ],
158
+ handler_class: AppMap::Handler::Rails::Template::RenderHandler,
159
+ package_name: 'action_view'
160
+ ),
161
+ package_hooks('actionview',
162
+ [
163
+ method_hook('ActionView::Resolver', %i[find_all find_all_anywhere], %w[mvc.template.resolver])
164
+ ],
165
+ handler_class: AppMap::Handler::Rails::Template::ResolverHandler,
166
+ package_name: 'action_view'
167
+ ),
168
+ package_hooks('actionpack',
169
+ [
170
+ method_hook('ActionDispatch::Request::Session', %i[destroy [] dig values []= clear update delete fetch merge], %w[http.session]),
171
+ method_hook('ActionDispatch::Cookies::CookieJar', %i[[]= clear update delete recycle], %w[http.session]),
172
+ method_hook('ActionDispatch::Cookies::EncryptedCookieJar', %i[[]= clear update delete recycle], %w[http.cookie crypto.encrypt])
173
+ ],
174
+ package_name: 'action_dispatch'
175
+ ),
176
+ package_hooks('cancancan',
177
+ [
178
+ method_hook('CanCan::ControllerAdditions', %i[authorize! can? cannot?], %w[security.authorization]),
179
+ method_hook('CanCan::Ability', %i[authorize?], %w[security.authorization])
180
+ ]
181
+ ),
182
+ package_hooks('actionpack',
183
+ [
184
+ method_hook('ActionController::Instrumentation', %i[process_action send_file send_data redirect_to], %w[mvc.controller])
185
+ ],
186
+ package_name: 'action_controller'
187
+ )
188
+ ].flatten.freeze
88
189
 
89
- # Methods that should always be hooked, with their containing
90
- # package and labels that should be applied to them.
91
- HOOKED_METHODS = {
92
- 'ActiveSupport::SecurityUtils' => Hook.new(:secure_compare, Package.build_from_path('active_support', labels: %w[crypto.secure_compare])),
93
- 'ActionView::Renderer' => Hook.new(:render, Package.build_from_path('action_view', labels: %w[mvc.view])),
94
- 'ActionDispatch::Request::Session' => Hook.new(%i[destroy [] dig values []= clear update delete fetch merge], Package.build_from_path('action_pack', labels: %w[http.session])),
95
- 'ActionDispatch::Cookies::CookieJar' => Hook.new(%i[[]= clear update delete recycle], Package.build_from_path('action_pack', labels: %w[http.cookie])),
96
- 'ActionDispatch::Cookies::EncryptedCookieJar' => Hook.new(%i[[]=], Package.build_from_path('action_pack', labels: %w[http.cookie crypto.encrypt])),
97
- 'CanCan::ControllerAdditions' => Hook.new(%i[authorize! can? cannot?], Package.build_from_path('cancancan', labels: %w[security.authorization])),
98
- 'CanCan::Ability' => Hook.new(%i[authorize!], Package.build_from_path('cancancan', labels: %w[security.authorization])),
99
- 'ActionController::Instrumentation' => [
100
- Hook.new(%i[process_action send_file send_data redirect_to], Package.build_from_path('action_view', labels: %w[mvc.controller])),
101
- Hook.new(%i[render], Package.build_from_path('action_view', labels: %w[mvc.view])),
102
- ]
103
- }.freeze
190
+ OPENSSL_PACKAGES = ->(labels) { Package.build_from_path('openssl', package_name: 'openssl', labels: labels) }
104
191
 
105
- BUILTIN_METHODS = {
106
- 'OpenSSL::PKey::PKey' => Hook.new(:sign, OPENSSL_PACKAGES.(%w[crypto.pkey])),
107
- 'OpenSSL::X509::Request' => Hook.new(%i[sign verify], OPENSSL_PACKAGES.(%w[crypto.x509])),
108
- 'OpenSSL::PKCS5' => Hook.new(%i[pbkdf2_hmac_sha1 pbkdf2_hmac], OPENSSL_PACKAGES.(%w[crypto.pkcs5])),
192
+ # Hook functions which are builtin to Ruby. Because they are builtins, they may be loaded before appmap.
193
+ # Therefore, we can't rely on TracePoint to report the loading of this code.
194
+ BUILTIN_HOOKS = {
195
+ 'OpenSSL::PKey::PKey' => TargetMethods.new(:sign, OPENSSL_PACKAGES.(%w[crypto.pkey])),
196
+ 'OpenSSL::X509::Request' => TargetMethods.new(%i[sign verify], OPENSSL_PACKAGES.(%w[crypto.x509])),
197
+ 'OpenSSL::PKCS5' => TargetMethods.new(%i[pbkdf2_hmac_sha1 pbkdf2_hmac], OPENSSL_PACKAGES.(%w[crypto.pkcs5])),
109
198
  'OpenSSL::Cipher' => [
110
- Hook.new(%i[encrypt], OPENSSL_PACKAGES.(%w[crypto.encrypt])),
111
- Hook.new(%i[decrypt], OPENSSL_PACKAGES.(%w[crypto.decrypt]))
199
+ TargetMethods.new(%i[encrypt], OPENSSL_PACKAGES.(%w[crypto.encrypt])),
200
+ TargetMethods.new(%i[decrypt], OPENSSL_PACKAGES.(%w[crypto.decrypt]))
112
201
  ],
113
202
  'ActiveSupport::Callbacks::CallbackSequence' => [
114
- Hook.new(:invoke_before, Package.build_from_path('active_support', package_name: 'active_support', labels: %w[mvc.before_action])),
115
- Hook.new(:invoke_after, Package.build_from_path('active_support', package_name: 'active_support', labels: %w[mvc.after_action])),
203
+ TargetMethods.new(:invoke_before, Package.build_from_gem('activesupport', force: true, package_name: 'active_support', labels: %w[mvc.before_action])),
204
+ TargetMethods.new(:invoke_after, Package.build_from_gem('activesupport', force: true, package_name: 'active_support', labels: %w[mvc.after_action])),
116
205
  ],
117
- 'OpenSSL::X509::Certificate' => Hook.new(:sign, OPENSSL_PACKAGES.(%w[crypto.x509])),
118
- 'Net::HTTP' => Hook.new(:request, Package.build_from_path('net/http', package_name: 'net/http', labels: %w[protocol.http]).tap do |package|
206
+ 'ActiveSupport::SecurityUtils' => TargetMethods.new(:secure_compare, Package.build_from_gem('activesupport', force: true, package_name: 'active_support/security_utils', labels: %w[crypto.secure_compare])),
207
+ 'OpenSSL::X509::Certificate' => TargetMethods.new(:sign, OPENSSL_PACKAGES.(%w[crypto.x509])),
208
+ 'Net::HTTP' => TargetMethods.new(:request, Package.build_from_path('net/http', package_name: 'net/http', labels: %w[protocol.http]).tap do |package|
119
209
  package.handler_class = AppMap::Handler::NetHTTP
120
210
  end),
121
- 'Net::SMTP' => Hook.new(:send, Package.build_from_path('net/smtp', package_name: 'net/smtp', labels: %w[protocol.email.smtp])),
122
- 'Net::POP3' => Hook.new(:mails, Package.build_from_path('net/pop3', package_name: 'net/pop', labels: %w[protocol.email.pop])),
123
- 'Net::IMAP' => Hook.new(:send_command, Package.build_from_path('net/imap', package_name: 'net/imap', labels: %w[protocol.email.imap])),
124
- 'Marshal' => Hook.new(%i[dump load], Package.build_from_path('marshal', labels: %w[format.marshal])),
125
- 'Psych' => Hook.new(%i[dump dump_stream load load_stream parse parse_stream], Package.build_from_path('yaml', package_name: 'psych', labels: %w[format.yaml])),
126
- 'JSON::Ext::Parser' => Hook.new(:parse, Package.build_from_path('json', package_name: 'json', labels: %w[format.json])),
127
- 'JSON::Ext::Generator::State' => Hook.new(:generate, Package.build_from_path('json', package_name: 'json', labels: %w[format.json])),
211
+ 'Net::SMTP' => TargetMethods.new(:send, Package.build_from_path('net/smtp', package_name: 'net/smtp', labels: %w[protocol.email.smtp])),
212
+ 'Net::POP3' => TargetMethods.new(:mails, Package.build_from_path('net/pop3', package_name: 'net/pop', labels: %w[protocol.email.pop])),
213
+ # This is happening: Method send_command not found on Net::IMAP
214
+ # 'Net::IMAP' => TargetMethods.new(:send_command, Package.build_from_path('net/imap', package_name: 'net/imap', labels: %w[protocol.email.imap])),
215
+ # 'Marshal' => TargetMethods.new(%i[dump load], Package.build_from_path('marshal', labels: %w[format.marshal])),
216
+ 'Psych' => TargetMethods.new(%i[dump dump_stream load load_stream parse parse_stream], Package.build_from_path('yaml', package_name: 'psych', labels: %w[format.yaml])),
217
+ 'JSON::Ext::Parser' => TargetMethods.new(:parse, Package.build_from_path('json', package_name: 'json', labels: %w[format.json])),
218
+ 'JSON::Ext::Generator::State' => TargetMethods.new(:generate, Package.build_from_path('json', package_name: 'json', labels: %w[format.json])),
128
219
  }.freeze
129
220
 
130
- attr_reader :name, :packages, :exclude, :builtin_methods
221
+ attr_reader :name, :packages, :exclude, :hooked_methods, :builtin_hooks
131
222
 
132
223
  def initialize(name, packages, exclude: [], functions: [])
133
224
  @name = name
134
225
  @packages = packages
226
+ @hook_paths = Set.new(packages.map(&:path))
135
227
  @exclude = exclude
136
- @builtin_methods = BUILTIN_METHODS
228
+ @builtin_hooks = BUILTIN_HOOKS
137
229
  @functions = functions
138
- @hooked_methods = HOOKED_METHODS.dup
230
+
231
+ @hooked_methods = METHOD_HOOKS.each_with_object(Hash.new { |h,k| h[k] = [] }) do |cls_target_methods, hooked_methods|
232
+ hooked_methods[cls_target_methods.cls] << cls_target_methods.target_methods
233
+ end
234
+
139
235
  functions.each do |func|
140
236
  package_options = {}
141
237
  package_options[:labels] = func.labels if func.labels
142
- @hooked_methods[func.cls] ||= []
143
- @hooked_methods[func.cls] << Hook.new(func.function_names, Package.build_from_path(func.package, package_options))
238
+ @hooked_methods[func.cls] << TargetMethods.new(func.function_names, Package.build_from_path(func.package, package_options))
239
+ end
240
+
241
+ @hooked_methods.each_value do |hooks|
242
+ Array(hooks).each do |hook|
243
+ @hook_paths << hook.package.path
244
+ end
144
245
  end
145
246
  end
146
247
 
@@ -191,57 +292,54 @@ module AppMap
191
292
  }
192
293
  end
193
294
 
194
- # package_for_method finds the Package, if any, which configures the hook
195
- # for a method.
196
- def package_for_method(method)
197
- package_hooked_by_class(method) || package_hooked_by_source_location(method)
198
- end
199
-
200
- def package_hooked_by_class(method)
201
- defined_class, _, method_name = ::AppMap::Hook.qualify_method_name(method)
202
- return find_package(defined_class, method_name)
295
+ # Determines if methods defined in a file path should possibly be hooked.
296
+ def path_enabled?(path)
297
+ path = AppMap::Util.normalize_path(path)
298
+ @hook_paths.find { |hook_path| path.index(hook_path) == 0 }
203
299
  end
204
300
 
205
- def package_hooked_by_source_location(method)
206
- location = method.source_location
207
- location_file, = location
208
- return unless location_file
301
+ # Looks up a class and method in the config, to find the matching Package configuration.
302
+ # This class is only used after +path_enabled?+ has returned `true`.
303
+ LookupPackage = Struct.new(:config, :cls, :method) do
304
+ def package
305
+ # Global "excludes" configuration can be used to ignore any class/method.
306
+ return if config.never_hook?(cls, method)
209
307
 
210
- location_file = location_file[Dir.pwd.length + 1..-1] if location_file.index(Dir.pwd) == 0
211
- packages.select { |pkg| pkg.path }.find do |pkg|
212
- (location_file.index(pkg.path) == 0) &&
213
- !pkg.exclude.find { |p| location_file.index(p) }
308
+ package_for_code_object || package_for_location
214
309
  end
215
- end
216
-
217
- def never_hook?(method)
218
- defined_class, separator, method_name = ::AppMap::Hook.qualify_method_name(method)
219
- return true if exclude.member?(defined_class) || exclude.member?([ defined_class, separator, method_name ].join)
220
- end
221
310
 
222
- # always_hook? indicates a method that should always be hooked.
223
- def always_hook?(defined_class, method_name)
224
- !!find_package(defined_class, method_name)
225
- end
311
+ # Hook a method which is specified by class and method name.
312
+ def package_for_code_object
313
+ Array(config.hooked_methods[cls.name])
314
+ .compact
315
+ .find { |hook| hook.include_method?(method.name) }
316
+ &.package
317
+ end
226
318
 
227
- # included_by_location? indicates a method whose source location matches a method definition that has been
228
- # configured for inclusion.
229
- def included_by_location?(method)
230
- !!package_for_method(method)
319
+ # Hook a method which is specified by code location (i.e. path).
320
+ def package_for_location
321
+ location = method.source_location
322
+ location_file, = location
323
+ return unless location_file
324
+
325
+ location_file = AppMap::Util.normalize_path(location_file)
326
+ config
327
+ .packages
328
+ .select { |pkg| pkg.path }
329
+ .find do |pkg|
330
+ (location_file.index(pkg.path) == 0) &&
331
+ !pkg.exclude.find { |p| location_file.index(p) }
332
+ end
333
+ end
231
334
  end
232
335
 
233
- def find_package(defined_class, method_name)
234
- hooks = find_hooks(defined_class)
235
- return nil unless hooks
236
-
237
- hook = Array(hooks).find do |hook|
238
- Array(hook.method_names).include?(method_name)
239
- end
240
- hook ? hook.package : nil
336
+ def lookup_package(cls, method)
337
+ LookupPackage.new(self, cls, method).package
241
338
  end
242
339
 
243
- def find_hooks(defined_class)
244
- Array(@hooked_methods[defined_class] || @builtin_methods[defined_class])
340
+ def never_hook?(cls, method)
341
+ _, separator, = ::AppMap::Hook.qualify_method_name(method)
342
+ return true if exclude.member?(cls.name) || exclude.member?([ cls.name, separator, method.name ].join)
245
343
  end
246
344
  end
247
345
  end