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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 172daf6a55bb4350983cdf15aafbe4b954f418183401ef2be69c6b68c31a575d
4
- data.tar.gz: '019cd6fd2053967bff554ae244eed994c614a585b16d16f1509e57a5ec5a444e'
3
+ metadata.gz: 30805a204ee568f587faac32d8436594b0b7268751c1c51d297a63a35f727a97
4
+ data.tar.gz: e5fc11f03078cca02c167ccff3fa306e7d1a433b31677f98193a29c506289a27
5
5
  SHA512:
6
- metadata.gz: 86454302178091a987db42dc9eff6e3fd320822ec38c29b27b35c7c0e92ffc16b4f2844131716202969929df8c90f3870e5b0a403ad290df7339d4fc986d2dc9
7
- data.tar.gz: 3acee9f7dd4398d23ce23d34150dd88e258b9fe09cc420f364132d1fbdea4fb6bed07cdcef7a2710b2264763fb83b7dc8365534991c3057488aa3f41e3d45440
6
+ metadata.gz: 06d263839600dd89c18155ddc836a08aa6d304a763d4c92bb7235c28974e1a8ed1ce83eb7db174f81b75b1d0f5d98ecb1acf84ce6b1c2f875a9ab8b3b21f7fd7
7
+ data.tar.gz: f441ede20bd32db451673f5643a12292e2609d6c884a89f255cded0e60ff3ee2fab170a1396d7ae86e23e6a6af77b89bf5a32aa0049394107ed050a4cd98cf8e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-09-30
4
+
5
+ ### Fixed
6
+ - Tracing a method that is already traced no longer aliases the wrapper onto itself and raises `SystemStackError`. The duplicate-wrap guard now checks the target class for the alias instead of only consulting the tracer's own bookkeeping, so a second `trace_methods` call, or a second tracer instance, is refused with a warning rather than crashing at call time.
7
+ - `CallTree` now honours `:max_calls`, capping both completed calls and retained root trees. Previously only the flat results list was bounded, so an `EnhancedTracer` left enabled grew without limit — every node, parent pointer and `Time` object stayed reachable for the life of the tracer.
8
+ - `:threshold` now applies to the call tree as well as the flat list. Calls below the threshold are dropped unless they have children worth reporting, so `print_tree`, the JSON hierarchy and the tree statistics no longer disagree with `fetch_results`.
9
+ - `trace_method` warns instead of silently doing nothing when the named method does not exist, and returns `false`. A typo, a rename or a method defined after the `trace_methods` call previously produced empty results with no explanation.
10
+ - `RubyMethodTracer.configure` no longer holds a `Mutex` across the caller's block, which made a nested `configure` — reachable through any helper or engine initializer — fail with `ThreadError: deadlock; recursive locking`. The lock provided no real safety, since `configuration` hands out the same mutable object without it.
11
+ - Methods that exit via `throw` or a non-`StandardError` exception are now recorded with a new `:incomplete` status instead of vanishing from the results.
12
+ - `Exportable#export` no longer raises `NameError` on platforms without the POSIX `O_NOFOLLOW` flag (notably Windows); the flag is applied only where `File::NOFOLLOW` is defined, and the stat-based symlink guard still applies everywhere.
13
+ - `Formatters::BaseFormatter#format` now takes `(_data, _options = {})`, matching every subclass. The abstract contract previously described a signature no implementation used.
14
+ - On Ruby 3.0, tracing a method that uses `...` argument forwarding raised `ArgumentError` on any keyword argument. Ruby 3.0 describes `def m(...)` as a bare rest plus a block and omits the keyword rest that 3.1+ reports, so the generated wrapper funnelled keywords into the positional array. The missing keyword rest is put back on that version only — on 3.1+ the same shape means `def m(*, &)`, which genuinely takes no keywords.
15
+ - Tracing a method whose name is not a plain identifier — a predicate (`ready?`), bang (`save!`), setter (`val=`) or operator (`==`, `[]`, `[]=`, `<=>`, `<<`, `-@`) — now works. The saved original is aliased under a name the parser accepts, since the generated wrapper calls it directly; previously such an alias could misparse silently rather than fail loudly.
16
+
17
+ ### Added
18
+ - `CallTree#calls_snapshot`, and an optional `execution_time` argument to `CallTree#end_call` so a caller that already timed the call does not pay for a second clock read.
19
+ - `SimpleTracer#untrace_method` and `#untrace_all` restore traced methods to their original implementation and visibility.
20
+ - `trace_class_methods` on the mixin traces class (singleton) methods, which the documented API previously could not reach. Traced singleton methods are reported as `Klass.method` rather than `#<Class:Klass>#method`.
21
+ - Real RBS signatures for the whole public API, validated in CI and by `rake rbs`. The shipped file was previously the generated template declaring only `VERSION`.
22
+ - `RubyMethodTracer::Wrapper`, which generates the traced replacement for a method from the original's `parameters`.
23
+ - `RubyMethodTracer::CallTreeStatistics`, extracted from `CallTree`.
24
+
25
+ ### Changed
26
+ - **Breaking:** `trace_methods` now returns the `SimpleTracer` it created instead of the array of method names, and memoizes one tracer per class. Results collected through the mixin were previously unreachable unless `auto_output: true` was set.
27
+ - Tracing a method preserves its `Method#arity` and `Method#parameters`. The wrapper is generated from the original signature rather than being a generic `proc { |*args, **kwargs, &block| }`, so reflection-driven callers (dependency injection, serializers, argument validators, documentation tooling) still see the real signature. Two limitations remain: a block parameter is always declared, because a method may `yield` without declaring one, and anonymous parameters (`def m(*)`, `def m(...)`) keep their arity but are given generated names.
28
+ - The alias holding the original implementation (`__ruby_method_tracer_original_<name>__`) is now private. It previously inherited the original's visibility and appeared in the public API of every instance.
29
+ - **Per-call overhead roughly halved for `EnhancedTracer` and cut by about a third for `SimpleTracer`.** Measured on one machine with the same harness for both versions, against an untraced call at ~54ns: `SimpleTracer` 464ns → 354ns below threshold and 836ns → 665ns when recording; `EnhancedTracer` 2144ns → 1081ns below threshold and 2264ns → 1344ns when recording. Three changes account for it:
30
+ - The timing path is now emitted inline in the generated wrapper — the reentrancy guard, both clock reads and the `begin/rescue/ensure` all live in the method itself. It calls the tracer once per invocation instead of threading a block down through `dispatch`, `wrap_call` and `timed`, each of which cost a frame and a `Proc`.
31
+ - `EnhancedTracer` no longer maintains a flat call list alongside the tree. `fetch_results` is derived from the tree instead, so a traced call is recorded once rather than twice — and the two views now agree by construction.
32
+ - Everything knowable at trace time is baked into the wrapper as a literal (the reentrancy key, the method name, the display name) and methods whose parameters all forward unconditionally skip building an argument array, so the common case allocates nothing and does no hash lookups on the call path.
33
+ - CI now runs the specs on Ruby 3.0 through 3.4 and head, with RuboCop and RBS validation as a separate job. The matrix previously tested only 3.3.5 even though the gemspec supports `>= 3.0.0` — and two of the last four releases fixed keyword-forwarding breakage that only shows up on specific versions.
34
+ - `render(format: :tree)` on a `SimpleTracer` now explains that the format needs a call tree instead of reporting an unknown format.
35
+ - The gemspec globs `lib/` and `sig/` instead of deriving its file list from `git ls-files`, and no longer ships development files. The git-based list silently omitted any file that was new and not yet staged — which is how the released 0.3.0 shipped without `EnhancedTracer` and the formatters (fixed in 0.3.1). A `gem-smoke` CI job now builds the gem, installs it and traces through it, so a missing file fails the build instead of a user's install.
36
+
3
37
  ## [0.4.0] - 2026-06-09
4
38
 
5
39
  ### Added
data/README.md CHANGED
@@ -6,13 +6,15 @@
6
6
  RubyMethodTracer is a lightweight Ruby mixin for targeted method tracing. It wraps instance methods, measures wall-clock runtime, flags errors, and can stream results to your logger without pulling in a full APM agent. Use it to surface slow paths in production or gather quick instrumentation while debugging.
7
7
 
8
8
  ## Highlights
9
- - Wrap only the methods you care about; public, protected, and private methods are supported.
9
+ - Wrap only the methods you care about; public, protected, and private methods are supported, along with class methods.
10
10
  - Records duration, success/error state, and timestamps with thread-safe storage.
11
11
  - **Hierarchical call tree visualization** to understand nested method calls and dependencies.
12
- - **NEW: JSON and flat-table formatters plus file export**, alongside the existing tree output.
13
- - **NEW: Global configuration** via `RubyMethodTracer.configure` for process-wide defaults.
12
+ - **Signature-preserving wrappers**: a traced method keeps its `arity` and `parameters`, so reflection-driven code still sees the real signature.
13
+ - **Reversible**: `untrace_method` puts the original implementation and visibility back.
14
+ - JSON and flat-table formatters plus file export, alongside the tree output.
15
+ - Global configuration via `RubyMethodTracer.configure` for process-wide defaults.
14
16
  - Configurable threshold to ignore fast calls and optional log streaming via `Logger`.
15
- - Zero dependencies beyond the Ruby standard library, keeping overhead minimal.
17
+ - Zero dependencies beyond the Ruby standard library.
16
18
 
17
19
  ## Installation
18
20
 
@@ -63,7 +65,7 @@ class Worker
63
65
  end
64
66
  end
65
67
 
66
- Worker.trace_methods(:perform, threshold: 0.005, auto_output: true)
68
+ tracer = Worker.trace_methods(:perform, threshold: 0.005, auto_output: true)
67
69
 
68
70
  Worker.new.perform(42)
69
71
  ```
@@ -74,11 +76,13 @@ With `auto_output: true`, each invocation prints a colorized summary:
74
76
  TRACE: Worker#perform [OK] took 6.3ms
75
77
  ```
76
78
 
77
- To inspect trace results programmatically, manage the tracer yourself:
79
+ `trace_methods` returns the tracer it created, so results are available directly.
80
+ One tracer is memoized per class: calling `trace_methods` again adds methods to
81
+ the same tracer rather than wrapping anything twice, and options are read on the
82
+ first call.
78
83
 
79
84
  ```ruby
80
- tracer = RubyMethodTracer::SimpleTracer.new(Worker, threshold: 0.002)
81
- tracer.trace_method(:perform)
85
+ tracer = Worker.trace_methods(:perform, threshold: 0.002)
82
86
 
83
87
  Worker.new.perform(42)
84
88
 
@@ -93,6 +97,40 @@ pp tracer.fetch_results
93
97
 
94
98
  # Clear results when needed to free memory
95
99
  tracer.clear_results
100
+
101
+ # Put the original method back when you are done
102
+ tracer.untrace_method(:perform)
103
+ ```
104
+
105
+ You can also construct a tracer directly, which is what you want when the class
106
+ is not yours to modify:
107
+
108
+ ```ruby
109
+ tracer = RubyMethodTracer::SimpleTracer.new(Worker, threshold: 0.002)
110
+ tracer.trace_method(:perform)
111
+ ```
112
+
113
+ ### Tracing class methods
114
+
115
+ Class (singleton) methods need `trace_class_methods`, or a tracer built on the
116
+ singleton class. They are reported as `Klass.method`.
117
+
118
+ ```ruby
119
+ class Report
120
+ include RubyMethodTracer
121
+
122
+ def self.generate(range)
123
+ # ...
124
+ end
125
+ end
126
+
127
+ tracer = Report.trace_class_methods(:generate, threshold: 0.0)
128
+ Report.generate(1..10)
129
+
130
+ tracer.fetch_results[:calls].first[:method_name] # => "Report.generate"
131
+
132
+ # Equivalent, without the mixin:
133
+ RubyMethodTracer::SimpleTracer.new(Report.singleton_class).trace_method(:generate)
96
134
  ```
97
135
 
98
136
  ### Example 2
@@ -263,9 +301,9 @@ Supported formats: `:json`, `:flat`, and `:tree` (EnhancedTracer only).
263
301
 
264
302
  ### Options (SimpleTracer)
265
303
 
266
- - `threshold` (Float, default `0.001`): minimum duration (in seconds) to record.
304
+ - `threshold` (Float, default `0.001`): minimum duration (in seconds) to record. On `EnhancedTracer` this also filters the call tree; a call below the threshold is kept only if it has children worth reporting.
267
305
  - `auto_output` (Boolean, default `false`): emit a log line using `Logger` for each recorded call.
268
- - `max_calls` (Integer, default `1000`): maximum number of calls to store in memory. When exceeded, the oldest calls are automatically removed to prevent memory leaks.
306
+ - `max_calls` (Integer, default `1000`): maximum number of calls to store in memory. When exceeded, the oldest calls are automatically removed to prevent memory leaks. On `EnhancedTracer` this bounds the call tree and the retained root trees as well.
269
307
  - `logger` (Logger, default `Logger.new($stdout)`): custom logger instance for output. Useful for directing logs to files or custom log handlers.
270
308
 
271
309
  ### Options (EnhancedTracer)
@@ -274,6 +312,15 @@ EnhancedTracer supports all SimpleTracer options plus:
274
312
 
275
313
  - `track_hierarchy` (Boolean, default `true`): enable call tree tracking. Set to `false` to use EnhancedTracer like SimpleTracer.
276
314
 
315
+ ### API Methods (SimpleTracer)
316
+
317
+ - `trace_method(name)` - Wrap a method. Returns `false` and warns if the method does not exist, or if it is already traced.
318
+ - `untrace_method(name)` - Restore the original implementation and visibility. Returns `false` if this tracer did not trace it.
319
+ - `untrace_all` - Restore every method this tracer wrapped; returns the method names.
320
+ - `fetch_results` - Hash with `:total_calls`, `:total_time` and `:calls`.
321
+ - `clear_results` - Discard recorded calls.
322
+ - `render(format:)` / `export(path, format:)` - See Reporting & Export.
323
+
277
324
  ### API Methods (EnhancedTracer)
278
325
 
279
326
  - `print_tree(options = {})` - Print formatted call tree to stdout
@@ -282,11 +329,41 @@ EnhancedTracer supports all SimpleTracer options plus:
282
329
  - `fetch_enhanced_results` - Get hash with `:flat_calls`, `:call_hierarchy`, and `:statistics`
283
330
  - `clear_results` - Clear both flat results and call tree
284
331
 
332
+ ## Overhead
333
+
334
+ Tracing wraps each call, so it is not free. As a rough shape on a warm VM,
335
+ `SimpleTracer` costs a handful of times what an empty method call costs, and
336
+ `EnhancedTracer` costs a few times that again to maintain the call tree.
337
+
338
+ The wrapper is generated per method and does the timing inline — one tracer call
339
+ per invocation, no intermediate frames, no `Proc` allocation, and the reentrancy
340
+ key and display name baked in as literals — so the floor is close to the cost of
341
+ two clock reads plus the bookkeeping you asked for.
342
+
343
+ In practice that is invisible next to a method that does real work: a database
344
+ query, an HTTP call, a template render. That is the case this gem is built for.
345
+ It is not something to leave enabled on a hot inner method in a tight loop; for
346
+ whole-process profiling, reach for a sampling profiler instead. Raising
347
+ `threshold` avoids storing a record, which is the larger half of the cost, but
348
+ the wrapper still runs on every call — so prefer tracing fewer methods over
349
+ tracing many with a high threshold.
350
+
351
+ ## Known limitations
352
+
353
+ - A traced method keeps its `arity` and `parameters`, with two exceptions: a
354
+ block parameter is always declared (a method may `yield` without declaring
355
+ one, and block parameters do not affect arity), and anonymous parameters
356
+ (`def m(*)`, `def m(...)`) keep their arity but are given generated names.
357
+ - Tracing changes the method on the class itself, so it affects every instance
358
+ and every subclass that inherits it.
359
+ - A method can only be traced by one tracer at a time. A second attempt warns
360
+ and is refused rather than wrapping twice.
361
+
285
362
  ## Choosing Between SimpleTracer and EnhancedTracer
286
363
 
287
364
  **Use SimpleTracer when:**
288
365
  - You only need flat timing data
289
- - You want minimal overhead
366
+ - You want the lower overhead of the two — roughly a third of `EnhancedTracer`'s
290
367
  - You're tracing independent methods
291
368
 
292
369
  **Use EnhancedTracer when:**
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "call_tree_statistics"
4
+
3
5
  module RubyMethodTracer
4
6
  # CallTree manages the hierarchical structure of method calls,
5
7
  # tracking parent-child relationships and call depths.
@@ -10,12 +12,22 @@ module RubyMethodTracer
10
12
  # Note: @calls and @root_calls are shared across threads and protected
11
13
  # by a Mutex. The call stack is stored in thread-local storage so that
12
14
  # concurrent callers each maintain their own independent call depth.
15
+ #
16
+ # Retention is bounded on both ends: calls faster than :threshold are
17
+ # dropped unless they have children worth keeping, and :max_calls caps how
18
+ # many completed calls and root trees are retained.
13
19
  class CallTree
14
20
  attr_reader :calls, :root_calls
15
21
 
16
- def initialize
22
+ DEFAULT_MAX_CALLS = 1000
23
+
24
+ # @param threshold [Float] Minimum duration in seconds for a leaf call to be kept
25
+ # @param max_calls [Integer] Maximum completed calls and root trees to retain
26
+ def initialize(threshold: 0.0, max_calls: DEFAULT_MAX_CALLS)
27
+ @threshold = threshold
28
+ @max_calls = max_calls
17
29
  @calls = [] # All recorded calls (flat list, shared)
18
- @root_calls = [] # Top-level calls (depth 0, shared)
30
+ @root_calls = [] # Completed top-level calls (depth 0, shared)
19
31
  @lock = Mutex.new # Protects @calls and @root_calls
20
32
  @thread_key = :"__ruby_method_tracer_call_stack_#{object_id}" # per-instance thread-local key
21
33
  end
@@ -41,29 +53,32 @@ module RubyMethodTracer
41
53
  # Add as child to parent if we're nested
42
54
  stack.last[:children] << call_record if stack.any?
43
55
 
44
- # Track root-level calls (lock required since @root_calls is shared)
45
- @lock.synchronize { @root_calls << call_record } if stack.empty?
46
-
56
+ # Root calls are collected on completion rather than here, so an
57
+ # in-flight call is never visible to readers of the hierarchy.
47
58
  stack.push(call_record)
48
59
  call_record
49
60
  end
50
61
 
51
62
  # End tracking a method call
52
63
  #
53
- # @param status [Symbol] :success or :error
64
+ # @param status [Symbol] :success, :error or :incomplete
54
65
  # @param error [Exception, nil] The exception if status is :error
55
- # @return [Hash, nil] The completed call record
56
- def end_call(status = :success, error = nil)
66
+ # @param execution_time [Float, nil] Duration in seconds. Callers that
67
+ # already timed the call pass it in, which saves a clock read here; when
68
+ # omitted it is measured from the record's start time.
69
+ # @return [Hash, nil] The completed call record, or nil if it was dropped
70
+ def end_call(status = :success, error = nil, execution_time = nil)
57
71
  stack = thread_call_stack
58
72
  return nil if stack.empty?
59
73
 
60
74
  call_record = stack.pop
61
75
  call_record[:status] = status
62
76
  call_record[:error] = error
63
- call_record[:execution_time] = monotonic_time - call_record[:start_time]
77
+ call_record[:execution_time] = execution_time || (monotonic_time - call_record[:start_time])
64
78
 
65
- @lock.synchronize { @calls << call_record }
66
- call_record
79
+ return discard(call_record, stack.last) if discardable?(call_record)
80
+
81
+ retain(call_record)
67
82
  end
68
83
 
69
84
  # Get the current call depth for the calling thread
@@ -73,6 +88,13 @@ module RubyMethodTracer
73
88
  thread_call_stack.size
74
89
  end
75
90
 
91
+ # Snapshot of every retained call, flat.
92
+ #
93
+ # @return [Array<Hash>] Completed call records, oldest first
94
+ def calls_snapshot
95
+ @lock.synchronize { @calls.dup }
96
+ end
97
+
76
98
  # Get call hierarchy as nested structure
77
99
  #
78
100
  # @return [Array<Hash>] Root calls with nested children
@@ -84,21 +106,7 @@ module RubyMethodTracer
84
106
  #
85
107
  # @return [Hash] Statistics including total calls, time, slowest methods, etc.
86
108
  def statistics
87
- @lock.synchronize do
88
- return default_statistics if @calls.empty?
89
-
90
- method_stats = calculate_method_stats
91
-
92
- {
93
- total_calls: @calls.size,
94
- total_time: @calls.sum { |c| c[:execution_time] },
95
- unique_methods: method_stats.size,
96
- slowest_methods: slowest_methods(method_stats),
97
- most_called_methods: most_called_methods(method_stats),
98
- average_time_per_method: average_times(method_stats),
99
- max_depth: @calls.map { |c| c[:depth] }.max || 0
100
- }
101
- end
109
+ CallTreeStatistics.new(calls_snapshot).to_h
102
110
  end
103
111
 
104
112
  # Clear all recorded calls and reset state
@@ -122,58 +130,42 @@ module RubyMethodTracer
122
130
 
123
131
  private
124
132
 
125
- # Returns the call stack for the current thread, creating it if needed.
126
- # Using a per-instance key prevents interference between multiple CallTree
127
- # instances running in the same thread.
128
- def thread_call_stack
129
- Thread.current[@thread_key] ||= []
130
- end
131
-
132
- def monotonic_time
133
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
133
+ # A call is dropped only when it is too fast to be interesting and has no
134
+ # children — dropping a parent would orphan the descendants it recorded.
135
+ def discardable?(call_record)
136
+ call_record[:execution_time] < @threshold && call_record[:children].empty?
134
137
  end
135
138
 
136
- def default_statistics
137
- {
138
- total_calls: 0,
139
- total_time: 0.0,
140
- unique_methods: 0,
141
- slowest_methods: [],
142
- most_called_methods: [],
143
- average_time_per_method: {},
144
- max_depth: 0
145
- }
139
+ # The record is always the last child appended by this thread's stack, so
140
+ # detaching it is a pop rather than a scan.
141
+ def discard(call_record, parent)
142
+ parent[:children].pop if parent && parent[:children].last.equal?(call_record)
143
+ nil
146
144
  end
147
145
 
148
- def calculate_method_stats
149
- method_stats = Hash.new { |h, k| h[k] = { calls: 0, total_time: 0.0, times: [] } }
150
-
151
- @calls.each do |call|
152
- stats = method_stats[call[:method_name]]
153
- stats[:calls] += 1
154
- stats[:total_time] += call[:execution_time]
155
- stats[:times] << call[:execution_time]
146
+ def retain(call_record)
147
+ @lock.synchronize do
148
+ @calls << call_record
149
+ @calls.shift while @calls.size > @max_calls
150
+ next unless call_record[:depth].zero?
151
+
152
+ # Dropping the oldest root releases its whole subtree; without this the
153
+ # flat cap above could not actually free anything.
154
+ @root_calls << call_record
155
+ @root_calls.shift while @root_calls.size > @max_calls
156
156
  end
157
-
158
- method_stats
159
- end
160
-
161
- def slowest_methods(method_stats)
162
- method_stats
163
- .map { |name, stats| { method: name, avg_time: stats[:total_time] / stats[:calls] } }
164
- .sort_by { |m| -m[:avg_time] }
165
- .take(10)
157
+ call_record
166
158
  end
167
159
 
168
- def most_called_methods(method_stats)
169
- method_stats
170
- .map { |name, stats| { method: name, count: stats[:calls] } }
171
- .sort_by { |m| -m[:count] }
172
- .take(10)
160
+ # Returns the call stack for the current thread, creating it if needed.
161
+ # Using a per-instance key prevents interference between multiple CallTree
162
+ # instances running in the same thread.
163
+ def thread_call_stack
164
+ Thread.current[@thread_key] ||= []
173
165
  end
174
166
 
175
- def average_times(method_stats)
176
- method_stats.transform_values { |stats| stats[:total_time] / stats[:calls] }
167
+ def monotonic_time
168
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
177
169
  end
178
170
  end
179
171
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyMethodTracer
4
+ # CallTreeStatistics summarises a list of completed call records.
5
+ #
6
+ # It is a pure function of the records handed to it — the caller is
7
+ # responsible for reading them under whatever lock protects them.
8
+ class CallTreeStatistics
9
+ TOP_N = 10
10
+
11
+ EMPTY = {
12
+ total_calls: 0,
13
+ total_time: 0.0,
14
+ unique_methods: 0,
15
+ slowest_methods: [],
16
+ most_called_methods: [],
17
+ average_time_per_method: {},
18
+ max_depth: 0
19
+ }.freeze
20
+
21
+ def initialize(calls)
22
+ @calls = calls
23
+ end
24
+
25
+ # @return [Hash] Totals, the slowest and most-called methods, and max depth
26
+ def to_h
27
+ return EMPTY.dup if @calls.empty?
28
+
29
+ stats = per_method
30
+
31
+ {
32
+ total_calls: @calls.size,
33
+ total_time: @calls.sum { |call| call[:execution_time] },
34
+ unique_methods: stats.size,
35
+ slowest_methods: slowest(stats),
36
+ most_called_methods: most_called(stats),
37
+ average_time_per_method: averages(stats),
38
+ max_depth: max_depth
39
+ }
40
+ end
41
+
42
+ private
43
+
44
+ def per_method
45
+ blank = Hash.new { |hash, key| hash[key] = { calls: 0, total_time: 0.0 } }
46
+ @calls.each_with_object(blank) do |call, stats|
47
+ entry = stats[call[:method_name]]
48
+ entry[:calls] += 1
49
+ entry[:total_time] += call[:execution_time]
50
+ end
51
+ end
52
+
53
+ def slowest(stats)
54
+ stats
55
+ .map { |name, entry| { method: name, avg_time: entry[:total_time] / entry[:calls] } }
56
+ .sort_by { |method| -method[:avg_time] }
57
+ .take(TOP_N)
58
+ end
59
+
60
+ def most_called(stats)
61
+ stats
62
+ .map { |name, entry| { method: name, count: entry[:calls] } }
63
+ .sort_by { |method| -method[:count] }
64
+ .take(TOP_N)
65
+ end
66
+
67
+ def averages(stats)
68
+ stats.transform_values { |entry| entry[:total_time] / entry[:calls] }
69
+ end
70
+
71
+ def max_depth
72
+ @calls.map { |call| call[:depth] }.max || 0
73
+ end
74
+ end
75
+ end
@@ -15,6 +15,12 @@ module RubyMethodTracer
15
15
  # - All options from SimpleTracer
16
16
  # - :track_hierarchy (Boolean): Enable call tree tracking; defaults to true
17
17
  #
18
+ # The call tree honours the same :threshold and :max_calls limits as the flat
19
+ # results, so leaving a tracer enabled cannot grow the tree without bound.
20
+ #
21
+ # The tree is the only store: `fetch_results` is derived from it rather than
22
+ # maintained alongside it, so a traced call is recorded once, not twice.
23
+ #
18
24
  # Usage:
19
25
  # tracer = RubyMethodTracer::EnhancedTracer.new(MyClass, threshold: 0.005)
20
26
  # tracer.trace_method(:expensive_call)
@@ -24,29 +30,11 @@ module RubyMethodTracer
24
30
 
25
31
  def initialize(target_class, **options)
26
32
  super
27
- @call_tree = CallTree.new
33
+ @call_tree = CallTree.new(threshold: @options[:threshold], max_calls: @options[:max_calls])
28
34
  @track_hierarchy = @options.fetch(:track_hierarchy, true)
29
35
  @formatter = Formatters::TreeFormatter.new
30
36
  end
31
37
 
32
- def trace_method(name)
33
- method_name = name.to_sym
34
- visibility = method_visibility(method_name)
35
- return unless visibility
36
- return unless mark_wrapped?(method_name)
37
-
38
- aliased = alias_for(method_name)
39
- @target_class.send(:alias_method, aliased, method_name)
40
-
41
- tracer = self
42
- key = @tracer_key # unique per tracer instance; prevents cross-tracer interference
43
-
44
- # Build wrapper that tracks hierarchy
45
- @target_class.define_method(method_name, &build_enhanced_wrapper(aliased, method_name, key, tracer))
46
-
47
- @target_class.send(visibility, method_name)
48
- end
49
-
50
38
  # Print the call tree visualization
51
39
  #
52
40
  # @param options [Hash] Formatting options
@@ -64,6 +52,25 @@ module RubyMethodTracer
64
52
  @formatter.format(@call_tree, options)
65
53
  end
66
54
 
55
+ # Flat results, derived from the call tree.
56
+ #
57
+ # The tree already holds every completed call with its duration, status and
58
+ # error, so keeping a second parallel list would mean recording each call
59
+ # twice. The projection below is what makes the two views agree by
60
+ # construction.
61
+ #
62
+ # @return [Hash] Totals and the flat call list
63
+ def fetch_results
64
+ return super unless @track_hierarchy
65
+
66
+ snapshot = @call_tree.calls_snapshot
67
+ {
68
+ total_calls: snapshot.size,
69
+ total_time: snapshot.sum { |call| call[:execution_time] },
70
+ calls: snapshot.map { |call| flat_record(call) }
71
+ }
72
+ end
73
+
67
74
  # Get enhanced results including both flat list and hierarchy
68
75
  #
69
76
  # @return [Hash] Results with call tree and statistics
@@ -75,6 +82,28 @@ module RubyMethodTracer
75
82
  }
76
83
  end
77
84
 
85
+ # Wrapper entry point: open a call-tree entry.
86
+ #
87
+ # Public because the generated wrapper calls it with an explicit receiver,
88
+ # which cannot reach a private method.
89
+ #
90
+ # @param display_name [String] Name as it should appear in reports
91
+ def start_call(display_name)
92
+ @call_tree.start_call(display_name)
93
+ end
94
+
95
+ # Wrapper entry point: close the call-tree entry for this invocation.
96
+ #
97
+ # Nothing is stored in the flat list — `fetch_results` derives it from the
98
+ # tree — so a traced call is recorded once. `end_call` returns nil when the
99
+ # call fell below the threshold, which is what gates auto output.
100
+ def record_call(method_name, execution_time, status, error = nil)
101
+ return super unless @track_hierarchy
102
+
103
+ call = @call_tree.end_call(status, error, execution_time)
104
+ output_call(flat_record(call)) if call && @options[:auto_output]
105
+ end
106
+
78
107
  # Clear both simple tracer results and call tree
79
108
  def clear_results
80
109
  super
@@ -83,6 +112,30 @@ module RubyMethodTracer
83
112
 
84
113
  private
85
114
 
115
+ def flat_record(call)
116
+ {
117
+ method_name: call[:method_name],
118
+ execution_time: call[:execution_time],
119
+ status: call[:status],
120
+ error: call[:error],
121
+ timestamp: call[:timestamp]
122
+ }
123
+ end
124
+
125
+ # Per-method reentrancy key so that *different* wrapped methods can nest
126
+ # inside each other; only self-recursion is blocked. Baked into the wrapper
127
+ # as a literal, so no lookup happens on the call path.
128
+ def wrapper_plan(method_name)
129
+ return super unless @track_hierarchy
130
+
131
+ Wrapper::Plan.new(
132
+ key: :"#{@tracer_key}_#{method_name}",
133
+ close: :record_call,
134
+ open: :start_call,
135
+ display_name: qualified_name(method_name)
136
+ )
137
+ end
138
+
86
139
  # Expose the call tree so JSON/flat exports include hierarchy + statistics.
87
140
  def report_source
88
141
  @call_tree
@@ -94,61 +147,6 @@ module RubyMethodTracer
94
147
  super
95
148
  end
96
149
 
97
- def build_enhanced_wrapper(aliased, method_name, key, tracer)
98
- track_hierarchy = tracer.instance_variable_get(:@track_hierarchy)
99
- # Use method-specific key to prevent only SELF-recursion, not all nested calls
100
- method_key = :"#{key}_#{method_name}"
101
-
102
- proc do |*args, **kwargs, &block|
103
- # Ruby 3+ compatible forwarding helper (avoids passing **{} which caused
104
- # SystemStackError with Ruby 3.4+ keyword argument forwarding)
105
- call_aliased = lambda do
106
- kwargs.empty? ? __send__(aliased, *args, &block) : __send__(aliased, *args, **kwargs, &block)
107
- end
108
-
109
- if track_hierarchy
110
- tracer.__send__(:run_with_hierarchy, method_name, method_key, call_aliased)
111
- else
112
- tracer.__send__(:wrap_call, method_name, key) { call_aliased.call }
113
- end
114
- end
115
- end
116
-
117
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
118
- def run_with_hierarchy(method_name, method_key, call_aliased)
119
- # Prevent only recursive calls to the SAME method
120
- return call_aliased.call if Thread.current[method_key]
121
-
122
- Thread.current[method_key] = true
123
- full_method_name = "#{@target_class}##{method_name}"
124
-
125
- # Start tracking in call tree before entering the timed section
126
- @call_tree.start_call(full_method_name)
127
-
128
- start = monotonic_time
129
- call_status = :success
130
- call_error = nil
131
-
132
- begin
133
- result = call_aliased.call
134
- execution_time = monotonic_time - start
135
- record_call(method_name, execution_time, :success)
136
- result
137
- rescue StandardError => e
138
- call_status = :error
139
- call_error = e
140
- execution_time = monotonic_time - start
141
- record_call(method_name, execution_time, :error, e)
142
- raise
143
- ensure
144
- Thread.current[method_key] = false
145
- # Always end the call tree entry, even for non-StandardError exceptions,
146
- # to prevent the per-thread call stack from becoming corrupted.
147
- @call_tree.end_call(call_status, call_error)
148
- end
149
- end
150
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
151
-
152
150
  def default_options
153
151
  super.merge(track_hierarchy: RubyMethodTracer.configuration.track_hierarchy)
154
152
  end