tapping_device 0.4.9 → 0.5.2
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/.ruby-version +1 -0
- data/Gemfile.lock +21 -23
- data/README.md +136 -353
- data/images/print_calls - single entry.png +0 -0
- data/images/print_calls.png +0 -0
- data/images/print_mutations.png +0 -0
- data/images/print_traces.png +0 -0
- data/lib/tapping_device.rb +108 -112
- data/lib/tapping_device/exceptions.rb +12 -0
- data/lib/tapping_device/output_payload.rb +173 -0
- data/lib/tapping_device/payload.rb +3 -44
- data/lib/tapping_device/trackable.rb +56 -17
- data/lib/tapping_device/trackers/association_call_tracker.rb +17 -0
- data/lib/tapping_device/trackers/initialization_tracker.rb +27 -0
- data/lib/tapping_device/trackers/method_call_tracker.rb +9 -0
- data/lib/tapping_device/trackers/mutation_tracker.rb +132 -0
- data/lib/tapping_device/trackers/passed_tracker.rb +16 -0
- data/lib/tapping_device/version.rb +1 -1
- data/tapping_device.gemspec +4 -5
- metadata +21 -22
- data/lib/tapping_device/sql_tapping_methods.rb +0 -89
Binary file
|
Binary file
|
Binary file
|
Binary file
|
data/lib/tapping_device.rb
CHANGED
@@ -2,9 +2,14 @@ require "active_record"
|
|
2
2
|
require "tapping_device/version"
|
3
3
|
require "tapping_device/manageable"
|
4
4
|
require "tapping_device/payload"
|
5
|
+
require "tapping_device/output_payload"
|
5
6
|
require "tapping_device/trackable"
|
6
7
|
require "tapping_device/exceptions"
|
7
|
-
require "tapping_device/
|
8
|
+
require "tapping_device/trackers/initialization_tracker"
|
9
|
+
require "tapping_device/trackers/passed_tracker"
|
10
|
+
require "tapping_device/trackers/association_call_tracker"
|
11
|
+
require "tapping_device/trackers/method_call_tracker"
|
12
|
+
require "tapping_device/trackers/mutation_tracker"
|
8
13
|
|
9
14
|
class TappingDevice
|
10
15
|
|
@@ -16,7 +21,6 @@ class TappingDevice
|
|
16
21
|
@devices = []
|
17
22
|
@suspend_new = false
|
18
23
|
|
19
|
-
include SqlTappingMethods
|
20
24
|
extend Manageable
|
21
25
|
|
22
26
|
def initialize(options = {}, &block)
|
@@ -25,29 +29,25 @@ class TappingDevice
|
|
25
29
|
@options = process_options(options)
|
26
30
|
@calls = []
|
27
31
|
@disabled = false
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
track(object, condition: :tap_passed?)
|
42
|
-
end
|
32
|
+
@with_condition = nil
|
33
|
+
TappingDevice.devices << self
|
34
|
+
end
|
35
|
+
|
36
|
+
def and_print(payload_method = nil, &block)
|
37
|
+
@output_block =
|
38
|
+
if block
|
39
|
+
-> (output_payload) { puts(block.call(output_payload)) }
|
40
|
+
elsif payload_method
|
41
|
+
-> (output_payload) { puts(output_payload.send(payload_method)) }
|
42
|
+
else
|
43
|
+
raise "need to provide either a payload method name or a block"
|
44
|
+
end
|
43
45
|
|
44
|
-
|
45
|
-
raise "argument should be an instance of ActiveRecord::Base" unless record.is_a?(ActiveRecord::Base)
|
46
|
-
track(record, condition: :tap_associations?)
|
46
|
+
self
|
47
47
|
end
|
48
48
|
|
49
|
-
def
|
50
|
-
@
|
49
|
+
def with(&block)
|
50
|
+
@with_condition = block
|
51
51
|
end
|
52
52
|
|
53
53
|
def set_block(&block)
|
@@ -56,7 +56,7 @@ class TappingDevice
|
|
56
56
|
|
57
57
|
def stop!
|
58
58
|
@disabled = true
|
59
|
-
|
59
|
+
TappingDevice.delete_device(self)
|
60
60
|
end
|
61
61
|
|
62
62
|
def stop_when(&block)
|
@@ -79,48 +79,42 @@ class TappingDevice
|
|
79
79
|
options[:descendants]
|
80
80
|
end
|
81
81
|
|
82
|
-
|
83
|
-
|
84
|
-
def track(object, condition:)
|
82
|
+
def track(object)
|
85
83
|
@target = object
|
86
|
-
|
87
|
-
if send(condition, object, tp)
|
88
|
-
filepath, line_number = get_call_location(tp)
|
89
|
-
|
90
|
-
next if should_be_skipped_by_paths?(filepath)
|
91
|
-
|
92
|
-
payload = build_payload(tp: tp, filepath: filepath, line_number: line_number)
|
84
|
+
validate_target!
|
93
85
|
|
94
|
-
|
86
|
+
@trace_point = build_minimum_trace_point(event_type: options[:event_type]) do |payload|
|
87
|
+
record_call!(payload)
|
95
88
|
|
96
|
-
|
97
|
-
end
|
89
|
+
stop_if_condition_fulfilled!(payload)
|
98
90
|
end
|
99
91
|
|
100
|
-
@trace_point.enable unless
|
92
|
+
@trace_point.enable unless TappingDevice.suspend_new
|
101
93
|
|
102
94
|
self
|
103
95
|
end
|
104
96
|
|
105
|
-
|
106
|
-
caller(get_trace_index(tp) + padding).first.split(":")[0..1]
|
107
|
-
end
|
97
|
+
private
|
108
98
|
|
109
|
-
def
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
99
|
+
def build_minimum_trace_point(event_type:)
|
100
|
+
TracePoint.new(event_type) do |tp|
|
101
|
+
next unless filter_condition_satisfied?(tp)
|
102
|
+
next if is_tapping_device_call?(tp)
|
103
|
+
|
104
|
+
filepath, line_number = get_call_location(tp)
|
105
|
+
payload = build_payload(tp: tp, filepath: filepath, line_number: line_number)
|
106
|
+
|
107
|
+
next if should_be_skipped_by_paths?(filepath)
|
108
|
+
next unless with_condition_satisfied?(payload)
|
109
|
+
|
110
|
+
yield(payload)
|
114
111
|
end
|
115
112
|
end
|
116
113
|
|
117
|
-
def
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
else
|
122
|
-
[]
|
123
|
-
end
|
114
|
+
def validate_target!; end
|
115
|
+
|
116
|
+
def filter_condition_satisfied?(tp)
|
117
|
+
false
|
124
118
|
end
|
125
119
|
|
126
120
|
# this needs to be placed upfront so we can exclude noise before doing more work
|
@@ -129,16 +123,29 @@ class TappingDevice
|
|
129
123
|
(options[:filter_by_paths].present? && !options[:filter_by_paths].any? { |pattern| pattern.match?(filepath) })
|
130
124
|
end
|
131
125
|
|
132
|
-
def
|
133
|
-
|
134
|
-
|
126
|
+
def is_tapping_device_call?(tp)
|
127
|
+
if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
|
128
|
+
return true
|
129
|
+
end
|
135
130
|
|
131
|
+
if Module.respond_to?(:module_parents)
|
132
|
+
tp.defined_class.module_parents.include?(TappingDevice)
|
133
|
+
else
|
134
|
+
tp.defined_class.parents.include?(TappingDevice)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def with_condition_satisfied?(payload)
|
139
|
+
@with_condition.blank? || @with_condition.call(payload)
|
140
|
+
end
|
141
|
+
|
142
|
+
def build_payload(tp:, filepath:, line_number:)
|
136
143
|
Payload.init({
|
137
144
|
target: @target,
|
138
145
|
receiver: tp.self,
|
139
146
|
method_name: tp.callee_id,
|
140
147
|
method_object: get_method_object_from(tp.self, tp.callee_id),
|
141
|
-
arguments:
|
148
|
+
arguments: collect_arguments(tp),
|
142
149
|
return_value: (tp.return_value rescue nil),
|
143
150
|
filepath: filepath,
|
144
151
|
line_number: line_number,
|
@@ -148,51 +155,6 @@ class TappingDevice
|
|
148
155
|
})
|
149
156
|
end
|
150
157
|
|
151
|
-
def tap_init?(klass, tp)
|
152
|
-
receiver = tp.self
|
153
|
-
method_name = tp.callee_id
|
154
|
-
|
155
|
-
if klass.ancestors.include?(ActiveRecord::Base)
|
156
|
-
method_name == :new && receiver.ancestors.include?(klass)
|
157
|
-
else
|
158
|
-
method_name == :initialize && receiver.is_a?(klass)
|
159
|
-
end
|
160
|
-
end
|
161
|
-
|
162
|
-
def tap_on?(object, tp)
|
163
|
-
is_from_target?(object, tp)
|
164
|
-
end
|
165
|
-
|
166
|
-
def tap_associations?(object, tp)
|
167
|
-
return false unless tap_on?(object, tp)
|
168
|
-
|
169
|
-
model_class = object.class
|
170
|
-
associations = model_class.reflections
|
171
|
-
associations.keys.include?(tp.callee_id.to_s)
|
172
|
-
end
|
173
|
-
|
174
|
-
def tap_passed?(object, tp)
|
175
|
-
# we don't care about calls from the device instance or helper methods
|
176
|
-
return false if is_from_target?(self, tp)
|
177
|
-
return false if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
|
178
|
-
|
179
|
-
method_object = get_method_object_from(tp.self, tp.callee_id)
|
180
|
-
return false unless method_object.is_a?(Method)
|
181
|
-
# if a no-arugment method is called, tp.binding.local_variables will be those local variables in the same scope
|
182
|
-
# so we need to make sure the method takes arguments, then we can be sure that the locals are arguments
|
183
|
-
return false unless method_object && method_object.arity.to_i > 0
|
184
|
-
|
185
|
-
argument_values = tp.binding.local_variables.map { |name| tp.binding.local_variable_get(name) }
|
186
|
-
|
187
|
-
argument_values.any? do |value|
|
188
|
-
# during comparison, Ruby might perform data type conversion like calling `to_sym` on the value
|
189
|
-
# but not every value supports every conversion methods
|
190
|
-
object == value rescue false
|
191
|
-
end
|
192
|
-
rescue
|
193
|
-
false
|
194
|
-
end
|
195
|
-
|
196
158
|
def get_method_object_from(target, method_name)
|
197
159
|
target.method(method_name)
|
198
160
|
rescue ArgumentError
|
@@ -204,6 +166,40 @@ class TappingDevice
|
|
204
166
|
nil
|
205
167
|
end
|
206
168
|
|
169
|
+
def get_call_location(tp, padding: 0)
|
170
|
+
caller(get_trace_index(tp) + padding).first.split(":")[0..1]
|
171
|
+
end
|
172
|
+
|
173
|
+
def get_trace_index(tp)
|
174
|
+
if tp.event == :c_call
|
175
|
+
C_CALLER_START_POINT
|
176
|
+
else
|
177
|
+
CALLER_START_POINT
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def get_traces(tp)
|
182
|
+
if with_trace_to = options[:with_trace_to]
|
183
|
+
trace_index = get_trace_index(tp)
|
184
|
+
caller[trace_index..(trace_index + with_trace_to)]
|
185
|
+
else
|
186
|
+
[]
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def collect_arguments(tp)
|
191
|
+
parameters =
|
192
|
+
if RUBY_VERSION.to_f >= 2.6
|
193
|
+
tp.parameters
|
194
|
+
else
|
195
|
+
get_method_object_from(tp.self, tp.callee_id)&.parameters || []
|
196
|
+
end.map { |parameter| parameter[1] }
|
197
|
+
|
198
|
+
tp.binding.local_variables.each_with_object({}) do |name, args|
|
199
|
+
args[name] = tp.binding.local_variable_get(name) if parameters.include?(name)
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
207
203
|
def process_options(options)
|
208
204
|
options[:filter_by_paths] ||= []
|
209
205
|
options[:exclude_by_paths] ||= []
|
@@ -215,19 +211,12 @@ class TappingDevice
|
|
215
211
|
options
|
216
212
|
end
|
217
213
|
|
218
|
-
def
|
219
|
-
if @stop_when&.call(payload)
|
220
|
-
stop!
|
221
|
-
root_device.stop!
|
222
|
-
end
|
223
|
-
end
|
224
|
-
|
225
|
-
def is_from_target?(object, tp)
|
214
|
+
def is_from_target?(tp)
|
226
215
|
comparsion = tp.self
|
227
|
-
is_the_same_record?(
|
216
|
+
is_the_same_record?(comparsion) || target.__id__ == comparsion.__id__
|
228
217
|
end
|
229
218
|
|
230
|
-
def is_the_same_record?(
|
219
|
+
def is_the_same_record?(comparsion)
|
231
220
|
return false unless options[:track_as_records]
|
232
221
|
if target.is_a?(ActiveRecord::Base) && comparsion.is_a?(target.class)
|
233
222
|
primary_key = target.class.primary_key
|
@@ -238,7 +227,7 @@ class TappingDevice
|
|
238
227
|
def record_call!(payload)
|
239
228
|
return if @disabled
|
240
229
|
|
241
|
-
@output_block.call(payload) if @output_block
|
230
|
+
@output_block.call(OutputPayload.init(payload)) if @output_block
|
242
231
|
|
243
232
|
if @block
|
244
233
|
root_device.calls << @block.call(payload)
|
@@ -246,4 +235,11 @@ class TappingDevice
|
|
246
235
|
root_device.calls << payload
|
247
236
|
end
|
248
237
|
end
|
238
|
+
|
239
|
+
def stop_if_condition_fulfilled!(payload)
|
240
|
+
if @stop_when&.call(payload)
|
241
|
+
stop!
|
242
|
+
root_device.stop!
|
243
|
+
end
|
244
|
+
end
|
249
245
|
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,173 @@
|
|
1
|
+
class TappingDevice
|
2
|
+
class OutputPayload < Payload
|
3
|
+
UNDEFINED = "[undefined]"
|
4
|
+
|
5
|
+
alias :raw_arguments :arguments
|
6
|
+
alias :raw_return_value :return_value
|
7
|
+
|
8
|
+
def method_name(options = {})
|
9
|
+
":#{super(options)}"
|
10
|
+
end
|
11
|
+
|
12
|
+
def arguments(options = {})
|
13
|
+
generate_string_result(raw_arguments, options[:inspect])
|
14
|
+
end
|
15
|
+
|
16
|
+
def return_value(options = {})
|
17
|
+
generate_string_result(raw_return_value, options[:inspect])
|
18
|
+
end
|
19
|
+
|
20
|
+
COLOR_CODES = {
|
21
|
+
green: 10,
|
22
|
+
yellow: 11,
|
23
|
+
blue: 12,
|
24
|
+
megenta: 13,
|
25
|
+
cyan: 14,
|
26
|
+
orange: 214
|
27
|
+
}
|
28
|
+
|
29
|
+
COLORS = COLOR_CODES.each_with_object({}) do |(name, code), hash|
|
30
|
+
hash[name] = "\u001b[38;5;#{code}m"
|
31
|
+
end.merge(
|
32
|
+
reset: "\u001b[0m",
|
33
|
+
nocolor: ""
|
34
|
+
)
|
35
|
+
|
36
|
+
PAYLOAD_ATTRIBUTES = {
|
37
|
+
method_name: {symbol: "", color: COLORS[:blue]},
|
38
|
+
location: {symbol: "from:", color: COLORS[:green]},
|
39
|
+
return_value: {symbol: "=>", color: COLORS[:megenta]},
|
40
|
+
arguments: {symbol: "<=", color: COLORS[:orange]},
|
41
|
+
ivar_changes: {symbol: "changes:\n", color: COLORS[:blue]},
|
42
|
+
defined_class: {symbol: "#", color: COLORS[:yellow]}
|
43
|
+
}
|
44
|
+
|
45
|
+
PAYLOAD_ATTRIBUTES.each do |attribute, attribute_options|
|
46
|
+
color = attribute_options[:color]
|
47
|
+
|
48
|
+
alias_method "original_#{attribute}".to_sym, attribute
|
49
|
+
|
50
|
+
# regenerate attributes with `colorize: true` support
|
51
|
+
define_method attribute do |options = {}|
|
52
|
+
call_result = send("original_#{attribute}", options)
|
53
|
+
|
54
|
+
if options[:colorize]
|
55
|
+
"#{color}#{call_result}#{COLORS[:reset]}"
|
56
|
+
else
|
57
|
+
call_result
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
define_method "#{attribute}_with_color" do |options = {}|
|
62
|
+
send(attribute, options.merge(colorize: true))
|
63
|
+
end
|
64
|
+
|
65
|
+
PAYLOAD_ATTRIBUTES.each do |and_attribute, and_attribute_options|
|
66
|
+
next if and_attribute == attribute
|
67
|
+
|
68
|
+
define_method "#{attribute}_and_#{and_attribute}" do |options = {}|
|
69
|
+
"#{send(attribute, options)} #{and_attribute_options[:symbol]} #{send(and_attribute, options)}"
|
70
|
+
end
|
71
|
+
|
72
|
+
define_method "#{attribute}_and_#{and_attribute}_with_color" do |options = {}|
|
73
|
+
send("#{attribute}_and_#{and_attribute}", options.merge(colorize: true))
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def passed_at(options = {})
|
79
|
+
with_method_head = options.fetch(:with_method_head, false)
|
80
|
+
arg_name = raw_arguments.keys.detect { |k| raw_arguments[k] == target }
|
81
|
+
|
82
|
+
return unless arg_name
|
83
|
+
|
84
|
+
arg_name = ":#{arg_name}"
|
85
|
+
arg_name = value_with_color(arg_name, :orange) if options[:colorize]
|
86
|
+
msg = "Passed as #{arg_name} in '#{defined_class(options)}##{method_name(options)}' at #{location(options)}"
|
87
|
+
msg += "\n > #{method_head.strip}" if with_method_head
|
88
|
+
msg
|
89
|
+
end
|
90
|
+
|
91
|
+
def detail_call_info(options = {})
|
92
|
+
<<~MSG
|
93
|
+
#{method_name_and_defined_class(options)}
|
94
|
+
from: #{location(options)}
|
95
|
+
<= #{arguments(options)}
|
96
|
+
=> #{return_value(options)}
|
97
|
+
|
98
|
+
MSG
|
99
|
+
end
|
100
|
+
|
101
|
+
def ivar_changes(options = {})
|
102
|
+
super.map do |ivar, value_changes|
|
103
|
+
before = generate_string_result(value_changes[:before], options[:inspect])
|
104
|
+
after = generate_string_result(value_changes[:after], options[:inspect])
|
105
|
+
|
106
|
+
if options[:colorize]
|
107
|
+
ivar = "#{COLORS[:orange]}#{ivar}#{COLORS[:reset]}"
|
108
|
+
before = "#{COLORS[:blue]}#{before.to_s}#{COLORS[:reset]}"
|
109
|
+
after = "#{COLORS[:blue]}#{after.to_s}#{COLORS[:reset]}"
|
110
|
+
end
|
111
|
+
|
112
|
+
" #{ivar}: #{before.to_s} => #{after.to_s}"
|
113
|
+
end.join("\n")
|
114
|
+
end
|
115
|
+
|
116
|
+
def call_info_with_ivar_changes(options = {})
|
117
|
+
<<~MSG
|
118
|
+
#{method_name_and_defined_class(options)}
|
119
|
+
from: #{location(options)}
|
120
|
+
changes:
|
121
|
+
#{ivar_changes(options)}
|
122
|
+
|
123
|
+
MSG
|
124
|
+
end
|
125
|
+
|
126
|
+
private
|
127
|
+
|
128
|
+
def value_with_color(value, color)
|
129
|
+
"#{COLORS[color]}#{value}#{COLORS[:reset]}"
|
130
|
+
end
|
131
|
+
|
132
|
+
def generate_string_result(obj, inspect)
|
133
|
+
case obj
|
134
|
+
when Array
|
135
|
+
array_to_string(obj, inspect)
|
136
|
+
when Hash
|
137
|
+
hash_to_string(obj, inspect)
|
138
|
+
when UNDEFINED
|
139
|
+
UNDEFINED
|
140
|
+
when String
|
141
|
+
"\"#{obj}\""
|
142
|
+
when nil
|
143
|
+
"nil"
|
144
|
+
else
|
145
|
+
inspect ? obj.inspect : obj.to_s
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def array_to_string(array, inspect)
|
150
|
+
elements_string = array.map do |elem|
|
151
|
+
generate_string_result(elem, inspect)
|
152
|
+
end.join(", ")
|
153
|
+
"[#{elements_string}]"
|
154
|
+
end
|
155
|
+
|
156
|
+
def hash_to_string(hash, inspect)
|
157
|
+
elements_string = hash.map do |key, value|
|
158
|
+
"#{key.to_s}: #{generate_string_result(value, inspect)}"
|
159
|
+
end.join(", ")
|
160
|
+
"{#{elements_string}}"
|
161
|
+
end
|
162
|
+
|
163
|
+
def obj_to_string(element, inspect)
|
164
|
+
to_string_method = inspect ? :inspect : :to_s
|
165
|
+
|
166
|
+
if !inspect && element.is_a?(String)
|
167
|
+
"\"#{element}\""
|
168
|
+
else
|
169
|
+
element.send(to_string_method)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|