ruby_method_tracer 0.3.3 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +134 -10
- data/lib/ruby_method_tracer/call_tree.rb +61 -69
- data/lib/ruby_method_tracer/call_tree_statistics.rb +75 -0
- data/lib/ruby_method_tracer/configuration.rb +57 -0
- data/lib/ruby_method_tracer/enhanced_tracer.rb +80 -71
- data/lib/ruby_method_tracer/exportable.rb +77 -0
- data/lib/ruby_method_tracer/formatters/base_formatter.rb +2 -1
- data/lib/ruby_method_tracer/formatters/flat_formatter.rb +119 -0
- data/lib/ruby_method_tracer/formatters/json_formatter.rb +105 -0
- data/lib/ruby_method_tracer/simple_tracer.rb +146 -51
- data/lib/ruby_method_tracer/version.rb +1 -1
- data/lib/ruby_method_tracer/wrapper.rb +274 -0
- data/lib/ruby_method_tracer.rb +66 -4
- data/sig/ruby_method_tracer.rbs +210 -1
- metadata +7 -4
- data/.rspec +0 -3
- data/.rubocop.yml +0 -27
- data/Rakefile +0 -12
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "set"
|
|
4
3
|
require "logger"
|
|
5
4
|
require_relative "formatters/base_formatter"
|
|
5
|
+
require_relative "exportable"
|
|
6
|
+
require_relative "wrapper"
|
|
6
7
|
|
|
7
8
|
module RubyMethodTracer
|
|
8
9
|
# SimpleTracer wraps instance methods on a target class and records
|
|
@@ -20,42 +21,83 @@ module RubyMethodTracer
|
|
|
20
21
|
# tracer = RubyMethodTracer::SimpleTracer.new(MyClass, threshold: 0.005)
|
|
21
22
|
# tracer.trace_method(:expensive_call)
|
|
22
23
|
# results = tracer.fetch_results
|
|
24
|
+
#
|
|
25
|
+
# To trace class (singleton) methods, pass the singleton class:
|
|
26
|
+
# RubyMethodTracer::SimpleTracer.new(MyClass.singleton_class)
|
|
27
|
+
# rubocop:disable Metrics/ClassLength
|
|
23
28
|
class SimpleTracer
|
|
29
|
+
include Exportable
|
|
30
|
+
|
|
31
|
+
# Method names the parser accepts as a bare identifier in a call.
|
|
32
|
+
PLAIN_IDENTIFIER = /\A[a-z_][A-Za-z0-9_]*\z/
|
|
33
|
+
private_constant :PLAIN_IDENTIFIER
|
|
34
|
+
|
|
24
35
|
def initialize(target_class, **options)
|
|
25
36
|
@target_class = target_class
|
|
26
37
|
@options = default_options.merge(options)
|
|
27
38
|
@calls = []
|
|
28
39
|
@lock = Mutex.new # Mutex to make writes to @calls thread safe.
|
|
29
|
-
@wrapped_methods =
|
|
40
|
+
@wrapped_methods = {} # method name => visibility it had before wrapping
|
|
41
|
+
@qualified_names = {} # method name => display name, precomputed
|
|
30
42
|
@logger = @options[:logger] || Logger.new($stdout)
|
|
31
43
|
# Unique per instance so separate tracers don't interfere with each other.
|
|
32
44
|
@tracer_key = :"__ruby_method_tracer_in_trace_#{object_id}"
|
|
45
|
+
@accessor = :"__ruby_method_tracer_#{object_id}__"
|
|
33
46
|
@formatter = Formatters::BaseFormatter.new
|
|
34
47
|
end
|
|
35
48
|
|
|
49
|
+
# Wrap a method so its calls are recorded.
|
|
50
|
+
#
|
|
51
|
+
# @param name [Symbol, String] Method to trace
|
|
52
|
+
# @return [Boolean] true if the method was wrapped by this call
|
|
36
53
|
def trace_method(name)
|
|
37
54
|
method_name = name.to_sym
|
|
38
55
|
visibility = method_visibility(method_name)
|
|
39
|
-
|
|
40
|
-
|
|
56
|
+
unless visibility
|
|
57
|
+
warn_missing(method_name)
|
|
58
|
+
return false
|
|
59
|
+
end
|
|
60
|
+
return false if already_traced?(method_name)
|
|
41
61
|
|
|
42
|
-
|
|
43
|
-
@
|
|
62
|
+
install_wrapper(method_name, visibility)
|
|
63
|
+
@wrapped_methods[method_name] = visibility
|
|
64
|
+
@qualified_names[method_name] = qualified_name(method_name)
|
|
65
|
+
true
|
|
66
|
+
end
|
|
44
67
|
|
|
45
|
-
|
|
46
|
-
|
|
68
|
+
# Restore a traced method to its original implementation and visibility.
|
|
69
|
+
#
|
|
70
|
+
# @param name [Symbol, String] Method to untrace
|
|
71
|
+
# @return [Boolean] true if the method was traced by this tracer
|
|
72
|
+
def untrace_method(name)
|
|
73
|
+
method_name = name.to_sym
|
|
74
|
+
visibility = @wrapped_methods.delete(method_name)
|
|
75
|
+
return false unless visibility
|
|
47
76
|
|
|
48
|
-
|
|
49
|
-
@target_class.
|
|
77
|
+
aliased = alias_for(method_name)
|
|
78
|
+
@target_class.send(:alias_method, method_name, aliased)
|
|
79
|
+
@target_class.send(:remove_method, aliased)
|
|
80
|
+
@target_class.send(visibility, method_name)
|
|
81
|
+
@qualified_names.delete(method_name)
|
|
82
|
+
true
|
|
83
|
+
end
|
|
50
84
|
|
|
51
|
-
|
|
85
|
+
# Restore every method this tracer wrapped.
|
|
86
|
+
#
|
|
87
|
+
# @return [Array<Symbol>] The methods that were untraced
|
|
88
|
+
def untrace_all
|
|
89
|
+
@wrapped_methods.keys.each_with_object([]) do |method_name, untraced|
|
|
90
|
+
untraced << method_name if untrace_method(method_name)
|
|
91
|
+
end
|
|
52
92
|
end
|
|
53
93
|
|
|
94
|
+
# Called by the generated wrapper once per invocation. Kept public because
|
|
95
|
+
# it is the documented way to feed a tracer by hand.
|
|
54
96
|
def record_call(method_name, execution_time, status, error = nil)
|
|
55
97
|
return if execution_time < @options[:threshold]
|
|
56
98
|
|
|
57
99
|
call_details = {
|
|
58
|
-
method_name:
|
|
100
|
+
method_name: @qualified_names[method_name] || qualified_name(method_name),
|
|
59
101
|
execution_time: execution_time,
|
|
60
102
|
status: status,
|
|
61
103
|
error: error,
|
|
@@ -88,12 +130,19 @@ module RubyMethodTracer
|
|
|
88
130
|
|
|
89
131
|
private
|
|
90
132
|
|
|
133
|
+
# Data passed to formatters by Exportable. Overridden by EnhancedTracer to
|
|
134
|
+
# expose the call tree.
|
|
135
|
+
def report_source
|
|
136
|
+
fetch_results
|
|
137
|
+
end
|
|
138
|
+
|
|
91
139
|
def default_options
|
|
140
|
+
config = RubyMethodTracer.configuration
|
|
92
141
|
{
|
|
93
|
-
threshold:
|
|
94
|
-
auto_output:
|
|
95
|
-
max_calls:
|
|
96
|
-
logger:
|
|
142
|
+
threshold: config.threshold,
|
|
143
|
+
auto_output: config.auto_output,
|
|
144
|
+
max_calls: config.max_calls,
|
|
145
|
+
logger: config.logger
|
|
97
146
|
}
|
|
98
147
|
end
|
|
99
148
|
|
|
@@ -105,55 +154,92 @@ module RubyMethodTracer
|
|
|
105
154
|
nil
|
|
106
155
|
end
|
|
107
156
|
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
|
|
157
|
+
# Alias the original body aside, define the traced replacement in its
|
|
158
|
+
# place, and put the original visibility back.
|
|
159
|
+
def install_wrapper(method_name, visibility)
|
|
160
|
+
aliased = alias_for(method_name)
|
|
161
|
+
@target_class.send(:alias_method, aliased, method_name)
|
|
162
|
+
# Keep the alias out of the public API: alias_method inherits the
|
|
163
|
+
# original's visibility, which would otherwise expose it on every object.
|
|
164
|
+
@target_class.send(:private, aliased)
|
|
165
|
+
define_accessor
|
|
166
|
+
Wrapper.install(@target_class, method_name, aliased, @accessor, wrapper_plan(method_name))
|
|
167
|
+
@target_class.send(visibility, method_name)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# What the generated wrapper should do around the call. Overridden by
|
|
171
|
+
# EnhancedTracer to add the call-tree hook.
|
|
172
|
+
def wrapper_plan(_method_name)
|
|
173
|
+
Wrapper::Plan.new(key: @tracer_key, close: :record_call)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Generated wrappers are compiled from a string and cannot close over the
|
|
177
|
+
# tracer, so they reach it through this private accessor instead.
|
|
178
|
+
def define_accessor
|
|
179
|
+
return if @accessor_defined
|
|
111
180
|
|
|
112
|
-
|
|
181
|
+
tracer = self
|
|
182
|
+
@target_class.define_method(@accessor) { tracer }
|
|
183
|
+
@target_class.send(:private, @accessor)
|
|
184
|
+
@accessor_defined = true
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A method is already traced when this tracer wrapped it, or when the alias
|
|
188
|
+
# is present because some other tracer did. Wrapping twice would alias the
|
|
189
|
+
# existing wrapper onto itself and recurse until the stack runs out.
|
|
190
|
+
def already_traced?(method_name)
|
|
191
|
+
return true if @wrapped_methods.key?(method_name)
|
|
192
|
+
|
|
193
|
+
aliased = alias_for(method_name)
|
|
194
|
+
return false unless @target_class.private_method_defined?(aliased) ||
|
|
195
|
+
@target_class.method_defined?(aliased)
|
|
196
|
+
|
|
197
|
+
@logger.warn("RubyMethodTracer: #{@target_class}##{method_name} is already traced; skipping")
|
|
113
198
|
true
|
|
114
199
|
end
|
|
115
200
|
|
|
116
|
-
def
|
|
117
|
-
:
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def build_wrapper(aliased, method_name, key, tracer)
|
|
121
|
-
proc do |*args, **kwargs, &block| # Captures args and block exactly like original.
|
|
122
|
-
tracer.__send__(:wrap_call, method_name, key) do # Delegates to wrapper to handle timing and flag.
|
|
123
|
-
# Ruby 3+ compatible keyword argument forwarding
|
|
124
|
-
if kwargs.empty?
|
|
125
|
-
__send__(aliased, *args, &block) # Calls without kwargs to avoid Ruby warnings
|
|
126
|
-
else
|
|
127
|
-
__send__(aliased, *args, **kwargs, &block) # Calls the original aliased implementation.
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
end
|
|
201
|
+
def warn_missing(method_name)
|
|
202
|
+
@logger.warn("RubyMethodTracer: #{@target_class} has no method ##{method_name}; not traced")
|
|
131
203
|
end
|
|
132
204
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
result = yield
|
|
140
|
-
record_call(method_name, monotonic_time - start, :success)
|
|
141
|
-
result
|
|
142
|
-
rescue StandardError => e
|
|
143
|
-
record_call(method_name, monotonic_time - start, :error, e)
|
|
144
|
-
raise
|
|
145
|
-
ensure
|
|
146
|
-
Thread.current[key] = false
|
|
147
|
-
end
|
|
205
|
+
# Singleton classes stringify as "#<Class:Foo>"; render those as "Foo.bar"
|
|
206
|
+
# so traced class methods read the way they are called.
|
|
207
|
+
def qualified_name(method_name)
|
|
208
|
+
return "#{@target_class}##{method_name}" unless @target_class.singleton_class?
|
|
209
|
+
|
|
210
|
+
"#{singleton_owner}.#{method_name}"
|
|
148
211
|
end
|
|
149
212
|
|
|
213
|
+
# Module#attached_object is Ruby 3.2+; fall back to unwrapping the string
|
|
214
|
+
# form on older versions.
|
|
215
|
+
def singleton_owner
|
|
216
|
+
return @target_class.attached_object if @target_class.respond_to?(:attached_object)
|
|
217
|
+
|
|
218
|
+
@target_class.to_s[/\A#<Class:(.+)>\z/, 1] || @target_class.to_s
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Name under which the original implementation is kept.
|
|
222
|
+
#
|
|
223
|
+
# The generated wrapper calls this alias directly rather than through
|
|
224
|
+
# `__send__`, which is faster but requires a name the parser accepts as an
|
|
225
|
+
# identifier. Predicate (`foo?`), bang (`foo!`), setter (`foo=`) and
|
|
226
|
+
# operator (`==`, `[]`, `<=>`) methods are not, so those are hex-encoded.
|
|
227
|
+
# Deterministic either way, so `untrace_method` reconstructs the same name.
|
|
228
|
+
def alias_for(method_name)
|
|
229
|
+
name = method_name.to_s
|
|
230
|
+
part = PLAIN_IDENTIFIER.match?(name) ? name : "op_#{name.unpack1("H*")}"
|
|
231
|
+
:"__ruby_method_tracer_original_#{part}__"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Only used by tracers feeding themselves; the generated wrapper reads the
|
|
235
|
+
# clock inline so the call path carries no extra frames.
|
|
150
236
|
def monotonic_time
|
|
151
237
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
152
238
|
end
|
|
153
239
|
|
|
154
240
|
def output_call(call)
|
|
155
241
|
time_str = colorize(format_time(call[:execution_time]), :yellow)
|
|
156
|
-
status_str = call[:status]
|
|
242
|
+
status_str = status_label(call[:status])
|
|
157
243
|
method_name = colorize(call[:method_name], :cyan)
|
|
158
244
|
if call[:status] == :error
|
|
159
245
|
@logger.warn(
|
|
@@ -164,6 +250,14 @@ module RubyMethodTracer
|
|
|
164
250
|
end
|
|
165
251
|
end
|
|
166
252
|
|
|
253
|
+
def status_label(status)
|
|
254
|
+
case status
|
|
255
|
+
when :error then colorize("[ERROR]", :red)
|
|
256
|
+
when :incomplete then colorize("[INCOMPLETE]", :yellow)
|
|
257
|
+
else colorize("[OK]", :green)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
167
261
|
def format_time(seconds)
|
|
168
262
|
@formatter.format_time(seconds)
|
|
169
263
|
end
|
|
@@ -172,4 +266,5 @@ module RubyMethodTracer
|
|
|
172
266
|
@formatter.colorize(text, color)
|
|
173
267
|
end
|
|
174
268
|
end
|
|
269
|
+
# rubocop:enable Metrics/ClassLength
|
|
175
270
|
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyMethodTracer
|
|
4
|
+
# Wrapper generates the traced replacement for a method.
|
|
5
|
+
#
|
|
6
|
+
# Two things drive the design:
|
|
7
|
+
#
|
|
8
|
+
# 1. **Signature fidelity.** The replacement is built with `module_eval` from
|
|
9
|
+
# the original method's `parameters`, not from a generic
|
|
10
|
+
# `proc { |*args, **kwargs, &block| }`, so `Method#arity` and
|
|
11
|
+
# `Method#parameters` survive tracing and reflection-driven callers
|
|
12
|
+
# (dependency injection, serializers, argument validators, documentation
|
|
13
|
+
# tooling) still see the real signature.
|
|
14
|
+
#
|
|
15
|
+
# 2. **Per-call cost.** The whole timing path is emitted inline: the
|
|
16
|
+
# reentrancy guard, both clock reads and the `begin/rescue/ensure` live in
|
|
17
|
+
# the generated method itself. It calls the tracer exactly once per
|
|
18
|
+
# invocation (twice when tracking hierarchy), instead of threading a block
|
|
19
|
+
# down through several tracer methods — each of those frames cost a call
|
|
20
|
+
# plus a `Proc`. Everything knowable at trace time (the reentrancy key, the
|
|
21
|
+
# method name, the display name) is baked in as a literal so no hash
|
|
22
|
+
# lookups happen on the call path.
|
|
23
|
+
#
|
|
24
|
+
# Optional positional and optional keyword parameters are declared with
|
|
25
|
+
# OMITTED as their default. Omitted values are dropped before forwarding, so
|
|
26
|
+
# the original method still applies its own defaults — `parameters` does not
|
|
27
|
+
# expose default values, so they cannot be reproduced here.
|
|
28
|
+
#
|
|
29
|
+
# The one signature difference that remains: a block parameter is always
|
|
30
|
+
# declared, even when the original had none, because a method may still
|
|
31
|
+
# `yield`. Block parameters do not affect arity.
|
|
32
|
+
module Wrapper
|
|
33
|
+
# Sentinel marking "this optional argument was not supplied". Referenced by
|
|
34
|
+
# generated code, so it must stay a public constant.
|
|
35
|
+
OMITTED = Object.new
|
|
36
|
+
|
|
37
|
+
SENTINEL = "::RubyMethodTracer::Wrapper::OMITTED"
|
|
38
|
+
IDENTIFIER = /\A[a-z_][A-Za-z0-9_]*\z/
|
|
39
|
+
private_constant :SENTINEL, :IDENTIFIER
|
|
40
|
+
|
|
41
|
+
# What the generated wrapper should do, beyond forwarding the call.
|
|
42
|
+
#
|
|
43
|
+
# @!attribute key
|
|
44
|
+
# @return [Symbol] Thread-local key for the reentrancy guard
|
|
45
|
+
# @!attribute close
|
|
46
|
+
# @return [Symbol] Tracer method called with (name, duration, status, error)
|
|
47
|
+
# @!attribute open
|
|
48
|
+
# @return [Symbol, nil] Tracer method called before the call, with the display name
|
|
49
|
+
# @!attribute display_name
|
|
50
|
+
# @return [String, nil] Literal passed to the open hook
|
|
51
|
+
Plan = Struct.new(:key, :close, :open, :display_name, keyword_init: true)
|
|
52
|
+
|
|
53
|
+
# Build a signature description for a parameter list.
|
|
54
|
+
#
|
|
55
|
+
# Produces the parameter declaration, the setup that rebuilds the argument
|
|
56
|
+
# list at call time, and the forwarding call itself. Methods whose every
|
|
57
|
+
# parameter forwards unconditionally — no optional positionals, no keywords —
|
|
58
|
+
# skip the argument array entirely and forward straight through, which is
|
|
59
|
+
# the common case.
|
|
60
|
+
class Signature
|
|
61
|
+
# Parameter kind => the builder that emits its declaration and forwarding.
|
|
62
|
+
HANDLERS = {
|
|
63
|
+
req: :required,
|
|
64
|
+
opt: :optional,
|
|
65
|
+
rest: :splat,
|
|
66
|
+
keyreq: :keyword_required,
|
|
67
|
+
key: :keyword_optional,
|
|
68
|
+
keyrest: :keyword_splat,
|
|
69
|
+
nokey: :nokey,
|
|
70
|
+
block: :block_param
|
|
71
|
+
}.freeze
|
|
72
|
+
private_constant :HANDLERS
|
|
73
|
+
|
|
74
|
+
# Ruby 3.0 reports `def m(...)` as a bare rest plus a block and omits the
|
|
75
|
+
# keyword rest; 3.1+ includes [:keyrest, :**]. On 3.1+ no repair is
|
|
76
|
+
# needed, and guessing would be wrong there — `def m(*, &)` reports the
|
|
77
|
+
# same shape and genuinely takes no keywords.
|
|
78
|
+
FORWARDING_OMITS_KEYWORDS = RUBY_VERSION < "3.1"
|
|
79
|
+
FORWARD_REST = %i[rest *].freeze
|
|
80
|
+
FORWARD_BLOCK = %i[block &].freeze
|
|
81
|
+
FORWARD_KEYREST = %i[keyrest **].freeze
|
|
82
|
+
private_constant :FORWARDING_OMITS_KEYWORDS, :FORWARD_REST, :FORWARD_BLOCK, :FORWARD_KEYREST
|
|
83
|
+
|
|
84
|
+
# @param params [Array] Parameter list as reported by Method#parameters
|
|
85
|
+
# @param repair_forwarding [Boolean] Put back the keyword rest that this
|
|
86
|
+
# Ruby omits when describing `...`. Injectable so the repair can be
|
|
87
|
+
# exercised on any version.
|
|
88
|
+
def initialize(params, repair_forwarding: FORWARDING_OMITS_KEYWORDS)
|
|
89
|
+
params = with_forwarded_keywords(params) if repair_forwarding
|
|
90
|
+
@declaration = []
|
|
91
|
+
@positional = []
|
|
92
|
+
@keyword = []
|
|
93
|
+
@direct = []
|
|
94
|
+
@block_name = nil
|
|
95
|
+
@nokey = false
|
|
96
|
+
@dynamic = false
|
|
97
|
+
params.each_with_index { |(kind, name), index| add(kind, name, index) }
|
|
98
|
+
@declaration << "&#{block_name}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def declaration
|
|
102
|
+
@declaration.join(", ")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Rebuilds the outgoing argument list. Empty for the direct path.
|
|
106
|
+
def setup
|
|
107
|
+
return "" if direct?
|
|
108
|
+
|
|
109
|
+
lines = ["__rmt_args__ = []", *@positional]
|
|
110
|
+
lines += ["__rmt_kwargs__ = {}", *@keyword] unless keywordless?
|
|
111
|
+
lines.join("\n ")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def forward(aliased)
|
|
115
|
+
return "#{aliased}(#{(@direct + ["&#{block_name}"]).join(", ")})" if direct?
|
|
116
|
+
|
|
117
|
+
plain = "#{aliased}(*__rmt_args__, &#{block_name})"
|
|
118
|
+
return plain if keywordless?
|
|
119
|
+
|
|
120
|
+
# The empty check keeps `**{}` off the call site, which is what broke
|
|
121
|
+
# keyword forwarding on Ruby 3.4 (see CHANGELOG 0.3.2 and 0.3.3).
|
|
122
|
+
# Parenthesised so the expression stays intact wherever it is spliced in.
|
|
123
|
+
"(__rmt_kwargs__.empty? ? #{plain} : " \
|
|
124
|
+
"#{aliased}(*__rmt_args__, **__rmt_kwargs__, &#{block_name}))"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# Every parameter forwards unconditionally and no keywords are involved,
|
|
130
|
+
# so the call can be written out literally.
|
|
131
|
+
def direct?
|
|
132
|
+
!@dynamic && @keyword.empty? && !@nokey
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def keywordless?
|
|
136
|
+
@nokey || @keyword.empty?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def block_name
|
|
140
|
+
@block_name ||= "__rmt_block__"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def add(kind, name, index)
|
|
144
|
+
handler = HANDLERS[kind]
|
|
145
|
+
send(handler, local_name(name, index)) if handler
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def required(name)
|
|
149
|
+
@declaration << name
|
|
150
|
+
@positional << "__rmt_args__ << #{name}"
|
|
151
|
+
@direct << name
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def optional(name)
|
|
155
|
+
@declaration << "#{name} = #{SENTINEL}"
|
|
156
|
+
@positional << "__rmt_args__ << #{name} unless #{SENTINEL}.equal?(#{name})"
|
|
157
|
+
@dynamic = true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def splat(name)
|
|
161
|
+
@declaration << "*#{name}"
|
|
162
|
+
@positional << "__rmt_args__.concat(#{name})"
|
|
163
|
+
@direct << "*#{name}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def keyword_required(name)
|
|
167
|
+
@declaration << "#{name}:"
|
|
168
|
+
@keyword << "__rmt_kwargs__[:#{name}] = #{name}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def keyword_optional(name)
|
|
172
|
+
@declaration << "#{name}: #{SENTINEL}"
|
|
173
|
+
@keyword << "__rmt_kwargs__[:#{name}] = #{name} unless #{SENTINEL}.equal?(#{name})"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def keyword_splat(name)
|
|
177
|
+
@declaration << "**#{name}"
|
|
178
|
+
@keyword << "__rmt_kwargs__.update(#{name})"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def nokey(_name)
|
|
182
|
+
@declaration << "**nil"
|
|
183
|
+
@nokey = true
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def block_param(name)
|
|
187
|
+
@block_name = name
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Ruby 3.0 omits the keyword rest when reporting `def m(...)`. Taken at
|
|
191
|
+
# face value the wrapper would declare only `*rest`, funnel any keywords
|
|
192
|
+
# into the positional array, and forward them as a trailing Hash — which
|
|
193
|
+
# raises ArgumentError at the original. Put the missing keyrest back so
|
|
194
|
+
# the generated wrapper matches what 3.1+ would have described.
|
|
195
|
+
def with_forwarded_keywords(params)
|
|
196
|
+
return params unless params.first == FORWARD_REST && params.include?(FORWARD_BLOCK)
|
|
197
|
+
return params if params.any? { |kind, _| kind == :keyrest }
|
|
198
|
+
|
|
199
|
+
block, rest = params.partition { |kind, _| kind == :block }
|
|
200
|
+
rest + [FORWARD_KEYREST] + block
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Anonymous parameters (`def m(*)`) and argument forwarding (`def m(...)`)
|
|
204
|
+
# report names that are unusable or absent; everything else keeps the
|
|
205
|
+
# original name so `parameters` still reports it.
|
|
206
|
+
def local_name(name, index)
|
|
207
|
+
return "__rmt_p#{index}__" if name.nil? || !IDENTIFIER.match?(name.to_s)
|
|
208
|
+
|
|
209
|
+
name.to_s
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
class << self
|
|
214
|
+
# Define the traced replacement for `method_name` on `target_class`.
|
|
215
|
+
#
|
|
216
|
+
# @param target_class [Module] Class the method lives on
|
|
217
|
+
# @param method_name [Symbol] Method being traced
|
|
218
|
+
# @param aliased [Symbol] Private alias holding the original body
|
|
219
|
+
# @param accessor [Symbol] Private method returning the owning tracer
|
|
220
|
+
# @param plan [Plan] What the wrapper should do around the call
|
|
221
|
+
# @return [Symbol] The defined method name
|
|
222
|
+
def install(target_class, method_name, aliased, accessor, plan)
|
|
223
|
+
signature = Signature.new(target_class.instance_method(aliased).parameters)
|
|
224
|
+
source = source(signature, method_name, aliased, accessor, plan)
|
|
225
|
+
target_class.module_eval(source, __FILE__, __LINE__)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
# rubocop:disable Metrics/MethodLength
|
|
231
|
+
def source(signature, method_name, aliased, accessor, plan)
|
|
232
|
+
forward = signature.forward(aliased)
|
|
233
|
+
<<~RUBY
|
|
234
|
+
def #{method_name}(#{signature.declaration})
|
|
235
|
+
#{signature.setup}
|
|
236
|
+
__rmt_guard__ = (::Thread.current[#{plan.key.inspect}] ||= [false])
|
|
237
|
+
return #{forward} if __rmt_guard__[0]
|
|
238
|
+
|
|
239
|
+
__rmt_tracer__ = #{accessor}
|
|
240
|
+
__rmt_guard__[0] = true
|
|
241
|
+
#{open_hook(plan)}
|
|
242
|
+
__rmt_started__ = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
243
|
+
__rmt_status__ = :incomplete
|
|
244
|
+
__rmt_failure__ = nil
|
|
245
|
+
begin
|
|
246
|
+
__rmt_result__ = #{forward}
|
|
247
|
+
__rmt_status__ = :success
|
|
248
|
+
__rmt_result__
|
|
249
|
+
rescue ::StandardError => __rmt_caught__
|
|
250
|
+
__rmt_status__ = :error
|
|
251
|
+
__rmt_failure__ = __rmt_caught__
|
|
252
|
+
raise
|
|
253
|
+
ensure
|
|
254
|
+
__rmt_tracer__.#{plan.close}(
|
|
255
|
+
#{method_name.inspect},
|
|
256
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - __rmt_started__,
|
|
257
|
+
__rmt_status__,
|
|
258
|
+
__rmt_failure__
|
|
259
|
+
)
|
|
260
|
+
__rmt_guard__[0] = false
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
RUBY
|
|
264
|
+
end
|
|
265
|
+
# rubocop:enable Metrics/MethodLength
|
|
266
|
+
|
|
267
|
+
def open_hook(plan)
|
|
268
|
+
return "" unless plan.open
|
|
269
|
+
|
|
270
|
+
"__rmt_tracer__.#{plan.open}(#{plan.display_name.inspect})"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
data/lib/ruby_method_tracer.rb
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "ruby_method_tracer/version"
|
|
4
|
+
require_relative "ruby_method_tracer/configuration"
|
|
5
|
+
require_relative "ruby_method_tracer/wrapper"
|
|
4
6
|
require_relative "ruby_method_tracer/formatters/base_formatter"
|
|
5
7
|
require_relative "ruby_method_tracer/simple_tracer"
|
|
6
8
|
require_relative "ruby_method_tracer/call_tree"
|
|
7
9
|
require_relative "ruby_method_tracer/enhanced_tracer"
|
|
8
10
|
require_relative "ruby_method_tracer/formatters/tree_formatter"
|
|
11
|
+
require_relative "ruby_method_tracer/formatters/json_formatter"
|
|
12
|
+
require_relative "ruby_method_tracer/formatters/flat_formatter"
|
|
9
13
|
|
|
10
14
|
# Public: Mixin that adds lightweight method tracing to classes.
|
|
11
15
|
#
|
|
@@ -26,6 +30,41 @@ require_relative "ruby_method_tracer/formatters/tree_formatter"
|
|
|
26
30
|
module RubyMethodTracer
|
|
27
31
|
class Error < StandardError; end
|
|
28
32
|
|
|
33
|
+
@configuration = Configuration.new
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Global configuration shared as defaults by tracers created via the mixin.
|
|
37
|
+
#
|
|
38
|
+
# @return [RubyMethodTracer::Configuration]
|
|
39
|
+
attr_reader :configuration
|
|
40
|
+
|
|
41
|
+
# Yield the global configuration for mutation. Intended to be called once
|
|
42
|
+
# at application boot, before any threads are tracing.
|
|
43
|
+
#
|
|
44
|
+
# RubyMethodTracer.configure do |config|
|
|
45
|
+
# config.threshold = 0.005
|
|
46
|
+
# end
|
|
47
|
+
#
|
|
48
|
+
# No lock is held across the block: a Mutex here bought no real safety
|
|
49
|
+
# (`configuration` hands out the same mutable object without one) while
|
|
50
|
+
# making a nested `configure` — easy to reach through a helper or an engine
|
|
51
|
+
# initializer — deadlock on recursive locking.
|
|
52
|
+
#
|
|
53
|
+
# @yieldparam config [RubyMethodTracer::Configuration]
|
|
54
|
+
# @return [RubyMethodTracer::Configuration]
|
|
55
|
+
def configure
|
|
56
|
+
yield(@configuration) if block_given?
|
|
57
|
+
@configuration
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Reset the global configuration back to built-in defaults.
|
|
61
|
+
#
|
|
62
|
+
# @return [RubyMethodTracer::Configuration]
|
|
63
|
+
def reset_configuration!
|
|
64
|
+
@configuration.reset!
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
29
68
|
def self.included(base)
|
|
30
69
|
base.extend(ClassMethods)
|
|
31
70
|
end
|
|
@@ -43,11 +82,34 @@ module RubyMethodTracer
|
|
|
43
82
|
# end
|
|
44
83
|
# MyService.trace_methods(:call, threshold: 0.005, auto_output: true)
|
|
45
84
|
module ClassMethods
|
|
85
|
+
# Trace instance methods on this class.
|
|
86
|
+
#
|
|
87
|
+
# The tracer is memoized per class, so repeated calls add methods to the
|
|
88
|
+
# same tracer rather than wrapping a method twice. Options are read on the
|
|
89
|
+
# first call; pass no names to fetch the existing tracer.
|
|
90
|
+
#
|
|
91
|
+
# @param method_names [Array<Symbol>] Methods to trace
|
|
92
|
+
# @param options [Hash] Options for SimpleTracer (first call only)
|
|
93
|
+
# @return [RubyMethodTracer::SimpleTracer] The tracer, so results can be read
|
|
46
94
|
def trace_methods(*method_names, **options)
|
|
47
|
-
tracer = SimpleTracer.new(self, **options)
|
|
48
|
-
method_names.each
|
|
49
|
-
|
|
50
|
-
|
|
95
|
+
tracer = (@__ruby_method_tracer ||= SimpleTracer.new(self, **options))
|
|
96
|
+
method_names.each { |method_name| tracer.trace_method(method_name) }
|
|
97
|
+
tracer
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Trace class (singleton) methods on this class.
|
|
101
|
+
#
|
|
102
|
+
# class Report
|
|
103
|
+
# include RubyMethodTracer
|
|
104
|
+
# def self.generate; end
|
|
105
|
+
# end
|
|
106
|
+
# tracer = Report.trace_class_methods(:generate)
|
|
107
|
+
#
|
|
108
|
+
# @return [RubyMethodTracer::SimpleTracer] The tracer, so results can be read
|
|
109
|
+
def trace_class_methods(*method_names, **options)
|
|
110
|
+
tracer = (@__ruby_method_tracer_class ||= SimpleTracer.new(singleton_class, **options))
|
|
111
|
+
method_names.each { |method_name| tracer.trace_method(method_name) }
|
|
112
|
+
tracer
|
|
51
113
|
end
|
|
52
114
|
end
|
|
53
115
|
end
|