appmap 0.42.1 → 0.46.0

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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.releaserc.yml +11 -0
  3. data/.travis.yml +33 -2
  4. data/CHANGELOG.md +44 -0
  5. data/README.md +74 -16
  6. data/README_CI.md +29 -0
  7. data/Rakefile +4 -2
  8. data/appmap.gemspec +5 -3
  9. data/lib/appmap.rb +3 -7
  10. data/lib/appmap/class_map.rb +11 -22
  11. data/lib/appmap/command/record.rb +1 -1
  12. data/lib/appmap/config.rb +180 -67
  13. data/lib/appmap/cucumber.rb +1 -1
  14. data/lib/appmap/event.rb +46 -27
  15. data/lib/appmap/handler/function.rb +19 -0
  16. data/lib/appmap/handler/net_http.rb +107 -0
  17. data/lib/appmap/handler/rails/request_handler.rb +124 -0
  18. data/lib/appmap/handler/rails/sql_handler.rb +152 -0
  19. data/lib/appmap/handler/rails/template.rb +149 -0
  20. data/lib/appmap/hook.rb +111 -70
  21. data/lib/appmap/hook/method.rb +6 -8
  22. data/lib/appmap/middleware/remote_recording.rb +1 -1
  23. data/lib/appmap/minitest.rb +22 -20
  24. data/lib/appmap/railtie.rb +5 -5
  25. data/lib/appmap/record.rb +1 -1
  26. data/lib/appmap/rspec.rb +22 -21
  27. data/lib/appmap/trace.rb +47 -6
  28. data/lib/appmap/util.rb +47 -2
  29. data/lib/appmap/version.rb +2 -2
  30. data/package-lock.json +3 -3
  31. data/release.sh +17 -0
  32. data/spec/abstract_controller_base_spec.rb +140 -34
  33. data/spec/class_map_spec.rb +5 -13
  34. data/spec/config_spec.rb +33 -1
  35. data/spec/fixtures/hook/custom_instance_method.rb +11 -0
  36. data/spec/fixtures/hook/method_named_call.rb +11 -0
  37. data/spec/fixtures/rails5_users_app/Gemfile +7 -3
  38. data/spec/fixtures/rails5_users_app/app/controllers/api/users_controller.rb +2 -0
  39. data/spec/fixtures/rails5_users_app/app/controllers/users_controller.rb +9 -1
  40. data/spec/fixtures/rails5_users_app/config/application.rb +2 -0
  41. data/spec/fixtures/rails5_users_app/create_app +8 -2
  42. data/spec/fixtures/rails5_users_app/spec/controllers/users_controller_api_spec.rb +13 -0
  43. data/spec/fixtures/rails5_users_app/spec/controllers/users_controller_spec.rb +2 -2
  44. data/spec/fixtures/rails5_users_app/spec/rails_helper.rb +3 -9
  45. data/spec/fixtures/rails6_users_app/Gemfile +5 -4
  46. data/spec/fixtures/rails6_users_app/app/controllers/api/users_controller.rb +1 -0
  47. data/spec/fixtures/rails6_users_app/app/controllers/users_controller.rb +9 -1
  48. data/spec/fixtures/rails6_users_app/config/application.rb +2 -0
  49. data/spec/fixtures/rails6_users_app/create_app +8 -2
  50. data/spec/fixtures/rails6_users_app/spec/controllers/users_controller_api_spec.rb +13 -0
  51. data/spec/fixtures/rails6_users_app/spec/controllers/users_controller_spec.rb +2 -2
  52. data/spec/fixtures/rails6_users_app/spec/rails_helper.rb +3 -9
  53. data/spec/hook_spec.rb +143 -22
  54. data/spec/record_net_http_spec.rb +160 -0
  55. data/spec/record_sql_rails_pg_spec.rb +1 -1
  56. data/spec/spec_helper.rb +16 -0
  57. data/test/expectations/openssl_test_key_sign1.json +2 -4
  58. data/test/gem_test.rb +1 -1
  59. data/test/rspec_test.rb +0 -13
  60. metadata +20 -14
  61. data/exe/appmap +0 -154
  62. data/lib/appmap/rails/request_handler.rb +0 -109
  63. data/lib/appmap/rails/sql_handler.rb +0 -150
  64. data/test/cli_test.rb +0 -116
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'appmap/event'
4
+
5
+ module AppMap
6
+ module Handler
7
+ module Function
8
+ class << self
9
+ def handle_call(defined_class, hook_method, receiver, args)
10
+ AppMap::Event::MethodCall.build_from_invocation(defined_class, hook_method, receiver, args)
11
+ end
12
+
13
+ def handle_return(call_event_id, elapsed, return_value, exception)
14
+ AppMap::Event::MethodReturn.build_from_invocation(call_event_id, return_value, exception, elapsed: elapsed)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'appmap/event'
4
+
5
+ module AppMap
6
+ module Handler
7
+ class HTTPClientRequest < AppMap::Event::MethodEvent
8
+ attr_accessor :request_method, :url, :params, :headers
9
+
10
+ def initialize(http, request)
11
+ super AppMap::Event.next_id_counter, :call, Thread.current.object_id
12
+
13
+ path, query = request.path.split('?')
14
+ query ||= ''
15
+
16
+ protocol = http.use_ssl? ? 'https' : 'http'
17
+ port = if http.use_ssl? && http.port == 443
18
+ nil
19
+ elsif !http.use_ssl? && http.port == 80
20
+ nil
21
+ else
22
+ ":#{http.port}"
23
+ end
24
+
25
+ url = [ protocol, '://', http.address, port, path ].compact.join
26
+
27
+ self.request_method = request.method
28
+ self.url = url
29
+ self.headers = AppMap::Util.select_headers(NetHTTP.request_headers(request))
30
+ self.params = Rack::Utils.parse_nested_query(query)
31
+ end
32
+
33
+ def to_h
34
+ super.tap do |h|
35
+ h[:http_client_request] = {
36
+ request_method: request_method,
37
+ url: url,
38
+ headers: headers
39
+ }.compact
40
+
41
+ unless params.blank?
42
+ h[:message] = params.keys.map do |key|
43
+ val = params[key]
44
+ {
45
+ name: key,
46
+ class: val.class.name,
47
+ value: self.class.display_string(val),
48
+ object_id: val.__id__,
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ class HTTPClientResponse < AppMap::Event::MethodReturnIgnoreValue
57
+ attr_accessor :status, :mime_type, :headers
58
+
59
+ def initialize(response, parent_id, elapsed)
60
+ super AppMap::Event.next_id_counter, :return, Thread.current.object_id
61
+
62
+ self.status = response.code.to_i
63
+ self.parent_id = parent_id
64
+ self.elapsed = elapsed
65
+ self.headers = AppMap::Util.select_headers(NetHTTP.response_headers(response))
66
+ end
67
+
68
+ def to_h
69
+ super.tap do |h|
70
+ h[:http_client_response] = {
71
+ status_code: status,
72
+ mime_type: mime_type,
73
+ headers: headers
74
+ }.compact
75
+ end
76
+ end
77
+ end
78
+
79
+ class NetHTTP
80
+ class << self
81
+ def request_headers(request)
82
+ {}.tap do |headers|
83
+ request.each_header do |k,v|
84
+ key = [ 'HTTP', k.underscore.upcase ].join('_')
85
+ headers[key] = v
86
+ end
87
+ end
88
+ end
89
+
90
+ alias response_headers request_headers
91
+
92
+ def handle_call(defined_class, hook_method, receiver, args)
93
+ # request will call itself again in a start block if it's not already started.
94
+ return unless receiver.started?
95
+
96
+ http = receiver
97
+ request = args.first
98
+ HTTPClientRequest.new(http, request)
99
+ end
100
+
101
+ def handle_return(call_event_id, elapsed, return_value, exception)
102
+ HTTPClientResponse.new(return_value, call_event_id, elapsed)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'appmap/event'
4
+ require 'appmap/hook'
5
+
6
+ module AppMap
7
+ module Handler
8
+ module Rails
9
+ module RequestHandler
10
+ class HTTPServerRequest < AppMap::Event::MethodEvent
11
+ attr_accessor :normalized_path_info, :request_method, :path_info, :params, :mime_type, :headers, :authorization
12
+
13
+ def initialize(request)
14
+ super AppMap::Event.next_id_counter, :call, Thread.current.object_id
15
+
16
+ self.request_method = request.request_method
17
+ self.normalized_path_info = normalized_path(request)
18
+ self.mime_type = request.headers['Content-Type']
19
+ self.headers = AppMap::Util.select_headers(request.env)
20
+ self.authorization = request.headers['Authorization']
21
+ self.path_info = request.path_info.split('?')[0]
22
+ # ActionDispatch::Http::ParameterFilter is deprecated
23
+ parameter_filter_cls = \
24
+ if defined?(ActiveSupport::ParameterFilter)
25
+ ActiveSupport::ParameterFilter
26
+ else
27
+ ActionDispatch::Http::ParameterFilter
28
+ end
29
+ self.params = parameter_filter_cls.new(::Rails.application.config.filter_parameters).filter(request.params)
30
+ end
31
+
32
+ def to_h
33
+ super.tap do |h|
34
+ h[:http_server_request] = {
35
+ request_method: request_method,
36
+ path_info: path_info,
37
+ mime_type: mime_type,
38
+ normalized_path_info: normalized_path_info,
39
+ authorization: authorization,
40
+ headers: headers,
41
+ }.compact
42
+
43
+ unless params.blank?
44
+ h[:message] = params.keys.map do |key|
45
+ val = params[key]
46
+ {
47
+ name: key,
48
+ class: val.class.name,
49
+ value: self.class.display_string(val),
50
+ object_id: val.__id__,
51
+ }.tap do |message|
52
+ properties = object_properties(val)
53
+ message[:properties] = properties if properties
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def normalized_path(request, router = ::Rails.application.routes.router)
63
+ router.recognize request do |route, _|
64
+ app = route.app
65
+ next unless app.matches? request
66
+ return normalized_path request, app.rack_app.routes.router if app.engine?
67
+
68
+ return route.path.spec.to_s
69
+ end
70
+ end
71
+ end
72
+
73
+ class HTTPServerResponse < AppMap::Event::MethodReturnIgnoreValue
74
+ attr_accessor :status, :mime_type, :headers
75
+
76
+ def initialize(response, parent_id, elapsed)
77
+ super AppMap::Event.next_id_counter, :return, Thread.current.object_id
78
+
79
+ self.status = response.status
80
+ self.mime_type = response.headers['Content-Type']
81
+ self.parent_id = parent_id
82
+ self.elapsed = elapsed
83
+ self.headers = AppMap::Util.select_headers(response.headers)
84
+ end
85
+
86
+ def to_h
87
+ super.tap do |h|
88
+ h[:http_server_response] = {
89
+ status_code: status,
90
+ mime_type: mime_type,
91
+ headers: headers
92
+ }.compact
93
+ end
94
+ end
95
+ end
96
+
97
+ class HookMethod < AppMap::Hook::Method
98
+ def initialize
99
+ # ActionController::Instrumentation has issued start_processing.action_controller and
100
+ # process_action.action_controller since Rails 3. Therefore it's a stable place to hook
101
+ # the request. Rails controller notifications can't be used directly because they don't
102
+ # provide response headers, and we want the Content-Type.
103
+ super(nil, ActionController::Instrumentation, ActionController::Instrumentation.instance_method(:process_action))
104
+ end
105
+
106
+ protected
107
+
108
+ def before_hook(receiver, defined_class, _) # args
109
+ call_event = HTTPServerRequest.new(receiver.request)
110
+ # http_server_request events are i/o and do not require a package name.
111
+ AppMap.tracing.record_event call_event, defined_class: defined_class, method: hook_method
112
+ [ call_event, TIME_NOW.call ]
113
+ end
114
+
115
+ def after_hook(receiver, call_event, start_time, _, _) # return_value, exception
116
+ elapsed = TIME_NOW.call - start_time
117
+ return_event = HTTPServerResponse.new receiver.response, call_event.id, elapsed
118
+ AppMap.tracing.record_event return_event
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'appmap/event'
4
+
5
+ module AppMap
6
+ module Handler
7
+ module Rails
8
+ class SQLHandler
9
+ class SQLCall < AppMap::Event::MethodCall
10
+ attr_accessor :payload
11
+
12
+ def initialize(payload)
13
+ super AppMap::Event.next_id_counter, :call, Thread.current.object_id
14
+
15
+ self.payload = payload
16
+ end
17
+
18
+ def to_h
19
+ super.tap do |h|
20
+ h[:sql_query] = {
21
+ sql: payload[:sql],
22
+ database_type: payload[:database_type]
23
+ }.tap do |sql_query|
24
+ %i[server_version].each do |attribute|
25
+ sql_query[attribute] = payload[attribute] if payload[attribute]
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ class SQLReturn < AppMap::Event::MethodReturnIgnoreValue
33
+ def initialize(parent_id, elapsed)
34
+ super AppMap::Event.next_id_counter, :return, Thread.current.object_id
35
+
36
+ self.parent_id = parent_id
37
+ self.elapsed = elapsed
38
+ end
39
+ end
40
+
41
+ module SQLExaminer
42
+ class << self
43
+ def examine(payload, sql:)
44
+ return unless (examiner = build_examiner)
45
+
46
+ payload[:server_version] = examiner.server_version
47
+ payload[:database_type] = examiner.database_type.to_s
48
+ end
49
+
50
+ protected
51
+
52
+ def build_examiner
53
+ if defined?(Sequel)
54
+ SequelExaminer.new
55
+ elsif defined?(ActiveRecord)
56
+ ActiveRecordExaminer.new
57
+ end
58
+ end
59
+ end
60
+
61
+ class SequelExaminer
62
+ def server_version
63
+ Sequel::Model.db.server_version
64
+ end
65
+
66
+ def database_type
67
+ Sequel::Model.db.database_type.to_sym
68
+ end
69
+
70
+ def execute_query(sql)
71
+ Sequel::Model.db[sql].all
72
+ end
73
+ end
74
+
75
+ class ActiveRecordExaminer
76
+ @@db_version_warning_issued = {}
77
+
78
+ def issue_warning
79
+ db_type = database_type
80
+ return if @@db_version_warning_issued[db_type]
81
+ warn("AppMap: Unable to determine database version for #{db_type.inspect}")
82
+ @@db_version_warning_issued[db_type] = true
83
+ end
84
+
85
+ def server_version
86
+ ActiveRecord::Base.connection.try(:database_version) || issue_warning
87
+ end
88
+
89
+ def database_type
90
+ type = ActiveRecord::Base.connection.adapter_name.downcase.to_sym
91
+ type = :postgres if type == :postgresql
92
+
93
+ type
94
+ end
95
+
96
+ def execute_query(sql)
97
+ ActiveRecord::Base.connection.execute(sql).inject([]) { |memo, r| memo << r; memo }
98
+ end
99
+ end
100
+ end
101
+
102
+ def call(_, started, finished, _, payload) # (name, started, finished, unique_id, payload)
103
+ return if AppMap.tracing.empty?
104
+
105
+ reentry_key = "#{self.class.name}#call"
106
+ return if Thread.current[reentry_key] == true
107
+
108
+ Thread.current[reentry_key] = true
109
+ begin
110
+ sql = payload[:sql].strip
111
+
112
+ # Detect whether a function call within a specified filename is present in the call stack.
113
+ find_in_backtrace = lambda do |file_name, function_name = nil|
114
+ Thread.current.backtrace.find do |line|
115
+ tokens = line.split(':')
116
+ matches_file = tokens.find { |t| t.rindex(file_name) == (t.length - file_name.length) }
117
+ matches_function = function_name.nil? || tokens.find { |t| t == "in `#{function_name}'" }
118
+ matches_file && matches_function
119
+ end
120
+ end
121
+
122
+ # Ignore SQL calls which are made while establishing a new connection.
123
+ #
124
+ # Example:
125
+ # /path/to/ruby/2.6.0/gems/sequel-5.20.0/lib/sequel/connection_pool.rb:122:in `make_new'
126
+ return if find_in_backtrace.call('lib/sequel/connection_pool.rb', 'make_new')
127
+ # lib/active_record/connection_adapters/abstract/connection_pool.rb:811:in `new_connection'
128
+ return if find_in_backtrace.call('lib/active_record/connection_adapters/abstract/connection_pool.rb', 'new_connection')
129
+
130
+ # Ignore SQL calls which are made while inspecting the DB schema.
131
+ #
132
+ # Example:
133
+ # /path/to/ruby/2.6.0/gems/sequel-5.20.0/lib/sequel/model/base.rb:812:in `get_db_schema'
134
+ return if find_in_backtrace.call('lib/sequel/model/base.rb', 'get_db_schema')
135
+ # /usr/local/bundle/gems/activerecord-5.2.3/lib/active_record/model_schema.rb:466:in `load_schema!'
136
+ return if find_in_backtrace.call('lib/active_record/model_schema.rb', 'load_schema!')
137
+ return if find_in_backtrace.call('lib/active_model/attribute_methods.rb', 'define_attribute_methods')
138
+ return if find_in_backtrace.call('lib/active_record/connection_adapters/schema_cache.rb')
139
+
140
+ SQLExaminer.examine payload, sql: sql
141
+
142
+ call = SQLCall.new(payload)
143
+ AppMap.tracing.record_event(call)
144
+ AppMap.tracing.record_event(SQLReturn.new(call.id, finished - started))
145
+ ensure
146
+ Thread.current[reentry_key] = nil
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'appmap/event'
4
+
5
+ module AppMap
6
+ module Handler
7
+ module Rails
8
+ class Template
9
+ LOG = (ENV['APPMAP_TEMPLATE_DEBUG'] == 'true' || ENV['DEBUG'] == 'true')
10
+
11
+ # All the code which is touched by the AppMap is recorded in the classMap.
12
+ # This duck-typed 'method' is used to represent a view template as a package,
13
+ # class, and method in the classMap.
14
+ # The class name is generated from the template path. The package name is
15
+ # 'app/views', and the method name is 'render'. The source location of the method
16
+ # is, of course, the path to the view template.
17
+ TemplateMethod = Struct.new(:path) do
18
+ private_instance_methods :path
19
+ attr_reader :class_name
20
+
21
+ def initialize(path)
22
+ super
23
+
24
+ @class_name = path.parameterize.underscore
25
+ end
26
+
27
+ def package
28
+ 'app/views'
29
+ end
30
+
31
+ def name
32
+ 'render'
33
+ end
34
+
35
+ def source_location
36
+ path
37
+ end
38
+
39
+ def static
40
+ true
41
+ end
42
+
43
+ def comment
44
+ nil
45
+ end
46
+
47
+ def labels
48
+ [ 'mvc.template' ]
49
+ end
50
+ end
51
+
52
+ # TemplateCall is a type of function call which is specialized to view template rendering. Since
53
+ # there isn't really a perfect method in Rails to hook, this one is synthesized from the available
54
+ # information.
55
+ class TemplateCall < AppMap::Event::MethodEvent
56
+ # This is basically the +self+ parameter.
57
+ attr_reader :render_instance
58
+ # Path to the view template.
59
+ attr_accessor :path
60
+
61
+ def initialize(render_instance)
62
+ super :call
63
+
64
+ AppMap::Event::MethodEvent.build_from_invocation(:call, event: self)
65
+ @render_instance = render_instance
66
+ end
67
+
68
+ def static?
69
+ true
70
+ end
71
+
72
+ def to_h
73
+ super.tap do |h|
74
+ h[:defined_class] = path ? path.parameterize.underscore : 'inline_template'
75
+ h[:method_id] = 'render'
76
+ h[:path] = path
77
+ h[:static] = static?
78
+ h[:parameters] = []
79
+ h[:receiver] = {
80
+ class: AppMap::Event::MethodEvent.best_class_name(render_instance),
81
+ object_id: render_instance.__id__,
82
+ value: AppMap::Event::MethodEvent.display_string(render_instance)
83
+ }
84
+ h.compact
85
+ end
86
+ end
87
+ end
88
+
89
+ TEMPLATE_RENDERER = 'appmap.handler.rails.template.renderer'
90
+
91
+ # Hooks the ActionView::Resolver methods +find_all+, +find_all_anywhere+. The resolver is used
92
+ # during template rendering to lookup the template file path from parameters such as the
93
+ # template name, prefix, and partial (boolean).
94
+ class ResolverHandler
95
+ class << self
96
+ # Handled as a normal function call.
97
+ def handle_call(defined_class, hook_method, receiver, args)
98
+ name, prefix, partial = args
99
+ warn "Resolver: #{{ name: name, prefix: prefix, partial: partial }}" if LOG
100
+
101
+ AppMap::Handler::Function.handle_call(defined_class, hook_method, receiver, args)
102
+ end
103
+
104
+ # When the resolver returns, look to see if there is template rendering underway.
105
+ # If so, populate the template path. In all cases, add a TemplateMethod so that the
106
+ # template will be recorded in the classMap.
107
+ def handle_return(call_event_id, elapsed, return_value, exception)
108
+ warn "Resolver return: #{return_value.inspect}" if LOG
109
+
110
+ renderer = Array(Thread.current[TEMPLATE_RENDERER]).last
111
+ path = Array(return_value).first&.inspect
112
+
113
+ if path
114
+ AppMap.tracing.record_method(TemplateMethod.new(path))
115
+ renderer.path ||= path if renderer
116
+ end
117
+
118
+ AppMap::Handler::Function.handle_return(call_event_id, elapsed, return_value, exception)
119
+ end
120
+ end
121
+ end
122
+
123
+ # Hooks the ActionView::Renderer method +render+. This method is used by Rails to perform
124
+ # template rendering. The TemplateCall event which is emitted by this handler has a
125
+ # +path+ parameter, which is nil until it's filled in by a ResolverHandler.
126
+ class RenderHandler
127
+ class << self
128
+ def handle_call(defined_class, hook_method, receiver, args)
129
+ context, options = args
130
+
131
+ warn "Renderer: #{options}" if LOG
132
+
133
+ TemplateCall.new(receiver).tap do |call|
134
+ Thread.current[TEMPLATE_RENDERER] ||= []
135
+ Thread.current[TEMPLATE_RENDERER] << call
136
+ end
137
+ end
138
+
139
+ def handle_return(call_event_id, elapsed, return_value, exception)
140
+ Array(Thread.current[TEMPLATE_RENDERER]).pop
141
+
142
+ AppMap::Event::MethodReturnIgnoreValue.build_from_invocation(call_event_id, elapsed: elapsed)
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end