Rails Logs: Levels, Tagged Logging, JSON, and Request IDs
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 31msYou 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
endInside 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.
inspectoutput 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-Idto 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
- 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)
- 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)
- ActiveSupport::TaggedLogging wraps a logger to stamp lines with tags such as request IDs (accessed Nov 8, 2026)
- Rails.event.notify, tagged, set_context, and subscribers that implement emit and encode events as JSON (accessed Nov 8, 2026)
- Rails 8.1 added structured event reporting through Rails.event (accessed Nov 8, 2026)
- 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)
- OpenTelemetry::Trace.current_span returns the active span (accessed Nov 8, 2026)