ruby 113 lines · 4 tabs

Rails Audit Log for Model Changes Using ActiveRecord Callbacks

Shared by codesnips Jul 2026
4 tabs
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
4 files · ruby Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Rails Audit Log for Model Changes Using ActiveRecord Callbacks — share card
Link copied