Choosing the Right Audit Trail Approach in Ruby

Ruby gems such as PaperTrail and Audited have been downloaded over a hundred million times and are becoming table stakes in many applications. The Ruby ecosystem offers a wide range of useful tools for building an audit trail, each with its respective pros and cons.

What is an audit trail?

An audit trail (audit log) is a chronological set of records representing documentary evidence of system activities. There are many use cases and benefits to having an audit trail, here are some examples:

Ruby audit trail solutions

Let’s explore and compare the following approaches to building an audit trail and decide which one of these to choose:


Callback-Based Solutions

PaperTrail and Audited are very popular gems that integrate with the ActiveRecord object-relational mapper (ORM) by using model callbacks to allow auditing data changes.

When a record is created, updated, or deleted, they insert an additional record that stores changes in a single audit table. This table stores the before/after state in JSON or JSONB formats and a reference pointing to the original record.

This approach is implemented purely on the application level and can be easily enabled for any ActiveRecord-supported database such as PostgreSQL, MySQL, or SQLite.

class MyModel < ApplicationRecord
  has_paper_trail
end

For MongoDB, Mongoid History gem works similarly and integrates with Mongoid, the officially supported object-document mapper (ODM).

They also allow passing and storing application-specific context with changes, such as a user who performed the changes or an API request where the changes were triggered:

# User
Audited.audit_class.as_user(current_user) do
  # Additional context
  audit_comment = { endpoint: "#{request.method} #{request.path}" }.to_json

  my_record.update!(published: true, audit_comment: audit_comment)
end

Pros

Cons

Trigger-Based Solutions

Logidze leverages the PostgreSQL triggers functionality and creates a new log_data JSONB column in each auditable table.

When a record is created or updated, PostgreSQL executes a row-based trigger which takes the current values of the record and appends them in the log_data column in a separate SQL query within the same transaction behind the scenes. Here is an example of the log_data:

{
  "v": 2, // current record version,
  "h": [  // list of changes
    {
      "v": 1,                          // change version
      "ts": 1460805759352,             // change timestamp
      "c": { "published": false }      // new values
      "m": {
        "_r": 42,                      // User ID
        "endpoint": "POST /my_records" // Additional context
      }
    },
    ...
  ]
}

It also allows passing and storing application-specific context with ActiveRecord changes, for example:

# User ID
Logidze.with_responsible(current_user.id) do
  my_record.update!(published: true)
end

# Additional context
Logidze.with_meta({ endpoint: "#{request.method} #{request.path}" }) do
  my_record.update!(published: true)
end

Pros

Cons

Replication Log-Based Solutions

Bemi Rails uses the native PostgreSQL replication log called Write-Ahead Log (WAL) which records all changes before they are flushed on a disk. 

Traditionally, the PostgreSQL WAL is used for data recovery after a database crash by replaying records or replicating changes to standby read replicas. Bemi uses the same functionality:

# Custom context
Bemi.set_context(
  user_id: current_user.id,
  endpoint: "#{request.method} #{request.path}",
)

# Data change that will be passed with the context into the PostgreSQL WAL
my_record.update!(published: true)

Pros

Cons

Full disclosure: I’m one of the Bemi core contributors. Check out our Bemi.io cloud platform if you want to enable an automatic audit trail without the need to manage the infrastructure and deal with scalability issues yourself.

Manual Tracking

PublicActivity is a gem similar to callback-based solutions that track data changes. Its main difference is that it also allows creating custom activity events for database records that can be serialized and translated with Rails i18n.

my_record.create_activity(
  key: 'my_model.commented_on',
  owner: current_user
)

Ahoy allows tracking and collecting analytics data in a Ruby on Rails application. It is similar to, for example, automatic page visit tracking in Google Analytics. But it can also record custom events in controllers.

def update
  ahoy.track('Updated', endpoint: "#{request.method} #{request.path}")
  MyModel.find(params[:id]).update!(published: true)
end

Pros

Cons

Console Command Logging

Console1984 forces developers to specify a reason when they load a Rails console and record it with all executed console commands by storing them in a database.

$ rails c

Bob, why are you using this console today?
> Migrating customer data, see ticket #781923

> user = User.find(...)
...

In a Rails console, it breaks down a session into two access modes. One is the regular “protected” mode available after specifying a Rails console access reason. The other one is the “sensitive” mode which requires additional explicit consent when accessing sensitive information, such as executing a method that decrypts sensitive data or making external HTTP requests.

It also comes with the UI after installing the Audits1984 gem. This gem allows reviewing console sessions by approving or flagging them and leaving comments.

Pros

Cons

Custom Logging

Ruby on Rails logging functionality allows logging anything in any text format.

payload = {
  user_id: current_user.id,
  endpoint: "#{request.method} #{request.path}",
}

Rails.logger.info("CUSTOM_LOG_MESSAGE: #{payload.to_json}")

Starting with Ruby on Rails version 7, previously with the Marginalia gem, it is also possible to pass custom application context via ActiveSupport::CurrentAttributes with ActiveRecord logs.

Current.user = current_user.id
Current.endpoint = "#{request.method} #{request.path}"

config.active_record.query_log_tags = [
  {
    user_id: -> { Current.user.id },
    endpoint: -> { Current.endpoint },
  },
]

MyRecord.all
# Account Load (0.3ms)  SELECT `my_records`.* FROM `my_records`
# /*user_id:1,endpoint:POST /my_records*/

Pros

Cons


Conclusion

There is a large number of Ruby gems available that can help with building an audit trail. As a rule of thumb, you can choose the right tool or a combination depending on your needs:


Also check out the new Ruby Rogues podcast episode where we talk about tools, patterns, and techniques for data recovery in more detail. ММЦ Плюс — услуги