appmap 0.44.0 → 0.47.1

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: 02dfa68caaa5e5413c68ae06e89664a676624c9dbb301e2ebbc2a7ae26359afd
4
- data.tar.gz: af97e3fb4bb428a238b854ef30969f232c48d4f3a59956865a4cc95788ac35e8
3
+ metadata.gz: 12dbe41efff7d8fd40a884be376ff1579d961371b780eddf18d4724e7ce5997f
4
+ data.tar.gz: fb038cfbcc6c2432b780d4555b8948729fd65a2943a4bc1200c7d6943a62f5a8
5
5
  SHA512:
6
- metadata.gz: 9a0f2454acf5d10c48aaf1abb6e4b23d674acbbfe495770a25ff83093c38dd81cf0bb1c85dfbae4691773586ace8500c3f9b879b31749c4142851f460ed1a87c
7
- data.tar.gz: a2860fb0258b96d95e5ca98f7d13b382c86b635ce1c2d97084c1f651a55973d2ffe31e360695fdec963292081e527b96876c6b42976a90573b9201e14032d1f5
6
+ metadata.gz: 07a9ec31b08915e1d630a90200393d88db2b85b49b8dc21ffa595e598fa57ff8ed3afa463e3e9dc2ae9485d5dff399eb1724c6aa38cbcceeb0d3c6c0757d0f70
7
+ data.tar.gz: 3c6e629772604730b6acc14409c98694624cbfcbbc14a661182b6bb5388664b15a918c3e82ca5b55dd1daf6ca142741d0380a3e88c152f5bafcd57cb9b38dd6c
data/.releaserc.yml ADDED
@@ -0,0 +1,11 @@
1
+ plugins:
2
+ - '@semantic-release/commit-analyzer'
3
+ - '@semantic-release/release-notes-generator'
4
+ - '@semantic-release/changelog'
5
+ - 'semantic-release-rubygem'
6
+ - - '@semantic-release/git'
7
+ - assets:
8
+ - CHANGELOG.md
9
+ - appmap.gemspec
10
+ - lib/appmap/version.rb
11
+ - '@semantic-release/github'
data/.travis.yml CHANGED
@@ -16,23 +16,39 @@ before_script:
16
16
 
17
17
  cache:
18
18
  bundler: true
19
- directories:
20
- - $HOME/docker
21
-
22
- # https://stackoverflow.com/a/41975912
23
- before_cache:
24
- # Save tagged docker images
25
- - >
26
- mkdir -p $HOME/docker && docker images -a --filter='dangling=false' --format '{{.Repository}}:{{.Tag}} {{.ID}}'
27
- | xargs -n 2 -t sh -c 'test -e $HOME/docker/$1.tar.gz || docker save $0 | gzip -2 > $HOME/docker/$1.tar.gz'
28
19
 
29
20
  before_install:
30
- # Load cached docker images
31
- - if [[ -d $HOME/docker ]]; then ls $HOME/docker/*.tar.gz | xargs -I {file} sh -c "zcat {file} | docker load"; fi
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
32
29
 
30
+
31
+ # GEM_ALTERNATIVE_NAME only needed for deployment
33
32
  jobs:
34
33
  include:
35
34
  - stage: test
36
35
  script:
37
36
  - mkdir tmp
38
- - bundle exec rake test
37
+ - GEM_ALTERNATIVE_NAME='' bundle exec rake test
38
+
39
+
40
+ before_deploy:
41
+ - |
42
+ nvm install --lts \
43
+ && nvm use --lts \
44
+ && npm i -g \
45
+ semantic-release \
46
+ @semantic-release/git \
47
+ @semantic-release/changelog \
48
+ semantic-release-rubygem
49
+
50
+ deploy:
51
+ - provider: script
52
+ script: ./release.sh
53
+ on:
54
+ branch: master
data/CHANGELOG.md CHANGED
@@ -1,3 +1,45 @@
1
+ ## [0.47.1](https://github.com/applandinc/appmap-ruby/compare/v0.47.0...v0.47.1) (2021-05-13)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * Add the proper template function hooks for Rails 6.0.7 ([175f489](https://github.com/applandinc/appmap-ruby/commit/175f489acbaed77ad52a18d805e4b6eeae1abfdb))
7
+
8
+ # [0.47.0](https://github.com/applandinc/appmap-ruby/compare/v0.46.0...v0.47.0) (2021-05-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * Emit swagger-style normalized paths instead of Rails-style ones ([5a93cd7](https://github.com/applandinc/appmap-ruby/commit/5a93cd7096ca195146a84a6733c7d502dbcd0272))
14
+
15
+ # [0.46.0](https://github.com/applandinc/appmap-ruby/compare/v0.45.1...v0.46.0) (2021-05-12)
16
+
17
+
18
+ ### Features
19
+
20
+ * Record view template rendering events and template paths ([973b258](https://github.com/applandinc/appmap-ruby/commit/973b2581b6e2d4e15a1b93331e4e95a88678faae))
21
+
22
+ ## [0.45.1](https://github.com/applandinc/appmap-ruby/compare/v0.45.0...v0.45.1) (2021-05-04)
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * Optimize instrumentation and load time ([db4a8ce](https://github.com/applandinc/appmap-ruby/commit/db4a8ceed4103a52caafa46626c66f33fbfeac27))
28
+
29
+ # [0.45.0](https://github.com/applandinc/appmap-ruby/compare/v0.44.0...v0.45.0) (2021-05-03)
30
+
31
+
32
+ ### Bug Fixes
33
+
34
+ * Properly name status_code in HTTP server response ([556e87c](https://github.com/applandinc/appmap-ruby/commit/556e87c9a7bf214f6b8714add4f77448fd223d33))
35
+
36
+
37
+ ### Features
38
+
39
+ * Record http_client_request and http_client_response ([1db32ae](https://github.com/applandinc/appmap-ruby/commit/1db32ae0d26a7f1400b6b814d25b13368f06c158))
40
+ * Update AppMap format version to 1.5.0 ([061705e](https://github.com/applandinc/appmap-ruby/commit/061705e4619cb881e8edd022ef835183e399e127))
41
+ * **build:** add deployment via `semantic-release` with automatic publication to rubygems ([9f183de](https://github.com/applandinc/appmap-ruby/commit/9f183de13f405900000c3da979c3a8a5b6e34a24))
42
+
1
43
  # v0.44.0
2
44
 
3
45
  * Support recording and labeling of indivudal functions via `functions:` section in *appmap.yml*.
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)
@@ -325,25 +325,25 @@ if defined?(AppMap)
325
325
  end
326
326
  ```
327
327
 
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.
328
+ 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
329
 
330
- 3. Start your Rails application server. For example:
330
+ 3. Start your Rails application server, with `APPMAP_RECORD=true`. For example:
331
331
 
332
332
  ```sh-session
333
- $ bundle exec rails server
333
+ $ APPMAP_RECORD=true bundle exec rails server
334
334
  ```
335
335
 
336
- 4. Open the AppLand browser extension and push `Start`.
336
+ 4. Start the recording
337
337
 
338
- 5. Use your app. For example, perform a login flow, or run through a manual UI test.
339
-
340
- 6. Open the AppLand browser extension and push `Stop`. The recording will be transferred to the AppLand website and opened in your browser.
338
+ Option 1: Open the AppLand browser extension and push `Start`.
339
+ Option 2: `curl -XPOST localhost:3000/_appmap/record` (be sure and get the port number right)
341
340
 
342
- ## Server process recording
341
+ 5. Use your app. For example, perform a login flow, or run through a manual UI test.
343
342
 
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.
343
+ 6. Finish the recording.
345
344
 
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.
345
+ Option 1: Open the AppLand browser extension and push `Stop`. The recording will be transferred to the AppLand website and opened in your browser.
346
+ Option 2: `curl -XDELETE localhost:3000/_appmap/record > recording.appmap.json` - Saves the recording as a local file.
347
347
 
348
348
 
349
349
  # AppMap for VSCode
@@ -369,6 +369,34 @@ For instructions on uploading, see the documentation of the [AppLand CLI](https:
369
369
  # Development
370
370
  [![Build Status](https://travis-ci.com/applandinc/appmap-ruby.svg?branch=master)](https://travis-ci.com/applandinc/appmap-ruby)
371
371
 
372
+ ## Internal architecture
373
+
374
+ **Configuration**
375
+
376
+ *appmap.yml* is loaded into an `AppMap::Config`.
377
+
378
+ **Hooking**
379
+
380
+ Once configuration is loaded, `AppMap::Hook` is enabled. "Hooking" refers to the process of replacing a method
381
+ with a "hooked" version of the method. The hooked method checks to see if tracing is enabled. If so, it wraps the original
382
+ method with calls that record the parameters and return value.
383
+
384
+ **Builtins**
385
+
386
+ `Hook` begins by iterating over builtin classes and modules defined in the `Config`. Builtins include code
387
+ like `openssl` and `net/http`. This code is not dependent on any external libraries being present, and
388
+ `appmap` cannot guarantee that it will be loaded before builtins. Therefore, it's necessary to require it and
389
+ hook it by looking up the classes and modules as constants in the `Object` namespace.
390
+
391
+ **User code and gems**
392
+
393
+ After hooking builtins, `Hook` attaches a [TracePoint](https://ruby-doc.org/core-2.6/TracePoint.html) to `:begin` events.
394
+ This TracePoint is notified each time a new class or module is being evaluated. When this happens, `Hook` uses the `Config`
395
+ to determine whether any code within the evaluated file is configured for hooking. If so, a `TracePoint` is attached to
396
+ `:end` events. Each `:end` event is fired when a class or module definition is completed. When this happens, the `Hook` enumerates
397
+ the public methods of the class or module, hooking the ones that are targeted by the `Config`. Once the `:end` TracePoint leaves
398
+ the scope of the `:begin`, the `:end` TracePoint is disabled.
399
+
372
400
  ## Running tests
373
401
 
374
402
  Before running tests, configure `local.appmap` to point to your local `appmap-ruby` directory.
data/README_CI.md ADDED
@@ -0,0 +1,29 @@
1
+ # Configuration variables:
2
+
3
+ * `GH_TOKEN`: used by `semantic-release` to push changes to Github and manage releases
4
+ * `GEM_HOST_API_KEY`: rubygems API key
5
+ * `GEM_ALTERNATIVE_NAME` (optional): used for testing of CI flows,
6
+ to avoid publication of test releases under official package name
7
+ * `DOCKERHUB\_USERNAME`, `DOCKERHUB_PASSWORD`: optional dockerhub credentials,
8
+ to avoid throttling of dockerhub anonymous pulls
9
+
10
+ Note: for security reasons, it's better to use dedicated (not personal)
11
+ Dockerhub account,
12
+ and also use [access tokens](https://docs.docker.com/docker-hub/access-tokens/)
13
+ instead of primary password
14
+
15
+ # Release command
16
+
17
+ `./release.sh`
18
+
19
+ Bash wrapper script is used merely as a launcher of `semantic-release`
20
+ with extra logic to explicitly determine git url from `TRAVIS_REPO_SLUG` \
21
+ variable if its defined (otherwise git url is taken from `package.json`,
22
+ which breaks CI on forked repos).
23
+
24
+ # CI flow
25
+
26
+ 1. Test happens using current version number specified in `lib/appmap/version.rb`, then `release.sh` launches `semantic-release` to do the rest
27
+ 2. The version number is increased (including modicication of `version.rb`)
28
+ 3. Gem is published under new version number
29
+ 4. Github release is created with the new version number
data/Rakefile CHANGED
@@ -37,7 +37,8 @@ end
37
37
 
38
38
  def build_base_image(ruby_version)
39
39
  run_cmd "docker build" \
40
- " --build-arg RUBY_VERSION=#{ruby_version} --build-arg GEM_VERSION=#{GEM_VERSION}" \
40
+ " --build-arg RUBY_VERSION=#{ruby_version}" \
41
+ " --build-arg GEM_VERSION=#{GEM_VERSION}" \
41
42
  " -t appmap:#{GEM_VERSION} -f Dockerfile.appmap ."
42
43
  end
43
44
 
@@ -46,7 +47,7 @@ def build_app_image(app, ruby_version)
46
47
  run_cmd( {"RUBY_VERSION" => ruby_version, "GEM_VERSION" => GEM_VERSION},
47
48
  " docker-compose build" \
48
49
  " --build-arg RUBY_VERSION=#{ruby_version}" \
49
- " --build-arg GEM_VERSION=#{GEM_VERSION}")
50
+ " --build-arg GEM_VERSION=#{GEM_VERSION}" )
50
51
  end
51
52
  end
52
53
 
@@ -138,3 +139,4 @@ task spec: %i[spec:all]
138
139
  task test: %i[spec:all minitest]
139
140
 
140
141
  task default: :test
142
+
data/appmap.gemspec CHANGED
@@ -4,8 +4,12 @@ lib = File.expand_path('lib', __dir__)
4
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
5
  require 'appmap/version'
6
6
 
7
+
8
+
7
9
  Gem::Specification.new do |spec|
8
- spec.name = 'appmap'
10
+ # ability to parameterize gem name is added intentionally,
11
+ # to support the possibility of unofficial releases, e.g. during CI tests
12
+ spec.name = (ENV['GEM_ALTERNATIVE_NAME'].to_s.empty? ? 'appmap' : ENV["GEM_ALTERNATIVE_NAME"] )
9
13
  spec.version = AppMap::VERSION
10
14
  spec.authors = ['Kevin Gilpin']
11
15
  spec.email = ['kgilpin@gmail.com']
@@ -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,32 @@
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.
23
+ attr_writer :handler_class
24
+
25
+ def handler_class
26
+ require 'appmap/handler/function'
27
+ @handler_class || AppMap::Handler::Function
28
+ end
29
+
6
30
  # Indicates that only the entry points to a package will be recorded.
7
31
  # Once the code has entered a package, subsequent calls within the package will not be
8
32
  # recorded unless the code leaves the package and re-enters it.
@@ -11,25 +35,36 @@ module AppMap
11
35
  end
12
36
 
13
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.
14
40
  def build_from_path(path, shallow: false, package_name: nil, exclude: [], labels: [])
15
41
  Package.new(path, nil, package_name, exclude, labels, shallow)
16
42
  end
17
43
 
18
- def build_from_gem(gem, shallow: true, package_name: nil, exclude: [], labels: [])
19
- 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)
20
48
  warn "WARNING: #{gem} cannot be AppMapped because it is a dependency of the appmap gem"
21
49
  return
22
50
  end
23
- 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
24
57
  end
25
58
 
26
59
  private_class_method :new
27
60
 
28
61
  protected
29
62
 
30
- def gem_path(gem)
31
- gemspec = Gem.loaded_specs[gem] or raise "Gem #{gem.inspect} not found"
32
- 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
33
68
  end
34
69
  end
35
70
 
@@ -42,6 +77,7 @@ module AppMap
42
77
  path: path,
43
78
  package_name: package_name,
44
79
  gem: gem,
80
+ handler_class: handler_class.name,
45
81
  exclude: exclude.blank? ? nil : exclude,
46
82
  labels: labels.blank? ? nil : labels,
47
83
  shallow: shallow
@@ -49,7 +85,33 @@ module AppMap
49
85
  end
50
86
  end
51
87
 
52
- 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:
53
115
  def to_h
54
116
  {
55
117
  package: package,
@@ -59,78 +121,127 @@ module AppMap
59
121
  }.compact
60
122
  end
61
123
  end
124
+ private_constant :Function
62
125
 
63
- class Hook
64
- attr_reader :method_names, :package
126
+ ClassTargetMethods = Struct.new(:cls, :target_methods) # :nodoc:
127
+ private_constant :ClassTargetMethods
65
128
 
66
- def initialize(method_names, package)
67
- @method_names = method_names
68
- @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
69
141
  end
70
142
 
71
- def to_h
72
- {
73
- package: package.name,
74
- method_names: method_names
75
- }
143
+ def method_hook(cls, method_names, labels)
144
+ MethodHook.new(cls, method_names, labels)
76
145
  end
77
146
  end
78
147
 
79
- 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
80
189
 
81
- # Methods that should always be hooked, with their containing
82
- # package and labels that should be applied to them.
83
- HOOKED_METHODS = {
84
- 'ActiveSupport::SecurityUtils' => Hook.new(:secure_compare, Package.build_from_path('active_support', labels: %w[crypto.secure_compare])),
85
- 'ActionView::Renderer' => Hook.new(:render, Package.build_from_path('action_view', labels: %w[mvc.view])),
86
- 'ActionDispatch::Request::Session' => Hook.new(%i[destroy [] dig values []= clear update delete fetch merge], Package.build_from_path('action_pack', labels: %w[http.session])),
87
- 'ActionDispatch::Cookies::CookieJar' => Hook.new(%i[[]= clear update delete recycle], Package.build_from_path('action_pack', labels: %w[http.cookie])),
88
- 'ActionDispatch::Cookies::EncryptedCookieJar' => Hook.new(%i[[]=], Package.build_from_path('action_pack', labels: %w[http.cookie crypto.encrypt])),
89
- 'CanCan::ControllerAdditions' => Hook.new(%i[authorize! can? cannot?], Package.build_from_path('cancancan', labels: %w[security.authorization])),
90
- 'CanCan::Ability' => Hook.new(%i[authorize!], Package.build_from_path('cancancan', labels: %w[security.authorization])),
91
- 'ActionController::Instrumentation' => [
92
- Hook.new(%i[process_action send_file send_data redirect_to], Package.build_from_path('action_view', labels: %w[mvc.controller])),
93
- Hook.new(%i[render], Package.build_from_path('action_view', labels: %w[mvc.view])),
94
- ]
95
- }.freeze
190
+ OPENSSL_PACKAGES = ->(labels) { Package.build_from_path('openssl', package_name: 'openssl', labels: labels) }
96
191
 
97
- BUILTIN_METHODS = {
98
- 'OpenSSL::PKey::PKey' => Hook.new(:sign, OPENSSL_PACKAGES.(%w[crypto.pkey])),
99
- 'OpenSSL::X509::Request' => Hook.new(%i[sign verify], OPENSSL_PACKAGES.(%w[crypto.x509])),
100
- '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])),
101
198
  'OpenSSL::Cipher' => [
102
- Hook.new(%i[encrypt], OPENSSL_PACKAGES.(%w[crypto.encrypt])),
103
- 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]))
104
201
  ],
105
202
  'ActiveSupport::Callbacks::CallbackSequence' => [
106
- Hook.new(:invoke_before, Package.build_from_path('active_support', package_name: 'active_support', labels: %w[mvc.before_action])),
107
- 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])),
108
205
  ],
109
- 'OpenSSL::X509::Certificate' => Hook.new(:sign, OPENSSL_PACKAGES.(%w[crypto.x509])),
110
- 'Net::HTTP' => Hook.new(:request, Package.build_from_path('net/http', package_name: 'net/http', labels: %w[protocol.http])),
111
- 'Net::SMTP' => Hook.new(:send, Package.build_from_path('net/smtp', package_name: 'net/smtp', labels: %w[protocol.email.smtp])),
112
- 'Net::POP3' => Hook.new(:mails, Package.build_from_path('net/pop3', package_name: 'net/pop', labels: %w[protocol.email.pop])),
113
- 'Net::IMAP' => Hook.new(:send_command, Package.build_from_path('net/imap', package_name: 'net/imap', labels: %w[protocol.email.imap])),
114
- 'Marshal' => Hook.new(%i[dump load], Package.build_from_path('marshal', labels: %w[format.marshal])),
115
- '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])),
116
- 'JSON::Ext::Parser' => Hook.new(:parse, Package.build_from_path('json', package_name: 'json', labels: %w[format.json])),
117
- 'JSON::Ext::Generator::State' => Hook.new(:generate, Package.build_from_path('json', package_name: 'json', labels: %w[format.json])),
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|
209
+ package.handler_class = AppMap::Handler::NetHTTP
210
+ end),
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])),
118
219
  }.freeze
119
220
 
120
- attr_reader :name, :packages, :exclude, :builtin_methods
221
+ attr_reader :name, :packages, :exclude, :hooked_methods, :builtin_hooks
121
222
 
122
223
  def initialize(name, packages, exclude: [], functions: [])
123
224
  @name = name
124
225
  @packages = packages
226
+ @hook_paths = Set.new(packages.map(&:path))
125
227
  @exclude = exclude
126
- @builtin_methods = BUILTIN_METHODS
228
+ @builtin_hooks = BUILTIN_HOOKS
127
229
  @functions = functions
128
- @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
+
129
235
  functions.each do |func|
130
236
  package_options = {}
131
237
  package_options[:labels] = func.labels if func.labels
132
- @hooked_methods[func.cls] ||= []
133
- @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
134
245
  end
135
246
  end
136
247
 
@@ -181,57 +292,54 @@ module AppMap
181
292
  }
182
293
  end
183
294
 
184
- # package_for_method finds the Package, if any, which configures the hook
185
- # for a method.
186
- def package_for_method(method)
187
- package_hooked_by_class(method) || package_hooked_by_source_location(method)
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 }
188
299
  end
189
300
 
190
- def package_hooked_by_class(method)
191
- defined_class, _, method_name = ::AppMap::Hook.qualify_method_name(method)
192
- return find_package(defined_class, method_name)
193
- end
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)
194
307
 
195
- def package_hooked_by_source_location(method)
196
- location = method.source_location
197
- location_file, = location
198
- return unless location_file
199
-
200
- location_file = location_file[Dir.pwd.length + 1..-1] if location_file.index(Dir.pwd) == 0
201
- packages.select { |pkg| pkg.path }.find do |pkg|
202
- (location_file.index(pkg.path) == 0) &&
203
- !pkg.exclude.find { |p| location_file.index(p) }
308
+ package_for_code_object || package_for_location
204
309
  end
205
- end
206
310
 
207
- def never_hook?(method)
208
- defined_class, separator, method_name = ::AppMap::Hook.qualify_method_name(method)
209
- return true if exclude.member?(defined_class) || exclude.member?([ defined_class, separator, method_name ].join)
210
- 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
211
318
 
212
- # always_hook? indicates a method that should always be hooked.
213
- def always_hook?(defined_class, method_name)
214
- !!find_package(defined_class, method_name)
215
- end
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
216
324
 
217
- # included_by_location? indicates a method whose source location matches a method definition that has been
218
- # configured for inclusion.
219
- def included_by_location?(method)
220
- !!package_for_method(method)
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
221
334
  end
222
335
 
223
- def find_package(defined_class, method_name)
224
- hooks = find_hooks(defined_class)
225
- return nil unless hooks
226
-
227
- hook = Array(hooks).find do |hook|
228
- Array(hook.method_names).include?(method_name)
229
- end
230
- hook ? hook.package : nil
336
+ def lookup_package(cls, method)
337
+ LookupPackage.new(self, cls, method).package
231
338
  end
232
339
 
233
- def find_hooks(defined_class)
234
- 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)
235
343
  end
236
344
  end
237
345
  end