0.0
The project is in a healthy, maintained state
Track changes to your Rails models with multiple configurable loggers using PostgreSQL
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Runtime

>= 1.1.0
>= 5.2.0
 Project Readme

ModelTimeline

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.

How this gem is different than paper_trail and audited?

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.

Installation

Add this line to your application's Gemfile:

gem 'model_timeline'

And then execute:

$ bundle install

Or install it yourself as:

$ gem install model_timeline

Run the generator to create the necessary migration:

$ rails generate model_timeline:install
$ rails db:migrate

For a custom table name:

$ rails generate model_timeline:install --table_name=custom_timeline_entries
$ rails db:migrate

Configuration

Initializer

Configure 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
end

Model Configuration

Include 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_name option. 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 }
               }
end

Using Metadata

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

Adding Metadata Through Configuration

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 },
               }
end

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

Adding Metadata at Runtime

# 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')

Custom Timeline Tables with Domain-specific Columns

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
end

Then, 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
               }
end

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

Controller Integration

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
end

Usage

Basic Usage

Once 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'

Accessing Timeline Entries

# 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')

Custom Tables and Models

# 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_timelines

Controlling Timeline Recording

Temporarily 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')
end

Add 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')
end

Timeline Entry Scopes

ModelTimeline 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')

PostgreSQL-Specific Features

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.

RSpec Integration

Configuration

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
end

Enabling Timeline in Tests

ModelTimeline 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)
end

RSpec Matchers

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

Matchers for custom models/tables

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'
end

You 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)
end

Then 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')

License

The gem is available as open source under the terms of the MIT License.