appmap 0.44.0 → 0.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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