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
- Multi-tenancy
- Event streaming
- 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
↑ Table of contents
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:migrateConfiguration
↑ Table of contents
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:initializerThe 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? }
endEach option is documented in detail in its own Usage section below.
Web dashboard
↑ Table of contents
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
↑ Table of contents
Tracking a model
Include RailsAuditLog::Auditable in any ActiveRecord model:
class Article < ApplicationRecord
include RailsAuditLog::Auditable
endEvery 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 }
endThe 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 # => 42Actor 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")
endQuerying 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::MissingAttributeErrorNote: 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
endEach 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.tags.create!(name: "Ruby")
entry = post.audit_log_entries.updated_events.last
entry.object_changes
# => { "tags" => [nil, { "id" => 1, "type" => "Tag" }] }
post.tags.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 recordedhas_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
endbelongs_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 INSERTNested 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
endEnable async globally in an initializer — per-model async: true takes precedence:
# config/initializers/rails_audit_log.rb
RailsAuditLog.async = trueConfigure 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
endSet a global default in an initializer — per-model values take precedence:
# config/initializers/rails_audit_log.rb
RailsAuditLog.version_limit = 50Time-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.daysEntries 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
endScheduled 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 midnightOr run it once manually via the rake task:
bin/rails rails_audit_log:pruneEncrypting 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
endThe 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_saltEnable encryption globally so every audited model encrypts by default:
# config/initializers/rails_audit_log.rb
RailsAuditLog.encrypt = trueOpt 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
endDecryption 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:initThen run the encryption generator to produce a wired-up initializer and a re-encryption migration for existing entries:
bin/rails generate rails_audit_log:encryptionThe generator creates:
-
config/initializers/rails_audit_log_encryption.rb— reads the generated keys from credentials and passes them toActiveRecord::Encryption -
db/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
Multi-tenancy
Store the current tenant on every audit entry so queries are naturally isolated per tenant.
Run the generator to add the tenant_id column:
bin/rails generate rails_audit_log:tenant
bin/rails db:migrateSet a global resolver in your initializer — the block is called at write time:
# config/initializers/rails_audit_log.rb
RailsAuditLog.current_tenant { Current.tenant_id }Or override per model:
class Order < ApplicationRecord
include RailsAuditLog::Auditable
audit_log tenant: -> { Current.tenant_id }
endThe per-model lambda takes precedence over the global resolver. Both accept zero-argument lambdas and store whatever the block returns in the tenant_id string column.
Scope queries to a single tenant with for_tenant:
AuditLogEntry.for_tenant("acme")
AuditLogEntry.for_tenant(Current.tenant_id).updated_events.since(1.week.ago)The web dashboard (/audit) automatically applies for_tenant when current_tenant is configured, so entries from other tenants are never exposed.
Acts As Tenant integration
Wire the resolver to ActsAsTenant in one line:
# config/initializers/rails_audit_log.rb
RailsAuditLog.acts_as_tenant!This is equivalent to RailsAuditLog.current_tenant { ActsAsTenant.current_tenant&.id }.
Event streaming
Publish every audit entry to an external consumer as it is written. Set any object implementing #publish(entry) as the adapter:
# config/initializers/rails_audit_log.rb
RailsAuditLog.streaming_adapter = RailsAuditLog::Streaming::NotificationsAdapter.newNotificationsAdapter (built-in, zero dependencies)
Publishes rails_audit_log.entry_created synchronously via ActiveSupport::Notifications:
ActiveSupport::Notifications.subscribe("rails_audit_log.entry_created") do |*, payload|
entry = payload[:entry]
Rails.logger.info "Audit: #{entry.event} #{entry.item_type}##{entry.item_id}"
endActiveJobAdapter (async)
Enqueues PublishEntryJob so publishing does not block the request. The job fires the same rails_audit_log.entry_created notification when performed:
RailsAuditLog.streaming_adapter = RailsAuditLog::Streaming::ActiveJobAdapter.new
# or with a custom queue:
RailsAuditLog.streaming_adapter = RailsAuditLog::Streaming::ActiveJobAdapter.new(queue: :streaming)Custom adapters
Any object implementing #publish(entry) works — no companion gem needed for Kafka, SQS, or any other transport:
# Kafka via WaterDrop
class KafkaAuditAdapter
def initialize(producer: WaterDrop::SyncProducer)
@producer = producer
end
def publish(entry)
@producer.call(entry.attributes.to_json, topic: "audit_log")
end
end
RailsAuditLog.streaming_adapter = KafkaAuditAdapter.new# SQS via aws-sdk-sqs
class SqsAuditAdapter
def initialize(queue_url:, client: Aws::SQS::Client.new)
@queue_url = queue_url
@client = client
end
def publish(entry)
@client.send_message(
queue_url: @queue_url,
message_body: entry.attributes.to_json
)
end
end
RailsAuditLog.streaming_adapter = SqsAuditAdapter.new(
queue_url: ENV.fetch("AUDIT_SQS_URL")
)Wrap the adapter in ActiveJobAdapter if you want publishing to be asynchronous and not block the request thread.
Batch mode
batch_audit flushes the bulk INSERT first, then calls #publish for each entry individually — streaming consumers receive every entry even in batch mode.
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]
endConfigure 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? # => falseReturns 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 agoReturns 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.metadata # => { "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.capture_request_metadata = trueWhen enabled, the Controller concern automatically stores these into each entry's metadata column:
entry.metadata # => { "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 = falseSeparate 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
endexpect(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
endassert_audit_log_entry post, event: :update, touching: :title
refute_audit_log_entry post, event: :destroyStability and versioning
↑ Table of contents
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| } |
Block-style configuration |
.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
↑ Table of contents
Run the migration generator to produce a timestamped data migration:
bin/rails generate rails_audit_log:migrate_from_paper_trail
bin/rails db:migrateThe 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
endThis 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 timeAuditLogEntry#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
↑ Table of contents
Each companion gem declares rails_audit_log as a dependency so you only add what you use.
rails_audit_log-graphql
A mountable GraphQL endpoint for API-first apps. Shipped as a separate gem so graphql-ruby is never a transitive dependency for users who don't need it.
Add both gems to your Gemfile:
gem "rails_audit_log"
gem "rails_audit_log-graphql"Mount the GraphQL engine alongside the main engine:
# config/routes.rb
mount RailsAuditLog::Engine, at: "/audit"
mount RailsAuditLog::GraphQL::Engine, at: "/audit/graphql"Authentication re-uses RailsAuditLog.authenticate — nothing extra to configure.
Queries and subscriptions:
| Operation | Signature | Description |
|---|---|---|
Query.auditLogEntries |
(event:, itemType:, itemId:, actorId:, since:, until:, page:) |
Filterable, paginated list |
Query.auditLogEntry |
(id:) |
Single entry lookup with full diff |
Subscription.auditLogEntryCreated |
(itemType:, itemId:) |
Real-time stream for a resource (requires Action Cable) |
All fields on AuditLogEntry are exposed via AuditLogEntryType.
Requirements
↑ Table of contents
- Ruby >= 3.3
- Rails >= 7.2
Performance
↑ Table of contents
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 benchmarkContributing
↑ Table of contents
Bug reports and pull requests are welcome on GitHub. See CONTRIBUTING.md for setup instructions, branch workflow, and CHANGELOG conventions.
License
↑ Table of contents
The gem is available as open source under the terms of the MIT License.