tapping_device 0.5.1 → 0.5.6

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,10 +1,20 @@
1
1
  require "active_record"
2
+ require "active_support/core_ext/module/introspection"
3
+ require "pry" # for using Method#source
4
+
2
5
  require "tapping_device/version"
3
6
  require "tapping_device/manageable"
4
7
  require "tapping_device/payload"
5
- require "tapping_device/output_payload"
8
+ require "tapping_device/output"
6
9
  require "tapping_device/trackable"
10
+ require "tapping_device/configurable"
7
11
  require "tapping_device/exceptions"
12
+ require "tapping_device/method_hijacker"
13
+ require "tapping_device/trackers/initialization_tracker"
14
+ require "tapping_device/trackers/passed_tracker"
15
+ require "tapping_device/trackers/association_call_tracker"
16
+ require "tapping_device/trackers/method_call_tracker"
17
+ require "tapping_device/trackers/mutation_tracker"
8
18
 
9
19
  class TappingDevice
10
20
 
@@ -18,48 +28,17 @@ class TappingDevice
18
28
 
19
29
  extend Manageable
20
30
 
31
+ include Configurable
32
+ include Output::Helpers
33
+
21
34
  def initialize(options = {}, &block)
22
35
  @block = block
23
36
  @output_block = nil
24
- @options = process_options(options)
37
+ @options = process_options(options.dup)
25
38
  @calls = []
26
39
  @disabled = false
27
40
  @with_condition = nil
28
- self.class.devices << self
29
- end
30
-
31
- def tap_init!(klass)
32
- raise "argument should be a class, got #{klass}" unless klass.is_a?(Class)
33
- track(klass, condition: :tap_init?) do |payload|
34
- payload[:return_value] = payload[:receiver]
35
- payload[:receiver] = klass
36
- end
37
- end
38
-
39
- def tap_on!(object)
40
- track(object, condition: :tap_on?)
41
- end
42
-
43
- def tap_passed!(object)
44
- track(object, condition: :tap_passed?)
45
- end
46
-
47
- def tap_assoc!(record)
48
- raise "argument should be an instance of ActiveRecord::Base" unless record.is_a?(ActiveRecord::Base)
49
- track(record, condition: :tap_associations?)
50
- end
51
-
52
- def and_print(payload_method = nil, &block)
53
- @output_block =
54
- if block
55
- -> (output_payload) { puts(block.call(output_payload)) }
56
- elsif payload_method
57
- -> (output_payload) { puts(output_payload.send(payload_method)) }
58
- else
59
- raise "need to provide either a payload method name or a block"
60
- end
61
-
62
- self
41
+ TappingDevice.devices << self
63
42
  end
64
43
 
65
44
  def with(&block)
@@ -72,7 +51,7 @@ class TappingDevice
72
51
 
73
52
  def stop!
74
53
  @disabled = true
75
- self.class.delete_device(self)
54
+ TappingDevice.delete_device(self)
76
55
  end
77
56
 
78
57
  def stop_when(&block)
@@ -95,57 +74,48 @@ class TappingDevice
95
74
  options[:descendants]
96
75
  end
97
76
 
98
- private
99
-
100
- def track(object, condition:, &payload_block)
77
+ def track(object)
101
78
  @target = object
102
- @trace_point = TracePoint.new(options[:event_type]) do |tp|
103
- if send(condition, object, tp)
104
- filepath, line_number = get_call_location(tp)
105
-
106
- next if should_be_skipped_by_paths?(filepath)
79
+ validate_target!
107
80
 
108
- payload = build_payload(tp: tp, filepath: filepath, line_number: line_number, &payload_block)
81
+ MethodHijacker.new(@target).hijack_methods! if options[:hijack_attr_methods]
109
82
 
110
- next unless with_condition_satisfied?(payload)
111
-
112
- # skip TappingDevice related calls
113
- if Module.respond_to?(:module_parents)
114
- next if payload.defined_class.module_parents.include?(TappingDevice)
115
- else
116
- next if payload.defined_class.parents.include?(TappingDevice)
117
- end
83
+ @trace_point = build_minimum_trace_point(event_type: options[:event_type]) do |payload|
84
+ record_call!(payload)
118
85
 
119
- record_call!(payload)
120
-
121
- stop_if_condition_fulfilled(payload)
122
- end
86
+ stop_if_condition_fulfilled!(payload)
123
87
  end
124
88
 
125
- @trace_point.enable unless self.class.suspend_new
89
+ @trace_point.enable unless TappingDevice.suspend_new
126
90
 
127
91
  self
128
92
  end
129
93
 
130
- def get_call_location(tp, padding: 0)
131
- caller(get_trace_index(tp) + padding).first.split(":")[0..1]
132
- end
94
+ private
133
95
 
134
- def get_trace_index(tp)
135
- if tp.event == :c_call
136
- C_CALLER_START_POINT
137
- else
138
- CALLER_START_POINT
96
+ def build_minimum_trace_point(event_type:)
97
+ TracePoint.new(*event_type) do |tp|
98
+ next unless filter_condition_satisfied?(tp)
99
+
100
+ filepath, line_number = get_call_location(tp)
101
+ payload = build_payload(tp: tp, filepath: filepath, line_number: line_number)
102
+
103
+ unless @options[:force_recording]
104
+ next if is_tapping_device_call?(tp)
105
+ next if should_be_skipped_by_paths?(filepath)
106
+ next unless with_condition_satisfied?(payload)
107
+ next if payload.is_private_call? && @options[:ignore_private]
108
+ next if !payload.is_private_call? && @options[:only_private]
109
+ end
110
+
111
+ yield(payload)
139
112
  end
140
113
  end
141
114
 
142
- def get_traces(tp)
143
- if with_trace_to = options[:with_trace_to]
144
- trace_index = get_trace_index(tp)
145
- caller[trace_index..(trace_index + with_trace_to)]
146
- else
147
- []
148
- end
115
+ def validate_target!; end
116
+
117
+ def filter_condition_satisfied?(tp)
118
+ false
149
119
  end
150
120
 
151
121
  # this needs to be placed upfront so we can exclude noise before doing more work
@@ -154,8 +124,24 @@ class TappingDevice
154
124
  (options[:filter_by_paths].present? && !options[:filter_by_paths].any? { |pattern| pattern.match?(filepath) })
155
125
  end
156
126
 
127
+ def is_tapping_device_call?(tp)
128
+ if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
129
+ return true
130
+ end
131
+
132
+ if Module.respond_to?(:module_parents)
133
+ tp.defined_class.module_parents.include?(TappingDevice)
134
+ elsif Module.respond_to?(:parents)
135
+ tp.defined_class.parents.include?(TappingDevice)
136
+ end
137
+ end
138
+
139
+ def with_condition_satisfied?(payload)
140
+ @with_condition.blank? || @with_condition.call(payload)
141
+ end
142
+
157
143
  def build_payload(tp:, filepath:, line_number:)
158
- payload = Payload.init({
144
+ Payload.init({
159
145
  target: @target,
160
146
  receiver: tp.self,
161
147
  method_name: tp.callee_id,
@@ -166,60 +152,38 @@ class TappingDevice
166
152
  line_number: line_number,
167
153
  defined_class: tp.defined_class,
168
154
  trace: get_traces(tp),
155
+ is_private_call?: tp.defined_class.private_method_defined?(tp.callee_id),
169
156
  tp: tp
170
157
  })
171
-
172
- yield(payload) if block_given?
173
-
174
- payload
175
- end
176
-
177
- def tap_init?(klass, tp)
178
- receiver = tp.self
179
- method_name = tp.callee_id
180
-
181
- if klass.ancestors.include?(ActiveRecord::Base)
182
- method_name == :new && receiver.ancestors.include?(klass)
183
- else
184
- method_name == :initialize && receiver.is_a?(klass)
185
- end
186
158
  end
187
159
 
188
- def tap_on?(object, tp)
189
- is_from_target?(object, tp)
160
+ def get_method_object_from(target, method_name)
161
+ Object.instance_method(:method).bind(target).call(method_name)
162
+ rescue NameError
163
+ # if any part of the program uses Refinement to extend its methods
164
+ # we might still get NoMethodError when trying to get that method outside the scope
165
+ nil
190
166
  end
191
167
 
192
- def tap_associations?(object, tp)
193
- return false unless tap_on?(object, tp)
194
-
195
- model_class = object.class
196
- associations = model_class.reflections
197
- associations.keys.include?(tp.callee_id.to_s)
168
+ def get_call_location(tp, padding: 0)
169
+ caller(get_trace_index(tp) + padding).first.split(":")[0..1]
198
170
  end
199
171
 
200
- def tap_passed?(object, tp)
201
- # we don't care about calls from the device instance or helper methods
202
- return false if is_from_target?(self, tp)
203
- return false if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
204
-
205
- collect_arguments(tp).values.any? do |value|
206
- # during comparison, Ruby might perform data type conversion like calling `to_sym` on the value
207
- # but not every value supports every conversion methods
208
- object == value rescue false
172
+ def get_trace_index(tp)
173
+ if tp.event == :c_call
174
+ C_CALLER_START_POINT
175
+ else
176
+ CALLER_START_POINT
209
177
  end
210
- rescue
211
- false
212
178
  end
213
179
 
214
- def get_method_object_from(target, method_name)
215
- target.method(method_name)
216
- rescue ArgumentError
217
- method_method = Object.method(:method).unbind
218
- method_method.bind(target).call(method_name)
219
- rescue NameError
220
- # if any part of the program uses Refinement to extend its methods
221
- # we might still get NoMethodError when trying to get that method outside the scope
222
- nil
180
+ def get_traces(tp)
181
+ if with_trace_to = options[:with_trace_to]
182
+ trace_index = get_trace_index(tp)
183
+ caller[trace_index..(trace_index + with_trace_to)]
184
+ else
185
+ []
186
+ end
223
187
  end
224
188
 
225
189
  def collect_arguments(tp)
@@ -236,29 +200,28 @@ class TappingDevice
236
200
  end
237
201
 
238
202
  def process_options(options)
239
- options[:filter_by_paths] ||= []
240
- options[:exclude_by_paths] ||= []
241
- options[:with_trace_to] ||= 50
242
- options[:root_device] ||= self
243
- options[:event_type] ||= :return
203
+ options[:filter_by_paths] ||= config[:filter_by_paths]
204
+ options[:exclude_by_paths] ||= config[:exclude_by_paths]
205
+ options[:with_trace_to] ||= config[:with_trace_to]
206
+ options[:event_type] ||= config[:event_type]
207
+ options[:hijack_attr_methods] ||= config[:hijack_attr_methods]
208
+ options[:track_as_records] ||= config[:track_as_records]
209
+ options[:ignore_private] ||= config[:ignore_private]
210
+ options[:only_private] ||= config[:only_private]
211
+ # for debugging the gem more easily
212
+ options[:force_recording] ||= false
213
+
244
214
  options[:descendants] ||= []
245
- options[:track_as_records] ||= false
215
+ options[:root_device] ||= self
246
216
  options
247
217
  end
248
218
 
249
- def stop_if_condition_fulfilled(payload)
250
- if @stop_when&.call(payload)
251
- stop!
252
- root_device.stop!
253
- end
254
- end
255
-
256
- def is_from_target?(object, tp)
219
+ def is_from_target?(tp)
257
220
  comparsion = tp.self
258
- is_the_same_record?(object, comparsion) || object.__id__ == comparsion.__id__
221
+ is_the_same_record?(comparsion) || target.__id__ == comparsion.__id__
259
222
  end
260
223
 
261
- def is_the_same_record?(target, comparsion)
224
+ def is_the_same_record?(comparsion)
262
225
  return false unless options[:track_as_records]
263
226
  if target.is_a?(ActiveRecord::Base) && comparsion.is_a?(target.class)
264
227
  primary_key = target.class.primary_key
@@ -269,7 +232,7 @@ class TappingDevice
269
232
  def record_call!(payload)
270
233
  return if @disabled
271
234
 
272
- @output_block.call(OutputPayload.init(payload)) if @output_block
235
+ write_output!(payload) if @output_writer
273
236
 
274
237
  if @block
275
238
  root_device.calls << @block.call(payload)
@@ -278,7 +241,18 @@ class TappingDevice
278
241
  end
279
242
  end
280
243
 
281
- def with_condition_satisfied?(payload)
282
- @with_condition.blank? || @with_condition.call(payload)
244
+ def write_output!(payload)
245
+ @output_writer.write!(payload)
246
+ end
247
+
248
+ def stop_if_condition_fulfilled!(payload)
249
+ if @stop_when&.call(payload)
250
+ stop!
251
+ root_device.stop!
252
+ end
253
+ end
254
+
255
+ def config
256
+ TappingDevice.config
283
257
  end
284
258
  end
@@ -0,0 +1,27 @@
1
+ require "active_support/configurable"
2
+ require "active_support/concern"
3
+
4
+ class TappingDevice
5
+ module Configurable
6
+ extend ActiveSupport::Concern
7
+
8
+ DEFAULTS = {
9
+ filter_by_paths: [],
10
+ exclude_by_paths: [],
11
+ with_trace_to: 50,
12
+ event_type: :return,
13
+ hijack_attr_methods: false,
14
+ track_as_records: false,
15
+ ignore_private: false,
16
+ only_private: false
17
+ }.merge(TappingDevice::Output::DEFAULT_OPTIONS)
18
+
19
+ included do
20
+ include ActiveSupport::Configurable
21
+
22
+ DEFAULTS.each do |key, value|
23
+ config[key] = value
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,4 +1,16 @@
1
1
  class TappingDevice
2
2
  class Exception < StandardError
3
3
  end
4
+
5
+ class NotAnActiveRecordInstanceError < Exception
6
+ def initialize(object)
7
+ super("target object should be an instance of ActiveRecord::Base, got #{object}")
8
+ end
9
+ end
10
+
11
+ class NotAClassError < Exception
12
+ def initialize(object)
13
+ super("target object should be a class, got #{object}")
14
+ end
15
+ end
4
16
  end
@@ -0,0 +1,51 @@
1
+ class TappingDevice
2
+ class MethodHijacker
3
+ attr_reader :target
4
+
5
+ def initialize(target)
6
+ @target = target
7
+ end
8
+
9
+ def hijack_methods!
10
+ target.methods.each do |method_name|
11
+ if is_writer_method?(method_name)
12
+ redefine_writer_method!(method_name)
13
+ elsif is_reader_method?(method_name)
14
+ redefine_reader_method!(method_name)
15
+ end
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def is_writer_method?(method_name)
22
+ has_definition_source?(method_name) && method_name.match?(/\w+=/) && target.method(method_name).source.match?(/attr_writer|attr_accessor/)
23
+ end
24
+
25
+ def is_reader_method?(method_name)
26
+ has_definition_source?(method_name) && target.method(method_name).source.match?(/attr_reader|attr_accessor/)
27
+ end
28
+
29
+ def has_definition_source?(method_name)
30
+ target.method(method_name).source_location
31
+ end
32
+
33
+ def redefine_writer_method!(method_name)
34
+ ivar_name = "@#{method_name.to_s.sub("=", "")}"
35
+
36
+ target.instance_eval <<-RUBY, __FILE__, __LINE__ + 1
37
+ def #{method_name}(val)
38
+ #{ivar_name} = val
39
+ end
40
+ RUBY
41
+ end
42
+
43
+ def redefine_reader_method!(method_name)
44
+ target.instance_eval <<-RUBY, __FILE__, __LINE__ + 1
45
+ def #{method_name}
46
+ @#{method_name}
47
+ end
48
+ RUBY
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,42 @@
1
+ require "tapping_device/output/payload"
2
+ require "tapping_device/output/writer"
3
+ require "tapping_device/output/stdout_writer"
4
+ require "tapping_device/output/file_writer"
5
+
6
+ class TappingDevice
7
+ module Output
8
+ DEFAULT_OPTIONS = {
9
+ inspect: false,
10
+ colorize: true,
11
+ log_file: "/tmp/tapping_device.log"
12
+ }
13
+
14
+ module Helpers
15
+ def and_write(payload_method = nil, options: {}, &block)
16
+ and_output(payload_method, options: options, writer_klass: FileWriter, &block)
17
+ end
18
+
19
+ def and_print(payload_method = nil, options: {}, &block)
20
+ and_output(payload_method, options: options, writer_klass: StdoutWriter, &block)
21
+ end
22
+
23
+ def and_output(payload_method = nil, options: {}, writer_klass:, &block)
24
+ output_block = generate_output_block(payload_method, block)
25
+ @output_writer = writer_klass.new(options, output_block)
26
+ self
27
+ end
28
+
29
+ private
30
+
31
+ def generate_output_block(payload_method, block)
32
+ if block
33
+ block
34
+ elsif payload_method
35
+ -> (output_payload, output_options) { output_payload.send(payload_method, output_options) }
36
+ else
37
+ raise "need to provide either a payload method name or a block"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end