class CreateAuditLogs < ActiveRecord::Migration[7.1]
def change
create_table :audit_logs do |t|
t.references :auditable, polymorphic: true, null: false
t.string :actor
t.string :action, null: false
t.jsonb :changes, null: false, default: {}
t.datetime :created_at, null: false
end
add_index :audit_logs, [:auditable_type, :auditable_id, :created_at],
name: "index_audit_logs_on_auditable_timeline"
add_index :audit_logs, :changes, using: :gin
end
end
module Auditable
extend ActiveSupport::Concern
IGNORED_ATTRIBUTES = %w[id created_at updated_at].freeze
included do
has_many :audit_logs, as: :auditable, dependent: :nullify
after_create { record_audit(:create) }
after_update { record_audit(:update) }
after_destroy { record_audit(:destroy) }
end
private
def record_audit(action)
payload = action == :destroy ? attributes : audit_diff
return if action == :update && payload.blank?
audit_logs.create!(
action: action.to_s,
actor: current_actor,
changes: payload
)
end
def audit_diff
saved_changes.except(*IGNORED_ATTRIBUTES)
end
def current_actor
Audited.actor
end
end
class AuditLog < ApplicationRecord
self.table_name = "audit_logs"
belongs_to :auditable, polymorphic: true, optional: true
scope :for_actor, ->(actor) { where(actor: actor) }
scope :recent_first, -> { order(created_at: :desc) }
def field_changed?(name)
self.class.where(id: id).where("changes ? :key", key: name.to_s).exists?
end
def before(name)
Array(changes[name.to_s]).first
end
def after(name)
Array(changes[name.to_s]).last
end
end
module Audited
def self.actor
Thread.current[:audited_actor]
end
def self.actor=(value)
Thread.current[:audited_actor] = value
end
end
class ApplicationController < ActionController::Base
around_action :set_audit_actor
private
def set_audit_actor
Audited.actor = current_user&.email || "system"
yield
ensure
Audited.actor = nil
end
end
This snippet shows a compact but production-ready audit trail that records a JSON diff of every change to a model, driven entirely by ActiveRecord callbacks. The core idea is to capture only what actually changed — a { column => [before, after] } map — rather than snapshotting whole rows, which keeps the audit table small and the history readable at a glance.
The create_audit_logs migration defines the storage: a polymorphic auditable reference so any model can be audited, an action string, and a jsonb changes column. Using jsonb (not json) matters because it supports GIN indexing and containment queries, so history can later be searched by which fields changed. A partial index on auditable_type/auditable_id keeps per-record timeline lookups fast, and null: false on the association columns enforces that every log belongs to something.
The Auditable concern is the reusable part. Included via extend ActiveSupport::Concern, it wires after_create, after_update, and after_destroy hooks. The key detail is saved_changes, which ActiveRecord exposes after a save and returns the [before, after] pairs already in the shape the audit table wants. The concern filters out noisy columns through IGNORED_ATTRIBUTES (timestamps and id) so a routine touch does not spam the log. On destroy, there is no diff to compute, so it stores the final attributes snapshot instead, which is the one case where a full copy is genuinely useful for reconstruction.
current_actor reads a thread-local set by the controller, keeping the model layer decoupled from request state — the concern never references current_user directly. The AuditLog model is deliberately thin: belongs_to :auditable, polymorphic: true plus a for_actor scope and a field_changed? helper that leans on Postgres ? key existence against jsonb.
Finally, ApplicationController sets and clears Audited.actor around each request in an ensure block, which is important: without the reset, a pooled thread could leak one user's identity into another request. The trade-off of the callback approach is that bulk operations like update_all bypass callbacks and won't be audited, so it fits domain writes through model instances rather than mass SQL updates.
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
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
Share this code
Here's the card — post it anywhere.