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
|
@@ -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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
#
|
|
107
|
-
def
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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:
|
|
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
|