tapping_device 0.5.0 → 0.5.5
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/.github/workflows/ruby.yml +11 -4
- data/CHANGELOG.md +210 -0
- data/Gemfile.lock +16 -15
- data/README.md +192 -74
- data/images/print_mutations.png +0 -0
- data/lib/tapping_device.rb +112 -133
- 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 +114 -19
- data/lib/tapping_device/trackers/association_call_tracker.rb +17 -0
- data/lib/tapping_device/trackers/initialization_tracker.rb +41 -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 -16
- data/lib/tapping_device/output_payload.rb +0 -145
- data/lib/tapping_device/sql_tapping_methods.rb +0 -89
Binary file
|
data/lib/tapping_device.rb
CHANGED
@@ -1,11 +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"
|
8
|
-
require "tapping_device/
|
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"
|
9
18
|
|
10
19
|
class TappingDevice
|
11
20
|
|
@@ -17,51 +26,19 @@ class TappingDevice
|
|
17
26
|
@devices = []
|
18
27
|
@suspend_new = false
|
19
28
|
|
20
|
-
include SqlTappingMethods
|
21
29
|
extend Manageable
|
22
30
|
|
31
|
+
include Configurable
|
32
|
+
include Output::Helpers
|
33
|
+
|
23
34
|
def initialize(options = {}, &block)
|
24
35
|
@block = block
|
25
36
|
@output_block = nil
|
26
|
-
@options = process_options(options)
|
37
|
+
@options = process_options(options.dup)
|
27
38
|
@calls = []
|
28
39
|
@disabled = false
|
29
40
|
@with_condition = nil
|
30
|
-
|
31
|
-
end
|
32
|
-
|
33
|
-
def tap_init!(klass)
|
34
|
-
raise "argument should be a class, got #{klass}" unless klass.is_a?(Class)
|
35
|
-
track(klass, condition: :tap_init?) do |payload|
|
36
|
-
payload[:return_value] = payload[:receiver]
|
37
|
-
payload[:receiver] = klass
|
38
|
-
end
|
39
|
-
end
|
40
|
-
|
41
|
-
def tap_on!(object)
|
42
|
-
track(object, condition: :tap_on?)
|
43
|
-
end
|
44
|
-
|
45
|
-
def tap_passed!(object)
|
46
|
-
track(object, condition: :tap_passed?)
|
47
|
-
end
|
48
|
-
|
49
|
-
def tap_assoc!(record)
|
50
|
-
raise "argument should be an instance of ActiveRecord::Base" unless record.is_a?(ActiveRecord::Base)
|
51
|
-
track(record, condition: :tap_associations?)
|
52
|
-
end
|
53
|
-
|
54
|
-
def and_print(payload_method = nil, &block)
|
55
|
-
@output_block =
|
56
|
-
if block
|
57
|
-
-> (output_payload) { puts(block.call(output_payload)) }
|
58
|
-
elsif payload_method
|
59
|
-
-> (output_payload) { puts(output_payload.send(payload_method)) }
|
60
|
-
else
|
61
|
-
raise "need to provide either a payload method name or a block"
|
62
|
-
end
|
63
|
-
|
64
|
-
self
|
41
|
+
TappingDevice.devices << self
|
65
42
|
end
|
66
43
|
|
67
44
|
def with(&block)
|
@@ -74,7 +51,7 @@ class TappingDevice
|
|
74
51
|
|
75
52
|
def stop!
|
76
53
|
@disabled = true
|
77
|
-
|
54
|
+
TappingDevice.delete_device(self)
|
78
55
|
end
|
79
56
|
|
80
57
|
def stop_when(&block)
|
@@ -97,50 +74,48 @@ class TappingDevice
|
|
97
74
|
options[:descendants]
|
98
75
|
end
|
99
76
|
|
100
|
-
|
101
|
-
|
102
|
-
def track(object, condition:, &payload_block)
|
77
|
+
def track(object)
|
103
78
|
@target = object
|
104
|
-
|
105
|
-
if send(condition, object, tp)
|
106
|
-
filepath, line_number = get_call_location(tp)
|
107
|
-
|
108
|
-
next if should_be_skipped_by_paths?(filepath)
|
79
|
+
validate_target!
|
109
80
|
|
110
|
-
|
81
|
+
MethodHijacker.new(@target).hijack_methods! if options[:hijack_attr_methods]
|
111
82
|
|
112
|
-
|
113
|
-
|
114
|
-
record_call!(payload)
|
83
|
+
@trace_point = build_minimum_trace_point(event_type: options[:event_type]) do |payload|
|
84
|
+
record_call!(payload)
|
115
85
|
|
116
|
-
|
117
|
-
end
|
86
|
+
stop_if_condition_fulfilled!(payload)
|
118
87
|
end
|
119
88
|
|
120
|
-
@trace_point.enable unless
|
89
|
+
@trace_point.enable unless TappingDevice.suspend_new
|
121
90
|
|
122
91
|
self
|
123
92
|
end
|
124
93
|
|
125
|
-
|
126
|
-
caller(get_trace_index(tp) + padding).first.split(":")[0..1]
|
127
|
-
end
|
94
|
+
private
|
128
95
|
|
129
|
-
def
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
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)
|
134
112
|
end
|
135
113
|
end
|
136
114
|
|
137
|
-
def
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
else
|
142
|
-
[]
|
143
|
-
end
|
115
|
+
def validate_target!; end
|
116
|
+
|
117
|
+
def filter_condition_satisfied?(tp)
|
118
|
+
false
|
144
119
|
end
|
145
120
|
|
146
121
|
# this needs to be placed upfront so we can exclude noise before doing more work
|
@@ -149,8 +124,24 @@ class TappingDevice
|
|
149
124
|
(options[:filter_by_paths].present? && !options[:filter_by_paths].any? { |pattern| pattern.match?(filepath) })
|
150
125
|
end
|
151
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
|
+
|
152
143
|
def build_payload(tp:, filepath:, line_number:)
|
153
|
-
|
144
|
+
Payload.init({
|
154
145
|
target: @target,
|
155
146
|
receiver: tp.self,
|
156
147
|
method_name: tp.callee_id,
|
@@ -161,60 +152,38 @@ class TappingDevice
|
|
161
152
|
line_number: line_number,
|
162
153
|
defined_class: tp.defined_class,
|
163
154
|
trace: get_traces(tp),
|
155
|
+
is_private_call?: tp.defined_class.private_method_defined?(tp.callee_id),
|
164
156
|
tp: tp
|
165
157
|
})
|
166
|
-
|
167
|
-
yield(payload) if block_given?
|
168
|
-
|
169
|
-
payload
|
170
|
-
end
|
171
|
-
|
172
|
-
def tap_init?(klass, tp)
|
173
|
-
receiver = tp.self
|
174
|
-
method_name = tp.callee_id
|
175
|
-
|
176
|
-
if klass.ancestors.include?(ActiveRecord::Base)
|
177
|
-
method_name == :new && receiver.ancestors.include?(klass)
|
178
|
-
else
|
179
|
-
method_name == :initialize && receiver.is_a?(klass)
|
180
|
-
end
|
181
158
|
end
|
182
159
|
|
183
|
-
def
|
184
|
-
|
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
|
185
166
|
end
|
186
167
|
|
187
|
-
def
|
188
|
-
|
189
|
-
|
190
|
-
model_class = object.class
|
191
|
-
associations = model_class.reflections
|
192
|
-
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]
|
193
170
|
end
|
194
171
|
|
195
|
-
def
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
collect_arguments(tp).values.any? do |value|
|
201
|
-
# during comparison, Ruby might perform data type conversion like calling `to_sym` on the value
|
202
|
-
# but not every value supports every conversion methods
|
203
|
-
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
|
204
177
|
end
|
205
|
-
rescue
|
206
|
-
false
|
207
178
|
end
|
208
179
|
|
209
|
-
def
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
# we might still get NoMethodError when trying to get that method outside the scope
|
217
|
-
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
|
218
187
|
end
|
219
188
|
|
220
189
|
def collect_arguments(tp)
|
@@ -231,29 +200,28 @@ class TappingDevice
|
|
231
200
|
end
|
232
201
|
|
233
202
|
def process_options(options)
|
234
|
-
options[:filter_by_paths] ||= []
|
235
|
-
options[:exclude_by_paths] ||= []
|
236
|
-
options[:with_trace_to] ||=
|
237
|
-
options[:
|
238
|
-
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
|
+
|
239
214
|
options[:descendants] ||= []
|
240
|
-
options[:
|
215
|
+
options[:root_device] ||= self
|
241
216
|
options
|
242
217
|
end
|
243
218
|
|
244
|
-
def
|
245
|
-
if @stop_when&.call(payload)
|
246
|
-
stop!
|
247
|
-
root_device.stop!
|
248
|
-
end
|
249
|
-
end
|
250
|
-
|
251
|
-
def is_from_target?(object, tp)
|
219
|
+
def is_from_target?(tp)
|
252
220
|
comparsion = tp.self
|
253
|
-
is_the_same_record?(
|
221
|
+
is_the_same_record?(comparsion) || target.__id__ == comparsion.__id__
|
254
222
|
end
|
255
223
|
|
256
|
-
def is_the_same_record?(
|
224
|
+
def is_the_same_record?(comparsion)
|
257
225
|
return false unless options[:track_as_records]
|
258
226
|
if target.is_a?(ActiveRecord::Base) && comparsion.is_a?(target.class)
|
259
227
|
primary_key = target.class.primary_key
|
@@ -264,7 +232,7 @@ class TappingDevice
|
|
264
232
|
def record_call!(payload)
|
265
233
|
return if @disabled
|
266
234
|
|
267
|
-
|
235
|
+
write_output!(payload) if @output_writer
|
268
236
|
|
269
237
|
if @block
|
270
238
|
root_device.calls << @block.call(payload)
|
@@ -273,7 +241,18 @@ class TappingDevice
|
|
273
241
|
end
|
274
242
|
end
|
275
243
|
|
276
|
-
def
|
277
|
-
@
|
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
|
278
257
|
end
|
279
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
|