Guides
Guide

Rails Logs: Levels, Tagged Logging, JSON, and Request IDs

By Sazabi Team

Last verified on Nov 8, 2026.

A Rails app writes its logs through Rails.logger, an ActiveSupport::Logger wrapped in ActiveSupport::TaggedLogging. A production app generated by a recent Rails sends them to STDOUT at info level, tagged with the request ID. That is a good start, but the lines are multi-line text. This guide covers the level, tags, a JSON formatter, Rails 8.1 structured events, parameter filtering, and trace IDs. The code was run on a Rails 8.1.4 app in production mode.

Where Rails Logs Go

By default, Rails writes log/<environment>.log. The production config that rails new generates changes that:

# config/environments/production.rb
config.log_tags = [ :request_id ]
config.logger   = ActiveSupport::TaggedLogging.logger(STDOUT)
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
config.silence_healthcheck_path = "/up"

STDOUT is where container platforms collect output, so no file rotation is needed. silence_healthcheck_path keeps the /up health check out of the logs. An older app may still log to a file in production. If so, move it to STDOUT before you containerize.

Levels

The Rails guides list the levels :debug, :info, :warn, :error, :fatal, and :unknown, and the config.log_level reference says the default is :debug everywhere except production, where it is :info. Set it in config, or by environment variable as the generated file does:

config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")

The generated file carries a warning worth keeping: debug can log personally identifiable information. Raise the level for one incident, then lower it. See log levels for guidance on which level fits which event.

Write your own lines with logger in controllers and models, or Rails.logger elsewhere. The guides recommend the block form, which skips building the string when the level is off:

Rails.logger.debug { "Person attributes: #{@person.attributes.inspect}" }
Rails.logger.info "order placed order_id=#{order.id}"

Tagged Logging and Request IDs

ActiveSupport::TaggedLogging stamps tags on each line. config.log_tags accepts methods the request responds to, a proc, or anything with to_s. [:request_id] uses the value from the ActionDispatch::RequestId middleware, which makes an X-Request-Id header available on the response, reusing the incoming header if one was sent:

[req-8f3a] Started POST "/orders" for 10.0.0.4 at 2026-11-08 07:00:38 +0000
[req-8f3a] Processing by OrdersController#create as HTML
[req-8f3a] order placed order_id=4812
[req-8f3a] Completed 200 OK in 31ms

You can add tags for a block of code too:

Rails.logger.tagged("checkout", current_user.id) { Rails.logger.info "started" }

The request ID is your correlation ID. Searching it returns every line a request produced.

Switch to JSON Lines

The default output is text, and one request writes four or more lines. A formatter that emits JSON makes each line searchable by field. The Rails guides say that if you set config.logger, you must pass your formatter to it before it is wrapped in TaggedLogging, because Rails will not do that for you. Subclass the default formatter:

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

class JsonLogFormatter < ActiveSupport::Logger::SimpleFormatter
  def call(severity, timestamp, progname, msg)
    line = {
      time: timestamp.utc.iso8601(3),
      level: severity.downcase,
      message: msg.is_a?(String) ? msg.delete_prefix(tags_text) : msg.inspect
    }
    line[:tags] = current_tags if current_tags.any?
    "#{line.to_json}\n"
  end
end
# config/environments/production.rb
require_relative "../../lib/json_log_formatter"

config.log_formatter = JsonLogFormatter.new
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT, formatter: config.log_formatter)

Use require_relative rather than autoloading, because environment files load before autoload is ready. With log_tags = [:request_id] still set, a request produced:

{"time":"2026-11-08T07:01:24.353Z","level":"info","message":"Started POST \"/orders\" for  at 2026-11-08 07:01:24 +0000","tags":["req-8f3a"]}
{"time":"2026-11-08T07:01:24.354Z","level":"info","message":"Completed 200 OK in 0ms (Views: 0.1ms | GC: 0.0ms)","tags":["req-8f3a"]}

One caveat: current_tags and tags_text come from the formatter module that TaggedLogging mixes in, which Rails marks :nodoc:, meaning it is not public API. Tagging prepends [tag] to the message before your formatter sees it, which is why the code strips tags_text. Pin that behavior with a test in your suite so a Rails upgrade tells you if it changes.

Structured Events With Rails.event

Rails 8.1 added Rails.event, an event reporter for structured events. You emit an event with a name and a payload, and a subscriber that implements emit encodes it:

# config/initializers/event_subscriber.rb
class JsonEventSubscriber
  def emit(event)
    $stdout.puts(JSON.generate(event))
  end
end

Rails.event.subscribe(JsonEventSubscriber.new)
class OrdersController < ApplicationController
  before_action { Rails.event.set_context(request_id: request.request_id) }

  def create
    Rails.event.tagged("checkout") do
      Rails.event.notify("order.placed", order_id: 4812, amount_cents: 2599)
    end
    render json: { ok: true }
  end
end
{
  "name": "order.placed",
  "payload": { "order_id": 4812, "amount_cents": 2599 },
  "tags": { "checkout": true },
  "context": { "request_id": "req-8f3a" },
  "timestamp": 1794121368189724414,
  "source_location": { "filepath": "app/controllers/orders_controller.rb", "lineno": 6, "label": "block in create" }
}

set_context attaches request-scoped metadata to every event, tagged adds domain tags, and the timestamp is in nanoseconds. In testing, the same subscriber also received events from the framework itself, such as action_controller.request_completed with status and duration_ms in its payload, which is a cleaner request line than the text one. That framework behavior is not described in the guides, so check it against your Rails version. Use Rails.event for new events and keep Rails.logger for free-text messages.

Keep Secrets Out

config.filter_parameters masks request parameters in logs. The default initializer filters names that match fragments such as :passw, :email, :secret, :token, :_key, :crypt, :salt, and :ssn. Add your own:

Rails.application.config.filter_parameters += [ :api_token, :card_number ]

Filtering applies to the logged parameters hash. It does not touch strings you interpolate yourself, so do not write "user=#{user.inspect}". See secrets in logs for what to search for.

Add Trace and Span IDs

With the opentelemetry-sdk gem configured, the active span is available from OpenTelemetry::Trace.current_span. Add its IDs to the formatter:

ctx = OpenTelemetry::Trace.current_span.context if defined?(OpenTelemetry)
if ctx&.valid?
  line[:trace_id] = ctx.hex_trace_id
  line[:span_id] = ctx.hex_span_id
end

Inside a span, a test line gained "trace_id":"ed482882bd7e4089f8618d34e1027dee"; outside one, the context is invalid and nothing is added. A trace ID on each line lets you jump from a slow request to its logs.

Common Mistakes

  • Leaving production at debug. It is noisy and can include personal data.
  • Interpolating objects into messages. inspect output ignores parameter filtering.
  • Logging to a file inside a container. Use STDOUT.
  • Forgetting to pass the formatter to a custom config.logger. Rails will not do it for you.
  • Building expensive strings at debug. Use the block form.
  • Logging health checks. Set silence_healthcheck_path.

Production Tips

  • Keep one line of JSON per event so a collector can parse it. See the JSON logging guide and logging best practices.
  • Return X-Request-Id to clients so support can quote it.
  • Log IDs, not whole records.
  • For spotting log lines you have never seen before once they arrive, see novel events.

Sending Rails Logs to Sazabi

Sazabi, an AI-native observability and security platform, accepts logs from an application instrumented with any OpenTelemetry SDK, and the docs name Ruby in that list. The OpenTelemetry source docs cover the endpoint and protocol settings. Keep the JSON formatter above so each line arrives as parseable fields. See the OpenTelemetry integration, and client errors for watching 4xx and 5xx request lines.

Frequently Asked Questions

Where are Rails logs stored?

By default in log/<environment>.log. A newly generated production config instead writes to STDOUT, which is what container platforms expect.

How do I change the Rails log level?

Set config.log_level, for example config.log_level = :warn. The generated production config reads it from the RAILS_LOG_LEVEL environment variable and defaults to info.

How do I add the request ID to Rails logs?

Set config.log_tags = [:request_id]. Rails then prefixes each line in a request with the X-Request-Id value. Production configs generated by recent Rails versions already do this.

How do I get JSON logs from Rails?

Write a formatter that returns a JSON string per line, set it as config.log_formatter, and pass it to the logger you wrap in ActiveSupport::TaggedLogging. Rails 8.1 also has Rails.event for structured events.

Sources

  1. Rails uses ActiveSupport::Logger, the log levels debug through unknown, the default level of debug (info in production), the block form of logger methods, and tagged logging (accessed Nov 8, 2026)
  2. config.log_level, config.log_tags, config.log_formatter, config.logger and TaggedLogging, config.filter_parameters, config.silence_healthcheck_path, and the ActionDispatch::RequestId middleware (accessed Nov 8, 2026)
  3. ActiveSupport::TaggedLogging wraps a logger to stamp lines with tags such as request IDs (accessed Nov 8, 2026)
  4. Rails.event.notify, tagged, set_context, and subscribers that implement emit and encode events as JSON (accessed Nov 8, 2026)
  5. Rails 8.1 added structured event reporting through Rails.event (accessed Nov 8, 2026)
  6. The generated production config logs to STDOUT with the request ID tag and reads the level from RAILS_LOG_LEVEL, defaulting to info (accessed Nov 8, 2026)
  7. OpenTelemetry::Trace.current_span returns the active span (accessed Nov 8, 2026)