A runtime call tree of your own methods โ the arguments they got, what each one returned, and where an exception started. Gems, the framework and stdlib are filtered out, so what is left is the part you wrote.
# Which method turned a 200.00 order into nil?
session = RailsTracepointStack.capture(max_depth: 4) { order.total }
puts session.to_tree
Order#total (app/models/order.rb:88) {}
Order#subtotal (app/models/order.rb:95) {}
-> 200.0
Order#apply_discount (app/models/order.rb:102) {"amount":200.0}
Discount#rate_for (app/models/discount.rb:12) {"order":"#<Order id: 42>"}
-> null <- there it is
!! TypeError: nil can't be coerced into Float
4 calls, 3 returns, 1 raise, 2 classes
The gem has always been able to list the methods your app called. The questions that actually come up while debugging are the next two, and those are what a call tree answers.
called: Order#apply_discount in
/very/long/path/app/models/order.rb:102
with params: {amount=>200.0}
It ran. With that argument. Nothing about what came back, nothing about what it called, nothing about where the error started.
Order#apply_discount (app/models/order.rb:102) {"amount":200.0}
Discount#rate_for (app/models/discount.rb:12) {"order":โฆ}
-> null
!! TypeError: nil can't be coerced into Float
Nesting shows who called whom. -> is the
return value. !! is where it blew up.
Every method's result, right under the call. The nil you are hunting is usually visible without adding a single line of code.
The exception is tagged on the frame that raised it, inside your own code, without the forty frames of framework a backtrace hands you.
Indentation is app-level nesting, derived from the real stack, so framework frames in between never inflate the tree.
Depth, trace count, string length and collection size are all capped, and the session tells you when a cap cut it short.
A capture watches only the calling thread, so it stays clean under Puma instead of interleaving other requests.
as_json gives the same data as records,
and summary gives the counts alone.
Traced against a real Rails 8.1 app on Ruby 4.0 โ a full HTTP request through the router, the controller and the views. The tracer saw 13,869 method calls. Four of them were written by a human on that project.
RealEstateAgenciesController#index (app/controllers/real_estate_agencies_controller.rb:14) {}
IpLocation.guess_state_uf_and_city_name (app/services/ip_location.rb:15) {"ip":"127.0.0.1"}
-> [null,null]
-> null
render app/views/real_estate_agencies/index.html.erb {}
-> "<!-- BEGIN app/views/real_estate_agencies/index.html.erb\n-->โฆ (2507 chars)"
render app/views/layouts/application.html.erb {}
-> "<!-- BEGIN app/views/layouts/application.html.erb\n--><!DOCTYโฆ (5553 chars)"
4 calls, 4 returns, 0 raises, 3 classes
City.includes(:state).map(&:name) calls
no method anyone on the project wrote โ name is
generated by ActiveRecord. So the capture says so, along with how much it
dropped, instead of leaving you staring at a blank result:
0 calls, 0 returns, 0 raises, 0 classes
no app code ran: 34025 traces from gems, the framework and Ruby itself were filtered out
# Gemfile
gem "rails_tracepoint_stack"
session = RailsTracepointStack.capture(max_depth: 4) do
Order.find(42).recalculate!
end
puts session.to_tree
Indentation is call depth. -> is a return
value. !! is a raise.
render app/views/โฆ is a template with its
locals. The last line is the summary.
session.to_tree # the indented call tree
session.as_json # the same data, structured, one entry per trace
session.summary # {calls:, returns:, raises:, classes:, filtered:, truncated:}
session.result # what the block itself returned
session.error # the exception that escaped, if any
session.traces # the raw records
session.filtered_count # how much the filters dropped
capture
re-raises it. Take the session from the block argument to keep the traces:
session = nil
begin
RailsTracepointStack.capture { |s| session = s; thing_that_blows_up }
rescue => error
puts session.to_tree
end
A real request produces tens of thousands of traces. Every capture is bounded, and every bound is a keyword argument.
| Option | Default | What it does |
|---|---|---|
| max_depth | none | Drops traces nested deeper than this |
| max_traces | 5000 | Stops collecting; truncated? becomes true |
| max_string_length | 200 | Shortens long strings, keeping the original length |
| max_collection_size | 20 | Shortens long arrays and hashes |
| capture_params | true | Set false to show only the flow |
| capture_return | true | Set false to show only the calls |
| threads | :current | :all also records background threads |
To narrow by file instead, set patterns once and every capture respects them:
# config/initializers/rails_tracepoint_stack.rb
RailsTracepointStack.configure do |config|
config.file_path_to_filter_patterns << %r{app/services/}
config.ignore_patterns << %r{app/models/concerns/}
end
puts because it does not know this exists.One command writes a skill into your app describing when tracing beats reading code, how to keep the output inside a context window, and how to read the tree. After that the agent picks the gem on its own.
$ bin/rails generate rails_tracepoint_stack:install
create .claude/skills/debug-with-tracepoint/SKILL.md
The skill leads with the limit arguments, so an agent caps the capture before it floods its own context with a whole request.
It says when a binding.irb is the cheaper
tool, and that an empty tree means the code ran inside gems โ not that
the tool broke.
.claude/skills/debug-with-tracepoint/SKILL.md.
TracePoint is not free and the filters are not magic. Both are worth knowing before you reach for it.
Measured on a real Rails request: 11ms became 88ms. Fine for an investigation, wrong for anything left running in production.
Ruby emits a different event for blocks and the gem subscribes to method calls only. Logic living inside a block is invisible for now.
ActiveRecord attribute readers, associations and scopes are written by the framework, so they are filtered out like any other gem code.
Ruby fires a return event while unwinding, so a
-> null directly under a
!! is the frame unwinding, not a nil result.
RAILS_TRACEPOINT_STACK_ENABLED=true and the
tracer runs for the whole process, writing to
log/rails_tracepoint_stack.log in text or JSON.
puts to find out what returned nil.