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.
@@ -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.local_variables.map { |var|
26
- [var, trace_point.binding.local_variable_get(var)]
27
- }.to_h
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