ruby_method_tracer 0.3.2 → 0.3.3

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: 7d467252ce5eedf8fe0111fe1afb3983d0d4a80ad7558940908543804e963b58
4
+ data.tar.gz: e650becb6eee60e5bc118cf287a8dc05cbafbcb404536f1b2ab2aba2072f88f9
5
5
  SHA512:
6
- metadata.gz: 3f7ba3648b90cabfc0752f40e2f3b50b0fdb261162f67ad80e2832f8df922700e8837e04c70f874e3b6e3eb201df79b827f516cd74792b63753ba70424f3dd1f
7
- data.tar.gz: 3031618de6d982cc269e3b1f7220156ce67f648fcd1699c9dba10df3cd9e344d6eeb4e72550a03dcbf259fdb6caa5fae895a8c7d9718fbb9e6ba8a22f761860d
6
+ metadata.gz: 890623622e3f183687caba11a65577d20a82f9fc0032da94f8471b2947a2a0c805df3492b686623c3384584e338d625710531b37ca35adf7a251451fb0907e9f
7
+ data.tar.gz: 5cac3c1469df35c933e45a525e9384a7411553059633e98762fffdc1d56afe2eca1ab73b74d12c975a1d6dc2d6a7cd87720f3619aec9beffdf38d41a67961f0c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.3] - 2026-06-08
4
+
5
+ ### Changed
6
+ - `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.
7
+ - `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.
8
+ - `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.
9
+ - `SimpleTracer` now delegates `format_time` and `colorize` to a `Formatters::BaseFormatter` instance instead of duplicating the formatting logic inline.
10
+
11
+ ### Fixed
12
+ - Keyword-argument forwarding in `EnhancedTracer`'s wrapper now avoids passing `**{}`, preventing `SystemStackError` on Ruby 3.4+ (mirrors the `SimpleTracer` fix from 0.3.2).
13
+
3
14
  ## [0.3.2] - 2025-11-22
4
15
 
5
16
  ### Fixed
@@ -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
@@ -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,51 +83,59 @@ module RubyMethodTracer
83
83
 
84
84
  private
85
85
 
86
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
87
86
  def build_enhanced_wrapper(aliased, method_name, key, tracer)
88
87
  track_hierarchy = tracer.instance_variable_get(:@track_hierarchy)
89
88
  # Use method-specific key to prevent only SELF-recursion, not all nested calls
90
89
  method_key = :"#{key}_#{method_name}"
91
90
 
92
91
  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)
92
+ # Ruby 3+ compatible forwarding helper (avoids passing **{} which caused
93
+ # SystemStackError with Ruby 3.4+ keyword argument forwarding)
94
+ call_aliased = lambda do
95
+ kwargs.empty? ? __send__(aliased, *args, &block) : __send__(aliased, *args, **kwargs, &block)
96
+ end
119
97
 
120
- raise
121
- ensure
122
- Thread.current[method_key] = false
123
- end
98
+ if track_hierarchy
99
+ tracer.__send__(:run_with_hierarchy, method_name, method_key, call_aliased)
124
100
  else
125
- tracer.__send__(:wrap_call, method_name, key) do
126
- __send__(aliased, *args, **kwargs, &block)
127
- end
101
+ tracer.__send__(:wrap_call, method_name, key) { call_aliased.call }
128
102
  end
129
103
  end
130
104
  end
105
+
106
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
107
+ def run_with_hierarchy(method_name, method_key, call_aliased)
108
+ # Prevent only recursive calls to the SAME method
109
+ return call_aliased.call if Thread.current[method_key]
110
+
111
+ Thread.current[method_key] = true
112
+ full_method_name = "#{@target_class}##{method_name}"
113
+
114
+ # Start tracking in call tree before entering the timed section
115
+ @call_tree.start_call(full_method_name)
116
+
117
+ start = monotonic_time
118
+ call_status = :success
119
+ call_error = nil
120
+
121
+ begin
122
+ result = call_aliased.call
123
+ execution_time = monotonic_time - start
124
+ record_call(method_name, execution_time, :success)
125
+ result
126
+ rescue StandardError => e
127
+ call_status = :error
128
+ call_error = e
129
+ execution_time = monotonic_time - start
130
+ record_call(method_name, execution_time, :error, e)
131
+ raise
132
+ ensure
133
+ Thread.current[method_key] = false
134
+ # Always end the call tree entry, even for non-StandardError exceptions,
135
+ # to prevent the per-thread call stack from becoming corrupted.
136
+ @call_tree.end_call(call_status, call_error)
137
+ end
138
+ end
131
139
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
132
140
 
133
141
  def default_options
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "set"
4
4
  require "logger"
5
+ require_relative "formatters/base_formatter"
5
6
 
6
7
  module RubyMethodTracer
7
8
  # SimpleTracer wraps instance methods on a target class and records
@@ -19,7 +20,7 @@ module RubyMethodTracer
19
20
  # tracer = RubyMethodTracer::SimpleTracer.new(MyClass, threshold: 0.005)
20
21
  # tracer.trace_method(:expensive_call)
21
22
  # results = tracer.fetch_results
22
- class SimpleTracer # rubocop:disable Metrics/ClassLength
23
+ class SimpleTracer
23
24
  def initialize(target_class, **options)
24
25
  @target_class = target_class
25
26
  @options = default_options.merge(options)
@@ -27,6 +28,9 @@ module RubyMethodTracer
27
28
  @lock = Mutex.new # Mutex to make writes to @calls thread safe.
28
29
  @wrapped_methods = Set.new
29
30
  @logger = @options[:logger] || Logger.new($stdout)
31
+ # Unique per instance so separate tracers don't interfere with each other.
32
+ @tracer_key = :"__ruby_method_tracer_in_trace_#{object_id}"
33
+ @formatter = Formatters::BaseFormatter.new
30
34
  end
31
35
 
32
36
  def trace_method(name)
@@ -39,7 +43,7 @@ module RubyMethodTracer
39
43
  @target_class.send(:alias_method, aliased, method_name) # Aliases original implementation to our private name.
40
44
 
41
45
  tracer = self
42
- key = :__ruby_method_tracer_in_trace # local key to avoid recursive tracing.
46
+ key = @tracer_key # unique per tracer instance; prevents cross-tracer interference
43
47
 
44
48
  # Defines a new method with the original name that delegates to our wrapper.
45
49
  @target_class.define_method(method_name, &build_wrapper(aliased, method_name, key, tracer))
@@ -161,27 +165,11 @@ module RubyMethodTracer
161
165
  end
162
166
 
163
167
  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
168
+ @formatter.format_time(seconds)
171
169
  end
172
170
 
173
171
  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"
172
+ @formatter.colorize(text, color)
185
173
  end
186
174
  end
187
175
  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.3.3"
5
5
  end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ruby_method_tracer/version"
4
+ require_relative "ruby_method_tracer/formatters/base_formatter"
4
5
  require_relative "ruby_method_tracer/simple_tracer"
5
6
  require_relative "ruby_method_tracer/call_tree"
6
7
  require_relative "ruby_method_tracer/enhanced_tracer"
7
- require_relative "ruby_method_tracer/formatters/base_formatter"
8
8
  require_relative "ruby_method_tracer/formatters/tree_formatter"
9
9
 
10
10
  # Public: Mixin that adds lightweight method tracing to classes.
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.3.3
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.
@@ -42,7 +41,6 @@ metadata:
42
41
  source_code_uri: https://github.com/Seunadex/ruby_method_tracer
43
42
  changelog_uri: https://github.com/Seunadex/ruby_method_tracer/blob/main/CHANGELOG.md
44
43
  rubygems_mfa_required: 'true'
45
- post_install_message:
46
44
  rdoc_options: []
47
45
  require_paths:
48
46
  - lib
@@ -57,8 +55,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
57
55
  - !ruby/object:Gem::Version
58
56
  version: '0'
59
57
  requirements: []
60
- rubygems_version: 3.5.16
61
- signing_key:
58
+ rubygems_version: 3.6.7
62
59
  specification_version: 4
63
60
  summary: Lightweight method tracing for Ruby applications
64
61
  test_files: []