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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30805a204ee568f587faac32d8436594b0b7268751c1c51d297a63a35f727a97
|
|
4
|
+
data.tar.gz: e5fc11f03078cca02c167ccff3fa306e7d1a433b31677f98193a29c506289a27
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
- **
|
|
13
|
-
- **
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 = [] #
|
|
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
|
-
#
|
|
45
|
-
|
|
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 :
|
|
64
|
+
# @param status [Symbol] :success, :error or :incomplete
|
|
54
65
|
# @param error [Exception, nil] The exception if status is :error
|
|
55
|
-
# @
|
|
56
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
126
|
-
#
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
176
|
-
|
|
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
|