ruby 91 lines · 4 tabs

Audit Trail with JSON Diff (Minimal, Useful)

Shared by codesnips Jan 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.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
4 files · ruby Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Audit Trail with JSON Diff (Minimal, Useful) — share card
Link copied