ruby_method_tracer 0.3.2 → 0.4.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: c68cde18a5b8ae25abfbefdee213491e5f325aa7d47308e406fac073a4311e1d
4
- data.tar.gz: 44a7c4e3ee69f0c34747e1a9f6b3b69ea0cbb12732c3b25d44471163ed7620cd
3
+ metadata.gz: 172daf6a55bb4350983cdf15aafbe4b954f418183401ef2be69c6b68c31a575d
4
+ data.tar.gz: '019cd6fd2053967bff554ae244eed994c614a585b16d16f1509e57a5ec5a444e'
5
5
  SHA512:
6
- metadata.gz: 3f7ba3648b90cabfc0752f40e2f3b50b0fdb261162f67ad80e2832f8df922700e8837e04c70f874e3b6e3eb201df79b827f516cd74792b63753ba70424f3dd1f
7
- data.tar.gz: 3031618de6d982cc269e3b1f7220156ce67f648fcd1699c9dba10df3cd9e344d6eeb4e72550a03dcbf259fdb6caa5fae895a8c7d9718fbb9e6ba8a22f761860d
6
+ metadata.gz: 86454302178091a987db42dc9eff6e3fd320822ec38c29b27b35c7c0e92ffc16b4f2844131716202969929df8c90f3870e5b0a403ad290df7339d4fc986d2dc9
7
+ data.tar.gz: 3acee9f7dd4398d23ce23d34150dd88e258b9fe09cc420f364132d1fbdea4fb6bed07cdcef7a2710b2264763fb83b7dc8365534991c3057488aa3f41e3d45440
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-06-09
4
+
5
+ ### Added
6
+ - 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.
7
+ - `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`).
8
+ - `Formatters::FlatFormatter` — renders an aggregated text table (method, calls, total, avg, errors) sorted by total time.
9
+ - `Exportable` mixin adding `render(format:)` and `export(path, format:)` to both tracers. Supported formats: `:json`, `:flat`, and `:tree` (EnhancedTracer only).
10
+
11
+ ### Security
12
+ - 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.
13
+ - Export format dispatch compares on the string form to avoid interning arbitrary symbols from potentially untrusted input.
14
+
15
+ ## [0.3.3] - 2026-06-08
16
+
17
+ ### Changed
18
+ - `CallTree` now stores its call stack in per-thread storage (keyed per instance) instead of a single shared `@call_stack`, so concurrent callers each track their own nesting depth. `@calls` and `@root_calls` remain shared and `Mutex`-guarded.
19
+ - `SimpleTracer` and `EnhancedTracer` now use a per-instance reentrancy key (`__ruby_method_tracer_in_trace_<object_id>`) so separate tracer instances no longer interfere with each other's re-entry guards.
20
+ - `EnhancedTracer` hierarchy tracking extracted into a dedicated `run_with_hierarchy` method; the call-tree entry is now always closed in an `ensure` block (even for non-`StandardError` exceptions) to prevent the per-thread call stack from becoming corrupted.
21
+ - `SimpleTracer` now delegates `format_time` and `colorize` to a `Formatters::BaseFormatter` instance instead of duplicating the formatting logic inline.
22
+
23
+ ### Fixed
24
+ - Keyword-argument forwarding in `EnhancedTracer`'s wrapper now avoids passing `**{}`, preventing `SystemStackError` on Ruby 3.4+ (mirrors the `SimpleTracer` fix from 0.3.2).
25
+
3
26
  ## [0.3.2] - 2025-11-22
4
27
 
5
28
  ### Fixed
data/CLAUDE.md ADDED
@@ -0,0 +1,61 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ bundle exec rspec # Run all tests (also generates coverage report in coverage/)
9
+ bundle exec rubocop # Lint
10
+ bundle exec rake # Run both spec and rubocop (default task)
11
+
12
+ # Run a single spec file
13
+ bundle exec rspec spec/ruby_method_tracer/simple_tracer_spec.rb
14
+
15
+ # Run a specific example by description
16
+ bundle exec rspec spec/ruby_method_tracer/simple_tracer_spec.rb -e "records call details"
17
+
18
+ # Install gem locally for manual testing
19
+ bundle exec rake install
20
+
21
+ # Release: bump version in lib/ruby_method_tracer/version.rb, then:
22
+ bundle exec rake release
23
+ ```
24
+
25
+ ## Commit conventions
26
+
27
+ - Do NOT add a `Co-authored-by:` trailer (or any "Generated with"/agent attribution) to commit messages.
28
+
29
+ ## Architecture
30
+
31
+ The gem provides two ways to trace methods:
32
+
33
+ 1. **Mixin API** — `include RubyMethodTracer` in a class, then call `trace_methods(:method_name, **opts)` at the class level. This creates a `SimpleTracer` internally and is the simplest entry point.
34
+
35
+ 2. **Direct tracer API** — instantiate `SimpleTracer` or `EnhancedTracer` directly for programmatic access to results.
36
+
37
+ ### Tracing mechanism (SimpleTracer)
38
+
39
+ `SimpleTracer#trace_method` wraps a method by:
40
+ 1. Detecting visibility (public/protected/private) via `method_defined?` etc.
41
+ 2. Aliasing the original to `__ruby_method_tracer_original_<name>__`
42
+ 3. Redefining the method with a proc that times the call and delegates to the alias
43
+ 4. Restoring the original visibility
44
+
45
+ The wrapper uses `Thread.current[:__ruby_method_tracer_in_trace]` as a reentrancy flag to avoid recursive double-recording. Kwargs are forwarded conditionally (`kwargs.empty?` check) to stay compatible with Ruby 3.x.
46
+
47
+ Results are stored in `@calls` (an Array) guarded by a `Mutex`. The `max_calls` option enforces a sliding window by `shift`-ing the oldest entry.
48
+
49
+ ### EnhancedTracer
50
+
51
+ Inherits from `SimpleTracer` and adds call-tree tracking via `CallTree`. It overrides `trace_method` to use a per-method reentrancy key (`__ruby_method_tracer_in_trace_<method_name>`) so that *different* wrapped methods can nest inside each other (unlike `SimpleTracer` which blocks all nesting).
52
+
53
+ ### CallTree
54
+
55
+ Stack-based hierarchy tracker. `start_call` pushes a record (with a `parent` pointer and `children` array) onto `@call_stack`; `end_call` pops it, fills timing/status, and appends to the flat `@calls` list. Root-level calls (depth 0) are also collected in `@root_calls` for tree rendering.
56
+
57
+ ### Formatters
58
+
59
+ `Formatters::BaseFormatter` — abstract base providing `format_time` and `colorize` (ANSI).
60
+
61
+ `Formatters::TreeFormatter < BaseFormatter` — renders a `CallTree` as an ASCII tree with `└──`/`├──` connectors, then appends a statistics block (slowest methods, most-called methods).
data/README.md CHANGED
@@ -8,7 +8,9 @@ RubyMethodTracer is a lightweight Ruby mixin for targeted method tracing. It wra
8
8
  ## Highlights
9
9
  - Wrap only the methods you care about; public, protected, and private methods are supported.
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
+ - **NEW: JSON and flat-table formatters plus file export**, alongside the existing tree output.
13
+ - **NEW: Global configuration** via `RubyMethodTracer.configure` for process-wide defaults.
12
14
  - Configurable threshold to ignore fast calls and optional log streaming via `Logger`.
13
15
  - Zero dependencies beyond the Ruby standard library, keeping overhead minimal.
14
16
 
@@ -214,6 +216,51 @@ Most Called Methods:
214
216
  - Color-coded output for better readability
215
217
 
216
218
 
219
+ ### Global Configuration
220
+
221
+ Set process-wide defaults once at boot. Per-tracer options always override these globals.
222
+
223
+ ```ruby
224
+ RubyMethodTracer.configure do |config|
225
+ config.threshold = 0.005 # record calls slower than 5ms
226
+ config.auto_output = false
227
+ config.max_calls = 1000
228
+ config.logger = Rails.logger if defined?(Rails)
229
+ config.track_hierarchy = true # EnhancedTracer call-tree tracking
230
+ end
231
+ ```
232
+
233
+ `RubyMethodTracer.reset_configuration!` restores the built-in defaults (useful in tests).
234
+
235
+ ### Reporting & Export
236
+
237
+ Both tracers can render their results to a string or write them to a file via the formatter layer.
238
+
239
+ ```ruby
240
+ tracer = RubyMethodTracer::EnhancedTracer.new(OrderProcessor, threshold: 0.0)
241
+ tracer.trace_method(:process_order)
242
+ OrderProcessor.new.process_order(order)
243
+
244
+ # Render to a string
245
+ puts tracer.render(format: :flat) # aggregated table
246
+ json = tracer.render(format: :json, pretty: true)
247
+ tree = tracer.render(format: :tree) # EnhancedTracer only
248
+
249
+ # Write to a file (returns the absolute path written)
250
+ tracer.export("tmp/trace.json", format: :json)
251
+ tracer.export("tmp/trace.txt", format: :flat, colorize: false)
252
+ ```
253
+
254
+ Supported formats: `:json`, `:flat`, and `:tree` (EnhancedTracer only).
255
+
256
+ **JSON options**
257
+
258
+ - `pretty` (Boolean, default `false`): pretty-print the JSON.
259
+ - `include_backtrace` (Boolean, default `false`): include exception backtraces. Off by default to avoid leaking internal paths.
260
+ - `backtrace_limit` (Integer, default `10`): maximum backtrace lines when `include_backtrace` is enabled.
261
+
262
+ > 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).
263
+
217
264
  ### Options (SimpleTracer)
218
265
 
219
266
  - `threshold` (Float, default `0.001`): minimum duration (in seconds) to record.
@@ -4,45 +4,48 @@ module RubyMethodTracer
4
4
  # CallTree manages the hierarchical structure of method calls,
5
5
  # tracking parent-child relationships and call depths.
6
6
  #
7
- # It uses a stack-based approach to manage nested calls and builds
7
+ # It uses a per-thread stack to manage nested calls and builds
8
8
  # a tree structure showing the complete call hierarchy.
9
+ #
10
+ # Note: @calls and @root_calls are shared across threads and protected
11
+ # by a Mutex. The call stack is stored in thread-local storage so that
12
+ # concurrent callers each maintain their own independent call depth.
9
13
  class CallTree
10
14
  attr_reader :calls, :root_calls
11
15
 
12
16
  def initialize
13
- @calls = [] # All recorded calls (flat list)
14
- @call_stack = [] # Current execution stack
15
- @root_calls = [] # Top-level calls (depth 0)
16
- @lock = Mutex.new # Thread safety
17
+ @calls = [] # All recorded calls (flat list, shared)
18
+ @root_calls = [] # Top-level calls (depth 0, shared)
19
+ @lock = Mutex.new # Protects @calls and @root_calls
20
+ @thread_key = :"__ruby_method_tracer_call_stack_#{object_id}" # per-instance thread-local key
17
21
  end
18
22
 
19
23
  # Start tracking a method call
20
24
  #
21
25
  # @param method_name [String] The name of the method being called
22
26
  # @return [Hash] The call record that was pushed to the stack
23
- def start_call(method_name) # rubocop:disable Metrics/MethodLength
24
- @lock.synchronize do
25
- call_record = {
26
- method_name: method_name,
27
- start_time: monotonic_time,
28
- depth: @call_stack.size,
29
- parent: @call_stack.last,
30
- children: [],
31
- status: nil,
32
- error: nil,
33
- execution_time: nil,
34
- timestamp: Time.now
35
- }
27
+ def start_call(method_name)
28
+ stack = thread_call_stack
29
+
30
+ call_record = {
31
+ method_name: method_name,
32
+ start_time: monotonic_time,
33
+ depth: stack.size,
34
+ children: [],
35
+ status: nil,
36
+ error: nil,
37
+ execution_time: nil,
38
+ timestamp: Time.now
39
+ }
36
40
 
37
- # Add as child to parent if we're nested
38
- @call_stack.last[:children] << call_record if @call_stack.any?
41
+ # Add as child to parent if we're nested
42
+ stack.last[:children] << call_record if stack.any?
39
43
 
40
- # Track root-level calls
41
- @root_calls << call_record if @call_stack.empty?
44
+ # Track root-level calls (lock required since @root_calls is shared)
45
+ @lock.synchronize { @root_calls << call_record } if stack.empty?
42
46
 
43
- @call_stack.push(call_record)
44
- call_record
45
- end
47
+ stack.push(call_record)
48
+ call_record
46
49
  end
47
50
 
48
51
  # End tracking a method call
@@ -51,24 +54,23 @@ module RubyMethodTracer
51
54
  # @param error [Exception, nil] The exception if status is :error
52
55
  # @return [Hash, nil] The completed call record
53
56
  def end_call(status = :success, error = nil)
54
- @lock.synchronize do
55
- return nil if @call_stack.empty?
57
+ stack = thread_call_stack
58
+ return nil if stack.empty?
56
59
 
57
- call_record = @call_stack.pop
58
- call_record[:status] = status
59
- call_record[:error] = error
60
- call_record[:execution_time] = monotonic_time - call_record[:start_time]
60
+ call_record = stack.pop
61
+ call_record[:status] = status
62
+ call_record[:error] = error
63
+ call_record[:execution_time] = monotonic_time - call_record[:start_time]
61
64
 
62
- @calls << call_record
63
- call_record
64
- end
65
+ @lock.synchronize { @calls << call_record }
66
+ call_record
65
67
  end
66
68
 
67
- # Get the current call depth
69
+ # Get the current call depth for the calling thread
68
70
  #
69
71
  # @return [Integer] The current nesting level
70
72
  def current_depth
71
- @lock.synchronize { @call_stack.size }
73
+ thread_call_stack.size
72
74
  end
73
75
 
74
76
  # Get call hierarchy as nested structure
@@ -100,23 +102,33 @@ module RubyMethodTracer
100
102
  end
101
103
 
102
104
  # Clear all recorded calls and reset state
105
+ #
106
+ # Note: only the current thread's call stack is cleared; other threads
107
+ # that are mid-trace retain their stacks.
103
108
  def clear
104
109
  @lock.synchronize do
105
110
  @calls.clear
106
- @call_stack.clear
107
111
  @root_calls.clear
108
112
  end
113
+ thread_call_stack.clear
109
114
  end
110
115
 
111
- # Check if call stack is empty (no active calls)
116
+ # Check if the current thread has no active calls
112
117
  #
113
118
  # @return [Boolean]
114
119
  def empty?
115
- @lock.synchronize { @call_stack.empty? }
120
+ thread_call_stack.empty?
116
121
  end
117
122
 
118
123
  private
119
124
 
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
+
120
132
  def monotonic_time
121
133
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
122
134
  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
@@ -39,7 +39,7 @@ module RubyMethodTracer
39
39
  @target_class.send(:alias_method, aliased, method_name)
40
40
 
41
41
  tracer = self
42
- key = :__ruby_method_tracer_in_trace
42
+ key = @tracer_key # unique per tracer instance; prevents cross-tracer interference
43
43
 
44
44
  # Build wrapper that tracks hierarchy
45
45
  @target_class.define_method(method_name, &build_enhanced_wrapper(aliased, method_name, key, tracer))
@@ -83,55 +83,74 @@ module RubyMethodTracer
83
83
 
84
84
  private
85
85
 
86
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
86
+ # Expose the call tree so JSON/flat exports include hierarchy + statistics.
87
+ def report_source
88
+ @call_tree
89
+ end
90
+
91
+ def build_formatter(format)
92
+ return Formatters::TreeFormatter.new if format.to_sym == :tree
93
+
94
+ super
95
+ end
96
+
87
97
  def build_enhanced_wrapper(aliased, method_name, key, tracer)
88
98
  track_hierarchy = tracer.instance_variable_get(:@track_hierarchy)
89
99
  # Use method-specific key to prevent only SELF-recursion, not all nested calls
90
100
  method_key = :"#{key}_#{method_name}"
91
101
 
92
102
  proc do |*args, **kwargs, &block|
93
- if track_hierarchy
94
- # Prevent only recursive calls to the SAME method
95
- return __send__(aliased, *args, **kwargs, &block) if Thread.current[method_key]
96
-
97
- Thread.current[method_key] = true
98
- full_method_name = "#{tracer.instance_variable_get(:@target_class)}##{method_name}"
99
-
100
- # Start tracking in call tree
101
- tracer.call_tree.start_call(full_method_name)
102
-
103
- start = tracer.__send__(:monotonic_time)
104
- begin
105
- result = __send__(aliased, *args, **kwargs, &block)
106
- execution_time = tracer.__send__(:monotonic_time) - start
107
-
108
- # Record in both places
109
- tracer.__send__(:record_call, method_name, execution_time, :success)
110
- tracer.call_tree.end_call(:success)
111
-
112
- result
113
- rescue StandardError => e
114
- execution_time = tracer.__send__(:monotonic_time) - start
115
-
116
- # Record in both places
117
- tracer.__send__(:record_call, method_name, execution_time, :error, e)
118
- tracer.call_tree.end_call(:error, e)
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
119
108
 
120
- raise
121
- ensure
122
- Thread.current[method_key] = false
123
- end
109
+ if track_hierarchy
110
+ tracer.__send__(:run_with_hierarchy, method_name, method_key, call_aliased)
124
111
  else
125
- tracer.__send__(:wrap_call, method_name, key) do
126
- __send__(aliased, *args, **kwargs, &block)
127
- end
112
+ tracer.__send__(:wrap_call, method_name, key) { call_aliased.call }
128
113
  end
129
114
  end
130
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
131
150
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
132
151
 
133
152
  def default_options
134
- super.merge(track_hierarchy: true)
153
+ super.merge(track_hierarchy: RubyMethodTracer.configuration.track_hierarchy)
135
154
  end
136
155
  end
137
156
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyMethodTracer
4
+ # Exportable provides render/export helpers shared by the tracers.
5
+ #
6
+ # Host classes must implement a private `#report_source` returning the data
7
+ # handed to formatters (a results hash or a CallTree).
8
+ #
9
+ # Security notes:
10
+ # - `export` never invokes a shell and never interpolates the path into a
11
+ # command; it writes via `File.open` with `O_NOFOLLOW`.
12
+ # - The destination directory must already exist (no recursive mkdir of
13
+ # attacker-influenced paths) and the tracer refuses to write through an
14
+ # existing symlink, guarding against symlink-redirection.
15
+ #
16
+ # Concurrency: `render`/`export` read the tracer's in-memory buffers and are
17
+ # intended to be called when tracing is quiescent (e.g. after the traced work
18
+ # has finished). The flat results path is snapshotted under a lock; the call
19
+ # tree is read via its locked hierarchy/statistics accessors.
20
+ module Exportable
21
+ # Render the current results to a string.
22
+ #
23
+ # @param format [Symbol] :json, :flat (and :tree for EnhancedTracer)
24
+ # @return [String]
25
+ def render(format: :json, **opts)
26
+ build_formatter(format).format(report_source, opts)
27
+ end
28
+
29
+ # Render results and write them to a file.
30
+ #
31
+ # @param path [String] Destination file path
32
+ # @param format [Symbol] :json, :flat (and :tree for EnhancedTracer)
33
+ # @return [String] The absolute path written
34
+ def export(path, format: :json, **opts)
35
+ write_export(path, render(format: format, **opts))
36
+ end
37
+
38
+ private
39
+
40
+ # Compare on the string form so an unknown format never interns an
41
+ # arbitrary symbol (avoids unbounded symbol-table growth if a caller
42
+ # forwards untrusted input as the format).
43
+ def build_formatter(format)
44
+ case format.to_s
45
+ when "json" then Formatters::JsonFormatter.new
46
+ when "flat" then Formatters::FlatFormatter.new
47
+ else raise ArgumentError, "unknown export format: #{format.inspect}"
48
+ end
49
+ end
50
+
51
+ def write_export(path, content)
52
+ safe_path = validate_export_path(path)
53
+ # O_NOFOLLOW makes the open fail if the final component is a symlink,
54
+ # closing the check-then-write race left by the stat-based guard below.
55
+ flags = File::WRONLY | File::CREAT | File::TRUNC | File::NOFOLLOW
56
+ File.open(safe_path, flags) { |file| file.write(content) }
57
+ safe_path
58
+ rescue Errno::ELOOP
59
+ raise ArgumentError, "refusing to write through symlink: #{path}"
60
+ end
61
+
62
+ def validate_export_path(path)
63
+ raise ArgumentError, "export path must be provided" if path.nil? || path.to_s.strip.empty?
64
+
65
+ expanded = File.expand_path(path.to_s)
66
+ dir = File.dirname(expanded)
67
+ raise ArgumentError, "export directory does not exist: #{dir}" unless File.directory?(dir)
68
+ raise ArgumentError, "refusing to overwrite symlink: #{expanded}" if File.symlink?(expanded)
69
+
70
+ expanded
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_formatter"
4
+
5
+ module RubyMethodTracer
6
+ module Formatters
7
+ # FlatFormatter renders trace data as a flat, aggregated text table:
8
+ # one row per unique method with call count, total time, average time,
9
+ # and error count, sorted by total time descending.
10
+ #
11
+ # Accepts either a CallTree or a flat results hash from
12
+ # `SimpleTracer#fetch_results`.
13
+ class FlatFormatter < BaseFormatter
14
+ HEADERS = %w[Method Calls Total Avg Errors].freeze
15
+
16
+ def format(data, options = {})
17
+ opts = default_options.merge(options)
18
+ calls = extract_calls(data)
19
+ return "No method calls recorded.\n" if calls.empty?
20
+
21
+ rows = build_rows(calls)
22
+ render(rows, opts)
23
+ end
24
+
25
+ private
26
+
27
+ def default_options
28
+ { colorize: true }
29
+ end
30
+
31
+ def extract_calls(data)
32
+ if data.respond_to?(:call_hierarchy)
33
+ # Read the call tree through its lock-protected accessor, then flatten
34
+ # the hierarchy into a single list of calls.
35
+ data.call_hierarchy.flat_map { |node| flatten_node(node) }
36
+ elsif data.is_a?(Hash)
37
+ data[:calls] || []
38
+ else
39
+ []
40
+ end
41
+ end
42
+
43
+ def flatten_node(node)
44
+ [node, *(node[:children] || []).flat_map { |child| flatten_node(child) }]
45
+ end
46
+
47
+ def build_rows(calls)
48
+ rows = aggregate(calls).map { |name, agg| build_row(name, agg) }
49
+ rows.sort_by { |row| -row[:total] }
50
+ end
51
+
52
+ def build_row(name, agg)
53
+ {
54
+ method: name,
55
+ calls: agg[:count],
56
+ total: agg[:total_time],
57
+ avg: agg[:total_time] / agg[:count],
58
+ errors: agg[:errors]
59
+ }
60
+ end
61
+
62
+ def aggregate(calls)
63
+ stats = Hash.new { |h, k| h[k] = { count: 0, total_time: 0.0, errors: 0 } }
64
+ calls.each do |call|
65
+ agg = stats[call[:method_name]]
66
+ agg[:count] += 1
67
+ agg[:total_time] += call[:execution_time].to_f
68
+ agg[:errors] += 1 if call[:status] == :error
69
+ end
70
+ stats
71
+ end
72
+
73
+ def render(rows, opts)
74
+ cell_rows = rows.map { |row| cells_for(row) }
75
+ widths = column_widths(cell_rows)
76
+ lines = [align(HEADERS, widths), separator(widths)]
77
+ rows.zip(cell_rows).each { |row, cells| lines << data_line(row, cells, widths, opts) }
78
+ "#{lines.join("\n")}\n"
79
+ end
80
+
81
+ # Plain (uncolored) cell strings, used for both width calc and rendering.
82
+ def cells_for(row)
83
+ [row[:method], row[:calls].to_s, format_time(row[:total]), format_time(row[:avg]), row[:errors].to_s]
84
+ end
85
+
86
+ def column_widths(cell_rows)
87
+ HEADERS.each_index.map do |i|
88
+ (cell_rows.map { |cells| cells[i].length } + [HEADERS[i].length]).max
89
+ end
90
+ end
91
+
92
+ def align(cells, widths)
93
+ cells.each_index.map { |i| pad(cells[i], widths[i], i.zero?) }.join(" ")
94
+ end
95
+
96
+ # Justify a plain cell, then wrap it in color so ANSI codes never affect
97
+ # the computed column width.
98
+ def data_line(row, cells, widths, opts)
99
+ cells.each_index.map { |i| color_cell(pad(cells[i], widths[i], i.zero?), i, row, opts) }.join(" ")
100
+ end
101
+
102
+ def pad(text, width, left)
103
+ left ? text.ljust(width) : text.rjust(width)
104
+ end
105
+
106
+ def color_cell(padded, index, row, opts)
107
+ return padded unless opts[:colorize]
108
+ return colorize(padded, :cyan) if index.zero?
109
+ return colorize(padded, :red) if index == 4 && row[:errors].positive?
110
+
111
+ padded
112
+ end
113
+
114
+ def separator(widths)
115
+ widths.map { |w| "-" * w }.join(" ")
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "base_formatter"
6
+
7
+ module RubyMethodTracer
8
+ module Formatters
9
+ # JsonFormatter serializes trace data to JSON.
10
+ #
11
+ # Security/privacy notes:
12
+ # - Serialization uses `JSON.generate` only; no `Marshal`/`eval`/`YAML` is
13
+ # involved, so the output cannot be used as a deserialization gadget.
14
+ # - Method arguments are never captured or emitted, avoiding accidental
15
+ # leakage of secrets passed as parameters.
16
+ # - Exceptions are reduced to their class name and message. Backtraces are
17
+ # opt-in (`include_backtrace: true`) and truncated to `backtrace_limit`
18
+ # lines to avoid leaking large amounts of internal path information.
19
+ #
20
+ # Accepts either a CallTree (serializes hierarchy + statistics) or a flat
21
+ # results hash as produced by `SimpleTracer#fetch_results`.
22
+ class JsonFormatter < BaseFormatter
23
+ def format(data, options = {})
24
+ opts = default_options.merge(options)
25
+ payload = build_payload(data, opts)
26
+ opts[:pretty] ? JSON.pretty_generate(payload) : JSON.generate(payload)
27
+ end
28
+
29
+ private
30
+
31
+ def default_options
32
+ {
33
+ pretty: false,
34
+ include_backtrace: false,
35
+ backtrace_limit: 10
36
+ }
37
+ end
38
+
39
+ def build_payload(data, opts)
40
+ if data.respond_to?(:call_hierarchy) && data.respond_to?(:statistics)
41
+ {
42
+ generated_at: Time.now.utc.iso8601,
43
+ call_hierarchy: data.call_hierarchy.map { |node| serialize_node(node, opts) },
44
+ statistics: serialize_statistics(data.statistics)
45
+ }
46
+ else
47
+ serialize_flat(data, opts)
48
+ end
49
+ end
50
+
51
+ def serialize_flat(results, opts)
52
+ results = {} unless results.is_a?(Hash)
53
+ calls = results[:calls] || []
54
+ {
55
+ generated_at: Time.now.utc.iso8601,
56
+ total_calls: results[:total_calls] || calls.size,
57
+ total_time: results[:total_time] || 0.0,
58
+ calls: calls.map { |call| serialize_call(call, opts) }
59
+ }
60
+ end
61
+
62
+ def serialize_call(call, opts)
63
+ {
64
+ method_name: call[:method_name],
65
+ execution_time: call[:execution_time],
66
+ status: call[:status],
67
+ error: serialize_error(call[:error], opts),
68
+ timestamp: iso8601(call[:timestamp])
69
+ }
70
+ end
71
+
72
+ def serialize_node(node, opts)
73
+ {
74
+ method_name: node[:method_name],
75
+ execution_time: node[:execution_time],
76
+ status: node[:status],
77
+ depth: node[:depth],
78
+ error: serialize_error(node[:error], opts),
79
+ timestamp: iso8601(node[:timestamp]),
80
+ children: (node[:children] || []).map { |child| serialize_node(child, opts) }
81
+ }
82
+ end
83
+
84
+ def serialize_statistics(stats)
85
+ stats
86
+ end
87
+
88
+ # Reduce an exception to a safe, bounded representation.
89
+ def serialize_error(error, opts)
90
+ return nil unless error
91
+
92
+ result = { class: error.class.name, message: error.message.to_s }
93
+ if opts[:include_backtrace] && error.backtrace
94
+ limit = opts[:backtrace_limit].to_i
95
+ result[:backtrace] = limit.positive? ? error.backtrace.first(limit) : []
96
+ end
97
+ result
98
+ end
99
+
100
+ def iso8601(time)
101
+ time.respond_to?(:iso8601) ? time.iso8601(6) : time
102
+ end
103
+ end
104
+ end
105
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "set"
4
4
  require "logger"
5
+ require_relative "formatters/base_formatter"
6
+ require_relative "exportable"
5
7
 
6
8
  module RubyMethodTracer
7
9
  # SimpleTracer wraps instance methods on a target class and records
@@ -19,7 +21,10 @@ module RubyMethodTracer
19
21
  # tracer = RubyMethodTracer::SimpleTracer.new(MyClass, threshold: 0.005)
20
22
  # tracer.trace_method(:expensive_call)
21
23
  # results = tracer.fetch_results
22
- class SimpleTracer # rubocop:disable Metrics/ClassLength
24
+ # rubocop:disable Metrics/ClassLength
25
+ class SimpleTracer
26
+ include Exportable
27
+
23
28
  def initialize(target_class, **options)
24
29
  @target_class = target_class
25
30
  @options = default_options.merge(options)
@@ -27,6 +32,9 @@ module RubyMethodTracer
27
32
  @lock = Mutex.new # Mutex to make writes to @calls thread safe.
28
33
  @wrapped_methods = Set.new
29
34
  @logger = @options[:logger] || Logger.new($stdout)
35
+ # Unique per instance so separate tracers don't interfere with each other.
36
+ @tracer_key = :"__ruby_method_tracer_in_trace_#{object_id}"
37
+ @formatter = Formatters::BaseFormatter.new
30
38
  end
31
39
 
32
40
  def trace_method(name)
@@ -39,7 +47,7 @@ module RubyMethodTracer
39
47
  @target_class.send(:alias_method, aliased, method_name) # Aliases original implementation to our private name.
40
48
 
41
49
  tracer = self
42
- key = :__ruby_method_tracer_in_trace # local key to avoid recursive tracing.
50
+ key = @tracer_key # unique per tracer instance; prevents cross-tracer interference
43
51
 
44
52
  # Defines a new method with the original name that delegates to our wrapper.
45
53
  @target_class.define_method(method_name, &build_wrapper(aliased, method_name, key, tracer))
@@ -84,12 +92,19 @@ module RubyMethodTracer
84
92
 
85
93
  private
86
94
 
95
+ # Data passed to formatters by Exportable. Overridden by EnhancedTracer to
96
+ # expose the call tree.
97
+ def report_source
98
+ fetch_results
99
+ end
100
+
87
101
  def default_options
102
+ config = RubyMethodTracer.configuration
88
103
  {
89
- threshold: 0.001,
90
- auto_output: false,
91
- max_calls: 1000,
92
- logger: nil
104
+ threshold: config.threshold,
105
+ auto_output: config.auto_output,
106
+ max_calls: config.max_calls,
107
+ logger: config.logger
93
108
  }
94
109
  end
95
110
 
@@ -161,27 +176,12 @@ module RubyMethodTracer
161
176
  end
162
177
 
163
178
  def format_time(seconds)
164
- if seconds >= 1.0
165
- "#{seconds.round(3)}s"
166
- elsif seconds >= 0.001
167
- "#{(seconds * 1000).round(1)}ms"
168
- else
169
- "#{(seconds * 1_000_000).round(0)}µs"
170
- end
179
+ @formatter.format_time(seconds)
171
180
  end
172
181
 
173
182
  def colorize(text, color)
174
- colors = {
175
- red: "31",
176
- green: "32",
177
- yellow: "33",
178
- blue: "34",
179
- magenta: "35",
180
- cyan: "36",
181
- white: "37",
182
- reset: "0"
183
- }
184
- "\e[#{colors[color]}m#{text}\e[#{colors[:reset]}m"
183
+ @formatter.colorize(text, color)
185
184
  end
186
185
  end
186
+ # rubocop:enable Metrics/ClassLength
187
187
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyMethodTracer
4
- VERSION = "0.3.2"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ruby_method_tracer/version"
4
+ require_relative "ruby_method_tracer/configuration"
5
+ require_relative "ruby_method_tracer/formatters/base_formatter"
4
6
  require_relative "ruby_method_tracer/simple_tracer"
5
7
  require_relative "ruby_method_tracer/call_tree"
6
8
  require_relative "ruby_method_tracer/enhanced_tracer"
7
- require_relative "ruby_method_tracer/formatters/base_formatter"
8
9
  require_relative "ruby_method_tracer/formatters/tree_formatter"
10
+ require_relative "ruby_method_tracer/formatters/json_formatter"
11
+ require_relative "ruby_method_tracer/formatters/flat_formatter"
9
12
 
10
13
  # Public: Mixin that adds lightweight method tracing to classes.
11
14
  #
@@ -26,6 +29,39 @@ require_relative "ruby_method_tracer/formatters/tree_formatter"
26
29
  module RubyMethodTracer
27
30
  class Error < StandardError; end
28
31
 
32
+ @configuration = Configuration.new
33
+ @configuration_mutex = Mutex.new
34
+
35
+ class << self
36
+ # Global configuration shared as defaults by tracers created via the mixin.
37
+ #
38
+ # @return [RubyMethodTracer::Configuration]
39
+ attr_reader :configuration
40
+
41
+ # Yield the global configuration for mutation. Intended to be called once
42
+ # at application boot.
43
+ #
44
+ # RubyMethodTracer.configure do |config|
45
+ # config.threshold = 0.005
46
+ # end
47
+ #
48
+ # @yieldparam config [RubyMethodTracer::Configuration]
49
+ # @return [RubyMethodTracer::Configuration]
50
+ def configure
51
+ @configuration_mutex.synchronize do
52
+ yield(@configuration) if block_given?
53
+ end
54
+ @configuration
55
+ end
56
+
57
+ # Reset the global configuration back to built-in defaults.
58
+ #
59
+ # @return [RubyMethodTracer::Configuration]
60
+ def reset_configuration!
61
+ @configuration_mutex.synchronize { @configuration.reset! }
62
+ end
63
+ end
64
+
29
65
  def self.included(base)
30
66
  base.extend(ClassMethods)
31
67
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_method_tracer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seun Adekunle
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-11-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
12
  description: A developer-friendly gem for tracing method calls, execution times, with
14
13
  minimal overhead.
@@ -21,14 +20,19 @@ files:
21
20
  - ".rspec"
22
21
  - ".rubocop.yml"
23
22
  - CHANGELOG.md
23
+ - CLAUDE.md
24
24
  - CODE_OF_CONDUCT.md
25
25
  - LICENSE.txt
26
26
  - README.md
27
27
  - Rakefile
28
28
  - lib/ruby_method_tracer.rb
29
29
  - lib/ruby_method_tracer/call_tree.rb
30
+ - lib/ruby_method_tracer/configuration.rb
30
31
  - lib/ruby_method_tracer/enhanced_tracer.rb
32
+ - lib/ruby_method_tracer/exportable.rb
31
33
  - lib/ruby_method_tracer/formatters/base_formatter.rb
34
+ - lib/ruby_method_tracer/formatters/flat_formatter.rb
35
+ - lib/ruby_method_tracer/formatters/json_formatter.rb
32
36
  - lib/ruby_method_tracer/formatters/tree_formatter.rb
33
37
  - lib/ruby_method_tracer/simple_tracer.rb
34
38
  - lib/ruby_method_tracer/version.rb
@@ -42,7 +46,6 @@ metadata:
42
46
  source_code_uri: https://github.com/Seunadex/ruby_method_tracer
43
47
  changelog_uri: https://github.com/Seunadex/ruby_method_tracer/blob/main/CHANGELOG.md
44
48
  rubygems_mfa_required: 'true'
45
- post_install_message:
46
49
  rdoc_options: []
47
50
  require_paths:
48
51
  - lib
@@ -57,8 +60,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
57
60
  - !ruby/object:Gem::Version
58
61
  version: '0'
59
62
  requirements: []
60
- rubygems_version: 3.5.16
61
- signing_key:
63
+ rubygems_version: 3.6.7
62
64
  specification_version: 4
63
65
  summary: Lightweight method tracing for Ruby applications
64
66
  test_files: []