Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 064 / 100

Debugging Rails apps: a Ruby on Rails guide for developers

Find problems fast.

Expona Admin6 min read

Introduction

Production bugs don't announce themselves politely. They surface at 2 AM through error reports, customer complaints, or mysterious timeouts that your monitoring tools can't quite explain. Debugging Rails apps: a Ruby on Rails guide for developers means having the right tools and practices ready before you need them—and knowing how to use them when things go wrong.

We've all experienced that moment: an error in production that works fine locally, or a slow endpoint that doesn't reproduce in your test environment. The difference between a quick resolution and hours of frustration often comes down to how well you've instrumented your application and how systematically you approach investigation. This guide walks through the practical techniques our teams use daily to find problems fast.

The debug gem with breakpoints

Rails 6.0+ ships with the debug gem by default, replacing the older byebug. It's a modern, performant debugger that gives you interactive control over your application's execution. When you hit a breakpoint, you can inspect variables, step through code, and evaluate expressions in the actual context where the problem occurs.

Setting up breakpoints is straightforward. Add debugger to your code at the point where you suspect the issue:

ruby
class OrdersController < ApplicationController
  def create
    order = Order.new(order_params)
    debugger
    order.save!
    render json: order
  end
end

When your request hits this line, the debugger pauses and opens an interactive console. You can now inspect the order object, check what order_params contains, and step through the save operation line by line. This beats adding puts statements for investigation—you get full context without restarting the process.

The debugging workflow in production is different, though. Never commit debugger statements to production code. Instead, use conditional breakpoints in development and staging to replicate production behavior. When investigating a production issue locally, recreate the exact conditions: same database state, same user permissions, same input data.

Key commands in the debugger:

  • n (next) - step to the next line
  • s (step) - step into a method call
  • c (continue) - resume execution until the next breakpoint
  • p variable_name - print a variable's value
  • pp object - pretty-print an object
  • eval expression - evaluate any Ruby code

For tricky bugs involving associations or callbacks, the debugger lets you step through ActiveRecord's internal calls and see exactly when your hooks fire and what state the object is in.

Logs with tagged request IDs

Logs are your detective's notebook in production. But raw logs from a busy system are overwhelming—without request tracking, you'll spend hours correlating entries across different background jobs, sidekiq workers, and concurrent requests. Rails makes this manageable with tagged logging.

Rails already includes request IDs by default (enabled in modern Rails), but you should ensure your logging captures them consistently:

ruby
Rails.logger.tagged_logging(request_id: request.id) do
  Rails.logger.info("Processing order: #{order.id}")
end

Better yet, configure this globally in your config/initializers/logging.rb:

ruby
if Rails.env.production?
  Rails.logger = ActiveSupport::TaggedLogging.new(
    ActiveSupport::Logger.new($stdout)
  )
end

class RequestIdTagging
  def initialize(app)
    @app = app
  end

  def call(env)
    request_id = env["action_dispatch.request_id"]
    Rails.logger.tagged(request_id: request_id) do
      @app.call(env)
    end
  end
end

Rails.application.config.middleware.unshift RequestIdTagging

Now every log line includes the request ID. When you get an error report, you can grep for that ID and see the entire sequence of events that led to the failure. This is especially valuable with concurrent requests—you're no longer guessing which log entries belong together.

For background jobs and Sidekiq workers, add similar tagging with the job ID:

ruby
class ProcessPaymentJob
  include Sidekiq::Job

  def perform(payment_id)
    Rails.logger.tagged(job_id: jid, payment_id: payment_id) do
      payment = Payment.find(payment_id)
      payment.process!
    end
  end
end

Include user IDs or customer IDs too when relevant—this makes it trivial to replay issues affecting specific users. If a customer reports a problem, you can provide their user ID to your support team, who can then grep logs and send you the complete context.

Web-console in development

Web-console is magical for development. When an error occurs, instead of just seeing a backtrace, you get an interactive Rails console right in your browser. You can explore the exact state of your application at the moment of failure.

It's enabled by default in development (via the web-console gem), but make sure it's in your Gemfile:

ruby
group :development do
  gem 'web-console', '>= 4.1.0'
end

When an error page appears in development, you'll see a console tab. Click it to open a full Rails console context bound to that request. You can now:

  • Inspect local variables and instance variables
  • Query the database to understand the state of records
  • Call methods and test fixes
  • Modify objects and retry operations

This is invaluable when debugging complex associations or N+1 queries. A view throws an error trying to access a nested attribute? Open web-console, inspect the object, and see exactly what's missing.

Web-console is development-only—it won't appear in production. For staging or production-like environments where you need this capability, consider deploying to a staging instance with identical data, or use the debugger gem approach mentioned above to replicate the issue locally.

Error pages with backtraces

Rails error pages contain more information than most developers realize. The backtrace shows the call stack, but the real value comes from understanding the context of each frame.

In development, the error page displays:

  • The exception type and message
  • A clickable backtrace (clicking opens that file in your editor if configured)
  • Request parameters and session data
  • Environment variables
  • Local variables at each stack frame

Pay attention to where the error actually originated versus where Rails caught it. A "nil can't be coerced into Integer" error might be caught in a view rendering, but the actual problem is three layers up in the controller where you didn't validate something. Follow the backtrace upward to find the root cause, not just the symptom.

In production, Rails serves a generic error page to users (you configure this with custom error pages in public/). But your error tracking service—and you should have one—captures the full backtrace, request context, and environment. This is where your production debugging happens.

Configure your error handler to capture everything useful. For example, with Sentry:

ruby
Sentry.init do |config|
  config.dsn = ENV['SENTRY_DSN']
  config.environment = Rails.env
  config.include_local_variables = true
  config.traces_sample_rate = 0.1
end

The include_local_variables option adds variable inspection to your backtraces—this is a game-changer for production debugging because you see the exact values that led to the error. Just be careful with sensitive data; configure which variables to filter out.

Common mistakes

We've seen teams waste time on these patterns:

Removing request IDs from logs: Some teams strip request IDs to make logs "cleaner" or to avoid perceived overhead. Don't. The ability to correlate entries across a distributed request is worth far more than cleaner output.

Over-relying on puts debugging: Developers often add puts statements instead of using the debugger. This works for simple cases but falls apart with async operations, callbacks, or anything non-linear. Use the debugger for interactive investigation; reserve puts for data you actually want to log persistently.

Not replicating production data: "It works locally" is the classic complaint. Production bugs rarely reproduce on a clean development database. Periodically restore recent production snapshots to a local environment to test against realistic data.

Ignoring the backtrace order: Developers jump to the most recent frame, which is often just a symptom. Read the backtrace from top to bottom to understand the call sequence. The actual bug is usually earlier in the chain.

Forgetting context in background jobs: Web requests have request IDs and user context built-in. Background jobs don't. If you don't explicitly tag them, you'll spend hours correlating worker logs to the requests that triggered them.

Key takeaways

  • Use the debug gem for interactive debugging in development and staging—step through code, inspect objects, and evaluate expressions in real context.
  • Tag all logs with request IDs and relevant identifiers (user ID, job ID, payment ID) so you can trace entire transaction sequences in production.
  • Leverage web-console in development to inspect application state the moment an error occurs—it's faster and more thorough than adding debug statements.
  • Read error page backtraces from top to bottom to find root causes, not just symptoms—and configure your production error tracking to capture local variables.
  • Replicate production data locally when investigating bugs; clean development databases mask real-world conditions.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles