class CreateAuditLogs < ActiveRecord::Migration[7.1]
def change
create_table :audit_logs do |t|
t.references :auditable, polymorphic: true, null: false
t.references :user, null: true, foreign_key: true
t.string :action, null: false
t.jsonb :changes, null: false, default: {}
t.datetime :created_at, null: false
end
add_index :audit_logs, %i[auditable_type auditable_id]
add_index :audit_logs, :changes, using: :gin
add_index :audit_logs, :created_at
end
end
class AuditLog < ApplicationRecord
ACTIONS = %w[create update destroy].freeze
belongs_to :auditable, polymorphic: true
belongs_to :user, optional: true
validates :action, inclusion: { in: ACTIONS }
scope :recent, -> { order(created_at: :desc) }
scope :for_action, ->(action) { where(action: action) }
def changed_attribute?(name)
changes.key?(name.to_s)
end
end
module Auditable
extend ActiveSupport::Concern
IGNORED = %w[id created_at updated_at].freeze
included do
has_many :audit_logs, as: :auditable, dependent: :nullify
after_create :log_create
after_update :log_update
after_destroy :log_destroy
end
private
def log_create
write_audit("create", audited_attributes)
end
def log_update
diff = saved_changes.except(*audited_ignored_attributes)
return if diff.empty?
write_audit("update", diff)
end
def log_destroy
write_audit("destroy", audited_attributes)
end
def write_audit(action, changes)
audit_logs.create!(
action: action,
user: Current.user,
changes: changes
)
end
def audited_attributes
attributes.except(*audited_ignored_attributes)
end
def audited_ignored_attributes
IGNORED
end
end
class ArticlesController < ApplicationController
before_action :authenticate_user!
before_action :set_current_actor
before_action :set_article, only: %i[update]
def create
@article = current_user.articles.new(article_params)
if @article.save
redirect_to @article, notice: "Article created."
else
render :new, status: :unprocessable_entity
end
end
def update
if @article.update(article_params)
redirect_to @article, notice: "Article updated."
else
render :edit, status: :unprocessable_entity
end
end
private
def set_current_actor
Current.user = current_user
end
def set_article
@article = Article.find(params[:id])
end
def article_params
params.require(:article).permit(:title, :body, :published)
end
end
Auditing is the practice of recording who changed what and when so that a system can be reconciled, debugged, or held accountable long after the fact. This snippet builds a reusable audit log for any ActiveRecord model using lifecycle callbacks, keeping the change history in a separate append-only table rather than mutating the original record.
The create_audit_logs migration defines the storage. Each row captures the polymorphic auditable association, the action that occurred, and a changes column typed as jsonb so per-attribute before/after pairs can be queried directly in Postgres. Indexing on [:auditable_type, :auditable_id] keeps per-record history lookups fast, and a GIN index on the jsonb payload allows filtering by which fields changed.
The AuditLog model is a thin record that belongs to its auditable and, optionally, to the acting user. It stays deliberately dumb — all the interesting logic lives in the concern that produces the rows.
Auditable concern is the heart of the pattern. Including it registers after_create, after_update, and after_destroy callbacks so every persisted transition writes a log entry. The key subtlety is when the diff is read: saved_changes is only populated immediately after a save, so log_update captures it inside the same callback while it is still available. The concern filters out noisy columns via audited_ignored_attributes (timestamps and the primary key) so the recorded changes reflect only meaningful edits, and it skips writing a row when nothing relevant changed. Actor attribution is threaded through a thread-local Current.user rather than passed explicitly, which keeps model code clean while still capturing context set by the controller.
The ArticlesController shows the trigger end: Current.user = current_user is set per request in a before_action, so ordinary create and update calls automatically generate attributed audit entries with no extra bookkeeping in the action bodies.
The trade-off is write amplification — every change costs an extra insert — and callbacks silently skip update_all or raw SQL, which bypass ActiveRecord. For most CRUD-driven apps that predictability is worth it; when bulk operations matter, an explicit service or database triggers are the better tool.
Related snips
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
import os
import stat
for root, _dirs, files in os.walk('/etc'):
for name in files:
path = os.path.join(root, name)
Python security audit script for exposed risky filesystem state
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
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
Share this code
Here's the card — post it anywhere.