Tracking PaperTrail versions while saving in batches

10 minutes read

Auditing model changes is a common task in modern software development. Whether it is a feature request or just some debugging purpose in mind when the complexity of the system grows it is natural to add the ability to quickly see the changes made by someone.

There are plenty of tools which allow to quickly add versions to the app and most of them have very nice DSL on top, which implies interaction with auditing to be very efficient.

However, it is often easy to solve the regular daily tasks, but it can be much harder to deal with more complicated issues.

Let’s say, we would like to efficiently track a group of model versions using PaperTrail which were created during a specific POST/PATCH request. This can be useful when the app has some heavy endpoint which doesn’t just create/update a single record in a database, but performs a batch of save operations on different kinds of models.

Tracking save requests

At first, we would like to have a mechanism to track save requests in the app. To solve that, we can just create a SaveRequest model with a few extra columns for debugging purposes.

A Rails migration could look like this:

create_table :save_requests do |t|
  t.datetime :created_at, null: false, default: -> { 'CURRENT_TIMESTAMP' }
  t.belongs_to :user, null: false
end

And the model would just be as simple as this:

class SaveRequest < ApplicationRecord
  belongs_to :user
end

Now, in the controller of our heavy endpoint we are going to create a new SaveRequest record on each create/update HTTP request:

class BatchCategoriesController < ApplicationController
  prepend_before_action :track_save_request, only: %i[update create]

  private

    def track_save_request
      @save_request ||= SaveRequest.create!(user: current_user)
    end
end

So we have an ability to track the save requests performed by the client using our endpoint. However, how can we track the changes made during this call?

Log versions created during save request

To be able to solve this, the first step will be to add a reference between SaveRequest and PaperTrail::Version models so the db schema would look like this:

db-schema

A new migration just adds a new reference to versions table:

def change
  add_reference :versions, :save_request, foreign_key: true, index: true
end

and it looks obvious to add a has_many relation to the SaveRequest model now:

class SaveRequest < ApplicationRecord
  belongs_to :user
+ has_many :request_versions, class_name: 'PaperTrail::Version', foreign_key: :save_request_id
end

At this point we have a relationship between the save request and the versions, but how do we actually associate these records properly? The solution is not really straightforward and depends on the PaperTrail’s metadata feature.

PaperTrail allows passing some extra information to the versions by overriding the info_for_paper_trail method in the controller. So all the created versions in this endpoint will have that information.

That way we can attach the specific save request to the each of the created version:

class BatchCategoriesController < ApplicationController
+ attr_reader :save_request

  prepend_before_action :track_save_request, only: %i[update create]

+ # Store metadata for PaperTrail::Version
+ def info_for_paper_trail
+   { save_request_id: save_request.id }
+ end

  private

    def track_save_request
-     @save_request ||= SaveRequest.create!(user: current_user)
+     @save_request ||= PaperTrail.request(enabled: false) do
+       SaveRequest.create!(user: current_user)
+     end
    end
end

That’s actually it, let’s give it a try.

Demo time

If we create (or update) multiple categories using our BatchCategoriesController we will see that a new save request is created and there are PaperTrail versions associated with it:

> request = SaveRequest.last # =>
  # <SaveRequest id: 1, user_id: 3, created_at: "2020-09-16 23:58:33">

> request.request_versions.limit(2).map(&:changeset) # =>
  # [
  #   {
  #     "parent_category_id": [
  #       null,
  #       671
  #     ],
  #     "updated_at": [
  #       "2019-08-19 01:58:15 UTC",
  #       "2020-09-24 23:58:35 UTC"
  #     ]
  #   },
  #   {
  #     "name": [
  #       "Old Category Name",
  #       "New Category Name"
  #     ],
  #     "parent_category_id": [
  #       null,
  #       673
  #     ],
  #     "updated_at": [
  #       "2019-08-19 01:58:15 UTC",
  #       "2020-09-16 23:58:35 UTC"
  #     ]
  #   }
  # ]

Wrap Up

In this article, we described how to track paper trail versions on per save request basis using metadata to store information about the request at PaperTrail versions table. Such an approach will give an ability to quickly find the version changes made in a specific request.

There are some improvements to think about:

  • if the app cleans up stale versions, it would probably have to clean the orphan SaveRequest records as well
  • if the endpoint fails to process a save request, we would probably have to destroy the created SaveRequest record or wrap it into transaction and rollback it automatically
  • migrating versions table can be challenging if it is very big. Other techniques can be applied in order to speed up the process (i.e. creating a new table and copying the data)
  • delayed execution support can be added in different ways depending on the requirements (current save request can be passed to the job or a new record can be created to group versions created at the job level)

Leave a Comment