Trakable
Audit logging and version tracking for ActiveRecord models.
Installation
Add this line to your application's Gemfile:
gem 'trakable'And then execute:
$ bundle installOr install it yourself as:
$ gem install trakableQuick Start
1. Generate the migration
$ rails generate trakable:installThis creates:
-
db/migrate/create_traks.rb- Migration for the traks table -
config/initializers/trakable.rb- Configuration file
Run the migration:
$ rails db:migrate2. Add tracking to your models
class Post < ApplicationRecord
include Trakable::Model
trakable only: %i[title body], ignore: %i[views_count]
end3. Whodunnit is automatic
Trakable auto-includes its controller concern via Railtie. It calls current_user by default — no setup needed.
To use a different method:
Trakable.configure do |config|
config.whodunnit_method = :current_admin
endConfiguration
Global configuration
In config/initializers/trakable.rb:
Trakable.configure do |config|
# Enable/disable tracking globally
config.enabled = true
# Attributes to ignore by default
config.ignored_attrs = %w[created_at updated_at id]
# Controller method that returns the current user (default: :current_user)
config.whodunnit_method = :current_user
endPer-model options
class Post < ApplicationRecord
include Trakable::Model
trakable(
only: %i[title body], # Only track these attributes
ignore: %i[views_count], # Ignore these attributes
on: %i[create update destroy], # Only track these events (default: all)
if: -> { published? }, # Conditional tracking
unless: -> { draft? } # Skip if true
)
endUsage
Accessing traks
post = Post.first
# Get all traks for a record
post.traks
# Get the last trak
post.traks.lastTrak properties
trak = post.traks.last
trak.event # => "update"
trak.create? # => false
trak.update? # => true
trak.destroy? # => false
trak.changeset # => { "title" => ["Old Title", "New Title"] }
trak.object # => { "title" => "Old Title", "body" => "..." }
trak.metadata # => { "ip" => "192.168.1.1", "user_agent" => "..." }
trak.created_at # => 2024-01-15 10:30:00 UTC
# Whodunnit (polymorphic)
trak.whodunnit_type # => "User"
trak.whodunnit_id # => 42
trak.whodunnit # => #<User id: 42, ...>Setting metadata
You can add custom metadata to traks using the context:
Trakable::Context.metadata = { ip: request.ip, user_agent: request.user_agent }
post.update(title: "New Title")
# The created trak will include the metadataRevert changes
# Restore the record to the state before this trak
post.traks.last.revert!
# Revert and create a trak for the revert action
post.traks.last.revert!(trak_revert: true)Time travel
# Get the state at a specific point in time
post.trak_at(1.day.ago) # => Non-persisted record with state from 1 day ago
# Get the state from a specific trak
post.traks.last.reify # => Non-persisted record with state at that trakQuery scopes
# Filter by model type
Trakable::Trak.for_item_type('Post')
# Filter by event
Trakable::Trak.for_event(:update)
# Filter by whodunnit
Trakable::Trak.for_whodunnit(current_user)
# Filter by time range
Trakable::Trak.created_after(1.week.ago)
Trakable::Trak.created_before(Date.yesterday)
# Newest first
Trakable::Trak.recent
# Combine them
Trakable::Trak.for_item_type('Post').for_event(:update).created_after(1.day.ago).recentTemporarily disable tracking
# Disable tracking for a block
Trakable.without_tracking do
post.update(title: "Won't be tracked")
end
# Force tracking when globally disabled
Trakable.with_tracking do
post.update(title: "Will be tracked")
end
# Set whodunnit manually
Trakable.with_user(current_user) do
post.update(title: "Tracked with user")
endCleanup
Configure cleanup options per model:
class Post < ApplicationRecord
include Trakable::Model
trakable max_traks: 100 # Keep only last 100 traks
trakable retention: 90.days # Delete traks older than 90 days
endCleanup is not automatic — call it from a background job to keep your traks table lean:
# In a recurring job (e.g. daily cron)
Trakable::Cleanup.run_retention(Post)
# Per-record cleanup (e.g. after a batch import)
Trakable::Cleanup.run(post)Edge cases
# When no trak exists at the timestamp, returns current state
post.trak_at(1.year.ago) # => Returns current state if no older traks exist
# When whodunnit record is deleted, returns nil
trak.whodunnit # => nil (if the user was deleted)
# Revert on destroy re-creates the record (with new ID)
destroy_trak = post.traks.where(event: 'destroy').last
destroy_trak.revert! # => Creates new record with same attributes but new IDAPI Reference
Trakable::Model
| Method | Description |
|---|---|
trakable(options) |
Configure tracking for this model |
traks |
Association to all traks for this record |
trak_at(timestamp) |
Get record state at a specific time |
Trakable::Trak
| Method | Description |
|---|---|
item |
The tracked record (polymorphic) |
whodunnit |
The user who made the change (polymorphic) |
event |
The event type: "create", "update", or "destroy" |
changeset |
Hash of changed attributes with [old, new] values |
object |
Changed attributes before the change (delta for updates, full snapshot for destroys) |
create? |
True if this is a create event |
update? |
True if this is an update event |
destroy? |
True if this is a destroy event |
reify |
Build non-persisted record with state at this trak |
revert! |
Restore record to state before this trak |
for_item_type(type) |
Scope: filter by item type |
for_event(event) |
Scope: filter by event |
for_whodunnit(user) |
Scope: filter by whodunnit (polymorphic) |
created_before(time) |
Scope: traks before a timestamp |
created_after(time) |
Scope: traks after a timestamp |
recent |
Scope: newest first |
Trakable::Controller (auto-included via Railtie)
| Option | Description |
|---|---|
config.whodunnit_method |
Controller method that returns the current user (default: :current_user) |
Performance Tips
Eager loading (N+1 prevention)
When loading multiple records with their traks, use includes to avoid N+1 queries:
# Bad — N+1
posts = Post.all
posts.each { |p| p.traks.count }
# Good — eager loaded
posts = Post.includes(:traks).all
posts.each { |p| p.traks.size }Compress serialized columns (Rails 7.1+)
For large object/changeset payloads, enable column compression by adding a custom initializer:
# config/initializers/trakable_compression.rb
Rails.application.config.after_initialize do
Trakable::Trak.serialize :object, coder: JSON, compress: true
Trakable::Trak.serialize :changeset, coder: JSON, compress: true
endThis uses zlib under the hood and can reduce storage by 60-80% for large payloads.
Differences from PaperTrail
| Feature | PaperTrail | Trakable |
|---|---|---|
| Whodunnit | String | Polymorphic (type + id) |
| Changeset | Opt-in | Always stored |
| Metadata | Not native | Built-in column |
| Retention | Manual | Built-in (max_traks, retention) |
| Serialization | YAML default | JSON only |
| Table name | versions | traks |
| Updated_at | Yes | No (immutable) |
License
The gem is available as open source under the terms of the MIT License.