Ruby

Making the Most of Your Logs in Rails

Making the Most of Your Logs in Rails

Rails logs through Rails.logger — on Rails 8.1, a BroadcastLogger that writes tagged lines to STDOUT in production and to log/development.log in development. Six levels exist: debug, info, warn, error, fatal, and unknown; production defaults to info via RAILS_LOG_LEVEL. Good log messages are descriptive, carry context like request IDs, and become structured JSON when machines read them.

Logs earn their keep at the worst possible moments: when your application breaks, user complaints flood in, and the messages that could explain why were never written. Good logs pay for themselves tenfold. They point at the failing code before you reach for a debugger, and done well, they surface issues before your users notice. In this post, we’ll dig into what Rails gives you out of the box and how to shape it into logs you can act on.

Logging in Rails

When you start a new Rails application, logging is already set up for you. On Rails 8.1, Rails.logger is an ActiveSupport::BroadcastLogger — a wrapper that forwards every log call to one or more destination loggers. In development, it wraps a single ActiveSupport::Logger writing to log/development.log, and you can call it from anywhere in your application:

Ruby
Rails.logger.info("Hello logs!")

Rails’ default formatter prints the message as-is, so running this in bin/rails console appends a plain Hello logs! line to the development log. The logger also records incoming requests, database queries, and errors without any work on your part.

Production is configured differently. A freshly generated Rails 8.1 app ships these defaults in config/environments/production.rb:

Ruby
# config/environments/production.rb
 
# Log to STDOUT with the current request id as a default log tag.
config.log_tags = [ :request_id ]
config.logger   = ActiveSupport::TaggedLogging.logger(STDOUT)
 
# Change to "debug" to log everything (including potentially personally-identifiable information!)
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")

In production, logs go to standard output — where your hosting platform or a log shipper picks them up — every line is tagged with the request ID that produced it, and any message under the info level is discarded. Each of those defaults returns later in this post.

There are more ways to configure Rails logs, outlined in the Rails guides. To make the most of our logs, we are especially interested in log levels and log formatting. But first: what separates a good log from a bad one?

Good Logging, Bad Logging

The purpose of logs is to inform you about system events so you can react to them. For example, when an error happens, a log message should tell you about it in a way you can understand.

How well you understand a log message depends on how descriptive and contextual it is. A descriptive log message provides relevant information about what happened. A contextual log message includes information about the system’s state when the system wrote it.

We need both, and this code has neither. It calls an external API and returns the response when a user performs a request, sprinkled with the kind of log messages many real codebases settle for:

Ruby
def call_external_api(user_id, payload)
  Rails.logger.info('Method entered')
  client = Client.new(user_id)
  response = client.request(payload)
  if response.ok?
    Rails.logger.info('Response success')
    return response
  else
    Rails.logger.info('Response failure')
    return response
  end
rescue ClientError => e
  Rails.logger.debug('An error occurred')
end

Now imagine a customer reports an issue, and you turn to the production logs for help. This is what you might see:

Shell
[ef55816f-a3e4-4d9c-b36f-254ead6fd694] Started GET "/users?search=name" for 127.0.0.1 at 2026-08-31 20:43:59 +0000
[ef55816f-a3e4-4d9c-b36f-254ead6fd694] Processing by UsersController#index as HTML
[ef55816f-a3e4-4d9c-b36f-254ead6fd694]   Parameters: {"search" => "name"}
[ef55816f-a3e4-4d9c-b36f-254ead6fd694] Method entered
[ef55816f-a3e4-4d9c-b36f-254ead6fd694] Response success
[ef55816f-a3e4-4d9c-b36f-254ead6fd694] Completed 200 OK in 8ms (Views: 0.3ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
[5ac4eacd-a6f3-4710-809b-1d844bf6bf9c] Started GET "/users?search=" for 127.0.0.1 at 2026-08-31 20:43:59 +0000
[5ac4eacd-a6f3-4710-809b-1d844bf6bf9c] Processing by UsersController#index as HTML
[5ac4eacd-a6f3-4710-809b-1d844bf6bf9c]   Parameters: {"search" => ""}
[5ac4eacd-a6f3-4710-809b-1d844bf6bf9c] Method entered
[5ac4eacd-a6f3-4710-809b-1d844bf6bf9c] Response failure
[5ac4eacd-a6f3-4710-809b-1d844bf6bf9c] Completed 200 OK in 0ms (Views: 0.3ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
[9ec836cd-0678-4467-b32d-0fa9741c5a95] Started GET "/users?search=long+name" for 127.0.0.1 at 2026-08-31 20:43:59 +0000
[9ec836cd-0678-4467-b32d-0fa9741c5a95] Processing by UsersController#index as HTML
[9ec836cd-0678-4467-b32d-0fa9741c5a95]   Parameters: {"search" => "long name"}
[9ec836cd-0678-4467-b32d-0fa9741c5a95] Method entered
[9ec836cd-0678-4467-b32d-0fa9741c5a95] Completed 200 OK in 0ms (Views: 0.3ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)

These messages provide some information, but not nearly enough. The third request hit the rescue branch — a real failure — yet its log ends with a healthy-looking Completed 200 OK. The one message that could have told us about the error was logged at debug, and production’s default info level discarded it. The messages that did get through lack descriptiveness and context. They are noise. How can we improve them?

Log Levels in Rails

Rails uses the log levels of Ruby’s Logger class — six severities, in ascending order: DEBUG, INFO, WARN, ERROR, FATAL, and UNKNOWN. Levels group log messages into categories of relevance. They also decide what gets written at all: the logger drops any message whose level is under the configured threshold.

It can be challenging to decide when to use which log level. Here are some rules of thumb:

  • DEBUG: Detailed information about actions happening within the system — methods entered and exited, computed values, anywhere you think it may add value during debugging. Verbose by design, and usually discarded in production.
  • INFO: Whenever your system changes its state or some relevant event occurs, reach for INFO. Common examples in Rails applications are received requests, requests sent to external APIs, and background jobs starting and finishing.
  • WARN: Something unexpected happened, but your application can handle it — a retry that succeeded on the second attempt, or a deprecated code path still being hit. If it keeps happening, it might warrant your attention.
  • ERROR: An operation failed and someone needs to fix it — an unhandled exception, a failed payment, an external service that stayed down through every retry. The application keeps running, but the problem won’t resolve itself.
  • FATAL: An error the application cannot recover from: a crash during boot, or a lost connection to a critical resource. A FATAL line is often the last thing a process writes, so treat one as a page-worthy event. In day-to-day application code, you’ll rarely write these yourself.
  • UNKNOWN: The highest severity, meant for messages that must appear no matter how the log level is configured. Rails and most libraries leave it alone; you’ll read about it more often than you’ll use it.

Production discards DEBUG messages because its threshold defaults to info. You can change the threshold per environment with config.log_level:

Ruby
# config/environments/production.rb
config.log_level = :debug

On Rails 8.1, you don’t need a code change: the generated configuration reads the RAILS_LOG_LEVEL environment variable and falls back to info, so RAILS_LOG_LEVEL=debug turns on verbose logging for a single deploy or console session.

With the log-level rules of thumb applied, our code sample reads like this:

Ruby
def call_external_api(user_id, payload)
  Rails.logger.debug('Method entered')
  client = Client.new(user_id)
  response = client.request(payload)
  if response.ok?
    Rails.logger.info('Response success')
    return response
  else
    Rails.logger.warn('Response failure')
    return response
  end
rescue ClientError => e
  Rails.logger.error('An error occurred')
end

When debugging, we can now identify root causes by looking into WARN and ERROR messages, and the error no longer hides at a level production throws away. Sadly, our log messages haven’t gotten any more descriptive.

Descriptive Log Messages

Descriptive log messages leave no room for interpretation. They provide the details necessary to give the reader instant knowledge about what happened.

When you read a message such as 'An error occurred', you are left wondering what the error is. The most important thing when writing descriptive log messages is to put yourself in the shoes of the log reader. Will they get all the information they need when reading your logs?

The message 'An error occurred' becomes useful once it names the error:

Ruby
Rails.logger.error { "#{e.class}: #{e.message}" }

That’s an improvement. The other log messages in our example deserve the same treatment: 'Method entered' doesn’t tell us which method was called, 'Response success' leaves out the response, and 'Response failure' provides no information about the nature of the failure.

Ruby
def call_external_api(user_id, payload)
  Rails.logger.debug('Calling external API')
  client = Client.new(user_id)
  response = client.request(payload)
  if response.ok?
    Rails.logger.info { "Request success, received #{response.body}" }
    return response
  else
    Rails.logger.warn { "Request returned #{response.code}: #{response.body}" }
    return response
  end
rescue ClientError => e
  Rails.logger.error { "#{e.class}: #{e.message}" }
end

You might have noticed we use the block syntax when performing string interpolation with our logs. Using it avoids unnecessary computation when the application’s log level outranks the message’s level: Rails.logger.debug("Some #{concatenation}") always performs the string concatenation, but Rails.logger.debug { "Some #{concatenation}" } only does so when the logger writes debug messages.

With the log level set to debug, the same three requests now produce:

Shell
[45d65191-6b0a-47c1-b2df-b122ee6f3df1] Started GET "/users?search=name" for 127.0.0.1 at 2026-08-31 20:44:00 +0000
[45d65191-6b0a-47c1-b2df-b122ee6f3df1] Processing by UsersController#index as HTML
[45d65191-6b0a-47c1-b2df-b122ee6f3df1]   Parameters: {"search" => "name"}
[45d65191-6b0a-47c1-b2df-b122ee6f3df1] Calling external API
[45d65191-6b0a-47c1-b2df-b122ee6f3df1] Request success, received {"userId" => 1, "id" => 1, "title" => "title", "completed" => false}
[45d65191-6b0a-47c1-b2df-b122ee6f3df1] Completed 200 OK in 5ms (Views: 0.5ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.3ms)
[f8686a18-70f1-41b6-b2aa-e3e199f2b65c] Started GET "/users?search=" for 127.0.0.1 at 2026-08-31 20:44:00 +0000
[f8686a18-70f1-41b6-b2aa-e3e199f2b65c] Processing by UsersController#index as HTML
[f8686a18-70f1-41b6-b2aa-e3e199f2b65c]   Parameters: {"search" => ""}
[f8686a18-70f1-41b6-b2aa-e3e199f2b65c] Calling external API
[f8686a18-70f1-41b6-b2aa-e3e199f2b65c] Request returned 400: Empty search
[f8686a18-70f1-41b6-b2aa-e3e199f2b65c] Completed 200 OK in 1ms (Views: 0.4ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
[f8b71c35-4d16-4b2d-8ec7-27ff9e1627a9] Started GET "/users?search=long+name" for 127.0.0.1 at 2026-08-31 20:44:00 +0000
[f8b71c35-4d16-4b2d-8ec7-27ff9e1627a9] Processing by UsersController#index as HTML
[f8b71c35-4d16-4b2d-8ec7-27ff9e1627a9]   Parameters: {"search" => "long name"}
[f8b71c35-4d16-4b2d-8ec7-27ff9e1627a9] Calling external API
[f8b71c35-4d16-4b2d-8ec7-27ff9e1627a9] ClientError: Request timeout exceeded
[f8b71c35-4d16-4b2d-8ec7-27ff9e1627a9] Completed 200 OK in 1ms (Views: 0.4ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.2ms)

Our logs read much better now. There is little doubt about what each log message signifies.

Further Context for Log Messages

Our new log messages provide a clear picture of what happened. Because this is a simple example, we also have a good understanding of why certain things happened. In reality, it’s usually not that easy.

Providing additional information about the context in which the system wrote each message can significantly help with debugging. When logging requests or responses, it helps to know who performed the request. For errors, attaching a stack trace lets us gather information about where the error occurred.

Ruby
def call_external_api(user_id, payload)
  Rails.logger.debug { "Calling external API. user_id: #{user_id}, payload: #{payload}" }
  client = Client.new(user_id)
  response = client.request(payload)
  if response.ok?
    Rails.logger.info { "Request completed. Received #{response.parsed_response}. user_id: #{user_id}, payload: #{payload}" }
    return response
  else
    Rails.logger.warn { "Request failed with #{response.code}: #{response.parsed_response}. user_id: #{user_id}, payload: #{payload}" }
    return response
  end
rescue ClientError => e
  Rails.logger.error { "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}" }
end

Building context into every message by hand gets cumbersome with Rails’ default logger. Structured loggers such as Ougai accept a hash of context fields alongside the message, and so does AppSignal’s logger, which we’ll get to. Rails also ships a context mechanism of its own: tags.

Tagged Logging in Rails

Descriptive messages say what happened; context says where and for whom. Tagged logging attaches that context without stuffing it into every message by hand. Wrap log calls in tagged, and every line written inside the block carries a prefix:

Ruby
Rails.logger.tagged("PAYMENT") do
  Rails.logger.info("Charging customer 42")
end
Shell
[PAYMENT] Charging customer 42

Tags nest, too — Rails.logger.tagged("PAYMENT", "STRIPE") prefixes lines with [PAYMENT] [STRIPE]. Because the default production logger is wrapped in ActiveSupport::TaggedLogging, this works out of the box.

You’ve already seen the most valuable tag in this post’s log excerpts: config.log_tags = [ :request_id ], a Rails 8.1 production default, prefixes every line with the ID of the request that produced it. In a production log where multiple processes and requests write at once, that one tag turns interleaved noise into something you can follow — filter for one request ID and you get that request’s full story. You can add your own entries to config.log_tags, such as :remote_ip.

Structured Logging

Once your logs read well for humans, make them readable for machines too, so you can use them for monitoring and data analysis.

The go-to format is JSON. Every log line becomes an object with consistent fields — and severity, which Rails’ default formatter never prints, becomes a queryable field instead of getting lost. You can change the format of your log messages by creating a custom log formatter:

Ruby
# lib/json_log_formatter.rb
class JsonLogFormatter < ::Logger::Formatter
  def call(severity, time, progname, msg)
    json = { time: time, progname: progname, severity: severity, message: msg2str(msg) }
      .compact_blank
      .to_json
    "#{json}\n"
  end
end

Where you plug it in depends on the environment. config.log_formatter only applies when Rails builds its default logger, as it does in development. The generated production config creates its own logger, so pass the formatter there:

Ruby
# config/environments/production.rb
require_relative "../../lib/json_log_formatter"
 
Rails.application.configure do
  # ...
  config.logger = ActiveSupport::TaggedLogging.logger(STDOUT, formatter: JsonLogFormatter.new)
end
Shell
{"time":"2026-08-31T20:42:22.171+00:00","severity":"INFO","message":"Request completed"}

Rather than writing custom formatters yourself, you can use one of the many gems that provide them. Lograge is a solid choice: it offers out-of-the-box structured formatting and condenses Rails’ verbose multi-line request logging into a single line per request.

Broadcasting Logs to Multiple Destinations

Levels, messages, tags, and structure cover what your logs say. Where they go matters too. Rails.logger being a BroadcastLogger means every log call can fan out to any number of destinations, and adding one takes a single line:

Ruby
Rails.logger.broadcast_to(Logger.new("log/audit.log"))
Rails.logger.info("User 42 upgraded their plan")

The message is written to the default destination and to log/audit.log. Each broadcast target is a full logger that applies its own formatter, so you can emit human-readable lines to standard output and JSON to a file at the same time.

One thing broadcasting alone doesn’t change: your logs still sit on the machines that wrote them.

Rails Logging with AppSignal

Readable logs still live on a server you have to reach. AppSignal’s log management ingests your Rails logs alongside your errors and performance data, so the log line, the exception, and the slow query from one request sit on one timeline — swap in Appsignal::Logger, or broadcast to it, and the logs you improved in this post become searchable next to everything else AppSignal already tracks.

You can find your logs under the Logging tab:

AppSignal navigation menu with the Logging tab

With the appsignal gem (4.10.1 at the time of writing) installed and configured, create an Appsignal::Logger and hand it to Rails in an initializer:

Ruby
# config/initializers/logging.rb
appsignal_logger = Appsignal::Logger.new("rails")
Rails.logger = ActiveSupport::TaggedLogging.new(appsignal_logger)

The "rails" argument is the group your logs appear under in AppSignal. Appsignal::Logger subclasses Ruby’s ::Logger, so everything in this post — levels, block syntax, tagged logging — keeps working, and the ActiveSupport::TaggedLogging wrapper keeps config.log_tags request IDs flowing.

If replacing the Rails logger outright feels drastic, keep your existing log and add AppSignal as a second destination with the AppSignal logger’s own broadcast_to helper:

Ruby
# config/initializers/logging.rb
appsignal_logger = Appsignal::Logger.new("rails")
appsignal_logger.broadcast_to(Rails.logger)
Rails.logger = ActiveSupport::TaggedLogging.new(appsignal_logger)

AppSignal’s docs recommend this helper over Rails’ built-in broadcast logger, which has compatibility issues alongside tagged logging.

Appsignal::Logger also picks up where the JSON formatter left off: log calls accept a hash of attributes that become filterable fields in the AppSignal UI.

Ruby
logger = Appsignal::Logger.new("invoice_helper")
logger.info("Generated invoice for customer", { customer_id: 42, invoice_id: 1207 })

Already-structured logs work, too — the gem auto-detects JSON and logfmt lines and parses their attributes, and the Ruby logging docs include a ready-made Lograge setup.

Once you’ve set everything up, you should see your Rails logs show up in AppSignal:

Rails log lines in AppSignal’s Logging view

Logging and Error Reporting

You’re reading this on the AppSignal blog, so you might wonder: why should I care about logging if I’m already using a nice error reporting platform?

Error tracking tools capture the full stack trace of any application error, plus plenty of context, out of the box.

However, while most tools allow you to capture events other than errors, sending these tools an arbitrary number of messages is generally infeasible. You won’t be able to replace the detailed, historical information that well-written logs provide.

In reality, well-written logs augment and support error tracking tools. You should likely use both — and when both live in the same place, an error report and the log lines written around it end up on the same timeline.

Wrap Up

In this post, we turned Rails 8.1’s out-of-the-box logging into logs worth reading: six levels used with intent, messages that are descriptive and contextual, tags that let you follow a single request through an interleaved production log, JSON formatting for the machines, and broadcasting for when one destination isn’t enough. From there, a log management tool like AppSignal makes those logs searchable next to your errors and performance data.

Happy logging!

P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Frequently asked questions

What are the log levels in Rails?
Rails uses Ruby’s Logger severities: debug, info, warn, error, fatal, and unknown. Each message carries a level, and the logger discards anything under its configured threshold. Production defaults to info, so debug messages are dropped unless you lower the level.
How do I change the Rails log level in production?
Set config.log_level in config/environments/production.rb, or set the RAILS_LOG_LEVEL environment variable — the generated Rails 8.1 configuration reads it and falls back to info. Setting RAILS_LOG_LEVEL=debug enables verbose logging without a code change.
How do I send Rails logs to AppSignal?
Create an Appsignal::Logger in an initializer and assign it to Rails.logger, wrapped in ActiveSupport::TaggedLogging. To keep writing to your existing log as well, call the AppSignal logger’s broadcast_to helper with your current logger before reassigning Rails.logger.
What are structured logs?
Structured logs are written in a machine-readable format, usually JSON, where every entry carries consistent fields such as timestamp, severity, and message. They make logs searchable and filterable by tools. In Rails, a custom log formatter or the Lograge gem produces them.

Published , Updated

Wondering what you can do next?

  • Share this article on social media
Hans-Jörg Schnedlitz

Hans-Jörg Schnedlitz

Our guest author Hans is a Rails engineer from Vienna, Austria. He spends most of his time coding or reading about coding, and sometimes even writes about it on his blog! When he's not sitting in front of a screen, you'll probably find him outside, climbing some mountain.

All articles by Hans-Jörg Schnedlitz

Become our next author!

Find out more
$appsignal install

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal