RailsAuditLog
Audit logging for Rails. Tracks create, update, and destroy events as structured JSON records with a mountable web dashboard, whodunnit actor context, batch writes, time-travel reconstruction, and test helpers — a clean, well-documented replacement for PaperTrail with a built-in migration path.
Table of contents
- Installation
- Configuration
- Web dashboard
- Usage
- Tracking a model
- Recording who made the change
- Actor context outside of controllers
- Querying the audit log
- Lightweight queries
- Association tracking
- Bulk audit writes
- Async audit writes
- Capping history per record
- Time-based retention
- Scheduled and manual pruning
- Selective tracking
- Disabling auditing
- Object reconstruction
- Attaching a reason
- Arbitrary metadata
- Request metadata capture
- Actor display name snapshot
- Object snapshot storage
- Separate audit database
- Test helpers
- Stability and versioning
- Migrating from PaperTrail
- Performance
- Companion gems
- Requirements
- Contributing
- License
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
Configuration
Run the initializer generator to create config/initializers/rails_audit_log.rb with every option documented as a commented example:
bin/rails generate rails_audit_log:initializer
The generated file (shown below with all options) uses the block-style configure API. Every setting has a sensible default — uncomment only what you need:
# config/initializers/rails_audit_log.rb
RailsAuditLog.configure do |config|
# Global columns excluded from all audited models.
# Default: ["updated_at"]
# config.ignored_attributes = %w[updated_at cached_at]
# Store a full attribute snapshot alongside object_changes.
# Default: true
# Disable to save storage; reify and version_at fall back to diff-only reconstruction.
# config.store_snapshot = false
# Capture remote_ip and user_agent into each entry's metadata column.
# Default: false — requires RailsAuditLog::Controller in ApplicationController.
# config.capture_request_metadata = true
# Customise how the actor's display name is stored at write time.
# Default: actor.name if available, otherwise actor.to_s
# config.whodunnit_display = ->(actor) { actor.email }
# Global cap on entries retained per tracked record. nil = no limit.
# Per-model `audit_log version_limit: N` takes precedence.
# config.version_limit = 100
# Global time-based TTL — entries older than this duration are pruned after
# each write. Composes with version_limit: an entry is removed when it
# exceeds either constraint. Default: nil (no TTL)
# config.retention_period = 90.days
# Write all audit entries asynchronously via WriteAuditLogJob.
# Default: false — per-model `audit_log async: true` also works.
# config.async = true
# Route AuditLogEntry to a dedicated database (Rails multi-DB).
# config.connects_to = { database: { writing: :audit_log, reading: :audit_log } }
# Entries per page in the web dashboard. Default: 25
# config.page_size = 50
# Gate web dashboard access. Block runs in controller context so controller
# helpers like current_user are available directly. Falls back to HTTP Basic
# auth when the block returns falsy. Leave unset for unauthenticated access.
# config.authenticate { current_user&.admin? }
# config.authenticate { |c| c.current_user&.admin? }
end
Each option is documented in detail in its own Usage section below.
Web dashboard
Mount the engine in config/routes.rb to enable the built-in audit trail browser:
mount RailsAuditLog::Engine, at: "/audit"
Then visit /audit to browse all audit entries. The dashboard is delivered via propshaft, importmaps, and CDN-pinned Turbo and Stimulus — no asset pipeline configuration required in the host app.
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" } }
Lightweight queries
Use .slim to exclude the three JSON blob columns (object_changes, object, metadata) from the SQL projection. This is useful for index or listing views where you only need the entry header (who, what event, when):
entries = AuditLogEntry.slim.for_resource(article).since(1.week.ago)
entries.first.event # => "update"
entries.first.actor_type # => "User"
entries.first.object_changes # => raises ActiveModel::MissingAttributeError
Note: Use
.count(:id)instead of.countwhen chaining.slimwith other scopes — Rails'COUNTwith a multi-columnSELECTis not supported by all databases.
Association tracking
Track has_many add and remove events by passing associations: true to audit_log. Call audit_log before the has_many declarations so the callbacks are wired at class load time:
class Post < ApplicationRecord
include RailsAuditLog::Auditable
audit_log associations: true
has_many :tags
has_many :comments, dependent: :destroy
end
Each add or remove creates an update entry on the parent with the associated record's identity in object_changes:
post = Post.create!(title: "Hello")
tag = post..create!(name: "Ruby")
entry = post.audit_log_entries.updated_events.last
entry.object_changes
# => { "tags" => [nil, { "id" => 1, "type" => "Tag" }] }
post..delete(tag)
entry = post.audit_log_entries.updated_events.last
entry.object_changes
# => { "tags" => [{ "id" => 1, "type" => "Tag" }, nil] }
Track only a named subset of associations:
audit_log associations: [:tags] # comments changes are not recorded
has_many :through and has_and_belongs_to_many work the same way — no extra configuration:
class Post < ApplicationRecord
include RailsAuditLog::Auditable
audit_log associations: true
has_many :taggings
has_many :tags, through: :taggings # tracked automatically
has_and_belongs_to_many :categories # tracked automatically
end
belongs_to foreign-key changes are already tracked as regular column updates and require no extra configuration.
Bulk audit writes
Wrap a block with RailsAuditLog.batch_audit to buffer all audit entries and flush them in a single insert_all! call at the end, eliminating N+1 inserts during imports:
RailsAuditLog.batch_audit do
records.each { |attrs| Post.create!(attrs) }
end
# All audit entries written in one INSERT
Nested calls accumulate into the outermost batch — only one insert_all! fires. If the block raises, the buffer is discarded and no entries are written.
Note: Version pruning (
version_limit) is deferred in batch mode — the next write outside the batch will trigger pruning as usual.
Async audit writes
Offload audit writes to a background job by passing async: true to audit_log. The entry is enqueued via RailsAuditLog::WriteAuditLogJob (a subclass of ActiveJob::Base) so the request does not block on the database write:
class Post < ApplicationRecord
include RailsAuditLog::Auditable
audit_log async: true
end
Enable async globally in an initializer — per-model async: true takes precedence:
# config/initializers/rails_audit_log.rb
RailsAuditLog.async = true
Configure the queue adapter and queue name through standard ActiveJob settings. Version pruning also runs inside the job when version_limit is set.
Capping history per record
Limit how many audit entries are kept per record with version_limit:. Oldest entries are pruned automatically after each write once the cap is reached:
class Post < ApplicationRecord
include RailsAuditLog::Auditable
audit_log version_limit: 10 # keep only the 10 most recent entries
end
Set a global default in an initializer — per-model values take precedence:
# config/initializers/rails_audit_log.rb
RailsAuditLog.version_limit = 50
Time-based retention
Automatically prune entries older than a configured duration by setting retention_period in an initializer:
# config/initializers/rails_audit_log.rb
RailsAuditLog.retention_period = 90.days
Entries whose created_at is older than the period are deleted after each write. retention_period and version_limit compose — an entry is pruned when it exceeds either constraint.
Override the global default per model with retain_for::
class Post < ApplicationRecord
include RailsAuditLog::Auditable
audit_log retain_for: 30.days # takes precedence over RailsAuditLog.retention_period
end
Scheduled and manual pruning
RailsAuditLog::PruneAuditLogJob prunes all audited models in one pass. It iterates over every item_type present in audit_log_entries, resolves the effective retain_for / retention_period and version_limit per model, and deletes entries that exceed either constraint.
Enqueue it on a recurring schedule via your job backend:
# config/recurring.yml (Solid Queue)
prune_audit_log:
class: RailsAuditLog::PruneAuditLogJob
schedule: every day at midnight
Or run it once manually via the rake task:
bin/rails rails_audit_log:prune
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
Separate audit database
Route all audit writes to a dedicated database by setting connects_to in an initializer. The engine applies it to AuditLogEntry at boot:
# config/initializers/rails_audit_log.rb
RailsAuditLog.connects_to = {
database: { writing: :audit_log, reading: :audit_log }
}
The key (e.g. :audit_log) must match a database key in config/database.yml. All reads and writes on AuditLogEntry — including batch_audit inserts and WriteAuditLogJob — use that connection automatically.
Test helpers
RailsAuditLog::TestHelpers — silences audit tracking inside a block; useful in FactoryBot factories and seed data:
# spec/rails_helper.rb
require "rails_audit_log/test_helpers"
RSpec.configure do |config|
config.include RailsAuditLog::TestHelpers
end
# Or in a FactoryBot factory:
after(:create) { |p| without_audit_log { p.update!(cached_at: Time.current) } }
without_audit_log is a prefix-free wrapper around RailsAuditLog.disable — thread-safe and restores tracking even if the block raises.
RailsAuditLog::Matchers (RSpec) — add to spec/rails_helper.rb:
require "rails_audit_log/matchers"
RSpec.configure do |config|
config.include RailsAuditLog::Matchers
end
expect(post).to have_audit_log_entry
expect(post).to have_audit_log_entry(:update)
expect(post).to have_audit_log_entry(:update).touching(:title)
expect { post.update!(title: "New") }.to create_audit_log_entry(event: :update).touching(:title)
RailsAuditLog::MinitestAssertions — add to test/test_helper.rb:
require "rails_audit_log/minitest_assertions"
class ActiveSupport::TestCase
include RailsAuditLog::MinitestAssertions
end
assert_audit_log_entry post, event: :update, touching: :title
refute_audit_log_entry post, event: :destroy
Stability and versioning
rails_audit_log follows Semantic Versioning from 1.0.0. The public API is everything documented in this README. Anything not listed here is internal and may change between minor versions.
| Version bump | Meaning |
|---|---|
| Patch (1.0.x) | Bug fixes only — no API changes |
| Minor (1.x.0) | New features, backward-compatible |
| Major (x.0.0) | Breaking changes with a documented migration path |
Public API surface
Module-level — RailsAuditLog
| Method / accessor | Purpose |
|---|---|
| `.configure { \ | config\ |
.with_actor(actor) { } |
Set whodunnit context for a block |
.disable { } |
Suppress all audit writes for a block |
.enabled? |
Whether audit writes are active |
.audit_log_reason(value) { } |
Attach a free-text reason for a block |
.batch_audit { } |
Buffer writes and flush with a single insert_all! |
.version_at(record, time) |
Reconstruct record state at a point in time |
.authenticate { } |
Gate web dashboard access |
.ignored_attributes= |
Global columns excluded from all models |
.store_snapshot= |
Store full object snapshot alongside diffs |
.capture_request_metadata= |
Capture remote_ip and user_agent |
.version_limit= |
Global entry cap per record |
.async= |
Write audit entries via WriteAuditLogJob |
.connects_to= |
Route AuditLogEntry to a separate database |
.page_size= |
Entries per page in the web dashboard |
.whodunnit_display= |
Proc for actor display name snapshot |
.retention_period= |
Global time-based TTL for audit entries |
Concerns
| Class | Include in | Key methods |
|---|---|---|
RailsAuditLog::Auditable |
ActiveRecord models | audit_log(only:, ignore:, meta:, associations:, version_limit:, retain_for:, async:), skip_audit_log { } |
RailsAuditLog::Controller |
ActionController | audit_log_actor { } |
Model — RailsAuditLog::AuditLogEntry
Scopes: created_events, updated_events, destroyed_events, by_actor, for_resource, since, until, touching, slim, for_period
Instance methods: reify, previous, next, diff, changed_attributes, actor
Constants: EVENTS, BLOB_COLUMNS, PERIODS
Testing helpers
| Module | Require | Usage |
|---|---|---|
RailsAuditLog::Matchers |
require "rails_audit_log/matchers" |
RSpec: have_audit_log_entry, create_audit_log_entry |
RailsAuditLog::MinitestAssertions |
require "rails_audit_log/minitest_assertions" |
Minitest: assert_audit_log_entry, refute_audit_log_entry |
RailsAuditLog::TestHelpers |
require "rails_audit_log/test_helpers" |
without_audit_log { } |
Jobs (configure via ActiveJob)
RailsAuditLog::WriteAuditLogJob — do not instantiate directly; enqueued when async: true is set.
RailsAuditLog::PruneAuditLogJob — enqueue on a schedule to prune all audited models; also invoked by bin/rails rails_audit_log:prune.
Generators
rails generate rails_audit_log:install · rails generate rails_audit_log:initializer
Migrating from PaperTrail
Run the migration generator to produce a timestamped data migration:
bin/rails generate rails_audit_log:migrate_from_paper_trail
bin/rails db:migrate
The generated migration reads every row from PaperTrail's versions table and inserts it into audit_log_entries.
Column mapping
PaperTrail versions |
audit_log_entries |
Notes |
|---|---|---|
item_type |
item_type |
Direct copy |
item_id |
item_id |
Direct copy |
event |
event |
create / update / destroy only; other values are skipped |
object_changes |
object_changes |
YAML or JSON → JSON (see below) |
object |
object |
YAML or JSON → JSON (see below) |
whodunnit |
whodunnit_snapshot |
PaperTrail stores actor as a plain string |
created_at |
created_at |
Direct copy |
| — | actor_type / actor_id |
Not populated — cannot be inferred from a string whodunnit |
YAML and JSON serialization
PaperTrail serializes object and object_changes as YAML by default and as JSON when PaperTrail.serializer = PaperTrail::Serializers::JSON is configured. The migration handles both transparently — it tries JSON first, then falls back to YAML (with a permissive class list for the Ruby types PaperTrail commonly serializes).
Compatibility shim for gradual migration
If you need your codebase to keep using PaperTrail's API while migrating, include RailsAuditLog::PaperTrailCompat alongside Auditable:
require "rails_audit_log/paper_trail_compat"
class Article < ApplicationRecord
include RailsAuditLog::Auditable
include RailsAuditLog::PaperTrailCompat
end
This adds the familiar PaperTrail surface:
article.versions # audit_log_entries ordered oldest-first
article.paper_trail.version # most recent AuditLogEntry
article.paper_trail.previous_version # reconstructed previous state (reify)
article.paper_trail.originator # whodunnit_snapshot string
article.paper_trail.version_at(1.week.ago) # reconstructed state at a point in time
AuditLogEntry#reify already matches PaperTrail's Version#reify — no additional alias needed.
What is not migrated
versionsrows with aneventvalue outsidecreate,update,destroy(custom events added by some apps)actor_typeandactor_id— PaperTrail'swhodunnitis a plain string and does not identify the actor model
Companion gems
Coming in the 1.x series
| Gem | What it adds |
|---|---|
rails_audit_log-graphql |
Mountable GraphQL endpoint at /audit/graphql — queryable audit trail for API-first apps without forcing graphql-ruby on users who don't need it |
Each companion gem declares rails_audit_log as a dependency so you only add what you use.
Requirements
- Ruby >= 3.3
- Rails >= 7.2
Performance
See BENCHMARKS.md for write throughput, batch_audit gains, query performance, storage efficiency, and notes on comparing against PaperTrail. To run the suite locally:
bundle exec rake dev:setup
bundle exec rake benchmark
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.