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.
@@ -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
- flags = File::WRONLY | File::CREAT | File::TRUNC | File::NOFOLLOW
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 = Set.new
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
- return unless visibility
44
- return unless mark_wrapped?(method_name)
56
+ unless visibility
57
+ warn_missing(method_name)
58
+ return false
59
+ end
60
+ return false if already_traced?(method_name)
45
61
 
46
- aliased = alias_for(method_name)
47
- @target_class.send(:alias_method, aliased, method_name) # Aliases original implementation to our private name.
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
- tracer = self
50
- key = @tracer_key # unique per tracer instance; prevents cross-tracer interference
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
- # Defines a new method with the original name that delegates to our wrapper.
53
- @target_class.define_method(method_name, &build_wrapper(aliased, method_name, key, tracer))
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
- @target_class.send(visibility, method_name) # Restores the original visibility after redefine.
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: "#{@target_class}##{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
- # Marks a method as wrapped to avoid duplicates
120
- def mark_wrapped?(method_name)
121
- return false if @wrapped_methods.include?(method_name)
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
- @wrapped_methods << method_name
197
+ @logger.warn("RubyMethodTracer: #{@target_class}##{method_name} is already traced; skipping")
124
198
  true
125
199
  end
126
200
 
127
- def alias_for(method_name)
128
- :"__ruby_method_tracer_original_#{method_name}__"
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
- def wrap_call(method_name, key)
145
- return yield if Thread.current[key]
146
-
147
- Thread.current[key] = true
148
- start = monotonic_time
149
- begin
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] == :error ? colorize("[ERROR]", :red) : colorize("[OK]", :green)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyMethodTracer
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  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
@@ -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
- @configuration_mutex.synchronize do
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
- @configuration_mutex.synchronize { @configuration.reset! }
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 do |method_name|
85
- tracer.trace_method(method_name)
86
- end
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