DEV Community

Cover image for Building a Custom Audit Trail in Ruby on Rails Without PaperTrail
Nemwel Boniface
Nemwel Boniface

Posted on

Building a Custom Audit Trail in Ruby on Rails Without PaperTrail

Introduction

Auditability in a system is a key feature, and having a way in which you can do that in your application will definitely help a lot. In your system, you need to know who did what, who created what, updated what, deleted what, and when this was done. Please note that for deletes, it is not a hard delete but a “soft” delete option whereby you have a boolean field maybe called “Archived,” which is set to false by default and set to true when you press to delete it (Archive it).

While I know there exist gems that can do this for you, such as the papertrail gem, you may want to reduce the number of dependencies in your application by coming up with your own custom maker checker. This involves adding fields in your database table, such as created_by, deleted_by, modified_by, etc. Look, hear me out, this may sound counterintuitive to add a created_by field, but the aim of making a maker checker is to add all the needed fields.

Prerequisites

  1. Ruby v 3+
  2. Rails V 7/8
  3. Rails app with Devise for Authentication
  4. Some experience with Rails MVC

Let's get started:

Cat getting started to build a custom audit system

Assume that you have a table called modules. To add the auditable fields, run the following migration to create a migration file that we will update below:

rails g migration AddAuditFieldsToModules

Update your migration file to look like the one shown below:

class AddAuditFieldsToModules < ActiveRecord::Migration[7.2]
  def change
    change_table :modules do |t|
      # Audit fields
      t.references :created_by, type: :uuid, foreign_key: { to_table: :users }
      t.references :modified_by, type: :uuid, foreign_key: { to_table: :users }
      t.references :deleted_by, type: :uuid, foreign_key: { to_table: :users }
      t.datetime :deleted_on

      # Archive status
      t.boolean :archive_status, default: false, null: false

      # Indexes
      t.index :archive_status
      t.index :deleted_on
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Run rails db:migrate to update your Schema file.

The next step would be to create a Current model inside app/models/current.rb, which inherits from ActiveSupport::CurrentAttributes and update it as follows:

class Current < ActiveSupport::CurrentAttributes
  attribute :user
end
Enter fullscreen mode Exit fullscreen mode

The Current class file is needed to provide a thread-safe way to access the current user (or other request-specific attributes) throughout your application, particularly in models where you normally can't access controller-level information like the current_user devise helper method. By using ActiveSupport::CurrentAttributes, it creates a globally accessible but request-specific storage that your Auditable concern can use to automatically track who created, modified, or deleted (In our case, a delete is actually an archive) records. This maintains clean separation of concerns while ensuring your audit trail always captures the correct user, whether the action originates from controllers, background jobs, or other parts of your application. The Current pattern is the Rails-recommended way to handle this cross-cutting concern without polluting your models with controller logic.

Our next step will be creating an auditable concern module inside app/models/concerns/auditable.rb file and add the following code to it:

module Auditable
  extend ActiveSupport::Concern

  included do
    # Associations
    belongs_to :created_by, class_name: 'User', optional: true
    belongs_to :modified_by, class_name: 'User', optional: true
    belongs_to :deleted_by, class_name: 'User', optional: true

    # Scopes
    scope :active, -> { where(archive_status: false) }
    scope :archived, -> { where(archive_status: true) }

    # Callbacks
    before_create :set_created_by
    before_save :set_modified_by
  end

  # Instance method for archiving
  def archive!
    update(
      archive_status: true,
      deleted_by: Current.user,
      deleted_on: Time.current,
      modified_by: Current.user
    )
  end

  # Instance method for unarchiving
  def unarchive!
    update(archive_status: false, modified_by: Current.user)
  end

  private

  def set_created_by
    self.created_by ||= Current.user
    self.modified_by ||= Current.user
  end

  def set_modified_by
    return unless !new_record? && Current.user
    self.modified_by = Current.user
  end
end
Enter fullscreen mode Exit fullscreen mode

Ok, ok, I know that this may seem like a lot, but we are mostly done now, you can relax.

Cat getting started to build a custom audit system

The Auditable concern provides automatic tracking of record authorship and changes through a complete audit trail system. It adds three user associations (created_by, modified_by, deleted_by) to any model that includes it, along with scopes for filtering active/archived records. Using Rails callbacks, it automatically stamps records with the current user (via Current.user) during creation, updates, and soft deletion (archiving). The concern also provides explicit archive! and unarchive! methods that handle soft deletion while maintaining referential integrity and a complete change history. This creates a self-contained audit system that tracks the complete lifecycle of records while keeping the implementation DRY and consistent across different models. The soft delete functionality preserves data by setting an archive_status flag rather than physically removing records.

In case you may still need a way to perform a hard delete, you can extend the functionality by adding this to your Auditable concern. Please note that this may be counterintuitive, as the record will no longer be there. So, setting the "deleted_on" or "deleted_by" will not be there as the record will be gone.

# Callbacks
    before_destroy :set_destroyed_by

  def set_destroyed_by
    self.deleted_on = Time.current
    self.deleted_by = Current.user
    self.archive_status = true # Archive when destroyed
  end
Enter fullscreen mode Exit fullscreen mode

Inside our module.rb model file, we shall add the following to first include the auditable module, including all its functionality, adding it into our module model class, and using the scope to create a query shortcut that automatically filters for non-archived records.

# Auditability concern
  include Auditable
Enter fullscreen mode Exit fullscreen mode

Our setup is mostly complete, and you do not need to make any changes to your controller logic and The only place you need to make changes is in your destroy action, as shown below, which instead of performing an actual hard delete, calls the archive method, which we defined in our auditable concern earlier:

def destroy
    @module.archive! # Soft delete / archive
    respond_to do |format|
      format.html { redirect_to modules_url, notice: "The Module '#{@module.name}' was successfully deleted." }
      format.json { head :no_content }
    end
  end 
Enter fullscreen mode Exit fullscreen mode

So, why are we re-inventing the wheel?

Cat getting started to build a custom audit system

So, why would someone want to use this approach as opposed to using pre-existing gems such as PaperTrail?

  1. Eliminating dependencies caused by using gems. While some gems are very useful, some may stop receiving updates, which may mean, further down the line, you may be forced to migrate to a custom auditability form or move to another gem
  2. It is in line with the native Rails way of doing things; you probably don't need those gems if you can implement it custom. Such a custom approach is generally faster than using PaperTrail by upto 2-3 times, reducing the amount of “magic” happening in your code.
  3. There is complete transparency of what is going on, what is being collected, and how everything works. This is unlike when using a gem, and there is some “magic’ that happens behind the scenes, and you do not fully understand how it works in the first place.
  4. To have control over the data you want to collect/ track in your system. While some gems are very useful for a quick fix for data collection, some may be designed to collect an excessive amount of data, which may not be needed and may definitely increase the database costs. Customization allows you to collect the least amount of data, only the ones that you need, and nothing more.
  5. This approach incorporates query efficiency in situations where you would like to know who did what; you just get it in the table columns instead of doing complex queries, making your application data load faster.
  6. For scenarios where you are using cloud storage, this will help you to avoid using cloud offerings such as AuditLog, which charges per event. This in place saves on costs for the company.

When it is better to use existing solutions:

Cat getting started to build a custom audit system

While the pros of a custom approach are undeniable, there exist scenarios where you may need to use pre-existing gems as opposed to a custom solution like ours:

  1. It may be a regulatory Requirement for the company. For example, healthcare systems bound by HIPAA may require complete object versioning with cryptographic signing of all changes.
  2. In situations where you need to perform cross-model Search in your system. This occurs when you need to search audit trails globally.
  3. In a situation where you need a Full Change History in your system. Gems such as PaperTrail have reify for undo functionality
  4. When a separation of concerns is needed. This is where the production database and the audit database are in two separate databases, keeping everything neat and maintainable.

Areas for Improvement (What Could Be Better)

While this approach is clean and avoids extra dependencies, there are several considerations to keep in mind if you want to make it production-ready:

  1. Sometimes your system performs certain tasks, such as background jobs, where in such scenarios, there usually isn’t a current_user value, and it will be nil, which would make auditing hard. In such situations, assigning such changes to a dedicated System user account when no explicit user is available would be a safe workaround. For example:
def set_created_by
    # Assign the system user if no current user is present
    user = Current.user || User.find_by(email: 'system@yourdomain.com')
    self.created_by ||= user
    self.modified_by ||= user
  end
Enter fullscreen mode Exit fullscreen mode
  1. When working with Rails APIs and you are exposing some of the audit fields, such as created_by or modified_by, directly through JSON APIs, you may unintentionally leak personal information (e.g., user IDs, emails). From a compliance standpoint, you may need to consider whether those fields should be public, anonymized, or restricted to admin roles.

Alternatives & Extensibility

Just as Rails has some best practices for using the framework effectively, below are my best practices rules, which I highly recommend considering when choosing to have a custom audit mechanism for your application, may it be a custom audit tooling or a ready-made software:

  1. It is best to focus on capturing the changes, and not just actors: While I am aware that my current approach only records who changed a record, but not what they changed, I would recommend extending the functionalities with a changes_log JSON column or an associated audit_logs table that stores the changes made.
  2. Embracing database-level auditing: databases such as PostgreSQL offer native auditing solutions using triggers or extensions such as pg_audit, which are battle-tested solutions that can help you to log all operations at the database layer.
  3. KISS (Keep it simple, stupid) and choose a hybrid approach of just recording “who/when” directly in your model (just as in my demonstrated examples), combined with a dedicated audit log table or external log system for mission-critical changes.

Auditing may feel daunting at first. However, knowing what amount of data you want to collect is the first step. Don’t sweat it.

Conclusion

The choice ultimately depends on your application’s specific requirements. However, using the custom approach as I have demonstrated is an excellent fit for most mid-sized applications, prioritizing maintainability and performance.

If you need a lightweight, Rails-native auditing functionality, this approach works perfectly well. However, if you need full versioning, support for cryptographic trails, or cross-model searching capabilities, it would be better to stick with PaperTrail or database-level solutions.

This is the end of the Creating custom audit logs in Ruby on Rails

I hope this was useful information for you. See you in my next article.

Top comments (0)