Rails Logging: How To Debug Production Apps

Learn Rails logging to debug production errors, trace requests, find slow queries, and build more reliable applications.


By Jean Emmanuel Cadet • 28 min read

Rails Logging: How to Debug Production Apps
Share with friends

Every Rails developer eventually meets the bug that only happens in production. It works on your laptop and passes the test suite. Then a customer reports that checkout failed at 2:14 PM, and you have no debugger, no console session, and no way to reproduce it.

What you do have is your application logs. Good Rails logging turns “something broke” into “this request failed in this controller action, for this reason, after this sequence of events.” Poor logging leaves you guessing.

This guide covers Rails logging in practice: how the Rails logger works, how to configure it for production, how to write messages that help during an incident, and how to use logs to debug errors, slow requests, and background jobs. It stays realistic about what logs can and cannot do.


Why Rails Logging Matters in Production

Application logging is the practice of recording events while your software runs: incoming requests, important business actions, warnings, and failures. Each entry is a small piece of evidence about what the application did and when.

Production debugging differs from local debugging in several ways:

  • You usually cannot attach a debugger. Pausing a live server affects real users.
  • You cannot control the inputs. Real users send data you never thought to test.
  • Many things happen at once. Concurrent requests and background jobs interleave, so you need a way to tell them apart.
  • Data is sensitive. You cannot casually dump a customer’s records to the screen.
  • The failure has already happened. You are doing forensics, not live inspection.

Logs help you investigate exceptions, failed requests, slow endpoints, unexpected behavior, and background job failures. When messages are clear and consistent, they also shorten troubleshooting time and make recurring problems easier to spot. If the same error appears every night at 3 AM, a log search will show it.

Be realistic about the limits, though. Logs are one tool in a larger toolbox. They do not replace error monitoring (which groups and alerts on exceptions), metrics (which show trends over time), or distributed tracing (which follows a request across services). A healthy observability strategy uses all of them, and logs provide the detailed narrative behind what the others tell you.


Understanding Rails Logs: Where Rails Production Logs Come From

Rails has a built-in logger, available anywhere in your application as Rails.logger. It is an instance of Ruby’s Logger class (extended by Active Support), and it is what Rails itself uses to record request activity. You can use it too:

Rails.logger.info("Order processing started")

Default Log Files

By default, Rails writes to a file named after the environment:

  • log/development.log for local development
  • log/production.log for production
  • log/test.log for the test suite

On a traditional server where you deploy files and run Rails directly, log/production.log is often where you will look first.

The Log Destination Depends on Your Setup

Do not assume every production app writes to log/production.log. In containerized and hosted deployments, the filesystem is often temporary, and the platform expects applications to write logs to standard output (STDOUT). The platform or container runtime then collects that stream and makes it available through a log viewer or command.

Recent Rails versions generate production configuration that logs to STDOUT by default, but the exact destination depends on your config/environments/production.rb, environment variables, and hosting platform. Check your own configuration before you need it during an incident.

What a Request Log Contains

Rails logs each HTTP request. A typical entry includes the HTTP method and path, the controller and action that handled it, the filtered parameters, and the response status with timing:

Started POST "/orders" for 203.0.113.10 at 2026-10-20 14:14:02 +0000
Processing by OrdersController#create as HTML
Parameters: {"cart_id"=>"4821", "authenticity_token"=>"[FILTERED]"}
Completed 302 Found in X ms (ActiveRecord: Y ms (N queries, 0 cached) | Allocations: Z)

(The X, Y, N, and Z values are placeholders for the real numbers Rails prints.) That one entry already answers several questions: which endpoint, which action, what status, and roughly how long it took.

Other Log Sources

  • Active Record queries: In development, Rails logs every SQL statement. In production, the default info level typically hides them (they are logged at debug).
  • Background jobs: Active Job logs when jobs are enqueued, performed, and retried.
  • Mailers: Action Mailer logs when messages are generated and delivered.
  • Your own code: Anything you write with Rails.logger.

During an incident you will often move between these sources: the request that started the work, the job that did it, and the email that should have followed.


Rails Log Levels Explained

Log levels classify messages by severity. They let you filter noise during an incident and decide how much detail to record in each environment. Rails supports the standard Ruby levels, from least to most severe:

Level

Purpose

DEBUG

Detailed diagnostic information, useful while developing

INFO

Normal, meaningful events (a request handled, a job started)

WARN

Something unexpected happened, but the app can continue

ERROR

An operation failed

FATAL

A severe failure, often one that threatens the whole process

UNKNOWN

A message that does not fit another level

You write messages with a method named after the level:

Rails.logger.debug("Debugging information")
Rails.logger.info("Order processing started")
Rails.logger.warn("Payment provider responded slowly")
Rails.logger.error("Order processing failed")
Rails.logger.fatal("Critical application failure")
Rails.logger.unknown("Unclassified message")

Choosing the Right Level

  • DEBUG: Internal details you would only want while actively investigating, such as intermediate values or branch decisions.
  • INFO: Business events and lifecycle milestones, such as “order created” or “job finished.”
  • WARN: Recoverable oddities, such as a slow third-party response, a retry, or use of a deprecated path.
  • ERROR: A failed operation that needs attention, such as a payment that did not go through.
  • FATAL: Reserve for situations where the application cannot continue to operate normally.

The level matters in production because teams build alerts and searches around it. If everything is logged as error, real errors drown in noise. If real failures are logged as info, nobody finds them.

Detail Versus Volume

More detail makes investigations easier, but it also increases storage costs, slows down searching, and can affect performance. Debug-level logging can also record data you would rather not store. Most production apps run at info (or sometimes warn) and raise the level temporarily when needed. The next section shows how.


How to Configure Rails Logging for Production

Rails reads logging settings from your application configuration. Production-specific settings live in config/environments/production.rb.

Setting the Log Level

The config.log_level setting controls the minimum severity that gets recorded. Messages below that level are ignored. A flexible approach reads the level from an environment variable:

# config/environments/production.rb
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info").to_sym

This defaults to info but lets you change the level per deployment without editing code. If arbitrary values could be supplied (for example, from a shared environment or a misconfigured deploy), validate the value so a typo does not break boot:

# config/environments/production.rb
allowed_levels = %w[debug info warn error fatal unknown]
requested_level = ENV.fetch("RAILS_LOG_LEVEL", "info").downcase

config.log_level = (allowed_levels.include?(requested_level) ? requested_level : "info").to_sym

Choosing Where Logs Go

For containers and many managed platforms, log to STDOUT and let the platform collect the output. In Rails 7.1 and newer, ActiveSupport::TaggedLogging.logger builds a tagged logger for you:

# config/environments/production.rb
config.logger = ActiveSupport::TaggedLogging.logger($stdout)
config.log_tags = [:request_id]

The log_tags setting prefixes every request log line with the request ID, which becomes very useful later in this guide. On a traditional server, you may keep the default file output and manage rotation with system tools (covered below).

Restarts and Deployment

Logging configuration is loaded when the application boots. If you change RAILS_LOG_LEVEL or edit production.rb, the running processes keep their old settings until the application is restarted or redeployed. Always confirm the change took effect.

Platform Differences

  • Traditional servers: Logs usually go to a file, and you manage rotation and access yourself.
  • Containers: Write to STDOUT and STDERR. The container runtime collects the stream.
  • Managed hosting platforms: Follow the platform’s documentation. Most expect STDOUT and provide their own log viewer or forwarding.

A Word of Caution About Debug Logging

It is tempting to set RAILS_LOG_LEVEL=debug and leave it on. Resist that. Verbose logging in production increases storage costs, adds overhead, and can record sensitive data in places you did not expect. Enable it temporarily, for a specific investigation, and turn it back down afterward.


Writing Useful Log Messages

A log line is only helpful if it answers questions the future you will ask. At 2 AM during an incident, “Something went wrong” tells you nothing.

A Poor Example

Rails.logger.error("Payment failed")

Which payment? For which order? In which request? Was it a timeout or a declined card? This message cannot answer any of those.

A Better Example

Rails.logger.error(
"event=payment_failed operation=checkout_create " \
"order_ref=#{order.public_id} request_id=#{request.request_id} " \
"error=#{error.class}"
)

This message names the event, identifies the operation, includes a safe order reference, ties the entry to a request, and records the error class. The key=value format is easy to read and easy to search with grep. (Here order.public_id stands for a non-guessable reference you expose safely. Adapt it to your own model.)

Guidelines for Better Messages

  • Be specific. Describe what happened, not just that something did.
  • Include identifiers. Request IDs, order references, and job IDs let you connect entries.
  • Log meaningful business events. State changes like order_paid or subscription_canceled are valuable breadcrumbs.
  • Pick the right level. A failed payment is an error. A slow provider response is a warn.
  • Avoid logging in tight loops. Writing a line per item in a loop over thousands of records floods your logs. Log a summary instead.
  • Choose context carefully. Identifiers help, but every field is a potential privacy leak. Never log passwords, tokens, full card numbers, or personal data just because it is convenient. Prefer internal IDs or public references over emails, names, or addresses.

Debugging Production Errors With Logs

Let’s walk through a realistic investigation. A customer reports that placing an order failed. Support gives you the approximate time and the user’s account.

Step 1: Find the Time and the Request

Search the logs around the reported time for the failing endpoint. Start narrow: filter by path (POST "/orders") and by the time window. Find the request entry and note its request ID, because it lets you pull every line belonging to that one request.

Step 2: Read the Status and Action

Look at the response status and the controller action. In this scenario, the log shows something like this (timings are replaced with placeholders, and the request ID is shortened for readability):

[5b1c0f2e] Started POST "/orders" for 203.0.113.10 at 2026-10-20 14:14:02 +0000
[5b1c0f2e] Processing by OrdersController#create as HTML
[5b1c0f2e] Parameters: {"cart_id"=>"4821", "authenticity_token"=>"[FILTERED]"}
[5b1c0f2e] event=order_create_started cart_id=4821
[5b1c0f2e] Cart Load (X ms) SELECT "carts".* FROM "carts" WHERE "carts"."id" = $1 LIMIT $2
[5b1c0f2e] ActiveRecord::RecordNotFound (Couldn't find Cart with 'id'=4821)
[5b1c0f2e] Completed 404 Not Found in X ms

The status is a 404, the action is OrdersController#create, and the exception is ActiveRecord::RecordNotFound. Notice what the logs tell you already: the app did not crash. Rails handled a missing record and returned a 404, which is the default behavior for RecordNotFound in production.

Step 3: Trace the Cause

The controller looked up a cart with Cart.find(params[:cart_id]), and the cart no longer existed. Why? Check what else touches carts. Searching the logs for cart_id=4821 might show a cleanup job that removed expired carts shortly before:

[job 8d2a] event=expired_carts_cleanup_finished removed=312

That is a hypothesis: the customer’s cart expired and was deleted while they sat on the checkout page. Notice the difference between the initial cause (the cart was deleted) and the symptom (a 404 error). Always look for what happened first, because later errors are often consequences.

Step 4: Improve the Code and the Logging

Handle the missing cart explicitly and log the context you wished you had:

class OrdersController < ApplicationController
def create
cart = Cart.find_by(id: params[:cart_id])

if cart.nil?
Rails.logger.warn(
"event=order_create_cart_missing cart_id=#{params[:cart_id]} " \
"request_id=#{request.request_id}"
)
redirect_to carts_path, alert: "Your cart expired. Please try again."
return
end

order = Orders::Create.call(cart: cart, user: current_user)
redirect_to order_path(order)
end
end

Now the failure is a clear, searchable event with a friendly user experience. If order creation involves several writes, wrapping them in a database transaction keeps data consistent when something fails halfway. The CodeCurious guide to Rails transactions explains how.

Step 5: Reproduce Safely

Where possible, reproduce the problem in development or staging using safe, non-production data. In this scenario, create a cart, delete it, and submit the form. Never copy real customer data into a test environment without following your organization’s policies.


Using Request IDs to Trace Requests

A production server handles many requests at the same time, so their log lines interleave. Without a way to group them, reading logs is like following one conversation in a crowded room. A request ID solves this.

How Rails Request IDs Work

Rails includes a middleware (ActionDispatch::RequestId) that assigns a unique identifier to every request. You can read it in a controller with request.request_id (also available as request.uuid). Rails also returns it in the X-Request-Id response header, so a client, a support engineer, or another service can quote it to you.

If the incoming request already includes an X-Request-Id header (for example, from a load balancer or reverse proxy), Rails uses that value after sanitizing it. This is what lets one ID follow a request across multiple services.

Adding the Request ID to Logs

The easiest approach is the log_tags setting shown earlier:

# config/environments/production.rb
config.log_tags = [:request_id]

Every request log line is then prefixed with the ID. For your own messages, include it explicitly, or store it somewhere you can reach it from deeper layers of your code. ActiveSupport::CurrentAttributes is a Rails feature for per-request global state:

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :request_id
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_current_request_id

private

def set_current_request_id
Current.request_id = request.request_id
end
end

Now a service object can log the request ID without it being passed around:

Rails.logger.info("event=order_created order_id=#{order.id} request_id=#{Current.request_id}")

Passing the ID to Other Services

Request IDs do not automatically propagate to every external service. If you want to correlate calls to another internal service, send the header yourself:

request = Net::HTTP::Get.new(uri)
request["X-Request-Id"] = Current.request_id

The receiving service must be built to read and log that header.

Security Considerations

A request ID is a correlation identifier. It is not an authentication credential and does not prove who a user is. Never use it to make authorization decisions.

Also remember that clients and proxies can supply request IDs. Rails sanitizes and truncates incoming values, but you should still only trust them as labels. If your app sits behind a proxy you control, let that proxy set the header and strip it from untrusted traffic when appropriate. Treat the ID as a search key, not as proof of anything.


Structured Logging and JSON Logs in Rails

Traditional Rails logs are plain text lines designed for human eyes. Structured logging records each event as data with named fields, usually as JSON, one object per line. Machines can parse it without guessing.

Why JSON Logs Help

Centralized log tools (the kind that collect logs from many servers) can index JSON fields. Instead of searching for text patterns, you can filter by field:

  • All entries where severity is ERROR
  • All entries for a specific request_id
  • All payment_failed events in the last hour, grouped by error_class

Common fields include timestamp, severity, environment, request ID, an event name, and relevant record identifiers.

An Example JSON Log Entry

{"timestamp":"2026-10-20T14:14:02.412Z","severity":"ERROR","environment":"production","request_id":"5b1c0f2e-9a77-4c1d-8e53-2f6d7a0c91b4","event":"payment_failed","order_ref":"ord_8f3k2m","error_class":"PaymentProvider::TimeoutError"}

Producing Structured Logs in Rails

Be careful here: Rails.logger.info does not automatically produce JSON. Plain Rails logging emits text unless you configure a formatter, a different logger, or a logging library. A small custom formatter is enough to illustrate the idea:

# lib/json_log_formatter.rb
require "json"
require "logger"

class JsonLogFormatter < ::Logger::Formatter
def call(severity, time, _progname, message)
payload = message.is_a?(Hash) ? message : { message: message.to_s }

entry = {
timestamp: time.utc.iso8601(3),
severity: severity,
environment: Rails.env
}.merge(payload)

"#{entry.to_json}\n"
end
end
# config/environments/production.rb
require Rails.root.join("lib/json_log_formatter")

logger = ActiveSupport::Logger.new($stdout)
logger.formatter = JsonLogFormatter.new
config.logger = logger

Then log hashes instead of strings:

Rails.logger.error(
event: "payment_failed",
request_id: Current.request_id,
order_ref: order.public_id,
error_class: error.class.name
)

A few notes on this example. It is intentionally minimal. Built-in Rails request logs would also be wrapped in JSON as a single message string, and tagged logging behaves differently with custom formatters, so test the setup in staging. If you need fully structured request logs, a dedicated logging library may be worth adding, but only if it clearly solves a problem you have. Rails 8.1 also introduced a structured event reporter (Rails.event) for emitting events as data, and what happens to those events depends on the subscribers you register. Check the Rails guides for your version before relying on it.

When Plain Text Is Enough

Structured logging pays off most when you aggregate logs from many servers. For a small app with a single server and a few logs a day, readable plain text with key=value pairs may be perfectly fine. Choose the simplest approach that answers your questions.


Debugging Slow Requests and Database Queries

Not every production problem is an exception. Sometimes everything “works,” but a page takes too long. Logs give you the first clues.

Reading the Completion Line

Every request ends with a line like this:

Completed 200 OK in X ms (Views: A ms | ActiveRecord: B ms (N queries, 0 cached) | Allocations: Z)

The total time (X) covers the entire request. The ActiveRecord portion covers the time spent on database work, and Views covers rendering. Comparing them tells you where to look. If the total is large but the database portion is small, the database is not the main suspect. The time may be spent in view rendering, an external API call, or application code. Do not assume every slow request is a database problem.

Investigating a Slow Action

Say users complain that the order history page is slow. Find a request for OrdersController#index in the logs and read it. Check these things:

  1. How many queries ran? A very high query count for a simple page is a red flag.
  2. Are similar queries repeated? Many near-identical queries with only the ID changing suggest an N+1 pattern.
  3. Is one query unusually slow? Look for a single statement with a large time next to it.
  4. How does the database share compare to the total? This shows whether to dig into SQL or elsewhere.

Recognizing N+1 Patterns

An N+1 query happens when code loads a list of records and then runs one additional query per record. In the logs it looks like this (times shown as placeholders):

Order Load (X ms)  SELECT "orders".* FROM "orders" WHERE "orders"."user_id" = $1
LineItem Load (X ms) SELECT "line_items".* FROM "line_items" WHERE "line_items"."order_id" = $1
LineItem Load (X ms) SELECT "line_items".* FROM "line_items" WHERE "line_items"."order_id" = $1
LineItem Load (X ms) SELECT "line_items".* FROM "line_items" WHERE "line_items"."order_id" = $1

The repeating LineItem Load line is the clue. Preloading the association fixes the usual case:

@orders = current_user.orders.includes(:line_items)

For a deeper look at preloading, indexing, and query design, read the CodeCurious guide on optimizing Active Record queries in Rails.

Finding Which Code Issued a Query

Rails can annotate SQL with its source. In Rails 7 and newer, query log tags append a comment to each statement identifying things like the controller and action:

# config/application.rb or an environment file
config.active_record.query_log_tags_enabled = true
config.active_record.query_log_tags = [:application, :controller, :action, :job]

This helps when a slow query shows up in your database’s own monitoring and you need to know which part of the app sent it.

Logs Are a Starting Point

Logs show symptoms. For deeper investigation, combine them with database tools such as EXPLAIN ANALYZE, your database’s slow query log, and performance monitoring. Those tools can show query plans, aggregate behavior over time, and trends that single log lines cannot.


Debugging Background Jobs and Mailers

Work that runs outside a web request fails quietly. The user’s request already returned a success response, so nobody sees the error unless you look. Logging is how you find out.

What to Log in Jobs

For important jobs, log the start, the finish, and the failure, with identifiers such as the record ID and the job ID. Active Job already logs when jobs are enqueued, performed, and retried, but your own business-level events add useful meaning. If you are new to the framework, the CodeCurious beginner’s guide to Active Job covers the fundamentals.

class ProcessOrderJob < ApplicationJob
queue_as :default

retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
discard_on ActiveJob::DeserializationError

def perform(order_id, origin_request_id = nil)
Rails.logger.info(
"event=order_processing_started order_id=#{order_id} " \
"job_id=#{job_id} origin_request_id=#{origin_request_id}"
)

order = Order.find(order_id)
Orders::Process.call(order)

Rails.logger.info(
"event=order_processing_finished order_id=#{order_id} job_id=#{job_id}"
)
rescue StandardError => error
Rails.logger.error(
"event=order_processing_failed order_id=#{order_id} job_id=#{job_id} " \
"attempt=#{executions} error=#{error.class}"
)
raise
end
end

A few things to notice. The job logs the order ID and its own job ID. It records the attempt number via executions, which makes repeated failures easy to spot. And it re-raises after logging, so Active Job’s retry behavior still works.

(polynomially_longer is the retry backoff name in Rails 7.1 and newer. Older versions use a different name, so check your version.)

Correlating Jobs With the Original Request

Request IDs do not automatically carry over into background jobs, because the job runs in a different process at a different time. If you want to correlate them, pass the value explicitly when enqueueing:

ProcessOrderJob.perform_later(order.id, request.request_id)

Now a search for the original request ID will find the job’s start and finish entries too.

Investigating Retries and Repeated Failures

When a job keeps failing, look at the sequence: how many attempts, the same error each time or different ones, and the delay between them. A timeout that succeeds on retry points to a flaky external service. The same exception on every attempt points to a code or data problem that retrying cannot fix.

Mailers

Action Mailer logs when a message is generated and delivered. When you use deliver_later, delivery runs through a background job, so failures appear in your job logs. You can add context in the mailer itself:

class OrderMailer < ApplicationMailer
def confirmation(order)
@order = order
Rails.logger.info("event=order_confirmation_building order_id=#{order.id}")

mail(to: order.customer_email, subject: "Your order #{order.public_id}")
end
end

Notice that the log records the order ID, not the customer’s email address. Also confirm that delivery errors are not hidden by your configuration (see config.action_mailer.raise_delivery_errors).

Avoiding Log Noise

A job that runs thousands of times an hour should not write several lines per run at info. Log failures and key milestones, and consider logging summaries (such as “processed 500 records”) for high-frequency work.


Logging Exceptions Without Hiding the Root Cause

Logging an exception is easy. Logging it without losing information, or accidentally swallowing it, takes some care. Here is a safe pattern:

begin
PaymentProcessor.call(order)
rescue StandardError => error
Rails.logger.error(
"Payment processing failed: #{error.class}: #{error.message}"
)

raise
end

This example is illustrative. Adapt it to your application’s error-handling strategy.

Why This Works

  • The exception class and message are useful. PaymentProvider::TimeoutError tells you far more than “an error occurred.”
  • The raise with no arguments re-raises the original exception. The stack trace is preserved, and the framework, your error monitoring tool, and retry logic still see the failure.

When You Need the Stack Trace

The message and class tell you what went wrong. The stack trace tells you where. For unexpected errors, include a limited number of backtrace lines:

Rails.logger.error(
"Payment processing failed: #{error.class}: #{error.message}\n" \
"#{error.backtrace&.first(10)&.join("\n")}"
)

Limiting the lines keeps logs readable. For expected, handled failures (such as a declined card), a stack trace adds noise.

Do Not Swallow Exceptions

This is a common trap:

rescue StandardError => error
Rails.logger.error(error.message)
# nothing else: the failure disappears
end

If the code continues as though nothing happened, you may create corrupted state or silent data problems. Either handle the error meaningfully (for example, show the user a clear message and take a defined fallback action) or re-raise it.

Avoid Logging the Same Exception at Every Layer

If a service logs and re-raises, the controller logs and re-raises, and a middleware logs again, one failure produces three or four entries that look like separate problems. Pick one place to log (often the outermost layer, or your error reporting integration) and let inner layers add context only when they have something new to say.

Be Careful With Exception Details

Exception messages can include private data: SQL fragments with values, provider responses with customer details, or URLs with tokens. Review what your code and external libraries put into messages before you write them to logs. Rails also provides an error reporter (Rails.error) that integrates with error monitoring tools, and it is worth using alongside logging rather than instead of it.


Searching, Filtering, and Reading Logs

Having logs is only useful if you can find the right lines quickly. Your approach depends on where logs live.

Search Strategies

  • By request ID: The most precise filter. It returns the full story of one request.
  • By error message or class: Good for finding every occurrence of a known problem.
  • By timestamp: Narrow to the window around the report.
  • By record identifier: Search for order_id=1042 to see everything that touched that order.
  • By severity: Start with ERROR and WARN, then widen.

Command-Line Tools for Local Log Files

When logs are files on a server you are allowed to access:

# Follow new log lines as they arrive
tail -f log/production.log

# Find every error line
grep "ERROR" log/production.log

# Show all lines for one request
grep "5b1c0f2e-9a77-4c1d-8e53-2f6d7a0c91b4" log/production.log

# Show 5 lines of context before and after each match
grep -n -C 5 "RecordNotFound" log/production.log

# Page through a large file
less log/production.log

In less, press G to jump to the end and / to search. For JSON logs, jq can filter by field:

jq 'select(.severity == "ERROR")' log/production.log

Containers and Hosted Platforms

When logs go to STDOUT, use your runtime’s commands or your platform’s log viewer. For example:

docker logs --since 30m my-rails-app 2>&1 | grep "ERROR"

Commands and paths vary by environment, so adapt them to your setup. Also follow your organization’s production access policies. Not everyone should be reading production logs, and logs can contain sensitive information.

Reading Stack Traces and Surrounding Context

When you find an exception, read the first lines first: they name the error and where it was raised. Then scan for lines from your own code (usually paths starting with app/), because framework and gem frames are often less informative. Finally, read the entries before and after the failure. What happened just before often explains why, and what happened after shows the consequences.


Log Rotation, Retention, and Storage

Log files grow forever unless something stops them. A busy app can fill a disk, and a full disk can take down your application, which is an unpleasant way to learn about log management.

Rotation and Size Management

Log rotation renames or archives the current log file at intervals (or at a size limit) and starts a fresh one, then deletes old files. There are several ways to do it, and the right one depends on your setup.

Ruby’s logger can rotate by size:

config.logger = ActiveSupport::Logger.new(
Rails.root.join("log/production.log"),
5,
50.megabytes
)

This keeps up to 5 old files of 50 MB each. On Linux servers, logrotate is a common system-level alternative:

/var/www/myapp/shared/log/production.log {
daily
rotate 14
compress
missingok
notifempty
copytruncate
}

Pick one mechanism for a given file. Having both the application and an operating system tool rotate the same file can cause confusion.

Retention

Decide how long to keep logs. Operational needs (how far back do you investigate?) and legal or compliance requirements (some data must be kept, and some must be deleted) both matter. Retention is a policy question as much as a technical one, so involve your team or organization when needed.

Centralized Logging for Multiple Servers

When your app runs on several servers or containers, logs scattered across machines are painful to search. Centralized log aggregation collects them in one place where you can search by request ID across all instances. In containers, write to STDOUT and STDERR and let the platform or a log shipper forward the stream.

Local Files Versus External Services

Local files are simple, cheap, and always available, but they are hard to search at scale and disappear with ephemeral containers. External log-management services add search, retention, and alerting, but cost money and require sending your logs to a third party (which matters for privacy). Many vendors and self-hosted options exist, and no single one is the only valid choice. Rotation and retention may be handled differently by Rails, the operating system, your container platform, and your hosting provider, so find out who owns each part in your deployment.


Security and Privacy Best Practices for Rails Logs

Logs are often more widely accessible than your database, and they are copied, archived, and shipped to other systems. Treat them as sensitive.

What Should Never Be Casually Logged

  • Passwords and authentication credentials
  • Session identifiers and access tokens
  • API keys and secret environment variables
  • Payment information and sensitive personal data
  • Request parameters that might contain confidential values
  • Exception messages or external provider responses that might include private details

If your app handles tokens, such as in Rails API authentication with JWT, make sure they never end up in log output. For a broader view, the Rails security best practices guide covers other places data can leak.

Rails Parameter Filtering

Rails can mask sensitive request parameters in the logs, replacing values with [FILTERED]. You configure it in config/initializers/filter_parameter_logging.rb:

Rails.application.config.filter_parameters += [
:password,
:token,
:api_key,
:authorization
]

Filters match parameter names, and a symbol matches any parameter whose name contains it, so :token also covers access_token. Rails generates a default list of common sensitive names, and you add your own.

Know the Limits

Parameter filtering protects the parameters you configured. It does not automatically sanitize your custom log messages, exception messages, or arbitrary payloads. If you write Rails.logger.info("Response: #{provider_response}"), nothing filters that response. You are responsible for deciding what goes into your own messages.

Beyond the Code

Review who can access logs, how long they are retained, and whether you have a redaction process for the occasions when sensitive data slips through. Logging is a security surface, and the code is only one part of protecting it.


Common Rails Logging Mistakes

Here are the mistakes that show up most often, along with practical fixes.

1. Logging too much. Excess logs hide the useful ones, cost money, and slow searches. Fix: log meaningful events, summarize loops, and keep production at info unless investigating.

2. Logging too little context. “Payment failed” is unactionable. Fix: include an event name, safe identifiers, and the request ID.

3. Using puts in production code. puts bypasses the logger, so it ignores log levels, tags, formatters, and destinations. Fix: use Rails.logger everywhere.

4. Using the wrong level. Failures logged as info get missed, and routine events logged as error create alert fatigue. Fix: follow the level guidelines from earlier and review them periodically.

5. Swallowing exceptions after logging them. The app keeps running in a broken state. Fix: re-raise, or handle the failure deliberately.

6. Logging sensitive parameters or credentials. This creates a privacy and security risk. Fix: configure filter_parameters, review custom messages, and never log secrets.

7. Relying only on local log files in distributed deployments. Logs are scattered across servers or lost with containers. Fix: send logs to STDOUT and aggregate them centrally.

8. Keeping verbose debug logging enabled indefinitely. Costs and risk grow quietly. Fix: raise the level temporarily and lower it when done.

9. Logging the same exception repeatedly. One failure looks like many. Fix: log at a single, well-chosen layer.

10. Assuming logs alone provide complete observability. Logs cannot show trends, alert reliably on their own, or trace requests across services. Fix: pair logging with error monitoring, metrics, and tracing as your application grows.


A Practical Production Debugging Workflow

When an incident hits, a repeatable process keeps you calm and focused. Here is a workflow you can follow:

  1. Identify the affected endpoint, job, or business operation. What exactly is broken: checkout, a report, an email?
  2. Establish the approximate time of the failure. Get a window from the user, support, or an alert.
  3. Search logs for the relevant error or request ID. Start with a narrow window and the most specific key you have.
  4. Read the exception and stack trace. Identify the class, the message, and the first frames in your own code.
  5. Follow related log entries to understand the execution path. Pull every line for the request ID and read it in order.
  6. Check relevant SQL queries, background jobs, and external service calls. Look for slow or repeated queries, failed jobs, and timeouts from other services.
  7. Form a hypothesis and verify it with evidence. Write down what you think happened, then find the log lines that confirm or contradict it.
  8. Reproduce the problem safely when possible. Use a development or staging environment with non-production data.
  9. Implement and test a fix. Add a test that covers the failing scenario.
  10. Monitor after deployment and improve logging if needed. If you struggled to find information, the incident exposed a visibility gap, so add the missing context now.

The key principle is evidence-based debugging. Logs give you facts to reason from, so you do not need to guess or change code at random hoping the problem disappears.


Conclusion

Effective Rails logging is not about writing more logs. It is about writing logs that are clear, contextual, secure, and searchable. A good message names the event, carries the right identifiers, uses the right level, and never leaks private data. A good setup sends logs somewhere you can search them, ties them together with request IDs, and rotates and retains them sensibly.

When you invest in logging before an incident, failures become faster to understand, slow requests become easier to explain, and background jobs stop failing in silence. Logs will not replace error monitoring, metrics, or tracing, but they are the detailed record you will reach for first.

Start small: add the request ID to your log tags, improve your three vaguest error messages, and check your parameter filters. Then keep learning. Explore more Ruby on Rails tutorials on CodeCurious to keep building production-ready skills.

Jean Emmanuel Cadet
Written by Jean Emmanuel Cadet
Jean Emmanuel is a Full-Stack Software Engineer specializing in Ruby on Rails and the modern Rails ecosystem. He builds scalable, maintainable web applications using Rails, Hotwire (Turbo & Stimulus), PostgreSQL, and SQLite, with a focus on fast, dynamic user experiences. Through CodeCurious, he shares practical lessons, development insights, and real-world solutions for modern developers.

Coder. Apprendre. Progresser.

Une newsletter conviviale où je partage des conseils, des leçons et des réussites de mon parcours de développeur.