tapping_device 0.5.1 → 0.5.6
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 +4 -4
- data/.DS_Store +0 -0
- data/CHANGELOG.md +210 -0
- data/Gemfile.lock +15 -14
- data/README.md +192 -74
- data/images/print_mutations.png +0 -0
- data/lib/tapping_device.rb +112 -138
- data/lib/tapping_device/configurable.rb +27 -0
- data/lib/tapping_device/exceptions.rb +12 -0
- data/lib/tapping_device/method_hijacker.rb +51 -0
- data/lib/tapping_device/output.rb +42 -0
- data/lib/tapping_device/output/file_writer.rb +21 -0
- data/lib/tapping_device/output/payload.rb +179 -0
- data/lib/tapping_device/output/stdout_writer.rb +9 -0
- data/lib/tapping_device/output/writer.rb +20 -0
- data/lib/tapping_device/payload.rb +2 -3
- data/lib/tapping_device/trackable.rb +100 -19
- data/lib/tapping_device/trackers/association_call_tracker.rb +17 -0
- data/lib/tapping_device/trackers/initialization_tracker.rb +44 -0
- data/lib/tapping_device/trackers/method_call_tracker.rb +9 -0
- data/lib/tapping_device/trackers/mutation_tracker.rb +112 -0
- data/lib/tapping_device/trackers/passed_tracker.rb +16 -0
- data/lib/tapping_device/version.rb +1 -1
- data/tapping_device.gemspec +3 -1
- metadata +42 -15
- data/lib/tapping_device/output_payload.rb +0 -145
|
Binary file
|
data/lib/tapping_device.rb
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
def track(object, condition:, &payload_block)
|
|
77
|
+
def track(object)
|
|
101
78
|
@target = object
|
|
102
|
-
|
|
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
|
-
|
|
81
|
+
MethodHijacker.new(@target).hijack_methods! if options[:hijack_attr_methods]
|
|
109
82
|
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
89
|
+
@trace_point.enable unless TappingDevice.suspend_new
|
|
126
90
|
|
|
127
91
|
self
|
|
128
92
|
end
|
|
129
93
|
|
|
130
|
-
|
|
131
|
-
caller(get_trace_index(tp) + padding).first.split(":")[0..1]
|
|
132
|
-
end
|
|
94
|
+
private
|
|
133
95
|
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
189
|
-
|
|
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
|
|
193
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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] ||=
|
|
242
|
-
options[:
|
|
243
|
-
options[:
|
|
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[:
|
|
215
|
+
options[:root_device] ||= self
|
|
246
216
|
options
|
|
247
217
|
end
|
|
248
218
|
|
|
249
|
-
def
|
|
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?(
|
|
221
|
+
is_the_same_record?(comparsion) || target.__id__ == comparsion.__id__
|
|
259
222
|
end
|
|
260
223
|
|
|
261
|
-
def is_the_same_record?(
|
|
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
|
-
|
|
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
|
|
282
|
-
@
|
|
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
|