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:
- How are audit logs stored, and what data is captured?
- What triggers the creation of audit logs?
- 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:
- My client was cautious (and rightly so) about adding new dependencies. I only wanted to reach for a gem if it provided significant value.
- 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. - 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.
- I didn’t need to be able to diff, revert, or undelete a record.
- 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 anupdated_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:
- The
#saved_changes?
check could become a guard clause in theaudit_log
helper method, so that it doesn’t have to be repeated in everyupdate
action. - 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. - 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 theaudit_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?
Book a one-hour project inquiry call or reach out via email.