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 +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +134 -10
- 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/configuration.rb +57 -0
- data/lib/ruby_method_tracer/enhanced_tracer.rb +80 -71
- data/lib/ruby_method_tracer/exportable.rb +77 -0
- data/lib/ruby_method_tracer/formatters/base_formatter.rb +2 -1
- data/lib/ruby_method_tracer/formatters/flat_formatter.rb +119 -0
- data/lib/ruby_method_tracer/formatters/json_formatter.rb +105 -0
- data/lib/ruby_method_tracer/simple_tracer.rb +146 -51
- data/lib/ruby_method_tracer/version.rb +1 -1
- data/lib/ruby_method_tracer/wrapper.rb +274 -0
- data/lib/ruby_method_tracer.rb +66 -4
- data/sig/ruby_method_tracer.rbs +210 -1
- metadata +7 -4
- data/.rspec +0 -3
- data/.rubocop.yml +0 -27
- 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,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
|
-
- **
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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
|