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
- Encrypting audit data
- 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
Encrypting audit data
Encrypt object_changes and object at write time using ActiveRecord::Encryption (Rails 7.1+). Pass encrypt: true to audit_log in the model:
class Payment < ApplicationRecord
include RailsAuditLog::Auditable
audit_log encrypt: true
end
The host app must configure ActiveRecord::Encryption with primary, deterministic, and key-derivation-salt keys — typically in config/initializers/rails_audit_log.rb or config/application.rb:
config.active_record.encryption.primary_key = Rails.application.credentials.ral_primary_key
config.active_record.encryption.deterministic_key = Rails.application.credentials.ral_deterministic_key
config.active_record.encryption.key_derivation_salt = Rails.application.credentials.ral_kdf_salt
Enable encryption globally so every audited model encrypts by default:
# config/initializers/rails_audit_log.rb
RailsAuditLog.encrypt = true
Opt a specific model out when the global default is on:
class PublicLog < ApplicationRecord
include RailsAuditLog::Auditable
audit_log encrypt: false # plain JSON even when RailsAuditLog.encrypt = true
end
Decryption is transparent — #diff, #reify, #changed_attributes, and all other instance methods work without any changes.
Note: The
touchingscope uses database-level JSON extraction (json_extract/->>) and will not match encrypted entries. All Ruby-side query methods work normally.
Setting up encryption keys
Run the Rails built-in task to generate keys and store them in config/credentials.yml.enc:
bin/rails db:encryption:init
Then run the encryption generator to produce a wired-up initializer and a re-encryption migration for existing entries:
bin/rails generate rails_audit_log:encryption
The generator creates:
config/initializers/rails_audit_log_encryption.rb— reads the generated keys from credentials and passes them toActiveRecord::Encryptiondb/migrate/TIMESTAMP_encrypt_rails_audit_log_entries.rb— re-encrypts existing plain-text audit entries; editENCRYPTED_MODELSto list your model class names, then runbin/rails db:migrate
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. See CONTRIBUTING.md for setup instructions, branch workflow, and CHANGELOG conventions.
License
The gem is available as open source under the terms of the MIT License.