tapping_device 0.4.11 → 0.5.4

Sign up to get free protection for your applications and to get access to all the features.
Binary file
Binary file
@@ -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"
8
+ require "tapping_device/output"
5
9
  require "tapping_device/trackable"
10
+ require "tapping_device/configurable"
6
11
  require "tapping_device/exceptions"
7
- require "tapping_device/sql_tapping_methods"
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
 
@@ -16,41 +26,23 @@ class TappingDevice
16
26
  @devices = []
17
27
  @suspend_new = false
18
28
 
19
- include SqlTappingMethods
20
29
  extend Manageable
21
30
 
31
+ include Configurable
32
+ include Output::Helpers
33
+
22
34
  def initialize(options = {}, &block)
23
35
  @block = block
24
36
  @output_block = nil
25
- @options = process_options(options)
37
+ @options = process_options(options.dup)
26
38
  @calls = []
27
39
  @disabled = false
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?)
40
+ @with_condition = nil
41
+ TappingDevice.devices << self
41
42
  end
42
43
 
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)
53
- @output_block = -> (payload) { puts(payload.send(payload_method)) }
44
+ def with(&block)
45
+ @with_condition = block
54
46
  end
55
47
 
56
48
  def set_block(&block)
@@ -59,7 +51,7 @@ class TappingDevice
59
51
 
60
52
  def stop!
61
53
  @disabled = true
62
- self.class.delete_device(self)
54
+ TappingDevice.delete_device(self)
63
55
  end
64
56
 
65
57
  def stop_when(&block)
@@ -82,48 +74,48 @@ class TappingDevice
82
74
  options[:descendants]
83
75
  end
84
76
 
85
- private
86
-
87
- def track(object, condition:, &payload_block)
77
+ def track(object)
88
78
  @target = object
89
- @trace_point = TracePoint.new(options[:event_type]) do |tp|
90
- if send(condition, object, tp)
91
- filepath, line_number = get_call_location(tp)
79
+ validate_target!
92
80
 
93
- next if should_be_skipped_by_paths?(filepath)
94
-
95
- payload = build_payload(tp: tp, filepath: filepath, line_number: line_number, &payload_block)
81
+ MethodHijacker.new(@target).hijack_methods! if options[:hijack_attr_methods]
96
82
 
97
- record_call!(payload)
83
+ @trace_point = build_minimum_trace_point(event_type: options[:event_type]) do |payload|
84
+ record_call!(payload)
98
85
 
99
- stop_if_condition_fulfilled(payload)
100
- end
86
+ stop_if_condition_fulfilled!(payload)
101
87
  end
102
88
 
103
- @trace_point.enable unless self.class.suspend_new
89
+ @trace_point.enable unless TappingDevice.suspend_new
104
90
 
105
91
  self
106
92
  end
107
93
 
108
- def get_call_location(tp, padding: 0)
109
- caller(get_trace_index(tp) + padding).first.split(":")[0..1]
110
- end
94
+ private
111
95
 
112
- def get_trace_index(tp)
113
- if tp.event == :c_call
114
- C_CALLER_START_POINT
115
- else
116
- 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)
117
112
  end
118
113
  end
119
114
 
120
- def get_traces(tp)
121
- if with_trace_to = options[:with_trace_to]
122
- trace_index = get_trace_index(tp)
123
- caller[trace_index..(trace_index + with_trace_to)]
124
- else
125
- []
126
- end
115
+ def validate_target!; end
116
+
117
+ def filter_condition_satisfied?(tp)
118
+ false
127
119
  end
128
120
 
129
121
  # this needs to be placed upfront so we can exclude noise before doing more work
@@ -132,8 +124,24 @@ class TappingDevice
132
124
  (options[:filter_by_paths].present? && !options[:filter_by_paths].any? { |pattern| pattern.match?(filepath) })
133
125
  end
134
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
+
135
143
  def build_payload(tp:, filepath:, line_number:)
136
- payload = Payload.init({
144
+ Payload.init({
137
145
  target: @target,
138
146
  receiver: tp.self,
139
147
  method_name: tp.callee_id,
@@ -144,49 +152,9 @@ class TappingDevice
144
152
  line_number: line_number,
145
153
  defined_class: tp.defined_class,
146
154
  trace: get_traces(tp),
155
+ is_private_call?: tp.defined_class.private_method_defined?(tp.callee_id),
147
156
  tp: tp
148
157
  })
149
-
150
- yield(payload) if block_given?
151
-
152
- payload
153
- end
154
-
155
- def tap_init?(klass, tp)
156
- receiver = tp.self
157
- method_name = tp.callee_id
158
-
159
- if klass.ancestors.include?(ActiveRecord::Base)
160
- method_name == :new && receiver.ancestors.include?(klass)
161
- else
162
- method_name == :initialize && receiver.is_a?(klass)
163
- end
164
- end
165
-
166
- def tap_on?(object, tp)
167
- is_from_target?(object, tp)
168
- end
169
-
170
- def tap_associations?(object, tp)
171
- return false unless tap_on?(object, tp)
172
-
173
- model_class = object.class
174
- associations = model_class.reflections
175
- associations.keys.include?(tp.callee_id.to_s)
176
- end
177
-
178
- def tap_passed?(object, tp)
179
- # we don't care about calls from the device instance or helper methods
180
- return false if is_from_target?(self, tp)
181
- return false if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
182
-
183
- collect_arguments(tp).values.any? do |value|
184
- # during comparison, Ruby might perform data type conversion like calling `to_sym` on the value
185
- # but not every value supports every conversion methods
186
- object == value rescue false
187
- end
188
- rescue
189
- false
190
158
  end
191
159
 
192
160
  def get_method_object_from(target, method_name)
@@ -200,6 +168,27 @@ class TappingDevice
200
168
  nil
201
169
  end
202
170
 
171
+ def get_call_location(tp, padding: 0)
172
+ caller(get_trace_index(tp) + padding).first.split(":")[0..1]
173
+ end
174
+
175
+ def get_trace_index(tp)
176
+ if tp.event == :c_call
177
+ C_CALLER_START_POINT
178
+ else
179
+ CALLER_START_POINT
180
+ end
181
+ end
182
+
183
+ def get_traces(tp)
184
+ if with_trace_to = options[:with_trace_to]
185
+ trace_index = get_trace_index(tp)
186
+ caller[trace_index..(trace_index + with_trace_to)]
187
+ else
188
+ []
189
+ end
190
+ end
191
+
203
192
  def collect_arguments(tp)
204
193
  parameters =
205
194
  if RUBY_VERSION.to_f >= 2.6
@@ -214,29 +203,28 @@ class TappingDevice
214
203
  end
215
204
 
216
205
  def process_options(options)
217
- options[:filter_by_paths] ||= []
218
- options[:exclude_by_paths] ||= []
219
- options[:with_trace_to] ||= 50
220
- options[:root_device] ||= self
221
- options[:event_type] ||= :return
206
+ options[:filter_by_paths] ||= config[:filter_by_paths]
207
+ options[:exclude_by_paths] ||= config[:exclude_by_paths]
208
+ options[:with_trace_to] ||= config[:with_trace_to]
209
+ options[:event_type] ||= config[:event_type]
210
+ options[:hijack_attr_methods] ||= config[:hijack_attr_methods]
211
+ options[:track_as_records] ||= config[:track_as_records]
212
+ options[:ignore_private] ||= config[:ignore_private]
213
+ options[:only_private] ||= config[:only_private]
214
+ # for debugging the gem more easily
215
+ options[:force_recording] ||= false
216
+
222
217
  options[:descendants] ||= []
223
- options[:track_as_records] ||= false
218
+ options[:root_device] ||= self
224
219
  options
225
220
  end
226
221
 
227
- def stop_if_condition_fulfilled(payload)
228
- if @stop_when&.call(payload)
229
- stop!
230
- root_device.stop!
231
- end
232
- end
233
-
234
- def is_from_target?(object, tp)
222
+ def is_from_target?(tp)
235
223
  comparsion = tp.self
236
- is_the_same_record?(object, comparsion) || object.__id__ == comparsion.__id__
224
+ is_the_same_record?(comparsion) || target.__id__ == comparsion.__id__
237
225
  end
238
226
 
239
- def is_the_same_record?(target, comparsion)
227
+ def is_the_same_record?(comparsion)
240
228
  return false unless options[:track_as_records]
241
229
  if target.is_a?(ActiveRecord::Base) && comparsion.is_a?(target.class)
242
230
  primary_key = target.class.primary_key
@@ -247,7 +235,7 @@ class TappingDevice
247
235
  def record_call!(payload)
248
236
  return if @disabled
249
237
 
250
- @output_block.call(payload) if @output_block
238
+ write_output!(payload) if @output_writer
251
239
 
252
240
  if @block
253
241
  root_device.calls << @block.call(payload)
@@ -255,4 +243,19 @@ class TappingDevice
255
243
  root_device.calls << payload
256
244
  end
257
245
  end
246
+
247
+ def write_output!(payload)
248
+ @output_writer.write!(payload)
249
+ end
250
+
251
+ def stop_if_condition_fulfilled!(payload)
252
+ if @stop_when&.call(payload)
253
+ stop!
254
+ root_device.stop!
255
+ end
256
+ end
257
+
258
+ def config
259
+ TappingDevice.config
260
+ end
258
261
  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