The PaperTrail gem in Ruby on Rails provides an audit log of changes to specified models. It does this by hooking into the ActiveRecord object life cycle and when a model receives a change, the original state of that data is mirrored to another table canonically named “versions”. Overall PaperTrail is:
- Easy to understand
- Simple to add to your application
- Fast in production
My one objection to PaperTrail is that it injects — into your core database — its own table — without name spacing that table. That means that if you already have a table named “versions”, well, you’re mildly hosed.
And, as you might correctly suspect, I just hit this scenario on a consulting project for a client.
Here are the work arounds:
Step 01: Be Able to Install PaperTrail Migration without Conflicts
All databases in Rails uses an underlying technology called migration files to manage the state of the database. A migration file is just a Ruby class which creates or updates a table. And, yes, that migration file can do anything Ruby can do but, really, you want migrations to be single responsibility objects — create a table, delete a table or modify a table.
The way you install PaperTrail is to bundle the gem as follows:
bundle add paper_trail |
Note: There is also a papertrail gem; you want the one with the _ as papertrail is something else.
The bundle add command inserts the gem into the Gemfile and sets the current version spec.
One you’ve installed the gem, it exposes a command to create the migration file that you need. PaperTrail’s migration is installed with a command like this:
bundle exec rails generate paper_trail
:install
"[--with-changes]"
Note: Above I have to have the [–with-changes] in double quotes because I run zsh. Your mileage may vary based on your shell.
If you have an existing table named versions then you are going to get an error stating that you already have a versions table. You can get around this but you need to change the name of the migration file for your version migration. The reason for this is Rails checks the filename of every migration when something is generated and tries to avoid conflicts. Temporarily renaming your migration file fixes this issue.
The workaround is as follows:
- Rename your existing migration to something like:
- Original: 20211014100415_create_versions.rb
- New: 20211014100415_create_our_versions.rb
- This allows the Rails generator to run and create a migration that you can now rename to something like:
- Original: 20221026163642_create_versions.rb
- New: 20221026163642_create_paper_trail_versions.rb
- Modify the file 20221026163642_create_paper_trail_versions.rb (your date stamp will obviously vary) so that the table name is something different than versions. Personally I found “paper_trail_versions” to be what the paper_trail gem should have done since THE BEGINNING OF TIME. But that’s just me.
- Rename the create_our_versions migration back to its original state.
- Run a bundle exec rake db:migrate
Step 02: Create a Class for the New Table
You now need to create a new class to represent the table:
The body of this class need to be:
class PaperTrailVersion < PaperTrail::Version
self.table_name = :paper_trail_versions
end
Step 03: Add PaperTrail to Existing Models
Now that the table name has been changed and you have the right class, you can start using PaperTrail. PaperTrail only audits models you specifically configure for auditing so you need to add this code snippet to each model:
has_paper_trail versions: {
class_name:
'PaperTrailVersion'
}
Add this to the top of each model near where you have declarations like has_many or belongs_to (it doesn’t matter exactly where it goes; putting it near the top is just a matter of good form).
Step 04: Confirm PaperTrail Works When You Use Your Application
The testing process for PaperTrail is really simple:
- Find an object.
- Modify the object.
- Save the object.
- Verify if the object’s original state went into the paper_trail_versions table.
Here is a Rails console session to illustrate this:
❯ be rails c
Loading development environment (Rails
6
.
0
.
3
.
5
)
2
.
7
.
2
:
001
> g = Glossary.first
Glossary Load (
1
.6ms)
SELECT
"glossaries"
.*
FROM
"glossaries"
ORDER
BY
"glossaries"
.
"id"
ASC
LIMIT
$1
[[
"LIMIT"
,
1
]]
#<Glossary:0x00007fec48bda6f0> {
:id
=>
1
,
:term
=> {}
}
2
.
7
.
2
:
002
> g.term = {
"foo"
=>
"bar"
}
{
"foo"
=>
"bar"
}
2
.
7
.
2
:
003
> g.save
(
0
.1ms)
BEGIN
Glossary Update (
0
.4ms)
UPDATE
"glossaries"
SET
"term"
=
$1
WHERE
"glossaries"
.
"id"
=
$2
[[
"term"
,
"{\"foo\":\"bar\"}"
], [
"id"
,
1
]]
PaperTrailVersion Create (
4
.9ms)
INSERT
INTO
"paper_trail_versions"
(
"item_type"
,
"item_id"
,
"event"
,
"object"
,
"created_at"
)
VALUES
(
$1
,
$2
,
$3
,
$4
,
$5
)
RETURNING
"id"
[[
"item_type"
,
"Glossary"
], [
"item_id"
,
1
], [
"event"
,
"update"
], [
"object"
,
"---\nid: 1\nterm: \"{}\"\n"
], [
"created_at"
,
"2022-10-27 13:32:08.765389"
]]
(
1
.0ms)
COMMIT
true
You can see that when the glossary was updated, an entry went into the paper_trail_versions table.
Step 05: Administering Your PaperTrail Installation with ActiveAdmin
PaperTrail is brilliant but it is purely a back end technology — it handles the auditing of changes — it does not provide a way for you to look at them. Happily any Rails application that uses the common ActiveAdmin gem can also administer PaperTrail with a few changes.
Note: Below I use the term version table for the table that stores the different versions of your objects; that might be the original table named “versions” or my change to “paper_trail_versions”.
Viewing the Version Table
Here’s an ActiveAdmin class which lets you see the underlying version table:
ActiveAdmin.register PaperTrailVersion
do
menu parent:
'Admin'
actions
:index
,
:show
end
By only allowing :index and :show actions, the ability to modify the underlying audit log is suppressed — which is what you want.
Note: Audit trails that are editable, by definition, aren’t an actual audit trail so you want to suppress the ability to make changes.
The whodunnit Field
The whodunnit field in your version table stores the id number of the user who made the change to the object.
As long as your application relies on the current_user convention then all you need to store a user_id in this field is to add this next line to your application_controller:
before_action
:set_paper_trail_whodunnit
Note: Even though it makes no sense whatsoever, I found that until I restarted my Puma server, this change did not take affect.
Configuring ActiveAdmin to Show Username Not Just an ID
The version table just shows you a whodunnit field which is just a identifier (the id column) of whoever did the change that the version table tracks. And, yes, this isn’t actually useful. There are two things to do to make ActiveAdmin display something useful like the user’s email address:
- You need an email method. This is an instance method on the version model (so in my case paper_trail_version.rb) which looks up the user by id and then returns the data needed. Here was mine:
def
email
u = User.where(id: whodunnit).select(
"email"
).first
if
u
return
u.email
end
'Unknown User'
end
ActiveAdmin.register PaperTrailVersion
do
menu parent:
'Admin'
actions
:index
,
:show
index
do
column
:id
column
:item_type
column
:item_id
column
:event
column(
"User Id"
) { |v| v.whodunnit }
column(
"User Email"
) { |v| v.username }
column(
"Modified at"
) { |v| v.created_at.to_s
:long
}
actions
end
end
See Also:
These links were very helpful to me:
If you found this helpful or insightful and would like to check out more visit my professional blog here!
Additionally, if you need help with your Ruby on Rails product or project and are looking for a knowledgeable team to get the job done, reach out to Glass Ivy today.