object_tracer 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,34 @@
1
+ class ObjectTracer
2
+ class Configuration
3
+ DEFAULTS = {
4
+ filter_by_paths: [],
5
+ exclude_by_paths: [],
6
+ with_trace_to: 50,
7
+ event_type: :return,
8
+ hijack_attr_methods: false,
9
+ track_as_records: false,
10
+ ignore_private: false,
11
+ only_private: false
12
+ }.merge(ObjectTracer::Output::DEFAULT_OPTIONS)
13
+
14
+ def initialize
15
+ @options = {}
16
+
17
+ DEFAULTS.each do |key, value|
18
+ @options[key] = value
19
+ end
20
+ end
21
+
22
+ def [](key)
23
+ @options[key]
24
+ end
25
+
26
+ def []=(key, value)
27
+ @options[key] = value
28
+ end
29
+ end
30
+
31
+ def self.config
32
+ @config ||= Configuration.new
33
+ end
34
+ end
@@ -0,0 +1,16 @@
1
+ class ObjectTracer
2
+ class Exception < StandardError
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
16
+ end
@@ -0,0 +1,37 @@
1
+ class ObjectTracer
2
+ module Manageable
3
+
4
+ def suspend_new
5
+ @suspend_new
6
+ end
7
+
8
+ # list all registered devices
9
+ def devices
10
+ @devices
11
+ end
12
+
13
+ # disable given device and remove it from registered list
14
+ def delete_device(device)
15
+ device.trace_point&.disable
16
+ @devices -= [device]
17
+ end
18
+
19
+ # stops all registered devices and remove them from registered list
20
+ def stop_all!
21
+ @devices.each(&:stop!)
22
+ end
23
+
24
+ # suspend enabling new trace points
25
+ # user can still create new Device instances, but they won't be functional
26
+ def suspend_new!
27
+ @suspend_new = true
28
+ end
29
+
30
+ # reset everything to clean state and disable all devices
31
+ def reset!
32
+ @suspend_new = false
33
+ stop_all!
34
+ end
35
+ end
36
+ end
37
+
@@ -0,0 +1,55 @@
1
+ class ObjectTracer
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
+ rescue MethodSource::SourceNotFoundError
24
+ false
25
+ end
26
+
27
+ def is_reader_method?(method_name)
28
+ has_definition_source?(method_name) && target.method(method_name).source.match?(/attr_reader|attr_accessor/)
29
+ rescue MethodSource::SourceNotFoundError
30
+ false
31
+ end
32
+
33
+ def has_definition_source?(method_name)
34
+ target.method(method_name).source_location
35
+ end
36
+
37
+ def redefine_writer_method!(method_name)
38
+ ivar_name = "@#{method_name.to_s.sub("=", "")}"
39
+
40
+ target.instance_eval <<-RUBY, __FILE__, __LINE__ + 1
41
+ def #{method_name}(val)
42
+ #{ivar_name} = val
43
+ end
44
+ RUBY
45
+ end
46
+
47
+ def redefine_reader_method!(method_name)
48
+ target.instance_eval <<-RUBY, __FILE__, __LINE__ + 1
49
+ def #{method_name}
50
+ @#{method_name}
51
+ end
52
+ RUBY
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,41 @@
1
+ require "logger"
2
+ require "object_tracer/output/payload_wrapper"
3
+ require "object_tracer/output/writer"
4
+
5
+ class ObjectTracer
6
+ module Output
7
+ DEFAULT_OPTIONS = {
8
+ inspect: false,
9
+ colorize: true,
10
+ log_file: "/tmp/object_tracer.log"
11
+ }
12
+
13
+ module Helpers
14
+ def and_write(payload_method = nil, options: {}, &block)
15
+ and_output(payload_method, options: options, logger: Logger.new(options[:log_file]), &block)
16
+ end
17
+
18
+ def and_print(payload_method = nil, options: {}, &block)
19
+ and_output(payload_method, options: options, logger: Logger.new($stdout), &block)
20
+ end
21
+
22
+ def and_output(payload_method = nil, options: {}, logger:, &block)
23
+ output_block = generate_output_block(payload_method, block)
24
+ @output_writer = Writer.new(options: options, output_block: output_block, logger: logger)
25
+ self
26
+ end
27
+
28
+ private
29
+
30
+ def generate_output_block(payload_method, block)
31
+ if block
32
+ block
33
+ elsif payload_method
34
+ -> (output_payload, output_options) { output_payload.send(payload_method, output_options) }
35
+ else
36
+ raise "need to provide either a payload method name or a block"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,186 @@
1
+ require "pastel"
2
+
3
+ class ObjectTracer
4
+ module Output
5
+ class PayloadWrapper
6
+ UNDEFINED = "[undefined]"
7
+ PRIVATE_MARK = " (private)"
8
+
9
+ PASTEL = Pastel.new
10
+ PASTEL.alias_color(:orange, :bright_red, :bright_yellow)
11
+
12
+ ObjectTracer::Payload::ATTRS.each do |attr|
13
+ define_method attr do |options = {}|
14
+ @payload.send(attr)
15
+ end
16
+ end
17
+
18
+ alias :is_private_call? :is_private_call
19
+
20
+ def method_head
21
+ @payload.method_head
22
+ end
23
+
24
+ def location(options = {})
25
+ @payload.location(options)
26
+ end
27
+
28
+ alias :raw_arguments :arguments
29
+ alias :raw_return_value :return_value
30
+
31
+ def initialize(payload)
32
+ @payload = payload
33
+ end
34
+
35
+ def method_name(options = {})
36
+ name = ":#{@payload.method_name}"
37
+
38
+ name += " [#{tag}]" if tag
39
+ name += PRIVATE_MARK if is_private_call?
40
+
41
+ name
42
+ end
43
+
44
+ def arguments(options = {})
45
+ generate_string_result(raw_arguments, options[:inspect])
46
+ end
47
+
48
+ def return_value(options = {})
49
+ generate_string_result(raw_return_value, options[:inspect])
50
+ end
51
+
52
+ PAYLOAD_ATTRIBUTES = {
53
+ method_name: {symbol: "", color: :bright_blue},
54
+ location: {symbol: "from:", color: :green},
55
+ return_value: {symbol: "=>", color: :magenta},
56
+ arguments: {symbol: "<=", color: :orange},
57
+ ivar_changes: {symbol: "changes:\n", color: :blue},
58
+ defined_class: {symbol: "#", color: :yellow}
59
+ }
60
+
61
+ PAYLOAD_ATTRIBUTES.each do |attribute, attribute_options|
62
+ color = attribute_options[:color]
63
+
64
+ alias_method "original_#{attribute}".to_sym, attribute
65
+
66
+ # regenerate attributes with `colorize: true` support
67
+ define_method attribute do |options = {}|
68
+ call_result = send("original_#{attribute}", options).to_s
69
+
70
+ if options[:colorize]
71
+ PASTEL.send(color, call_result)
72
+ else
73
+ call_result
74
+ end
75
+ end
76
+
77
+ define_method "#{attribute}_with_color" do |options = {}|
78
+ send(attribute, options.merge(colorize: true))
79
+ end
80
+
81
+ PAYLOAD_ATTRIBUTES.each do |and_attribute, and_attribute_options|
82
+ next if and_attribute == attribute
83
+
84
+ define_method "#{attribute}_and_#{and_attribute}" do |options = {}|
85
+ "#{send(attribute, options)} #{and_attribute_options[:symbol]} #{send(and_attribute, options)}"
86
+ end
87
+
88
+ define_method "#{attribute}_and_#{and_attribute}_with_color" do |options = {}|
89
+ send("#{attribute}_and_#{and_attribute}", options.merge(colorize: true))
90
+ end
91
+ end
92
+ end
93
+
94
+ def passed_at(options = {})
95
+ with_method_head = options.fetch(:with_method_head, false)
96
+ arg_name = raw_arguments.keys.detect { |k| raw_arguments[k] == target }
97
+
98
+ return unless arg_name
99
+
100
+ arg_name = ":#{arg_name}"
101
+ arg_name = PASTEL.orange(arg_name) if options[:colorize]
102
+ msg = "Passed as #{arg_name} in '#{defined_class(options)}##{method_name(options)}' at #{location(options)}\n"
103
+ msg += " > #{method_head}\n" if with_method_head
104
+ msg
105
+ end
106
+
107
+ def detail_call_info(options = {})
108
+ <<~MSG
109
+ #{method_name_and_defined_class(options)}
110
+ from: #{location(options)}
111
+ <= #{arguments(options)}
112
+ => #{return_value(options)}
113
+
114
+ MSG
115
+ end
116
+
117
+ def ivar_changes(options = {})
118
+ @payload.ivar_changes.map do |ivar, value_changes|
119
+ before = generate_string_result(value_changes[:before], options[:inspect])
120
+ after = generate_string_result(value_changes[:after], options[:inspect])
121
+
122
+ if options[:colorize]
123
+ ivar = PASTEL.orange(ivar.to_s)
124
+ before = PASTEL.bright_blue(before.to_s)
125
+ after = PASTEL.bright_blue(after.to_s)
126
+ end
127
+
128
+ " #{ivar}: #{before} => #{after}"
129
+ end.join("\n")
130
+ end
131
+
132
+ def call_info_with_ivar_changes(options = {})
133
+ <<~MSG
134
+ #{method_name_and_defined_class(options)}
135
+ from: #{location(options)}
136
+ changes:
137
+ #{ivar_changes(options)}
138
+
139
+ MSG
140
+ end
141
+
142
+ private
143
+
144
+ def generate_string_result(obj, inspect)
145
+ case obj
146
+ when Array
147
+ array_to_string(obj, inspect)
148
+ when Hash
149
+ hash_to_string(obj, inspect)
150
+ when UNDEFINED
151
+ UNDEFINED
152
+ when String
153
+ "\"#{obj}\""
154
+ when nil
155
+ "nil"
156
+ else
157
+ inspect ? obj.inspect : obj.to_s
158
+ end
159
+ end
160
+
161
+ def array_to_string(array, inspect)
162
+ elements_string = array.map do |elem|
163
+ generate_string_result(elem, inspect)
164
+ end.join(", ")
165
+ "[#{elements_string}]"
166
+ end
167
+
168
+ def hash_to_string(hash, inspect)
169
+ elements_string = hash.map do |key, value|
170
+ "#{key.to_s}: #{generate_string_result(value, inspect)}"
171
+ end.join(", ")
172
+ "{#{elements_string}}"
173
+ end
174
+
175
+ def obj_to_string(element, inspect)
176
+ to_string_method = inspect ? :inspect : :to_s
177
+
178
+ if !inspect && element.is_a?(String)
179
+ "\"#{element}\""
180
+ else
181
+ element.send(to_string_method)
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,22 @@
1
+ class ObjectTracer
2
+ module Output
3
+ class Writer
4
+ def initialize(options:, output_block:, logger:)
5
+ @options = options
6
+ @output_block = output_block
7
+ @logger = logger
8
+ end
9
+
10
+ def write!(payload)
11
+ output = generate_output(payload)
12
+ @logger << output
13
+ end
14
+
15
+ private
16
+
17
+ def generate_output(payload)
18
+ @output_block.call(PayloadWrapper.new(payload), @options)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,40 @@
1
+ class ObjectTracer
2
+ class Payload
3
+ ATTRS = [
4
+ :target, :receiver, :method_name, :method_object, :arguments, :return_value, :filepath, :line_number,
5
+ :defined_class, :trace, :tag, :tp, :ivar_changes, :is_private_call
6
+ ]
7
+
8
+ attr_accessor(*ATTRS)
9
+
10
+ alias :is_private_call? :is_private_call
11
+
12
+ def initialize(
13
+ target:, receiver:, method_name:, method_object:, arguments:, return_value:, filepath:, line_number:,
14
+ defined_class:, trace:, tag:, tp:, is_private_call:
15
+ )
16
+ @target = target
17
+ @receiver = receiver
18
+ @method_name = method_name
19
+ @method_object = method_object
20
+ @arguments = arguments
21
+ @return_value = return_value
22
+ @filepath = filepath
23
+ @line_number = line_number
24
+ @defined_class = defined_class
25
+ @trace = trace
26
+ @tag = tag
27
+ @tp = tp
28
+ @ivar_changes = {}
29
+ @is_private_call = is_private_call
30
+ end
31
+
32
+ def method_head
33
+ method_object.source.strip if method_object.source_location
34
+ end
35
+
36
+ def location(options = {})
37
+ "#{filepath}:#{line_number}"
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,133 @@
1
+ class ObjectTracer
2
+ module Trackable
3
+ def tap_init!(object, options = {}, &block)
4
+ ObjectTracer::Trackers::InitializationTracker.new(options, &block).track(object)
5
+ end
6
+
7
+ def tap_passed!(object, options = {}, &block)
8
+ ObjectTracer::Trackers::PassedTracker.new(options, &block).track(object)
9
+ end
10
+
11
+ def tap_assoc!(object, options = {}, &block)
12
+ ObjectTracer::Trackers::AssociactionCallTracker.new(options, &block).track(object)
13
+ end
14
+
15
+ def tap_on!(object, options = {}, &block)
16
+ ObjectTracer::Trackers::MethodCallTracker.new(options, &block).track(object)
17
+ end
18
+
19
+ def tap_mutation!(object, options = {}, &block)
20
+ ObjectTracer::Trackers::MutationTracker.new(options, &block).track(object)
21
+ end
22
+
23
+ [:calls, :traces, :mutations].each do |subject|
24
+ [:print, :write].each do |output_action|
25
+ helper_method_name = "#{output_action}_#{subject}"
26
+
27
+ define_method helper_method_name do |target, options = {}|
28
+ send("output_#{subject}", target, options, output_action: "and_#{output_action}")
29
+ end
30
+
31
+ define_method "with_#{helper_method_name}" do |options = {}|
32
+ send(helper_method_name, self, options)
33
+ self
34
+ end
35
+
36
+ define_method "#{output_action}_instance_#{subject}" do |target_klass, options = {}|
37
+ collection_proxy = AsyncCollectionProxy.new
38
+
39
+ tap_init!(target_klass, options.merge(force_recording: true)) do |payload|
40
+ collection_proxy << send(helper_method_name, payload.return_value, options)
41
+ end
42
+
43
+ collection_proxy
44
+ end
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def output_calls(target, options = {}, output_action:)
51
+ device_options, output_options = separate_options(options)
52
+
53
+ tap_on!(target, device_options).send(output_action, options: output_options) do |output_payload, output_options|
54
+ output_payload.detail_call_info(output_options)
55
+ end
56
+ end
57
+
58
+ def output_traces(target, options = {}, output_action:)
59
+ device_options, output_options = separate_options(options)
60
+ device_options[:event_type] = :call
61
+
62
+ device_1 = tap_on!(target, device_options).send(output_action, options: output_options) do |output_payload, output_options|
63
+ "Called #{output_payload.method_name_and_location(output_options)}\n"
64
+ end
65
+ device_2 = tap_passed!(target, device_options).send(output_action, options: output_options) do |output_payload, output_options|
66
+ output_payload.passed_at(output_options)
67
+ end
68
+ CollectionProxy.new([device_1, device_2])
69
+ end
70
+
71
+ def output_mutations(target, options = {}, output_action:)
72
+ device_options, output_options = separate_options(options)
73
+
74
+ tap_mutation!(target, device_options).send(output_action, options: output_options) do |output_payload, output_options|
75
+ output_payload.call_info_with_ivar_changes(output_options)
76
+ end
77
+ end
78
+
79
+ def separate_options(options)
80
+ output_options = Output::DEFAULT_OPTIONS.keys.each_with_object({}) do |key, hash|
81
+ hash[key] = options.fetch(key, ObjectTracer.config[key])
82
+ options.delete(key)
83
+ end
84
+
85
+ [options, output_options]
86
+ end
87
+
88
+ # CollectionProxy delegates chained actions to multiple devices
89
+ class CollectionProxy
90
+ CHAINABLE_ACTIONS = [:stop!, :stop_when, :with]
91
+
92
+ def initialize(devices)
93
+ @devices = devices
94
+ end
95
+
96
+ CHAINABLE_ACTIONS.each do |method|
97
+ define_method method do |&block|
98
+ @devices.each do |device|
99
+ device.send(method, &block)
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ # AsyncCollectionProxy delegates chained actions to multiple device "asyncronously"
106
+ # when we use tapping methods like `tap_init!` to create sub-devices
107
+ # we need to find a way to pass the chained actions to every sub-device that's created
108
+ # and this can only happen asyncronously as we won't know when'll that happen
109
+ class AsyncCollectionProxy < CollectionProxy
110
+ def initialize(devices = [])
111
+ super
112
+ @blocks = {}
113
+ end
114
+
115
+ CHAINABLE_ACTIONS.each do |method|
116
+ define_method method do |&block|
117
+ super(&block)
118
+ @blocks[method] = block
119
+ end
120
+ end
121
+
122
+ def <<(device)
123
+ @devices << device
124
+
125
+ @blocks.each do |method, block|
126
+ device.send(method, &block)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ include ObjectTracer::Trackable