class JsonLogFormatter < ActiveSupport::Logger::SimpleFormatter
def call(severity, timestamp, _progname, message)
if message.is_a?(Hash)
entry = {
ts: timestamp.utc.iso8601(3),
level: severity
}.merge(current_tags).merge(message)
"#{entry.to_json}\n"
else
"#{message}\n"
end
end
def self.tag(**pairs)
Thread.current[:log_tags] ||= {}
Thread.current[:log_tags].merge!(pairs)
end
def self.reset_tags
Thread.current[:log_tags] = {}
end
private
def current_tags
Thread.current[:log_tags] || {}
end
end
class RequestLogSubscriber < ActiveSupport::Subscriber
attach_to :action_controller
def process_action(event)
payload = event.payload
exception = payload[:exception_object]
data = {
event: "request",
method: payload[:method],
path: payload[:path],
controller: payload[:controller],
action: payload[:action],
status: status_for(payload, exception),
duration_ms: event.duration.round(2),
db_ms: payload[:db_runtime].to_f.round(2),
view_ms: payload[:view_runtime].to_f.round(2),
params: filtered_params(payload)
}
data.merge!(format_exception(exception)) if exception
Rails.logger.info(data)
ensure
JsonLogFormatter.reset_tags
end
private
def status_for(payload, exception)
return 500 if exception
payload[:status] || 200
end
def filtered_params(payload)
params = payload[:params] || {}
params.except("controller", "action", "format")
end
def format_exception(exception)
{
error: exception.class.name,
error_message: exception.message,
backtrace: Array(exception.backtrace).first(5)
}
end
end
Rails.application.configure do
Rails.logger.formatter = JsonLogFormatter.new
# Silence Rails' default per-action log lines so we don't double-log.
ActiveSupport.on_load(:action_controller) do
ActionController::LogSubscriber.detach_from(:action_controller)
end
config.after_initialize do
RequestLogSubscriber.attach_to(:action_controller)
end
end
# Tag every request with a correlation id available to all log lines.
ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
request_id = event.payload.dig(:headers, "action_dispatch.request_id")
JsonLogFormatter.tag(request_id: request_id) if request_id
end
This snippet builds a lograge-style single-line JSON log for every request using only Rails' own building blocks, no external gems. The core idea is to replace Rails' noisy multi-line development log (which emits separate lines for Started, Processing, SQL, and Completed) with exactly one structured event per request that a log aggregator like Loki, Datadog, or CloudWatch can parse.
In JsonLogFormatter, a custom ActiveSupport::Logger::SimpleFormatter subclass is defined. When the message passed to the logger is already a Hash, it merges in a timestamp, severity, and any thread-local request tags, then serializes with to_json. Non-hash messages fall back to a plain string so unexpected logger.info "..." calls still work. Thread-local storage via Thread.current[:log_tags] is used so tags set anywhere in the request cycle end up on the final line without threading them through method arguments.
The real work happens in RequestLogSubscriber, which subscribes to the process_action.action_controller notification. Rails fires this event once per controller action with a rich payload and timing data, which makes it the natural single place to summarize a request. The subscriber flattens controller, action, status, method, and path, and derives duration_ms from event.duration. It also pulls db_runtime and view_runtime out of the payload — these are already computed by Rails, so no manual instrumentation is needed. Exceptions are captured from payload[:exception] so failed requests are logged with an error field and an appropriate status. The format_exception helper normalizes the [class, message] array Rails provides.
Wiring lives in initializers/structured_logging.rb. The formatter is attached to Rails.logger, the default ActionController::LogSubscriber is detached to silence the built-in per-action logs, and the custom subscriber is attached instead. This avoids double logging.
The trade-off is that this approach only summarizes controller actions; per-query SQL lines disappear, which is usually desirable in production but occasionally missed while debugging. It also depends on process_action payload keys that are stable but framework-internal. When a team wants clean JSON logs without adding a dependency, this pattern delivers most of lograge's value in about sixty lines.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.