Originally posted on Hint's blog.
This is the first in a series of blog posts taking an introductory look at various Ruby gems that we find useful and regularly use or have used in our projects over the years.
Today I am going to cover two related gems: PaperTrail, and PaperTrailScrapbook. These gems facilitate keeping an auditable trail of changes made to rows of a tracked table in your Rails application's database.
PaperTrail
Overview
The PaperTrail gem allows you to track changes made to your Rails models over time. Any time a tracked record in your database is changed, it creates a corresponding entry in a PaperTrail::Version
table. It accomplishes this by tying into the ActiveRecord callback chain and storing a new version when the record is created, updated, or destroyed.
Setup
To start using PaperTrail, you'll need to follow the steps outlined in the README on the project's GitHub page, which essentially guide you through the process of adding the gem to your gemfile, bundling, and then generating and running a migration that creates the PaperTrail::Version
table. This table will be leveraged to store an entry for each "version" of your model.
Use
Once you've got the gem set up, you can start using it. To start tracking versions for a model, you'll just need to add has_paper_trail
to your model file (usually near the top, inside the class):
class MyModel < ApplicationRecord
has_paper_trail
...
end
Now that your versions are being tracked, you will be able to go into your Rails console and view the various versions of your models using PaperTrail's API.
For a specific object, obj
, you can access a collection of its versions using: obj.versions
. This will show you a list of versions that each have this structure:
#<PaperTrail::Version:0x007fe111b03f70> {
:id => 314159,
:item_type => "Invoice",
:item_id => 555,
:event => "update",
:whodunnit => "1234",
:object => "---\nid: 5455\nactor_id: 2183\nsale_id: \namount: !ruby/object:BigDecimal 27:0.1276E3\nauthorization_id: \npaid_on: \ncreated_at: 2018-02-15 17:14:16.718224000 Z\nupdated_at: 2018-02-16 17:14:16.718224000 Z\nstatus: active\n",
:created_at => Mon, 19 Feb 2019 11:59:49 PST -08:00,
:object_changes => "---\nsale_id:\n- \n - 123\npaid_on:\n- \n- 2018-02-20 20:39:19.956256107 Z\nauthorization_id:\n- 412\nupdated_at:\n- 2018-02-19 20:39:19.956256107 Z\n- 2018-02-19 19:59:49.270910041 Z\n"
}
The fields of a version break down like this:
Field | Description |
---|---|
id |
The primary key for this PaperTrail::Version
|
item_type |
Type of record being tracked |
item_id |
The primary key of the record being tracked |
event |
The type of change that occurred |
whodunnit |
The id of the person making the change (you can set the class of the whodunnit in your PaperTrail configuration, ie. User , Actor , etc.) |
object |
A serialized version of the object being updated in its pre-update state |
created_at |
When this version was created |
object_changes |
A serialized list of changes that were applied to the object as shown in the object field of the version |
PaperTrail also provides the ability to revert an object to a previous state. To restore an object to a previous version, you can call reify
(which means to make something abstract more concrete or real) on the version.
For example, using the version from our last example:
last_version = object.versions.last # gets PaperTrail::Version shown above
puts last_version.reify
Invoice 314159 {
:id => 314159,
:actor_id => 2183,
:sale_id => nil,
:amount => 27.0,
:authorization_id => nil,
:paid_on => nil,
:created_at => Mon, 19 Feb 2018 11:59:49 PST -08:00,
:updated_at => Mon, 19 Feb 2018 11:59:49 PST -08:00,
:status => 'active'
}
reify
will give you an instance of the object that corresponds to the version before the object_changes
have been applied, which matches what is in the object
field of the version.
Calling reify
does not affect the object's current state in the database unless you call save
on the reified object. Doing so will persist the reified version to the database, creating a new version for the object in the process.
Caveats
PaperTrail falters a bit when tracking changes to a model's associations. For example, consider an Invoice
class that has_many :line_items
where LineItem
belongs_to :invoice
. If you had an invoice
that has several line_items
associated with it, you could remove the line items by calling some code like:
invoice.line_items = []
invoice.save!
If you did so and took a look at the tracked versions of one of the line_items
that had been associated with the invoice
, you'd see that the removal of the invoice_id
from the line_item
was not captured in a version.
PaperTrail has some information in the project's README mentioning this and suggests some "experimental" workarounds, but your mileage may vary.
In my opinion, PaperTrail's API is a bit cumbersome and does not provide a concise summary of the changes that have been made to a model over time. The information is definitely there, but extracting it into a meaningful, complete history can be painful.
This is where PaperTrailScrapbook comes in.
PaperTrailScrapbook
Overview
PaperTrailScrapbook was conceptualized by Tim Chambers in 2017. Its intent is to provide a human-readable summary of changes made to a model tracked by PaperTrail. It accomplishes this by providing a simple interface to obtain either a complete history of an object, or a list of changes a specific person has made. Disclaimer: I am a contributor on this project :)
Setup
To set up PaperTrailScrapbook, you'll just need to add gem 'paper_trail_scrapbook'
to your Gemfile and bundle your application. The other thing you'll need to do is specify the class of your app's whodunnit
. Most often, this is going to be User
. I'd recommend adding an initializer (e.g. config/initializers/paper_trail_scrapbook.rb
) for PaperTrailScrapbook.
If your whodunnit class is User
, your configuration would look similar to this:
PaperTrailScrapbook.configure do |config|
config.whodunnit_class = User
end
Once you've got the configuration in place, restart your app server and you should be good to go.
Use
PaperTrailScrapbook currently has two modes of use, LifeHistory
and UserJournal
. Each mode provides a story
for a given object or whodunnit
.
LifeHistory
The LifeHistory
module takes a model instance as its argument, and generates a list of changes made to that object over time.
For example, the following query for history:
widget = Widget.find(123)
puts PaperTrailScrapbook::LifeHistory.new(widget).story
Could output the following story:
On Friday, 08 Dec 2017 at 8:01 AM PST, Fred Flintstone <fred@flinstone.com> created the following Widget info:
• name: Tricky Spinner
• description:
• notes: Recommended for ages 3 an up
• created by: Fred Flintstone[123]
• pattern: Widget Template[386]
• price: 12.34
• status: inactive
•: 123
On Wednesday, 20 Dec 2017 at 12:53 PM PST, Barney Rubble <barney@rubble.com> updated the following Widget info:
• name: Recommended for ages 3 an up -> Recommended for ages 3 and up
• description: -> A fun spinning widget to keep your active fingers busy!
• price: 12.34 -> 12.99
On Thursday, 21 Dec 2017 at 12:34 PM PST, Wilma Flintstone <wilma@flinstone.com> updated the following Widget info:
• price: 12.99 -> 11.99
On Wednesday, 03 Jan 2018 at 10:24 AM PST, Betty Rubble <betty@rubble.com> updated the following Widget info:
• name: Tricky Spinner -> Spinning Trick Widget
• price: 11.99 -> 9.99
• status: inactive -> active
On Wednesday, 05 Jan 2018 at 3:24 PM PST, Fred Flintstone <fred@flinstone.com> updated the following Widget info:
• name: Spinning Trick Widget -> Spinning Brontosaurus Widget
• price: 11.99 -> 9.99
• status: inactive -> active
UserJournal
The UserJournal
module takes a whodunnit
instance as its primary argument and generates a summary of changes made by that person over time. It also provides options to scope the summary to a specific class and/or date range. This can be really useful as your app ages since the number of changes made by a person could grow quite large over time.
For example, searching by a specific whodunnit
:
fred = Person.find(5)
puts PaperTrailScrapbook::UserJournal.new(fred).story
Could output the following history:
Between Friday, 08 Dec 2017 at 8:01 AM PST and Wednesday, 05 Jan 2018 at 3:24 PM PST, Fred Flintstone made the following changes:
On Friday, 08 Dec 2017 at 8:01 AM PST, created Widget[123]:
• name: Tricky Spinner
• description:
• notes: Recommended for ages 3 an up
• created by: Fred Flintstone[123]
• pattern: Widget Template[386]
• price: 12.34
• status: inactive
•: 123
On Wednesday, 12 Dec 2017 at 2:31 PM PST, updated Wobble[538]:
• on_hand: 5 -> 12
On Thursday, 21 Dec 2017 at 12:34 PM PST, updated User[5]:
• favorite saying: Yabba dabba -> Yabba dabba doo!
On Wednesday, 05 Jan 2018 at 3:24 PM PST, updated the following Widget[123]:
• name: Spinning Trick Widget -> Spinning Brontosaurus Widget
• price: 11.99 -> 9.99
• status: inactive -> active
You can also scope changes to a specific class to hone in on the changes you're after. Consider the following where we look for changes Fred has made to instances of the Widget
class:
fred = Person.find(5)
puts PaperTrailScrapbook::UserJournal.new(fred, klass: Widget).story
Which would output the following:
Between Friday, 08 Dec 2017 at 8:01 AM PST and Wednesday, 05 Jan 2018 at 3:24 PM PST, Fred Flintstone made the following changes:
On Friday, 08 Dec 2017 at 8:01 AM PST, created Widget[123]:
• name: Tricky Spinner
• description:
• notes: Recommended for ages 3 an up
• created by: Fred Flintstone[123]
• pattern: Widget Template[386]
• price: 12.34
• status: inactive
•: 123
On Wednesday, 05 Jan 2018 at 3:24 PM PST, updated the following Widget[123]:
• name: Spinning Trick Widget -> Spinning Brontosaurus Widget
• price: 11.99 -> 9.99
• status: inactive -> active
History can also be constrained to a specific period of time, using the start
and end
parameters:
fred = Person.find(5)
puts PaperTrailScrapbook::UserJournal.new(fred, klass: Widget,
start: Date.parse('2018-01-01'),
end: Time.zone.now).story
Which would ouput this:
Between Monday, 01 Jan 2018 at 12:00 AM +00:00 and Friday, 23 Feb 2018 at 11:05 AM PST, Fred Flintstone made the following changes:
On Wednesday, 05 Jan 2018 at 3:24 PM PST, updated the following Widget[123]:
• name: Spinning Trick Widget -> Spinning Brontosaurus Widget
• price: 11.99 -> 9.99
• status: inactive -> active
Customization
If your system allows for instances of your whodunnit class to be deleted, you may run across a situation where the whodunnit for a version no longer exists in your database. You can customize the behavior of PaperTrailScrapbook for that scenario by providing a proc for invalid whodunnits in your configuration file, as follows:
config.invalid_whodunnit = proc { |id| "** DELETED: #{id}**"}
Caveats
This project is still in its infancy, so you may hit occasional quirks or behavior that isn't ideal for your application. If you do, I'd consider submitting an issue or even a pull request. Any contributions are definitely welcomed and appreciated.
That said, there are a few known behaviors that I'd like to mention.
Querying for changes made by a person can take a very long time if your project has several large tables and the person has been active. If you can, scoping by class or time period will improve performance drastically.
Another area that could use improvement is in the presentation of serialized fields.
Imagine you have a Container
class with a serialized Hash field, called capacity
. If you were to populate the capacity
field with the following hash:
{
"weight" => {
"min" => "25",
"max" => "50"
}
}
and ran history on your object instance, you would see something like this for the change on capacity
:
• capacity: --- !ruby/hash:ActiveSupport::HashWithIndifferentAccess
weight: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
min: '25'
max: '50'
added
and then if you updated the value to include height
limits so the hash was:
{
"weight" => {
"min" => "20",
"max" => "40"
},
"height" => {
"min" => "55",
"max" => "65"
}
}
and re-ran history, you'd see this for the change:
• capacity: --- !ruby/hash-with-ivars:ActionController::Parameters
elements:
weight: !ruby/hash-with-ivars:ActionController::Parameters
elements:
min: '25'
max: '50'
ivars:
:@permitted: false
ivars:
:@permitted: false
-> --- !ruby/hash:ActiveSupport::HashWithIndifferentAccess
weight: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
min: '20'
max: '40'
height: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
min: '55'
max: '65'
All of the information is there, but as you can see, it can get a little unwieldy to wrap your head around as the hash grows in size. Serialized arrays face similar challenges, but the amount of data displayed for a change is nowhere near as verbose.
Summary
These two gems provide a great way to track changes made to your application's data.
PaperTrail
is a well established gem and is used by many applications in their production environment.
PaperTrailScrapbook
is hugely helpful for following changes made over time. It provides useful tools for observing how people are using your application and the sorts of changes they are making to data. I am excited to see how the project continues to evolve and grow as time goes on.
Next Time
In the next post in this series, we'll take a look at the handy-dandy Procto gem, which provides an excellent way to turn "your ruby object into a method object".
Top comments (0)