ModelTimeline is a flexible audit logging gem for Rails applications that allows you to track changes to your models with comprehensive attribution and flexible configuration options.
ModelTimeline was designed with several unique features that differentiate it from other auditing gems:
- Multiple configurations per model: Unlike paper_trail and audited, ModelTimeline allows you to define multiple timeline configurations on the same model. This means you can track different sets of attributes for different purposes.
- Targeted tracking: Configure separate timelines for different aspects of your model (e.g., one for security events, another for content changes).
- PostgreSQL optimization: Built to leverage PostgreSQL's JSONB capabilities for efficient storage and advanced querying.
- IP address tracking: Automatically captures the client IP address for each change.
- Rich metadata support: Add custom metadata to timeline entries via configuration or at runtime.
- Flexible user attribution: Works with any authentication system by using a configurable method to retrieve the current user.
- Comprehensive RSpec support: Built-in matchers for testing timeline recording.
Add this line to your application's Gemfile:
gem 'model_timeline'And then execute:
$ bundle installOr install it yourself as:
$ gem install model_timelineRun the generator to create the necessary migration:
$ rails generate model_timeline:install
$ rails db:migrateFor a custom table name:
$ rails generate model_timeline:install --table_name=custom_timeline_entries
$ rails db:migrateConfigure the gem in an initializer:
# config/initializers/model_timeline.rb
ModelTimeline.configure do |config|
# Method to retrieve the current user in controllers (default: :current_user)
config.current_user_method = :current_user
# Method to retrieve the client IP address in controllers (default: :remote_ip)
config.current_ip_method = :remote_ip
# Enable/disable timeline tracking globally
# config.enabled = true # Enabled by default
endInclude the Timelineable module in your models (this happens automatically with Rails):
Important: When defining multiple timelines on the same model, each must use a unique
class_nameoption. Otherwise, the associations will conflict and an error will be raised.
# Basic usage with default settings
class User < ApplicationRecord
has_timeline
end
# Using a custom association name with class_name along default
class User < ApplicationRecord
has_timeline
has_timeline :security_events, class_name: 'SecurityTimelineEntry'
end
# Tracking only specific attributes
class User < ApplicationRecord
has_timeline only: [:last_login_at, :login_count, :status],
class_name: 'LoginTimelineEntry'
end
# Ignoring specific attributes
class User < ApplicationRecord
has_timeline :profile_changes,
ignore: [:password, :remember_token, :login_count],
class_name: 'ProfileTimelineEntry'
end
# Tracking only specific events
class User < ApplicationRecord
has_timeline :content_changes,
on: [:update, :destroy],
class_name: 'ContentTimelineEntry'
end
# Using a custom timeline entry class and table
class User < ApplicationRecord
has_timeline :custom_timeline_entries,
class_name: 'CustomTimelineEntry'
end
# Adding additional metadata to each entry
class Order < ApplicationRecord
has_timeline :admin_changes,
class_name: 'AdminTimelineEntry',
meta: {
app_version: "1.0",
# Dynamic values using methods or procs
section: :section_name,
category_id: ->(record) { record.category_id }
}
endModelTimeline allows you to include custom metadata with your timeline entries, which is especially useful for tracking changes across related entities or adding domain-specific context.
When defining a timeline, any fields you include in the meta option will be evaluated and stored in the timeline entry:
class Comment < ApplicationRecord
belongs_to :post
has_timeline :comment_changes,
class_name: 'ContentTimelineEntry',
meta: {
post_id: ->(record) { record.post_id },
}
endIf your timeline table has columns that match the keys in your meta hash, these values will be stored in
those dedicated columns. Otherwise, they will be stored inside metadata column.
# Add metadata for a specific operation
ModelTimeline.with_metadata(post_id: '123456') do
comment.update(body: 'Updated comment')
end
# Add metadata for the current thread/request
ModelTimeline.metadata = { post_id: '123456' }
comment.update(status: 'approved')For tracking related entities more effectively, you can create a custom timeline table with additional columns:
# Migration to create a product-specific timeline table
class CreatePostTimelineEntries < ActiveRecord::Migration[6.1]
def change
create_table :post_timeline_entries do |t|
# Default Columns - All of them are required.
t.string :timelineable_type
t.bigint :timelineable_id
t.string :action, null: false
t.jsonb :object_changes, default: {}, null: false
t.jsonb :metadata, default: {}, null: false
t.string :user_type
t.bigint :user_id
t.string :username
t.inet :ip_address
# Custom columns that can be populated via the meta option
t.integer :post_id
t.timestamps
end
add_index :post_timeline_entries, [:timelineable_type, :timelineable_id], name: 'idx_timeline_on_timelineable'
add_index :post_timeline_entries, [:user_type, :user_id], name: 'idx_timeline_on_user'
add_index :post_timeline_entries, :object_changes, using: :gin, name: 'idx_timeline_on_changes'
add_index :post_timeline_entries, :metadata, using: :gin, name: 'idx_timeline_on_meta'
add_index :post_timeline_entries, :ip_address, name: 'idx_timeline_on_ip'
add_index :post_timeline_entries, :post_id, name: 'idx_timeline_on_post_id'
end
endThen, use this table with your models:
class Comment < ApplicationRecord
belongs_to :post
has_timeline :product_changes,
class_name: 'PostTimelineEntry',
meta: {
post_id: ->(record) { record.post_id },
# OR
# post_id: :post_id
# OR
# post_id: :my_custom_post_id_method
}
endWith this approach, you can easily query all changes related to a specific post or product:
# Find all timeline entries for a specific post
PostTimelineEntry.where(post_id: post.id)This makes it significantly easier to track and analyze changes across related models within a specific domain context.
Define the current user and ip_address for the current request
class ApplicationController < ActionController::Base
private
# ModelTimeline will look for the methods set in the initializer.
# Given
# ModelTimeline.configure do |config|
# config.current_user_method = :my_current_user
# config.current_ip_method = :remote_ip
# end
#
def my_current_user
my_current_user_instance
end
def remote_ip
request.remote_ip
end
endOnce configured, ModelTimeline automatically tracks changes to your models:
user = User.create(username: 'johndoe', email: 'john@example.com')
# Creates a timeline entry with action: 'create'
user.update(email: 'new@example.com')
# Creates a timeline entry with action: 'update' and the changed attributes
user.destroy
# Creates a timeline entry with action: 'destroy'# Get all timeline entries for a model
user.timeline_entries
# Get timeline entries with a specific action
user.timeline_entries.where(action: 'update')
# Find entries for a specific user
ModelTimeline::TimelineEntry.for_user(admin)
# Find entries from a specific IP
ModelTimeline::TimelineEntry.for_ip_address('192.168.1.1')# Create a custom timeline entry class
class SecurityTimelineEntry < ModelTimeline::TimelineEntry
self.table_name = 'security_timeline_entries'
# Add custom scopes or methods
scope :critical, -> { where("object_changes::text ILIKE '%password%'") }
end
# Use it in your model
class User < ApplicationRecord
has_timeline :security_timelines,
class_name: 'SecurityTimelineEntry',
only: [:sign_in_count, :last_sign_in_at, :role]
end
# Access the custom timeline
user.security_timelinesTemporarily enable or disable timeline recording:
# Disable timeline recording for a block of code
ModelTimeline.without_timeline do
# Changes made here won't be recorded
user.update(name: 'New Name')
post.destroy
end
# Set custom context for timeline entries
ModelTimeline.with_timeline(current_user: admin_user, current_ip: '10.0.0.1', metadata: { reason: 'Admin action' }) do
# Changes made here will be attributed to admin_user from 10.0.0.1
# with the additional metadata
user.update(status: 'suspended')
endAdd additional contextual information to timeline entries:
# Set metadata for all timeline entries in the current request
ModelTimeline.metadata = { import_batch: 'daily_sync_2023_01_01' }
# Temporarily add or override metadata for a block
ModelTimeline.with_metadata(source: 'api') do
# All timeline entries created here will include this metadata
user.update(status: 'active')
endModelTimeline provides several useful scopes for querying timeline entries:
# Find entries for a specific model
ModelTimeline::TimelineEntry.for_timelineable(user)
# Find entries created by a specific user
ModelTimeline::TimelineEntry.for_user(admin)
# Find entries from a specific IP address
ModelTimeline::TimelineEntry.for_ip_address('192.168.1.1')
# Find entries where a specific attribute was changed
ModelTimeline::TimelineEntry.with_changed_attribute('email')
# Find entries where an attribute was changed to a specific value
ModelTimeline::TimelineEntry.with_changed_value('status', 'active')ModelTimeline leverages PostgreSQL's JSONB capabilities for efficient querying:
# Find timeline entries containing specific changes using JSONB containment
TimelineEntry.where("object_changes @> ?", {email: ["old@example.com", "new@example.com"]}.to_json)
# Search for any value in the changes
TimelineEntry.where("object_changes::text LIKE ?", "%specific_value%")The gem creates GIN indexes on the JSONB columns for optimized performance with large audit logs.
Configure RSpec to work with ModelTimeline:
# spec/support/model_timeline.rb
require 'model_timeline/rspec'
RSpec.configure do |config|
# Include the RSpec helpers and matchers
config.include ModelTimeline::RSpec
endModelTimeline is disabled by default in tests for performance. Enable it selectively:
# Enable timeline for a single test with metadata
it 'tracks changes', :with_timeline do
# ModelTimeline is enabled here
user = create(:user)
expect(user.timeline_entries).to exist
end
# Enable timeline for a group of tests
describe 'tracked actions', :with_timeline do
it 'tracks creation' do
post = create(:post)
expect(post.timeline_entries.count).to eq(1)
end
it 'tracks updates' do
post = create(:post)
post.update(title: 'New Title')
expect(post.timeline_entries.count).to eq(2)
end
end
# Tests without the metadata will have timeline disabled
it 'does not track changes' do
user = create(:user)
expect(ModelTimeline::TimelineEntry.count).to eq(0)
endModelTimeline provides several matchers for testing timeline entries:
# Check for any timeline entries
expect(user).to have_timeline_entries
# Check for a specific number of entries
expect(user).to have_timeline_entries(3)
# Check for entries with a specific action
expect(user).to have_timeline_entry_action(:update)
# Check if a specific attribute was changed
expect(user).to have_timeline_entry_change(:email)
# Check if an attribute was changed to a specific value
expect(user).to have_timeline_entry(:status, 'active')
# Check if an entry was created with expected metadata
expect(user).to have_timeline_entry_metadata(foo: 'bar', baz: 'biz')These matchers make it easy to test that your application is correctly tracking model changes.
You can add a configuration in your support file to create matchers for your association. Given a model like this:
class User < ApplicationRecord
has_timeline :security_events, class_name: 'SecurityTimelineEntry'
endYou should set a configuration in your RSpec support file with:
# spec/support/model_timeline.rb
require 'model_timeline/rspec'
RSpec.configure do |config|
# Include the RSpec helpers and matchers
config.include ModelTimeline::RSpec
config.include ModelTimeline::RSpec::Matchers.define_timeline_matchers_for(:security_events)
endThen you have those new matchers:
# Check for any timeline entries
expect(user).to have_security_events
# Check for a specific number of entries
expect(user).to have_security_events(3)
# Check for entries with a specific action
expect(user).to have_security_event_action(:update)
# Check if a specific attribute was changed
expect(user).to have_security_event_change(:email)
# Check if an attribute was changed to a specific value
expect(user).to have_security_event(:status, 'active')
# Check if an entry was created with expected metadata
expect(user).to have_security_event_metadata(foo: 'bar', baz: 'biz')The gem is available as open source under the terms of the MIT License.