rails_tracepoint_stack 0.3.5 → 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/README.md +204 -0
- data/changelog.md +156 -0
- data/lib/generators/rails_tracepoint_stack/install/install_generator.rb +29 -0
- data/lib/rails_tracepoint_stack/depth_tracker.rb +54 -0
- data/lib/rails_tracepoint_stack/filter/gem_path.rb +20 -1
- data/lib/rails_tracepoint_stack/limits.rb +45 -0
- data/lib/rails_tracepoint_stack/log_formatter.rb +30 -4
- data/lib/rails_tracepoint_stack/renderer/summary.rb +37 -0
- data/lib/rails_tracepoint_stack/renderer/tree.rb +142 -0
- data/lib/rails_tracepoint_stack/sink/collector.rb +138 -0
- data/lib/rails_tracepoint_stack/sink/log.rb +17 -0
- data/lib/rails_tracepoint_stack/skill_installer.rb +39 -0
- data/lib/rails_tracepoint_stack/templates/skill.md +142 -0
- data/lib/rails_tracepoint_stack/trace.rb +33 -3
- data/lib/rails_tracepoint_stack/trace_record.rb +45 -0
- data/lib/rails_tracepoint_stack/trace_session.rb +77 -0
- data/lib/rails_tracepoint_stack/tracer.rb +100 -7
- data/lib/rails_tracepoint_stack/truncator.rb +85 -0
- data/lib/rails_tracepoint_stack/version.rb +1 -1
- data/lib/rails_tracepoint_stack.rb +41 -0
- metadata +26 -8
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module RailsTracepointStack
|
|
4
|
+
module Renderer
|
|
5
|
+
# Renders a session as an indented call tree.
|
|
6
|
+
#
|
|
7
|
+
# The format is built for reading in a terminal or pasting into a prompt,
|
|
8
|
+
# so it favours short lines: paths are relative to the working directory,
|
|
9
|
+
# values are compact JSON, and a return sits one level under the call it
|
|
10
|
+
# belongs to.
|
|
11
|
+
module Tree
|
|
12
|
+
INDENT = " ".freeze
|
|
13
|
+
|
|
14
|
+
def self.call(session)
|
|
15
|
+
lines = session.traces.map { |record| line_for(record) }
|
|
16
|
+
lines << truncation_notice if session.truncated?
|
|
17
|
+
lines << Summary.line(session)
|
|
18
|
+
lines << empty_notice(session) if session.traces.empty?
|
|
19
|
+
lines.join("\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# An empty tree reads like a broken tool. It usually means the block ran
|
|
23
|
+
# entirely inside gems - a bare ActiveRecord query calls no method the
|
|
24
|
+
# developer wrote - so say which of the two happened.
|
|
25
|
+
def self.empty_notice(session)
|
|
26
|
+
return "no code ran inside the block" if session.filtered_count.to_i.zero?
|
|
27
|
+
|
|
28
|
+
"no app code ran: #{session.filtered_count} traces from gems, the framework " \
|
|
29
|
+
"and Ruby itself were filtered out"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.line_for(record)
|
|
33
|
+
case record.kind
|
|
34
|
+
when :call then call_line(record)
|
|
35
|
+
when :b_call then block_line(record)
|
|
36
|
+
when :return, :b_return then return_line(record)
|
|
37
|
+
when :raise then raise_line(record)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A block has no name of its own. TracePoint reports the method it was
|
|
42
|
+
# written in, which is the useful label; a block written at class-body
|
|
43
|
+
# level - a scope, a lambda constant - has neither, so the location is
|
|
44
|
+
# all there is to show.
|
|
45
|
+
def self.block_line(record)
|
|
46
|
+
"#{indent(record.depth)}#{repeat_marker(record)}#{block_name(record)} { } " \
|
|
47
|
+
"(#{location(record)}) #{compact(record.params)}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.block_name(record)
|
|
51
|
+
return "block" if record.class_name.nil? || record.method_name.nil?
|
|
52
|
+
|
|
53
|
+
qualified_name(record)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.repeat_marker(record)
|
|
57
|
+
count = record.repeats.to_i
|
|
58
|
+
return "" if count <= 1
|
|
59
|
+
|
|
60
|
+
"↻ #{count}× "
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Stops at the first character that cannot be part of a constant name, so
|
|
64
|
+
# a singleton class that printed extra detail after its name still
|
|
65
|
+
# resolves. Requires a leading capital, which keeps anonymous singletons
|
|
66
|
+
# like #<Class:0x00007f1234> out.
|
|
67
|
+
SINGLETON_CLASS = /\A#<Class:([A-Z][\w:]*)[\s(>]/
|
|
68
|
+
TEMPLATE_FILE = /\.(erb|haml|slim|builder|jbuilder|rabl)\z/i
|
|
69
|
+
# Everything a template receives besides the locals belongs to the
|
|
70
|
+
# rendering machinery, not to the developer.
|
|
71
|
+
TEMPLATE_LOCALS = "local_assigns".freeze
|
|
72
|
+
|
|
73
|
+
def self.call_line(record)
|
|
74
|
+
return template_line(record) if template?(record)
|
|
75
|
+
|
|
76
|
+
"#{indent(record.depth)}#{qualified_name(record)} " \
|
|
77
|
+
"(#{location(record)}) #{compact(record.params)}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A class method is defined on the singleton class, which prints as
|
|
81
|
+
# #<Class:Foo>. Ruby writes that call as Foo.bar, so the tree does too.
|
|
82
|
+
# An anonymous singleton prints as an address and is left alone, since
|
|
83
|
+
# turning that into `0x00007f1234.render` helps nobody.
|
|
84
|
+
def self.qualified_name(record)
|
|
85
|
+
singleton = SINGLETON_CLASS.match(record.class_name.to_s)
|
|
86
|
+
return "#{singleton[1]}.#{record.method_name}" if singleton
|
|
87
|
+
|
|
88
|
+
"#{record.class_name}##{record.method_name}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A compiled template is a generated method on an anonymous class, named
|
|
92
|
+
# after a hash of the file. None of that is worth showing: the path is.
|
|
93
|
+
def self.template?(record)
|
|
94
|
+
TEMPLATE_FILE.match?(record.file_path.to_s)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.template_line(record)
|
|
98
|
+
"#{indent(record.depth)}render #{relative_path(record.file_path)} " \
|
|
99
|
+
"#{compact(template_locals(record))}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.template_locals(record)
|
|
103
|
+
params = record.params
|
|
104
|
+
return {} unless params.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
params.fetch(TEMPLATE_LOCALS, {})
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.return_line(record)
|
|
110
|
+
"#{indent(record.depth + 1)}-> #{compact(record.return_value)}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.raise_line(record)
|
|
114
|
+
"#{indent(record.depth + 1)}!! #{record.exception_class}: #{record.exception_message}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.truncation_notice
|
|
118
|
+
"... truncated: the limit was reached before the block finished"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.indent(depth)
|
|
122
|
+
INDENT * depth
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.location(record)
|
|
126
|
+
"#{relative_path(record.file_path)}:#{record.line_number}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.relative_path(file_path)
|
|
130
|
+
return file_path unless file_path.to_s.start_with?("#{Dir.pwd}/")
|
|
131
|
+
|
|
132
|
+
file_path[(Dir.pwd.length + 1)..]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.compact(value)
|
|
136
|
+
JSON.generate(value)
|
|
137
|
+
rescue SystemStackError, StandardError
|
|
138
|
+
value.to_s
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
require "rails_tracepoint_stack/limits"
|
|
2
|
+
require "rails_tracepoint_stack/log_formatter"
|
|
3
|
+
require "rails_tracepoint_stack/trace_record"
|
|
4
|
+
require "rails_tracepoint_stack/trace_session"
|
|
5
|
+
require "rails_tracepoint_stack/truncator"
|
|
6
|
+
|
|
7
|
+
module RailsTracepointStack
|
|
8
|
+
module Sink
|
|
9
|
+
# Turns live traces into immutable records held in memory, so a caller can
|
|
10
|
+
# inspect them once the traced block is done.
|
|
11
|
+
class Collector
|
|
12
|
+
attr_reader :session, :limits
|
|
13
|
+
|
|
14
|
+
def initialize(session: RailsTracepointStack::TraceSession.new, limits: RailsTracepointStack::Limits.new)
|
|
15
|
+
@session = session
|
|
16
|
+
@limits = limits
|
|
17
|
+
@open_block = nil
|
|
18
|
+
@suppressed_returns = Hash.new(0)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def record(trace)
|
|
22
|
+
return if limits.too_deep?(trace.depth)
|
|
23
|
+
|
|
24
|
+
unless limits.room_for?(session.traces.size)
|
|
25
|
+
session.truncated = true
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
built = build_record(trace)
|
|
30
|
+
return if collapsed?(built)
|
|
31
|
+
|
|
32
|
+
session.add(built)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# A block written inside a loop runs once per element and reports the
|
|
38
|
+
# same location every time, which buries the trace it belongs to. Runs of
|
|
39
|
+
# the same block at the same depth become one record carrying a count,
|
|
40
|
+
# and the matching returns are dropped with them.
|
|
41
|
+
def collapsed?(record)
|
|
42
|
+
return drop_suppressed_return(record) if record.block_return?
|
|
43
|
+
return break_run(record) unless record.block_call?
|
|
44
|
+
|
|
45
|
+
key = [record.file_path, record.line_number, record.depth]
|
|
46
|
+
|
|
47
|
+
if @open_block && @open_block.first == key
|
|
48
|
+
@open_block.last.repeats += 1
|
|
49
|
+
@suppressed_returns[record.depth] += 1
|
|
50
|
+
return true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@open_block = [key, record]
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def drop_suppressed_return(record)
|
|
58
|
+
return false unless @suppressed_returns[record.depth] > 0
|
|
59
|
+
|
|
60
|
+
@suppressed_returns[record.depth] -= 1
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Anything that is not part of the block's own call/return pair ends the
|
|
65
|
+
# run, so two separate loops over the same line stay separate.
|
|
66
|
+
def break_run(record)
|
|
67
|
+
@open_block = nil unless record.returned?
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_record(trace)
|
|
72
|
+
exception = trace.exception
|
|
73
|
+
|
|
74
|
+
RailsTracepointStack::TraceRecord.new(
|
|
75
|
+
kind: trace.kind,
|
|
76
|
+
class_name: class_name_for(trace),
|
|
77
|
+
method_name: trace.method_name,
|
|
78
|
+
file_path: trace.file_path,
|
|
79
|
+
line_number: trace.line_number,
|
|
80
|
+
params: params_for(trace),
|
|
81
|
+
return_value: return_value_for(trace),
|
|
82
|
+
exception_class: exception && exception.class.to_s,
|
|
83
|
+
exception_message: exception && RailsTracepointStack::LogFormatter.stringify(exception.message),
|
|
84
|
+
depth: trace.depth || 0,
|
|
85
|
+
repeats: 1
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# `to_s` on a class is whatever that class decided to print. ActiveRecord
|
|
90
|
+
# makes a model's singleton class print its entire schema, which turns one
|
|
91
|
+
# method call into hundreds of characters of column definitions. A module
|
|
92
|
+
# knows its own name, so ask for that instead and only fall back to `to_s`
|
|
93
|
+
# for the anonymous ones that have none.
|
|
94
|
+
def class_name_for(trace)
|
|
95
|
+
klass = trace.class_name
|
|
96
|
+
return RailsTracepointStack::LogFormatter.stringify(klass) unless klass.is_a?(Module)
|
|
97
|
+
|
|
98
|
+
name = klass.name
|
|
99
|
+
return name if name
|
|
100
|
+
|
|
101
|
+
attached = attached_module(klass)
|
|
102
|
+
return "#<Class:#{attached.name}>" if attached&.name
|
|
103
|
+
|
|
104
|
+
RailsTracepointStack::LogFormatter.stringify(klass)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Singleton classes have no name of their own. Ruby 3.2 can hand back the
|
|
108
|
+
# object they belong to; older versions leave only the printed form.
|
|
109
|
+
def attached_module(klass)
|
|
110
|
+
return nil unless klass.respond_to?(:attached_object)
|
|
111
|
+
|
|
112
|
+
attached = klass.attached_object
|
|
113
|
+
attached.is_a?(Module) ? attached : nil
|
|
114
|
+
rescue SystemStackError, StandardError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def params_for(trace)
|
|
119
|
+
return {} unless limits.capture_params
|
|
120
|
+
|
|
121
|
+
snapshot(trace.params)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def return_value_for(trace)
|
|
125
|
+
return nil unless limits.capture_return
|
|
126
|
+
|
|
127
|
+
snapshot(trace.return_value)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def snapshot(value)
|
|
131
|
+
RailsTracepointStack::Truncator.bounded(
|
|
132
|
+
RailsTracepointStack::LogFormatter.safe_value(value),
|
|
133
|
+
limits
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "rails_tracepoint_stack/logger"
|
|
2
|
+
require "rails_tracepoint_stack/log_formatter"
|
|
3
|
+
|
|
4
|
+
module RailsTracepointStack
|
|
5
|
+
module Sink
|
|
6
|
+
# Formats each trace and writes it out, which is what the gem has always
|
|
7
|
+
# done. Kept as the default so enabling the tracer globally behaves the
|
|
8
|
+
# same as before sinks existed.
|
|
9
|
+
class Log
|
|
10
|
+
def record(trace)
|
|
11
|
+
RailsTracepointStack::Logger.log(
|
|
12
|
+
RailsTracepointStack::LogFormatter.message(trace)
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module RailsTracepointStack
|
|
4
|
+
# Copies the packaged agent skill into the host app.
|
|
5
|
+
#
|
|
6
|
+
# A skill living in this gem's repository only helps someone working on the
|
|
7
|
+
# gem. Agents look for skills inside the project they are working on, so the
|
|
8
|
+
# file has to land there for the gem to ever get picked up on its own.
|
|
9
|
+
class SkillInstaller
|
|
10
|
+
SKILL_PATH = ".claude/skills/debug-with-tracepoint/SKILL.md".freeze
|
|
11
|
+
TEMPLATE_PATH = File.expand_path("templates/skill.md", __dir__).freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :destination, :force
|
|
14
|
+
|
|
15
|
+
def initialize(destination: Dir.pwd, force: false)
|
|
16
|
+
@destination = destination
|
|
17
|
+
@force = force
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the path written, or nil when a skill was already there.
|
|
21
|
+
def install
|
|
22
|
+
return nil if File.exist?(target_path) && !force
|
|
23
|
+
|
|
24
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
25
|
+
File.write(target_path, template)
|
|
26
|
+
target_path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def target_path
|
|
30
|
+
File.join(destination, SKILL_PATH)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def template
|
|
36
|
+
File.read(TEMPLATE_PATH)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: debug-with-tracepoint
|
|
3
|
+
description: Use when you need to know what a Ruby/Rails code path actually did at runtime - which methods ran, what arguments they got, what each one returned, and where an exception was first raised. Reach for this before adding puts/Rails.logger lines or reading call sites by hand, and whenever a value is wrong but you cannot tell which method produced it.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Debugging with rails_tracepoint_stack
|
|
7
|
+
|
|
8
|
+
Traces one block of code and hands back the call tree: every method your app
|
|
9
|
+
defined that ran, with its arguments, its return value, and any exception,
|
|
10
|
+
nested by call depth. Gems, the framework and stdlib are filtered out.
|
|
11
|
+
|
|
12
|
+
## When this beats the alternatives
|
|
13
|
+
|
|
14
|
+
| Question | Use this |
|
|
15
|
+
|---|---|
|
|
16
|
+
| "Which of these methods is returning nil?" | Yes - return values are the point |
|
|
17
|
+
| "Where did this exception actually start?" | Yes - the raise is tagged in the tree |
|
|
18
|
+
| "What is the real order of calls here?" | Yes - the tree shows nesting |
|
|
19
|
+
| "What is the value of one variable right here?" | No - a `binding.irb` is cheaper |
|
|
20
|
+
| "Is this endpoint slow?" | No - this measures nothing |
|
|
21
|
+
|
|
22
|
+
## Run it
|
|
23
|
+
|
|
24
|
+
One shot, no config file, no server restart, output bounded:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bin/rails runner '
|
|
28
|
+
session = RailsTracepointStack.capture(max_depth: 4) do
|
|
29
|
+
Order.find(42).recalculate!
|
|
30
|
+
end
|
|
31
|
+
puts session.to_tree
|
|
32
|
+
'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Reading the output:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
Order#recalculate! (app/models/order.rb:88) {}
|
|
39
|
+
Order#apply_discount (app/models/order.rb:102) {"total":200.0}
|
|
40
|
+
Discount#rate_for (app/models/discount.rb:12) {"order":"#<Order id: 42>"}
|
|
41
|
+
-> null <- rate_for returned nil, which is the bug
|
|
42
|
+
-> 200.0
|
|
43
|
+
-> 200.0
|
|
44
|
+
3 calls, 3 returns, 0 raises, 2 classes
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- indentation is call depth
|
|
48
|
+
- `-> value` is what the method on the line above returned
|
|
49
|
+
- `!! ArgumentError: message` marks where an exception was raised
|
|
50
|
+
- a `-> null` right after a `!!` is the frame unwinding, not a real return
|
|
51
|
+
- `render app/views/...` is a template; the `{...}` after it are its locals
|
|
52
|
+
- the last line is the summary; `... truncated` means a limit cut it short
|
|
53
|
+
|
|
54
|
+
### An empty tree is an answer, not a failure
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
0 calls, 0 returns, 0 raises, 0 classes
|
|
58
|
+
no app code ran: 34025 traces from gems, the framework and Ruby itself were filtered out
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This means the block ran entirely inside gems. A bare `Order.where(...).map(&:name)`
|
|
62
|
+
calls no method anyone in this app wrote — `name` is generated by ActiveRecord.
|
|
63
|
+
Do not conclude the tool is broken and fall back to `puts`. Either widen the
|
|
64
|
+
capture to include the code that calls into this, or accept that the bug is not
|
|
65
|
+
in app code. `session.filtered_count` is that number if you want it directly.
|
|
66
|
+
|
|
67
|
+
## Keep the output small
|
|
68
|
+
|
|
69
|
+
`capture` is scoped to the calling thread and takes limits. Use them - an
|
|
70
|
+
unbounded capture of a real request is tens of thousands of lines.
|
|
71
|
+
|
|
72
|
+
| Option | Default | Use it to |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `max_depth:` | none | Stay near the top of the tree |
|
|
75
|
+
| `max_traces:` | 5000 | Cap total lines |
|
|
76
|
+
| `max_string_length:` | 200 | Keep big payloads readable |
|
|
77
|
+
| `max_collection_size:` | 20 | Keep loaded associations readable |
|
|
78
|
+
| `max_value_length:` | 1000 | Cap any single value, whatever its shape |
|
|
79
|
+
| `capture_params: false` | on | Show only the flow |
|
|
80
|
+
| `capture_return: false` | on | Show only the calls |
|
|
81
|
+
| `threads: :all` | current only | Include background threads |
|
|
82
|
+
| `blocks: true` | off | Also trace blocks — see below |
|
|
83
|
+
|
|
84
|
+
### When a scope or lambda seems to do nothing
|
|
85
|
+
|
|
86
|
+
A Rails scope is a lambda, not a method, so by default nothing inside it shows
|
|
87
|
+
up — you see the call that used the scope and then its result, with no steps in
|
|
88
|
+
between. Same for any `yield`ed block. Add `blocks: true`:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
RailsTracepointStack.capture(blocks: true) { News.latest(User.current) }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Blocks render as `Class#method { }`, or as `block { }` plus a location when the
|
|
95
|
+
block has no enclosing method (which is what a scope looks like). A line like
|
|
96
|
+
`↻ 40× ... { }` means that block ran 40 times from the same place — a predicate
|
|
97
|
+
inside a loop — and only the first run's arguments were kept.
|
|
98
|
+
|
|
99
|
+
Leave it off unless you need it. A block inside a loop fires once per element,
|
|
100
|
+
so this costs far more traces than watching methods does.
|
|
101
|
+
|
|
102
|
+
To narrow by file instead, set patterns before capturing:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
RailsTracepointStack.configure do |config|
|
|
106
|
+
config.file_path_to_filter_patterns << %r{app/services/}
|
|
107
|
+
config.ignore_patterns << %r{app/models/concerns/}
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Other shapes of the same data
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
session.as_json # structured: one entry per trace
|
|
115
|
+
session.summary # counts only: {calls:, returns:, raises:, classes:, truncated:}
|
|
116
|
+
session.result # what the block itself returned
|
|
117
|
+
session.error # the exception that escaped, if any
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`capture` re-raises whatever the block raised. To keep the traces in that
|
|
121
|
+
case, take the session from the block argument:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
session = nil
|
|
125
|
+
begin
|
|
126
|
+
RailsTracepointStack.capture { |s| session = s; thing_that_blows_up }
|
|
127
|
+
rescue => error
|
|
128
|
+
puts session.to_tree
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Worth knowing
|
|
133
|
+
|
|
134
|
+
- Requires the gem in the app: `gem "rails_tracepoint_stack"`.
|
|
135
|
+
- TracePoint slows the traced block down noticeably. Fine for a one-off
|
|
136
|
+
investigation, not for anything left running.
|
|
137
|
+
- Only methods defined in the app appear. If a method you expected is
|
|
138
|
+
missing, it is probably in a gem - add its path to
|
|
139
|
+
`file_path_to_filter_patterns` to force it in.
|
|
140
|
+
- `RAILS_TRACEPOINT_STACK_ENABLED=true` traces the whole process to
|
|
141
|
+
`log/rails_tracepoint_stack.log` instead. Prefer `capture` unless you need
|
|
142
|
+
to trace something you cannot wrap in a block, such as boot.
|
|
@@ -5,6 +5,7 @@ module RailsTracepointStack
|
|
|
5
5
|
extend Forwardable
|
|
6
6
|
|
|
7
7
|
attr_reader :params, :trace_point
|
|
8
|
+
attr_accessor :depth
|
|
8
9
|
|
|
9
10
|
def_delegator :@trace_point, :defined_class, :class_name
|
|
10
11
|
def_delegator :@trace_point, :method_id, :method_name
|
|
@@ -15,16 +16,45 @@ module RailsTracepointStack
|
|
|
15
16
|
@trace_point = trace_point
|
|
16
17
|
end
|
|
17
18
|
|
|
19
|
+
def kind
|
|
20
|
+
trace_point.event
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ENTERING = [:call, :b_call].freeze
|
|
24
|
+
LEAVING = [:return, :b_return].freeze
|
|
25
|
+
|
|
18
26
|
def params
|
|
27
|
+
return {} unless ENTERING.include?(kind)
|
|
28
|
+
|
|
19
29
|
@params ||= fetch_params(trace_point)
|
|
20
30
|
end
|
|
21
31
|
|
|
32
|
+
def return_value
|
|
33
|
+
return nil unless LEAVING.include?(kind)
|
|
34
|
+
|
|
35
|
+
trace_point.return_value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def exception
|
|
39
|
+
return nil unless kind == :raise
|
|
40
|
+
|
|
41
|
+
trace_point.raised_exception
|
|
42
|
+
end
|
|
43
|
+
|
|
22
44
|
private
|
|
23
45
|
|
|
46
|
+
# Every local the method body declares is already in scope at :call time,
|
|
47
|
+
# holding nil. Reading the whole binding would report those as arguments
|
|
48
|
+
# the caller passed as nil, so the parameter list decides what to read.
|
|
24
49
|
def fetch_params(trace_point)
|
|
25
|
-
trace_point.binding
|
|
26
|
-
|
|
27
|
-
|
|
50
|
+
binding = trace_point.binding
|
|
51
|
+
declared = binding.local_variables
|
|
52
|
+
|
|
53
|
+
trace_point.parameters.each_with_object({}) do |(_type, name), params|
|
|
54
|
+
next if name.nil? || !declared.include?(name)
|
|
55
|
+
|
|
56
|
+
params[name] = binding.local_variable_get(name)
|
|
57
|
+
end
|
|
28
58
|
end
|
|
29
59
|
end
|
|
30
60
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module RailsTracepointStack
|
|
2
|
+
# An immutable snapshot of a single trace. The TracePoint object is only
|
|
3
|
+
# valid while its event is being handled, so anything worth keeping has to
|
|
4
|
+
# be copied out before the handler returns.
|
|
5
|
+
TraceRecord = Struct.new(
|
|
6
|
+
:kind,
|
|
7
|
+
:class_name,
|
|
8
|
+
:method_name,
|
|
9
|
+
:file_path,
|
|
10
|
+
:line_number,
|
|
11
|
+
:params,
|
|
12
|
+
:return_value,
|
|
13
|
+
:exception_class,
|
|
14
|
+
:exception_message,
|
|
15
|
+
:depth,
|
|
16
|
+
:repeats,
|
|
17
|
+
keyword_init: true
|
|
18
|
+
) do
|
|
19
|
+
def call?
|
|
20
|
+
kind == :call
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def return?
|
|
24
|
+
kind == :return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def raise?
|
|
28
|
+
kind == :raise
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def block_call?
|
|
32
|
+
kind == :b_call
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def block_return?
|
|
36
|
+
kind == :b_return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# True for anything that produced a value on the way out, so callers do not
|
|
40
|
+
# have to know whether a method or a block produced it.
|
|
41
|
+
def returned?
|
|
42
|
+
return? || block_return?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "rails_tracepoint_stack/renderer/summary"
|
|
3
|
+
require "rails_tracepoint_stack/renderer/tree"
|
|
4
|
+
|
|
5
|
+
module RailsTracepointStack
|
|
6
|
+
# The traces gathered by one capture, plus whatever the traced block itself
|
|
7
|
+
# produced.
|
|
8
|
+
class TraceSession
|
|
9
|
+
attr_reader :traces
|
|
10
|
+
attr_accessor :result, :error, :filtered_count
|
|
11
|
+
attr_writer :truncated
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@traces = []
|
|
15
|
+
@truncated = false
|
|
16
|
+
@filtered_count = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add(record)
|
|
20
|
+
@traces << record
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def empty?
|
|
24
|
+
@traces.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# True when a limit stopped the collection, so the traces are a prefix of
|
|
28
|
+
# what actually ran rather than the whole story.
|
|
29
|
+
def truncated?
|
|
30
|
+
@truncated
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_tree
|
|
34
|
+
RailsTracepointStack::Renderer::Tree.call(self)
|
|
35
|
+
end
|
|
36
|
+
alias_method :to_s, :to_tree
|
|
37
|
+
|
|
38
|
+
def summary
|
|
39
|
+
RailsTracepointStack::Renderer::Summary.call(self)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def as_json
|
|
43
|
+
{
|
|
44
|
+
summary: summary,
|
|
45
|
+
traces: traces.map { |record| entry_for(record) }
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_json(*args)
|
|
50
|
+
JSON.generate(as_json, *args)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def entry_for(record)
|
|
56
|
+
entry = {
|
|
57
|
+
kind: record.kind,
|
|
58
|
+
depth: record.depth,
|
|
59
|
+
class_name: record.class_name,
|
|
60
|
+
method_name: record.method_name,
|
|
61
|
+
file_path: "#{RailsTracepointStack::Renderer::Tree.relative_path(record.file_path)}:#{record.line_number}",
|
|
62
|
+
params: record.params
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# A method that returned nil is often the answer being looked for, so
|
|
66
|
+
# the key stays even when there is nothing in it.
|
|
67
|
+
entry[:return_value] = record.return_value if record.return?
|
|
68
|
+
|
|
69
|
+
if record.raise?
|
|
70
|
+
entry[:exception_class] = record.exception_class
|
|
71
|
+
entry[:exception_message] = record.exception_message
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
entry
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|