object_tracer 1.0.0

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.
@@ -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