0.0
There's a lot of open issues
A modern Rails engine that tracks ActiveRecord create, update, and destroy events with JSON-first storage, whodunnit actor context, and a clean query API. Drop-in replacement for PaperTrail with no legacy baggage.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 7.2
 Project Readme

RailsAuditLog

CI Gem Version Downloads Ruby codecov

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" } }

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.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 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.

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

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.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 = true

When 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 = 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.