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.
@@ -15,6 +15,12 @@ module RubyMethodTracer
15
15
  # - All options from SimpleTracer
16
16
  # - :track_hierarchy (Boolean): Enable call tree tracking; defaults to true
17
17
  #
18
+ # The call tree honours the same :threshold and :max_calls limits as the flat
19
+ # results, so leaving a tracer enabled cannot grow the tree without bound.
20
+ #
21
+ # The tree is the only store: `fetch_results` is derived from it rather than
22
+ # maintained alongside it, so a traced call is recorded once, not twice.
23
+ #
18
24
  # Usage:
19
25
  # tracer = RubyMethodTracer::EnhancedTracer.new(MyClass, threshold: 0.005)
20
26
  # tracer.trace_method(:expensive_call)
@@ -24,29 +30,11 @@ module RubyMethodTracer
24
30
 
25
31
  def initialize(target_class, **options)
26
32
  super
27
- @call_tree = CallTree.new
33
+ @call_tree = CallTree.new(threshold: @options[:threshold], max_calls: @options[:max_calls])
28
34
  @track_hierarchy = @options.fetch(:track_hierarchy, true)
29
35
  @formatter = Formatters::TreeFormatter.new
30
36
  end
31
37
 
32
- def trace_method(name)
33
- method_name = name.to_sym
34
- visibility = method_visibility(method_name)
35
- return unless visibility
36
- return unless mark_wrapped?(method_name)
37
-
38
- aliased = alias_for(method_name)
39
- @target_class.send(:alias_method, aliased, method_name)
40
-
41
- tracer = self
42
- key = @tracer_key # unique per tracer instance; prevents cross-tracer interference
43
-
44
- # Build wrapper that tracks hierarchy
45
- @target_class.define_method(method_name, &build_enhanced_wrapper(aliased, method_name, key, tracer))
46
-
47
- @target_class.send(visibility, method_name)
48
- end
49
-
50
38
  # Print the call tree visualization
51
39
  #
52
40
  # @param options [Hash] Formatting options
@@ -64,6 +52,25 @@ module RubyMethodTracer
64
52
  @formatter.format(@call_tree, options)
65
53
  end
66
54
 
55
+ # Flat results, derived from the call tree.
56
+ #
57
+ # The tree already holds every completed call with its duration, status and
58
+ # error, so keeping a second parallel list would mean recording each call
59
+ # twice. The projection below is what makes the two views agree by
60
+ # construction.
61
+ #
62
+ # @return [Hash] Totals and the flat call list
63
+ def fetch_results
64
+ return super unless @track_hierarchy
65
+
66
+ snapshot = @call_tree.calls_snapshot
67
+ {
68
+ total_calls: snapshot.size,
69
+ total_time: snapshot.sum { |call| call[:execution_time] },
70
+ calls: snapshot.map { |call| flat_record(call) }
71
+ }
72
+ end
73
+
67
74
  # Get enhanced results including both flat list and hierarchy
68
75
  #
69
76
  # @return [Hash] Results with call tree and statistics
@@ -75,6 +82,28 @@ module RubyMethodTracer
75
82
  }
76
83
  end
77
84
 
85
+ # Wrapper entry point: open a call-tree entry.
86
+ #
87
+ # Public because the generated wrapper calls it with an explicit receiver,
88
+ # which cannot reach a private method.
89
+ #
90
+ # @param display_name [String] Name as it should appear in reports
91
+ def start_call(display_name)
92
+ @call_tree.start_call(display_name)
93
+ end
94
+
95
+ # Wrapper entry point: close the call-tree entry for this invocation.
96
+ #
97
+ # Nothing is stored in the flat list — `fetch_results` derives it from the
98
+ # tree — so a traced call is recorded once. `end_call` returns nil when the
99
+ # call fell below the threshold, which is what gates auto output.
100
+ def record_call(method_name, execution_time, status, error = nil)
101
+ return super unless @track_hierarchy
102
+
103
+ call = @call_tree.end_call(status, error, execution_time)
104
+ output_call(flat_record(call)) if call && @options[:auto_output]
105
+ end
106
+
78
107
  # Clear both simple tracer results and call tree
79
108
  def clear_results
80
109
  super
@@ -83,63 +112,43 @@ module RubyMethodTracer
83
112
 
84
113
  private
85
114
 
86
- def build_enhanced_wrapper(aliased, method_name, key, tracer)
87
- track_hierarchy = tracer.instance_variable_get(:@track_hierarchy)
88
- # Use method-specific key to prevent only SELF-recursion, not all nested calls
89
- method_key = :"#{key}_#{method_name}"
90
-
91
- proc do |*args, **kwargs, &block|
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
97
-
98
- if track_hierarchy
99
- tracer.__send__(:run_with_hierarchy, method_name, method_key, call_aliased)
100
- else
101
- tracer.__send__(:wrap_call, method_name, key) { call_aliased.call }
102
- end
103
- end
115
+ def flat_record(call)
116
+ {
117
+ method_name: call[:method_name],
118
+ execution_time: call[:execution_time],
119
+ status: call[:status],
120
+ error: call[:error],
121
+ timestamp: call[:timestamp]
122
+ }
123
+ end
124
+
125
+ # Per-method reentrancy key so that *different* wrapped methods can nest
126
+ # inside each other; only self-recursion is blocked. Baked into the wrapper
127
+ # as a literal, so no lookup happens on the call path.
128
+ def wrapper_plan(method_name)
129
+ return super unless @track_hierarchy
130
+
131
+ Wrapper::Plan.new(
132
+ key: :"#{@tracer_key}_#{method_name}",
133
+ close: :record_call,
134
+ open: :start_call,
135
+ display_name: qualified_name(method_name)
136
+ )
104
137
  end
105
138
 
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
139
+ # Expose the call tree so JSON/flat exports include hierarchy + statistics.
140
+ def report_source
141
+ @call_tree
142
+ end
143
+
144
+ def build_formatter(format)
145
+ return Formatters::TreeFormatter.new if format.to_sym == :tree
146
+
147
+ super
138
148
  end
139
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
140
149
 
141
150
  def default_options
142
- super.merge(track_hierarchy: true)
151
+ super.merge(track_hierarchy: RubyMethodTracer.configuration.track_hierarchy)
143
152
  end
144
153
  end
145
154
  end
@@ -0,0 +1,77 @@
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
+ when "tree" then raise ArgumentError, "the :tree format needs a call tree; use EnhancedTracer"
48
+ else raise ArgumentError, "unknown export format: #{format.inspect}"
49
+ end
50
+ end
51
+
52
+ def write_export(path, content)
53
+ safe_path = validate_export_path(path)
54
+ # O_NOFOLLOW makes the open fail if the final component is a symlink,
55
+ # closing the check-then-write race left by the stat-based guard below.
56
+ # It is a POSIX open(2) flag and is absent on some platforms (Windows);
57
+ # there the stat-based guard alone applies.
58
+ flags = File::WRONLY | File::CREAT | File::TRUNC
59
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
60
+ File.open(safe_path, flags) { |file| file.write(content) }
61
+ safe_path
62
+ rescue Errno::ELOOP
63
+ raise ArgumentError, "refusing to write through symlink: #{path}"
64
+ end
65
+
66
+ def validate_export_path(path)
67
+ raise ArgumentError, "export path must be provided" if path.nil? || path.to_s.strip.empty?
68
+
69
+ expanded = File.expand_path(path.to_s)
70
+ dir = File.dirname(expanded)
71
+ raise ArgumentError, "export directory does not exist: #{dir}" unless File.directory?(dir)
72
+ raise ArgumentError, "refusing to overwrite symlink: #{expanded}" if File.symlink?(expanded)
73
+
74
+ expanded
75
+ end
76
+ end
77
+ end
@@ -40,8 +40,9 @@ module RubyMethodTracer
40
40
  # Abstract method to be implemented by subclasses
41
41
  #
42
42
  # @param _data [Object] Data to format
43
+ # @param _options [Hash] Formatter-specific options
43
44
  # @raise [NotImplementedError] Must be implemented by subclass
44
- def format(_data)
45
+ def format(_data, _options = {})
45
46
  raise NotImplementedError, "#{self.class} must implement #format"
46
47
  end
47
48
  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