Ruby gem ยท v0.4.0 ยท MIT

See what your Rails code actually did.

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.

bin/rails runner
# 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

Logs tell you a method ran. This tells you what it did.

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.

What a log line gives you
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.

What a capture gives you
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.

->

Return values

Every method's result, right under the call. The nil you are hunting is usually visible without adding a single line of code.

!!

Where a raise started

The exception is tagged on the frame that raised it, inside your own code, without the forty frames of framework a backtrace hands you.

โ”œโ”€

Real call depth

Indentation is app-level nesting, derived from the real stack, so framework frames in between never inflate the tree.

โŒ

Bounded by default

Depth, trace count, string length and collection size are all capped, and the session tells you when a cap cut it short.

โ‰ก

Thread-scoped

A capture watches only the calling thread, so it stays clean under Puma instead of interleaving other requests.

{}

Structured too

as_json gives the same data as records, and summary gives the counts alone.

A whole Rails request, in nine lines.

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.

GET / โ€” capture(max_depth: 6)
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
13,869method calls the tracer saw
4kept as your app's code
9lines to read the whole request
An empty tree is an answer, not a failure. A bare 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

Three lines to your first trace.

Add the gem

# Gemfile
gem "rails_tracepoint_stack"

Wrap the code you are suspicious of

bin/rails runner
session = RailsTracepointStack.capture(max_depth: 4) do
  Order.find(42).recalculate!
end

puts session.to_tree

Read the 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.

Everything a session carries

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
When the block raises, 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

Sized for a context window, not for a disk.

A real request produces tens of thousands of traces. Every capture is bounded, and every bound is a keyword argument.

OptionDefaultWhat it does
max_depthnoneDrops traces nested deeper than this
max_traces5000Stops collecting; truncated? becomes true
max_string_length200Shortens long strings, keeping the original length
max_collection_size20Shortens long arrays and hashes
capture_paramstrueSet false to show only the flow
capture_returntrueSet 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

Your AI agent reaches for 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.

your app
$ bin/rails generate rails_tracepoint_stack:install

      create  .claude/skills/debug-with-tracepoint/SKILL.md
โ—‡

Bounded output by construction

The skill leads with the limit arguments, so an agent caps the capture before it floods its own context with a whole request.

โ—ˆ

Honest about the edges

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.

No Rails dependency. The generator is a thin wrapper over a plain Ruby installer, so the gem still ships without pulling Rails in. Not using Claude Code? The file is ordinary Markdown โ€” point whatever you use at .claude/skills/debug-with-tracepoint/SKILL.md.

What it costs, and what it misses.

TracePoint is not free and the filters are not magic. Both are worth knowing before you reach for it.

โ—”

Around 8x slower while tracing

Measured on a real Rails request: 11ms became 88ms. Fine for an investigation, wrong for anything left running in production.

โ—Œ

Blocks do not appear

Ruby emits a different event for blocks and the gem subscribes to method calls only. Logic living inside a block is invisible for now.

โ—‘

Generated methods are not yours

ActiveRecord attribute readers, associations and scopes are written by the framework, so they are filtered out like any other gem code.

โ—

A raise still returns

Ruby fires a return event while unwinding, so a -> null directly under a !! is the frame unwinding, not a nil result.

Prefer to trace something you cannot wrap in a block โ€” boot, a rake task, a request handled by a running server? Set RAILS_TRACEPOINT_STACK_ENABLED=true and the tracer runs for the whole process, writing to log/rails_tracepoint_stack.log in text or JSON.

Stop adding puts to find out what returned nil.