RailsAuditLog
A modern, Zeitwerk-native Rails engine for auditing ActiveRecord changes. Tracks create, update, and destroy events with JSON-first storage, whodunnit actor context, and a clean query API.
Installation
Add to your Gemfile:
gem "rails_audit_log"
Run the install generator to create the migration:
bin/rails generate rails_audit_log:install
bin/rails db:migrate
Usage
Tracking a model
Include RailsAuditLog::Auditable in any ActiveRecord model:
class Article < ApplicationRecord
include RailsAuditLog::Auditable
end
Every create, update, and destroy is now recorded automatically:
article = Article.create!(title: "Hello")
article.audit_log_entries.count # => 1
article.audit_log_entries.first.event # => "create"
article.update!(title: "World")
article.audit_log_entries.last.object_changes
# => { "title" => ["Hello", "World"] }
Recording who made the change
Include RailsAuditLog::Controller in your ApplicationController and declare the actor source once:
class ApplicationController < ActionController::Base
include RailsAuditLog::Controller
audit_log_actor { current_user }
end
The actor is captured automatically on every request and stored on each entry:
entry = article.audit_log_entries.last
entry.actor # => #<User id: 42, name: "Alice">
entry.actor_type # => "User"
entry.actor_id # => 42
Actor context outside of controllers
Use RailsAuditLog.with_actor in background jobs, rake tasks, or seeds:
RailsAuditLog.with_actor(current_user) do
article.update!(status: "published")
end
Querying the audit log
# Event scopes
article.audit_log_entries.created_events
article.audit_log_entries.updated_events
article.audit_log_entries.destroyed_events
# Filter by actor, resource, or time
RailsAuditLog::AuditLogEntry.by_actor(current_user)
RailsAuditLog::AuditLogEntry.for_resource(Article)
RailsAuditLog::AuditLogEntry.for_resource(article)
RailsAuditLog::AuditLogEntry.since(1.week.ago)
RailsAuditLog::AuditLogEntry.until(Date.yesterday)
# Find entries that touched a specific attribute
RailsAuditLog::AuditLogEntry.touching(:title)
# Inspect what changed
entry = article.audit_log_entries.last
entry.changed_attributes # => ["title"]
entry.diff
# => { "title" => { from: "Hello", to: "World" } }
Selective tracking
Track only specific attributes, or exclude noisy ones:
class Article < ApplicationRecord
include RailsAuditLog::Auditable
# Track only these columns
audit_log only: [:title, :status]
# Or exclude specific columns
audit_log ignore: [:cached_at, :views_count]
end
Configure global defaults in an initializer:
# config/initializers/rails_audit_log.rb
RailsAuditLog.ignored_attributes = %w[updated_at cached_at]
Updates that produce no tracked changes after filtering are silently skipped.
Disabling auditing
Suppress all audit writes inside a block — thread-safe, works in jobs and imports:
RailsAuditLog.disable do
Article.insert_all!(bulk_rows)
end
RailsAuditLog.enabled? # => true (restored after the block)
Disable on a specific record instance:
article.skip_audit_log { article.update!(cached_at: Time.current) }
Object reconstruction
Reify a single entry
AuditLogEntry#reify returns an unsaved ActiveRecord instance reflecting the record's state before the entry was recorded:
article.update!(title: "v2")
entry = article.audit_log_entries.updated_events.last
previous = entry.reify
previous.title # => "v1" (the pre-update state)
previous.persisted? # => false
Returns nil for create entries (nothing existed before).
Reconstruct state at any point in time
snapshot = RailsAuditLog.version_at(article, 1.week.ago)
snapshot.title # => whatever the title was a week ago
Returns nil if the record had no history at that time or was already destroyed.
Navigate the version chain
entry = article.audit_log_entries.updated_events.last
entry.previous # => the entry before this one
entry.next # => the entry after this one (nil if last)
Attaching a reason
Record a free-text rationale alongside any write using a thread-local block — safe to nest, cleared automatically:
RailsAuditLog.audit_log_reason("Approved by legal") do
contract.update!(status: "approved")
end
entry.reason # => "Approved by legal"
Arbitrary metadata
The metadata JSON column accepts any key/value hash. Populate it automatically with audit_log meta::
class Article < ApplicationRecord
include RailsAuditLog::Auditable
# Zero-arg lambda — evaluated at write time (good for thread-locals)
# One-arg lambda — receives the record instance
audit_log meta: {
tenant_id: -> { Current.tenant.id },
title_length: ->(record) { record.title.length }
}
end
entry. # => { "tenant_id" => "acme", "title_length" => 12 }
Request metadata capture
Enable opt-in capture of remote_ip and user_agent from the current request in an initializer:
# config/initializers/rails_audit_log.rb
RailsAuditLog. = true
When enabled, the Controller concern automatically stores these into each entry's metadata column:
entry. # => { "remote_ip" => "203.0.113.1", "user_agent" => "Mozilla/5.0 ..." }
Request metadata and audit_log meta: values are merged together automatically.
Actor display name snapshot
whodunnit_snapshot stores the actor's display name at write time so entries remain meaningful even after the actor record is deleted:
entry.whodunnit_snapshot # => "Alice" (even if the User record is later deleted)
The default display proc uses actor.name if available, otherwise actor.to_s. Override globally in an initializer:
RailsAuditLog.whodunnit_display = ->(actor) { actor.email }
Object snapshot storage
By default every entry stores a full object snapshot of the pre-change state alongside object_changes. This makes reify and version_at reliable without any database lookups:
entry.object # => { "id" => 1, "title" => "v1", ... }
entry.object_changes # => { "title" => ["v1", "v2"] }
To save storage at the cost of reduced reification accuracy, switch to diff-only mode:
RailsAuditLog.store_snapshot = false
Requirements
- Ruby >= 3.3
- Rails >= 7.2
Contributing
Bug reports and pull requests are welcome on GitHub.
License
The gem is available as open source under the terms of the MIT License.