Choosing the Right Audit Trail Approach in Ruby

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:

  • Disaster recovery: selectively find and restore historical records
  • Customer observability: save time tracking customer activity
  • Regulatory compliance: track data access and simplify audits
  • Fraud detection: identify fraudulent or malicious user activity
  • Enterprise table stakes: allow monitoring activity in an organization
  • Engineering on-call: quickly understand reasons behind data changes

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, Audited, Mongoid History
  • Trigger-based solutions: Logidze
  • Replication log-based solutions: Bemi Rails
  • Manual tracking: PublicActivity, Ahoy
  • Console command logging: Console1984, Audits1984
  • Custom logging: Marginalia

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

  • Easy to get started. An audit trail can be enabled by installing a gem and configuring it with a few lines of code.
  • Customization. It’s possible, for example, to use custom serializers for the before/after state or add a complex condition for disabling tracking.

Cons

  • Reliability and accuracy. Many ActiveRecord methods such as delete, update_column, update_all, delete_all, and so on don’t trigger callbacks. Thus, changes produced by these methods can’t be tracked. Additionally, inserting data changes does not always happen atomically, which may lead to data loss and inconsistency if, for example, there is a network issue.
  • Performance. The database workload increases by roughly 2x because each single record change produces an additional database query that inserts an audit record. This affects the application and database performance.
  • Scalability. A single audit table can get very large. I’ve seen cases where such tables ran out of integers used for primary keys. A large table makes it harder to manage and query at scale while also significantly increasing database resource usage and costs.

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

  • Improved performance. It can be almost 100% faster than callback-based solutions for record inserts and about 10% faster for updates. It still makes additional database queries on each record change, but they’re triggered on the database level skipping ActiveRecord.

Cons

  • No delete tracking. Because audit logs are attached and stored with an original record, deleting the record will lead to losing its entire change history. To overcome this limitation, you can use callback-based solutions designed for marking records as soft-deleted and ignoring them when querying, such as Paranoia, Discard, or ActsAsParanoid. But if you decide to use them, be careful and make sure to read our blog post about their danger and how they can lead to critical incidents.
  • Data structure. The data structure can be hard to work with and query directly. The field names in JSON are shortened to 1-2 characters to save disk space but this worsens the readability. Selecting records with the included JSON can significantly decrease database query performance because of PostgreSQL TOAST, so be careful with SELECT * ... SQL statements. It’s also difficult to construct, for example, a timeline of all changes across multiple records without knowing and fetching them in advance.
  • Complexity. Understanding and changing the code for complex triggers with hundreds of lines in SQL can be challenging. Just a single mistake in an SQL function can break all queries. The context passing can also be tricky. For example, if you use PostgreSQL with a connection pooler such as PgBouncer, you need to wrap your queries into a transaction because Logidze relies on PostgreSQL local parameters. But at the same time, if you use transactions, it’s impossible to pass application context to changes that are triggered after “commit” Rails callbacks.

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:

  • Bemi Core connects to the PostgreSQL WAL like a standby replica. It ingests and logically decodes all changes asynchronously and then stores them with the before/after states.
  • Bemi Rails allows setting the application context and passing it directly to the WAL with data changes without the need to update the database structure.
# 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

  • Reliability and accuracy. The PostgreSQL WAL is the source of truth for all data changes. Data changes will be captured even when they are produced by executing a direct SQL query within or outside the application.
  • Performance. Audit logs are ingested asynchronously without affecting runtime performance. The application context is passed to the WAL directly from the application, but it has a minimal performance impact since it doesn’t get stored and processed as regular PostgreSQL data.

Cons

  • Infrastructure complexity. Ingesting logically decoded changes requires running a separate worker process that connects to the database’s replication log. This can be similar to or even more challenging than trying to run a self-managed database replica instance in a cluster. For example, this solution requires creating a replication slot and maintaining the ingested position in the WAL, implementing heartbeats, ingesting and serializing logically decoded WAL records, stitching them with application context, etc.
  • Scalability. Similarly to the callback-based solutions, all audit records are stored in a single table. At scale, this table can become difficult to query and costly to manage.

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

  • Versatility. Creating custom audit trail records manually can be useful when it is necessary to record activities that didn’t change data or didn’t map 1-to-1 to records’ data changes.

Cons

  • Cumbersomeness. These solutions require manually triggering all actions that need to be recorded and updating the codebase in many places. This can be time-consuming and can increase code complexity. It is also easy to forget to trigger the right action, which may lead to an incomplete audit log.
  • Flexibility. While these solutions give some manual control, they are limited either to controller actions or ActiveRecord models. This may not be flexible enough to record all system activities. For example, recording that a background processing job made changes via an API request in an external system, such as a payment processing service.

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

  • Auditable console sessions. Manually executed commands by developers can automatically be logged and reviewed later.

Cons

  • Loose control. In Ruby, it is very easy to modify any class and method definitions dynamically. It means that someone who has access to a Rails console can find workarounds and execute some commands that won’t be logged. To make logging more reliable and improve internal controls, teams may want to disable production console access and build workflows for running only pre-approved scripts.

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

  • Flexibility. These are the most flexible solutions that allow recording activities in custom format in text logs.

Cons

  • Consumption. Collecting unstructured text logs across all application instances and consuming them might be challenging. Depending on the use case, it might be required to clean up the logs, parse them, and save them in some data storage in a more structured format that allows quick lookups with filters. It might also be required to aggregate the logs by a transaction, an API request, etc. For example, if one log entry says that a record was created by a user, there could be another entry that says that this record creation was not committed and was rolled back.

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:

  • Basic change tracking: if you need it for troubleshooting purposes, you can use Audited, which also allows automatically deleting all audit records keeping only the last N numbers of changes.
  • Change diffing and rollbacks: for basic change tracking with additional features for querying them, PaperTrail is your best choice.
  • MongoDB and Mongoid: if you use Mongoid object-document mapper, then go with Mongoid History.
  • Performance over deletion tracking and simplicity: if you use PostgreSQL, then you can choose Logidze.
  • Reliability with zero performance overhead: if you use PostgreSQL and need complete change tracking accuracy and reliability without runtime overhead, go with Bemi Rails.
  • Simple activity feed UIs: if you need to build a simple activity feed constructed around your records, then go with PublicActivity, which also supports i18n for multi-language interfaces.
  • HTTP request tracking: if you need to track HTTP requests in a structured format, then Ahoy is your best choice.
  • Console session auditing: if you need to log and audit the commands executed in Rails consoles, go with Console1984 and Audits1984.
  • Troubleshooting recent issues: you can use application logs to troubleshoot issues and use Marginalia to automatically annotate log entries to add more context.