ruby_method_tracer 0.4.0 → 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 +34 -0
- data/README.md +88 -11
- 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/enhanced_tracer.rb +72 -74
- data/lib/ruby_method_tracer/exportable.rb +5 -1
- data/lib/ruby_method_tracer/formatters/base_formatter.rb +2 -1
- data/lib/ruby_method_tracer/simple_tracer.rb +130 -47
- data/lib/ruby_method_tracer/version.rb +1 -1
- data/lib/ruby_method_tracer/wrapper.rb +274 -0
- data/lib/ruby_method_tracer.rb +36 -10
- data/sig/ruby_method_tracer.rbs +210 -1
- metadata +3 -5
- data/.rspec +0 -3
- data/.rubocop.yml +0 -27
- data/CLAUDE.md +0 -61
- data/Rakefile +0 -12
|
@@ -44,6 +44,7 @@ module RubyMethodTracer
|
|
|
44
44
|
case format.to_s
|
|
45
45
|
when "json" then Formatters::JsonFormatter.new
|
|
46
46
|
when "flat" then Formatters::FlatFormatter.new
|
|
47
|
+
when "tree" then raise ArgumentError, "the :tree format needs a call tree; use EnhancedTracer"
|
|
47
48
|
else raise ArgumentError, "unknown export format: #{format.inspect}"
|
|
48
49
|
end
|
|
49
50
|
end
|
|
@@ -52,7 +53,10 @@ module RubyMethodTracer
|
|
|
52
53
|
safe_path = validate_export_path(path)
|
|
53
54
|
# O_NOFOLLOW makes the open fail if the final component is a symlink,
|
|
54
55
|
# closing the check-then-write race left by the stat-based guard below.
|
|
55
|
-
|
|
56
|
+
# It is a POSIX open(2) flag and is absent on some platforms (Windows);
|
|
57
|
+
# there the stat-based guard alone applies.
|
|
58
|
+
flags = File::WRONLY | File::CREAT | File::TRUNC
|
|
59
|
+
flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
|
|
56
60
|
File.open(safe_path, flags) { |file| file.write(content) }
|
|
57
61
|
safe_path
|
|
58
62
|
rescue Errno::ELOOP
|
|
@@ -40,8 +40,9 @@ module RubyMethodTracer
|
|
|
40
40
|
# Abstract method to be implemented by subclasses
|
|
41
41
|
#
|
|
42
42
|
# @param _data [Object] Data to format
|
|
43
|
+
# @param _options [Hash] Formatter-specific options
|
|
43
44
|
# @raise [NotImplementedError] Must be implemented by subclass
|
|
44
|
-
def format(_data)
|
|
45
|
+
def format(_data, _options = {})
|
|
45
46
|
raise NotImplementedError, "#{self.class} must implement #format"
|
|
46
47
|
end
|
|
47
48
|
end
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "set"
|
|
4
3
|
require "logger"
|
|
5
4
|
require_relative "formatters/base_formatter"
|
|
6
5
|
require_relative "exportable"
|
|
6
|
+
require_relative "wrapper"
|
|
7
7
|
|
|
8
8
|
module RubyMethodTracer
|
|
9
9
|
# SimpleTracer wraps instance methods on a target class and records
|
|
@@ -21,45 +21,83 @@ module RubyMethodTracer
|
|
|
21
21
|
# tracer = RubyMethodTracer::SimpleTracer.new(MyClass, threshold: 0.005)
|
|
22
22
|
# tracer.trace_method(:expensive_call)
|
|
23
23
|
# results = tracer.fetch_results
|
|
24
|
+
#
|
|
25
|
+
# To trace class (singleton) methods, pass the singleton class:
|
|
26
|
+
# RubyMethodTracer::SimpleTracer.new(MyClass.singleton_class)
|
|
24
27
|
# rubocop:disable Metrics/ClassLength
|
|
25
28
|
class SimpleTracer
|
|
26
29
|
include Exportable
|
|
27
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
|
+
|
|
28
35
|
def initialize(target_class, **options)
|
|
29
36
|
@target_class = target_class
|
|
30
37
|
@options = default_options.merge(options)
|
|
31
38
|
@calls = []
|
|
32
39
|
@lock = Mutex.new # Mutex to make writes to @calls thread safe.
|
|
33
|
-
@wrapped_methods =
|
|
40
|
+
@wrapped_methods = {} # method name => visibility it had before wrapping
|
|
41
|
+
@qualified_names = {} # method name => display name, precomputed
|
|
34
42
|
@logger = @options[:logger] || Logger.new($stdout)
|
|
35
43
|
# Unique per instance so separate tracers don't interfere with each other.
|
|
36
44
|
@tracer_key = :"__ruby_method_tracer_in_trace_#{object_id}"
|
|
45
|
+
@accessor = :"__ruby_method_tracer_#{object_id}__"
|
|
37
46
|
@formatter = Formatters::BaseFormatter.new
|
|
38
47
|
end
|
|
39
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
|
|
40
53
|
def trace_method(name)
|
|
41
54
|
method_name = name.to_sym
|
|
42
55
|
visibility = method_visibility(method_name)
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
unless visibility
|
|
57
|
+
warn_missing(method_name)
|
|
58
|
+
return false
|
|
59
|
+
end
|
|
60
|
+
return false if already_traced?(method_name)
|
|
45
61
|
|
|
46
|
-
|
|
47
|
-
@
|
|
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
|
|
48
67
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
51
76
|
|
|
52
|
-
|
|
53
|
-
@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
|
|
54
84
|
|
|
55
|
-
|
|
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
|
|
56
92
|
end
|
|
57
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.
|
|
58
96
|
def record_call(method_name, execution_time, status, error = nil)
|
|
59
97
|
return if execution_time < @options[:threshold]
|
|
60
98
|
|
|
61
99
|
call_details = {
|
|
62
|
-
method_name:
|
|
100
|
+
method_name: @qualified_names[method_name] || qualified_name(method_name),
|
|
63
101
|
execution_time: execution_time,
|
|
64
102
|
status: status,
|
|
65
103
|
error: error,
|
|
@@ -116,55 +154,92 @@ module RubyMethodTracer
|
|
|
116
154
|
nil
|
|
117
155
|
end
|
|
118
156
|
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
180
|
+
|
|
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)
|
|
122
196
|
|
|
123
|
-
@
|
|
197
|
+
@logger.warn("RubyMethodTracer: #{@target_class}##{method_name} is already traced; skipping")
|
|
124
198
|
true
|
|
125
199
|
end
|
|
126
200
|
|
|
127
|
-
def
|
|
128
|
-
:
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def build_wrapper(aliased, method_name, key, tracer)
|
|
132
|
-
proc do |*args, **kwargs, &block| # Captures args and block exactly like original.
|
|
133
|
-
tracer.__send__(:wrap_call, method_name, key) do # Delegates to wrapper to handle timing and flag.
|
|
134
|
-
# Ruby 3+ compatible keyword argument forwarding
|
|
135
|
-
if kwargs.empty?
|
|
136
|
-
__send__(aliased, *args, &block) # Calls without kwargs to avoid Ruby warnings
|
|
137
|
-
else
|
|
138
|
-
__send__(aliased, *args, **kwargs, &block) # Calls the original aliased implementation.
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
end
|
|
201
|
+
def warn_missing(method_name)
|
|
202
|
+
@logger.warn("RubyMethodTracer: #{@target_class} has no method ##{method_name}; not traced")
|
|
142
203
|
end
|
|
143
204
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
result = yield
|
|
151
|
-
record_call(method_name, monotonic_time - start, :success)
|
|
152
|
-
result
|
|
153
|
-
rescue StandardError => e
|
|
154
|
-
record_call(method_name, monotonic_time - start, :error, e)
|
|
155
|
-
raise
|
|
156
|
-
ensure
|
|
157
|
-
Thread.current[key] = false
|
|
158
|
-
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}"
|
|
159
211
|
end
|
|
160
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.
|
|
161
236
|
def monotonic_time
|
|
162
237
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
163
238
|
end
|
|
164
239
|
|
|
165
240
|
def output_call(call)
|
|
166
241
|
time_str = colorize(format_time(call[:execution_time]), :yellow)
|
|
167
|
-
status_str = call[:status]
|
|
242
|
+
status_str = status_label(call[:status])
|
|
168
243
|
method_name = colorize(call[:method_name], :cyan)
|
|
169
244
|
if call[:status] == :error
|
|
170
245
|
@logger.warn(
|
|
@@ -175,6 +250,14 @@ module RubyMethodTracer
|
|
|
175
250
|
end
|
|
176
251
|
end
|
|
177
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
|
+
|
|
178
261
|
def format_time(seconds)
|
|
179
262
|
@formatter.format_time(seconds)
|
|
180
263
|
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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "ruby_method_tracer/version"
|
|
4
4
|
require_relative "ruby_method_tracer/configuration"
|
|
5
|
+
require_relative "ruby_method_tracer/wrapper"
|
|
5
6
|
require_relative "ruby_method_tracer/formatters/base_formatter"
|
|
6
7
|
require_relative "ruby_method_tracer/simple_tracer"
|
|
7
8
|
require_relative "ruby_method_tracer/call_tree"
|
|
@@ -30,7 +31,6 @@ module RubyMethodTracer
|
|
|
30
31
|
class Error < StandardError; end
|
|
31
32
|
|
|
32
33
|
@configuration = Configuration.new
|
|
33
|
-
@configuration_mutex = Mutex.new
|
|
34
34
|
|
|
35
35
|
class << self
|
|
36
36
|
# Global configuration shared as defaults by tracers created via the mixin.
|
|
@@ -39,18 +39,21 @@ module RubyMethodTracer
|
|
|
39
39
|
attr_reader :configuration
|
|
40
40
|
|
|
41
41
|
# Yield the global configuration for mutation. Intended to be called once
|
|
42
|
-
# at application boot.
|
|
42
|
+
# at application boot, before any threads are tracing.
|
|
43
43
|
#
|
|
44
44
|
# RubyMethodTracer.configure do |config|
|
|
45
45
|
# config.threshold = 0.005
|
|
46
46
|
# end
|
|
47
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
|
+
#
|
|
48
53
|
# @yieldparam config [RubyMethodTracer::Configuration]
|
|
49
54
|
# @return [RubyMethodTracer::Configuration]
|
|
50
55
|
def configure
|
|
51
|
-
@
|
|
52
|
-
yield(@configuration) if block_given?
|
|
53
|
-
end
|
|
56
|
+
yield(@configuration) if block_given?
|
|
54
57
|
@configuration
|
|
55
58
|
end
|
|
56
59
|
|
|
@@ -58,7 +61,7 @@ module RubyMethodTracer
|
|
|
58
61
|
#
|
|
59
62
|
# @return [RubyMethodTracer::Configuration]
|
|
60
63
|
def reset_configuration!
|
|
61
|
-
@
|
|
64
|
+
@configuration.reset!
|
|
62
65
|
end
|
|
63
66
|
end
|
|
64
67
|
|
|
@@ -79,11 +82,34 @@ module RubyMethodTracer
|
|
|
79
82
|
# end
|
|
80
83
|
# MyService.trace_methods(:call, threshold: 0.005, auto_output: true)
|
|
81
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
|
|
82
94
|
def trace_methods(*method_names, **options)
|
|
83
|
-
tracer = SimpleTracer.new(self, **options)
|
|
84
|
-
method_names.each
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
87
113
|
end
|
|
88
114
|
end
|
|
89
115
|
end
|