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.
@@ -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 = Set.new
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
- return unless visibility
40
- 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)
41
61
 
42
- aliased = alias_for(method_name)
43
- @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
44
67
 
45
- tracer = self
46
- 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
47
76
 
48
- # Defines a new method with the original name that delegates to our wrapper.
49
- @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
50
84
 
51
- @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
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: "#{@target_class}##{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: 0.001,
94
- auto_output: false,
95
- max_calls: 1000,
96
- logger: nil
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
- # Marks a method as wrapped to avoid duplicates
109
- def mark_wrapped?(method_name)
110
- 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
111
180
 
112
- @wrapped_methods << method_name
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 alias_for(method_name)
117
- :"__ruby_method_tracer_original_#{method_name}__"
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
- def wrap_call(method_name, key)
134
- return yield if Thread.current[key]
135
-
136
- Thread.current[key] = true
137
- start = monotonic_time
138
- begin
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] == :error ? colorize("[ERROR]", :red) : colorize("[OK]", :green)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyMethodTracer
4
- VERSION = "0.3.3"
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
@@ -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 do |method_name|
49
- tracer.trace_method(method_name)
50
- 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
51
113
  end
52
114
  end
53
115
  end