Jeremy Smith on November 22, 2021

Audit Logging in Rails

In simple, CRUD-only Rails applications, the records in your database represent the current state of your system, but don’t tell you much about it’s history. You can see when a record was created, and when it was last updated, but you have no way of knowing how many times it was updated, or by whom, or what changes were made. You also don’t know what records were deleted, unless you check for missing sequential IDs. And even then, you don’t know who deleted those records, when they were deleted, or what they contained.

As an application grows in complexity and importance, it’s common to reach a point where you need to begin tracking what changes are made to data in the system, and who makes those changes. When many users have the ability to change the same data, understanding who is responsible for what changes can be essential. One common technique to address this is using audit logs, or audit trails. Audit logs are a set of records that document the changes made to other data in a system, along with the user who made those changes, and the time those changes were made.

Review of Audit Logging Gems

There are a number of Ruby gems available that can be used for audit logging. We’ll look at five of the most popular below.

When evaluating these solutions, there are a few distinguishing characteristics I’m going to consider:

  1. How are audit logs stored, and what data is captured?
  2. What triggers the creation of audit logs?
  3. How is the user who made the change recorded?

PaperTrail

Track changes to your models, for auditing or versioning. See how a model looked at any stage in its lifecycle, revert it to any version, or restore it after it has been destroyed.

PaperTrail stores audit logs in a separate versions table and tracks:

  • item: the ActiveRecord model that was changed
  • event: the change event (create, destroy, update)
  • whodunnit: the user that made the change
  • object: the attributes from the changed record serialized into one column

When you add the has_paper_trail method to an Active Record model, PaperTrail will use the following model callbacks to trigger creation of a record in the versions table: after_create, after_update, after_touch, and before_destroy (by default).

PaperTrail uses thread local variables to store whodunnit using the RequestStore gem.

Audited

Audited (previously acts_as_audited) is an ORM extension that logs all changes to your models. Audited can also record who made those changes, save comments and associate models related to the changes.

Audited stores audit logs in a separate audits table and tracks:

  • auditable: the ActiveRecord model that was changed
  • user: the user that performed the change; a string or an ActiveRecord model
  • action: one of create, update, or delete
  • audited_changes: a hash of all the changes
  • comment: a comment set with the audit
  • version: the version of the model
  • request_uuid: a uuid based that allows audits from the same controller request

When you add the audited method to an Active Record model, Audited will use the following model callbacks to trigger creation of a record in the audits table: after_create, before_update, before_destroy.

Audited uses thread local variables to store current_user and whether or not auditing is enabled.

PublicActivity

public_activity provides easy activity tracking for your ActiveRecord, Mongoid 3 and MongoMapper models in Rails 3.0 - 5.0. Simply put: it records what has been changed or created and gives you the ability to present those recorded activities to users - in a similar way to how GitHub does it.

PublicActivity stores audit logs in a separate activities table and tracks:

  • trackable: the ActiveRecord model that was changed
  • owner: the user that made the change
  • key: the change event, which can be custom-defined
  • parameters: a hash of attributes that will be stored with the log
  • recipient: the user targeted by the change

When you add the tracked method to an Active Record model, PublicActivity will use the following model callbacks to trigger creation of a record in the activities table: after_create, after_update, before_destroy.

PublicActivity uses thread local variables to store the whole controller and make it available in the model inside the tracked method.

Logidze

Logidze provides tools for logging DB records changes when using PostgreSQL (>=9.6). Just like audited and paper_trail do (but faster).

Unlike the gems above, Logidze stores audit logs in a log_data JSONB column added to each table being tracked, containing the current record version, the list of changes, and the user who made the changes (responsibility_id).

Logidze allows you to create a DB-level log (using triggers) and gives you an API to browse this log. The log is stored with the record itself in JSONB column. No additional tables required.

Logidze uses thread local variables to store metadata like responsible user. Unlike the gems above, you must wrap record saves in a block, like this:

Logidze.with_responsible(user.id) do
  post.save!
end

Since changes are stored in a column on the record, Logidze cannot not track deletions, unless you are using a soft-delete tool:

Unlike, for example, PaperTrail, Logidze is designed to only track changes. If the record has been deleted, everything is lost.

If you want to keep changes history after records deletion as well, consider using specialized tools for soft-delete, such as, Discard or Paranoia.

AuditLog

Trail audit logs (Operation logs) into the database for user behaviors, including a Web UI to query logs.

AuditLog stores audit logs in a separate audit_logs table and tracks:

  • action: the custom-defined change event
  • user_id: the user that made the change
  • record: the ActiveRecord model that was changed
  • payload: a hash of attributes that will be stored with the log
  • request: a hash of attributes from the web request (e.g. User Agent, IP Address, etc.) that will be stored with the log

AuditLog does not use ActiveRecord callbacks or database triggers to create audit logs–they must be created explicitly. For example, you might call the audit! method directly from a controller, like this:

class TicketsController < ApplicationController
  def update
    if @ticket.save
      audit! :update_ticket, @ticket, payload: ticket_params
    else
      render :edit
    end
  end
end

Since the audit log records are created directly from the controller, AuditLog doesn’t need to use thread local variables to temporarily store and then retrieve the user or the request parameters.

Rolling Our Own Solution

A few months ago, I needed to add audit logging to a client’s Rails application. Here were my main considerations:

  1. My client was cautious (and rightly so) about adding new dependencies. I only wanted to reach for a gem if it provided significant value.
  2. The audit logs needed to be stored with a context. In this app, audit logs needed to track changes on all records hanging off a main record, like an Account record. Audit logs would be viewed in the context of that main record, and so being able to scope queries by that record would be very useful.
  3. At this point, I didn’t need to be able to see what changed on a record, only the type of change (add, update, delete), the time of the change, and what user made the change.
  4. I didn’t need to be able to diff, revert, or undelete a record.
  5. I wanted to avoid ActiveRecord callbacks and impacting performance in the request/response cycle, if possible.

After considering all the gem options above, I decided to roll my own solution. I wanted to take a similar approach to the AuditLog gem, but process audit logs in the background with Sidekiq. Here’s the approach I took.

First, I created a new audit_logs table in Postgres:

  • auditable is a polymorphic reference to the record being audit logged (allowing null, since the record may be deleted)
  • account is a reference to the account the audited record is under
  • user is a reference to the user who made the change
  • event is the change type (using Postgres enumerated types)
  • created_at is the datestamp for the audit log (there’s no need for an updated_at)
class CreateAuditLogs < ActiveRecord::Migration[6.1]
  def up
    execute <<-SQL
      CREATE TYPE audit_log_event AS ENUM ('create', 'update', 'destroy');
    SQL

    create_table :audit_logs do |t|
      t.references :auditable, polymorphic: true, null: false, index: true
      t.references :account, null: false, foreign_key: true, index: true
      t.references :user, null: false, foreign_key: true, index: true
      t.column :event, :audit_log_event, default: "create", null: false
      t.datetime :created_at, null: false
    end
  end

  def down
    drop_table :audit_logs

    execute <<-SQL
      DROP TYPE audit_log_event;
    SQL
  end
end

The ActiveRecord model looked like this:

class AuditLog < ApplicationRecord
  # auditable is optional because it may be destroyed when record is created
  belongs_to :auditable, polymorphic: true, optional: true
  belongs_to :account
  belongs_to :user

  enum event: { created: "create", updated: "update", destroyed: "destroy" }, _suffix: true
end

Next, I created a Sidekiq worker to create AuditLog records:

class AuditLogWorker
  include Sidekiq::Worker

  def perform(auditable_gid, account_id, user_id, event)
    auditable = parse_global_id(auditable_gid)
    account = Account.find_by(id: account_id)
    user = User.find_by(id: user_id)

    AuditLog.create!(auditable_type: auditable.model_name, auditable_id: auditable.model_id, account: account, user: user, event: event)
  end

  private

  # Use GlobalID.parse instead of GlobalID::Locator.locate because the record
  # may be destroyed and we don't want to raise ActiveRecord::RecordNotFound
  def parse_global_id(gid)
    GlobalID.parse(gid)
  end
end

I then added a helper method in the ApplicationController as a common interface to use throughout all other controllers:

def audit_log(auditable, account, event)
  AuditLogWorker.perform_async(auditable.to_global_id, account.id, current_user.id, event)
end

Now I could use that audit_log helper method in controller actions, like this:

def create
  @note = Note.new(note_params)
  if @note.save
    audit_log(@note, @note.account, "create")
    redirect_to note_path(@note), notice: t(:note_created)
  else
    render "new"
  end
end

One catch was that, for update events, I needed to ensure that something had actually changed on the record, using #saved_changes?:

def update
  if @note.update(note_params)
    audit_log(@note, @note.account, "update") if @note.saved_changes?
    redirect_to note_path(@note), notice: t(:note_updated)
  else
    render "edit"
  end
end

With all of the above in place, I was able to create a page where users could view the audit logs for the given account, using a controller like this:

class AuditLogsController < ApplicationController
  def index
    @audit_logs = current_account.audit_logs.includes(:auditable, :user).order(created_at: :desc)
  end
end

I was pretty satisfied with this lightweight approach, but there are a few things I’d like to do to improve this solution:

  1. The #saved_changes? check could become a guard clause in the audit_log helper method, so that it doesn’t have to be repeated in every update action.
  2. It would be best to pass the timestamp of the record change to the background job, since the audit log’s created_at will be set to the time the job finally runs by default, which could be some time later.
  3. I’m expecting to eventually need to store a snapshot of the changed record’s attributes. So, I could take an approach like the one described in the AuditLog Usage docs, and pass the request params or the model attributes to a payload argument, and store that hash in a new field on the audit_logs table.

Here’s what those changes might look like.

First, the worker perform method would need to be adjusted to accept a timestamp and payload hash (which requires adding a payload column to the audit_logs table):

def perform(auditable_gid, account_id, user_id, event, timestamp, payload)
  auditable = parse_global_id(auditable_gid)
  account = Account.find_by(id: account_id)
  user = User.find_by(id: user_id)

  AuditLog.create!(
    auditable_type: auditable.model_name,
    auditable_id: auditable.model_id,
    account: account,
    user: user,
    event: event,
    payload: payload,
    created_at: timestamp
  )
end

Next, the audit_log helper method would need to be updated to add the guard clause for the update event, as well as passing the timestamp and optional payload:

def audit_log(auditable, account, event, payload = {})
  return if event == "update" && !auditable.saved_changes?

  AuditLogWorker.perform_async(auditable.to_global_id, account.id, current_user.id, event, Time.current, payload)
end

Finally, the controller action would be adjusted slightly, to pass the request params, with the conditional check moved to the helper method:

def update
  if @note.update(note_params)
    audit_log(@note, @note.account, "update", note_params)
    redirect_to note_path(@note), notice: t(:note_updated)
  else
    render "edit"
  end
end

What Solution Should You Choose?

So after all this, what solution is best for you? As with most things, the short answer is: “it depends.” For a longer answer, here are my recommendations, based on various conditions.

Choose PaperTrail if you want to use the most popular solution, with a high level of usage and maintenance, and a large set of features, including versioning, reverting, and diffing.

Choose Audited if you want a popular solution, with a high level of usage (from what I can tell), but a smaller set of features.

Choose PublicActivity if you need support for Mongoid 3 or MongoMapper, if you want to build something more like an activity feed, or if you need to support custom activities beyond create/update/destroy.

Choose Logidze if you are using Postgres and have a good understanding of its features, app performance is a big consideration, you don’t want a separate table for audit logs, and you either don’t care about deletions or use a soft-delete approach.

Choose AuditLog if you prefer not to use ActiveRecord callbacks, but would rather explicitly create audit logs and specify the hash of attributes to store in the payload.

Choose to roll your own if you would prefer not to add a new dependency to your app, you need more control over the audit log schema, and your feature requirements are minimal.


Need help building or maintaining a Rails app?

Jeremy is currently booked until mid-2023, but always happy to chat.

Email Jeremy