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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d467252ce5eedf8fe0111fe1afb3983d0d4a80ad7558940908543804e963b58
4
- data.tar.gz: e650becb6eee60e5bc118cf287a8dc05cbafbcb404536f1b2ab2aba2072f88f9
3
+ metadata.gz: 30805a204ee568f587faac32d8436594b0b7268751c1c51d297a63a35f727a97
4
+ data.tar.gz: e5fc11f03078cca02c167ccff3fa306e7d1a433b31677f98193a29c506289a27
5
5
  SHA512:
6
- metadata.gz: 890623622e3f183687caba11a65577d20a82f9fc0032da94f8471b2947a2a0c805df3492b686623c3384584e338d625710531b37ca35adf7a251451fb0907e9f
7
- data.tar.gz: 5cac3c1469df35c933e45a525e9384a7411553059633e98762fffdc1d56afe2eca1ab73b74d12c975a1d6dc2d6a7cd87720f3619aec9beffdf38d41a67961f0c
6
+ metadata.gz: 06d263839600dd89c18155ddc836a08aa6d304a763d4c92bb7235c28974e1a8ed1ce83eb7db174f81b75b1d0f5d98ecb1acf84ce6b1c2f875a9ab8b3b21f7fd7
7
+ data.tar.gz: f441ede20bd32db451673f5643a12292e2609d6c884a89f255cded0e60ff3ee2fab170a1396d7ae86e23e6a6af77b89bf5a32aa0049394107ed050a4cd98cf8e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
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
+
37
+ ## [0.4.0] - 2026-06-09
38
+
39
+ ### Added
40
+ - Global configuration via `RubyMethodTracer.configure { |c| ... }`, with `RubyMethodTracer.configuration` and `RubyMethodTracer.reset_configuration!`. Tracers created through the mixin now use these as defaults; explicit per-tracer options still take precedence.
41
+ - `Formatters::JsonFormatter` — serializes flat results or a call tree to JSON. Method arguments are never captured; exceptions are reduced to class + message; backtraces are opt-in (`include_backtrace:`) and length-bounded (`backtrace_limit:`). Uses `JSON.generate` only (no `Marshal`/`eval`/`YAML`).
42
+ - `Formatters::FlatFormatter` — renders an aggregated text table (method, calls, total, avg, errors) sorted by total time.
43
+ - `Exportable` mixin adding `render(format:)` and `export(path, format:)` to both tracers. Supported formats: `:json`, `:flat`, and `:tree` (EnhancedTracer only).
44
+
45
+ ### Security
46
+ - File export never invokes a shell and never interpolates the path into a command; it writes via `File.open` with `O_NOFOLLOW`, requires the destination directory to already exist (no recursive mkdir of attacker-influenced paths), and refuses to write through an existing symlink.
47
+ - Export format dispatch compares on the string form to avoid interning arbitrary symbols from potentially untrusted input.
48
+
3
49
  ## [0.3.3] - 2026-06-08
4
50
 
5
51
  ### Changed
data/README.md CHANGED
@@ -6,11 +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
- - **NEW: Hierarchical call tree visualization** to understand nested method calls and dependencies.
11
+ - **Hierarchical call tree visualization** to understand nested method calls and dependencies.
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.
12
16
  - Configurable threshold to ignore fast calls and optional log streaming via `Logger`.
13
- - Zero dependencies beyond the Ruby standard library, keeping overhead minimal.
17
+ - Zero dependencies beyond the Ruby standard library.
14
18
 
15
19
  ## Installation
16
20
 
@@ -61,7 +65,7 @@ class Worker
61
65
  end
62
66
  end
63
67
 
64
- Worker.trace_methods(:perform, threshold: 0.005, auto_output: true)
68
+ tracer = Worker.trace_methods(:perform, threshold: 0.005, auto_output: true)
65
69
 
66
70
  Worker.new.perform(42)
67
71
  ```
@@ -72,11 +76,13 @@ With `auto_output: true`, each invocation prints a colorized summary:
72
76
  TRACE: Worker#perform [OK] took 6.3ms
73
77
  ```
74
78
 
75
- 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.
76
83
 
77
84
  ```ruby
78
- tracer = RubyMethodTracer::SimpleTracer.new(Worker, threshold: 0.002)
79
- tracer.trace_method(:perform)
85
+ tracer = Worker.trace_methods(:perform, threshold: 0.002)
80
86
 
81
87
  Worker.new.perform(42)
82
88
 
@@ -91,6 +97,40 @@ pp tracer.fetch_results
91
97
 
92
98
  # Clear results when needed to free memory
93
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)
94
134
  ```
95
135
 
96
136
  ### Example 2
@@ -214,11 +254,56 @@ Most Called Methods:
214
254
  - Color-coded output for better readability
215
255
 
216
256
 
257
+ ### Global Configuration
258
+
259
+ Set process-wide defaults once at boot. Per-tracer options always override these globals.
260
+
261
+ ```ruby
262
+ RubyMethodTracer.configure do |config|
263
+ config.threshold = 0.005 # record calls slower than 5ms
264
+ config.auto_output = false
265
+ config.max_calls = 1000
266
+ config.logger = Rails.logger if defined?(Rails)
267
+ config.track_hierarchy = true # EnhancedTracer call-tree tracking
268
+ end
269
+ ```
270
+
271
+ `RubyMethodTracer.reset_configuration!` restores the built-in defaults (useful in tests).
272
+
273
+ ### Reporting & Export
274
+
275
+ Both tracers can render their results to a string or write them to a file via the formatter layer.
276
+
277
+ ```ruby
278
+ tracer = RubyMethodTracer::EnhancedTracer.new(OrderProcessor, threshold: 0.0)
279
+ tracer.trace_method(:process_order)
280
+ OrderProcessor.new.process_order(order)
281
+
282
+ # Render to a string
283
+ puts tracer.render(format: :flat) # aggregated table
284
+ json = tracer.render(format: :json, pretty: true)
285
+ tree = tracer.render(format: :tree) # EnhancedTracer only
286
+
287
+ # Write to a file (returns the absolute path written)
288
+ tracer.export("tmp/trace.json", format: :json)
289
+ tracer.export("tmp/trace.txt", format: :flat, colorize: false)
290
+ ```
291
+
292
+ Supported formats: `:json`, `:flat`, and `:tree` (EnhancedTracer only).
293
+
294
+ **JSON options**
295
+
296
+ - `pretty` (Boolean, default `false`): pretty-print the JSON.
297
+ - `include_backtrace` (Boolean, default `false`): include exception backtraces. Off by default to avoid leaking internal paths.
298
+ - `backtrace_limit` (Integer, default `10`): maximum backtrace lines when `include_backtrace` is enabled.
299
+
300
+ > Security/privacy: method **arguments are never captured**, so secrets passed as parameters are not recorded. Exceptions are reduced to class + message (backtrace is opt-in). Export never invokes a shell, requires the target directory to already exist, and refuses to write through a symlink. Call `render`/`export` when tracing is quiescent (after the traced work completes).
301
+
217
302
  ### Options (SimpleTracer)
218
303
 
219
- - `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.
220
305
  - `auto_output` (Boolean, default `false`): emit a log line using `Logger` for each recorded call.
221
- - `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.
222
307
  - `logger` (Logger, default `Logger.new($stdout)`): custom logger instance for output. Useful for directing logs to files or custom log handlers.
223
308
 
224
309
  ### Options (EnhancedTracer)
@@ -227,6 +312,15 @@ EnhancedTracer supports all SimpleTracer options plus:
227
312
 
228
313
  - `track_hierarchy` (Boolean, default `true`): enable call tree tracking. Set to `false` to use EnhancedTracer like SimpleTracer.
229
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
+
230
324
  ### API Methods (EnhancedTracer)
231
325
 
232
326
  - `print_tree(options = {})` - Print formatted call tree to stdout
@@ -235,11 +329,41 @@ EnhancedTracer supports all SimpleTracer options plus:
235
329
  - `fetch_enhanced_results` - Get hash with `:flat_calls`, `:call_hierarchy`, and `:statistics`
236
330
  - `clear_results` - Clear both flat results and call tree
237
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
+
238
362
  ## Choosing Between SimpleTracer and EnhancedTracer
239
363
 
240
364
  **Use SimpleTracer when:**
241
365
  - You only need flat timing data
242
- - You want minimal overhead
366
+ - You want the lower overhead of the two — roughly a third of `EnhancedTracer`'s
243
367
  - You're tracing independent methods
244
368
 
245
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
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyMethodTracer
4
+ # Configuration holds global defaults for tracers created through the
5
+ # mixin API. Per-tracer options passed to `trace_methods` or to a tracer's
6
+ # constructor always take precedence over these globals.
7
+ #
8
+ # Configure once during application boot:
9
+ #
10
+ # RubyMethodTracer.configure do |config|
11
+ # config.threshold = 0.005
12
+ # config.max_calls = 500
13
+ # config.auto_output = true
14
+ # end
15
+ #
16
+ # The object is intended to be set at boot and then read concurrently.
17
+ # Mutating it after threads are tracing is not recommended.
18
+ class Configuration
19
+ # Minimum duration (seconds) a call must take to be recorded.
20
+ attr_accessor :threshold
21
+ # When true, each recorded call is emitted to the logger.
22
+ attr_accessor :auto_output
23
+ # Maximum number of calls retained in memory (sliding window).
24
+ attr_accessor :max_calls
25
+ # Logger instance used for auto output. Nil means each tracer builds its own.
26
+ attr_accessor :logger
27
+ # Whether EnhancedTracer builds a hierarchical call tree.
28
+ attr_accessor :track_hierarchy
29
+
30
+ def initialize
31
+ reset!
32
+ end
33
+
34
+ # Restore all settings to their built-in defaults.
35
+ def reset!
36
+ @threshold = 0.001
37
+ @auto_output = false
38
+ @max_calls = 1000
39
+ @logger = nil
40
+ @track_hierarchy = true
41
+ self
42
+ end
43
+
44
+ # Snapshot of the option keys consumed by the tracers.
45
+ #
46
+ # @return [Hash]
47
+ def to_h
48
+ {
49
+ threshold: @threshold,
50
+ auto_output: @auto_output,
51
+ max_calls: @max_calls,
52
+ logger: @logger,
53
+ track_hierarchy: @track_hierarchy
54
+ }
55
+ end
56
+ end
57
+ end