Rails Change Logger

Audit trails are extraordinarily useful in enterprise applications. When you’ve got dozens or hundreds of users doing CRUD operations on a large database full of data, sooner or later someone is going to come across a record that has been changed, and want to know: who did this, and when? (And maybe: why? - but they can find that out if they know the first two items, namely by going and asking the person who made the change.)

I recently put together a small module for storing change logs for one of our larger Rails apps. Here’s the code:

lib/change_logger.rb


module ChangeLogger
   def self.included(other_mod)
      other_mod.module_eval do
         has_many :change_logs, :as => :record, :order => 'created_at'

         def log_change(verb)
            log = ChangeLog.new
            log.record = self
            log.verb = verb
            log.user = ChangeLogger.user
            log.save!
         end

         after_create :log_create
         def log_create
            log_change('created')
         end

         after_update :log_update
         def log_update
            log_change('updated')
         end
      end
   end

   def ChangeLogger.user=(user)
      $changelog_user = user
   end
   def ChangeLogger.user
      $changelog_user
   end
end



(I didn’t want to use a global for the changelog_user, but it doesn’t appear that it’s possible to store any data on the module itself. @@ sort of works, but it’s accessible to all the classes mixing it in, which could be confusing. If anyone knows a better way to do this, please leave a comment.)

app/models/change_log.rb


class ChangeLog < ActiveRecord::Base
   belongs_to :record, :polymorphic => true
   belongs_to :user

   def to_s
      "#{user} #{verb} #{record}"
   end

   def self.find_recent
      ChangeLog.find(:all, :conditions => [ "created_at > date('now') - interval '30 days'" ],
:limit => 10, :order => 'created_at desc')
   end
end

schema


   create_table 'change_logs' do |t|
      t.column :record_type, :text, :null => false
      t.column :record_id, :int, :null => false
      t.column :verb, :text, :null => false
      t.column :created_at, :datetime, :null => false
      t.column :user_id, :int
   end

To use, just include the module in any record whose changes you want to track, like so:


class MyRecord < ActiveRecord::Base
   include ChangeLogger
   ...
end

Now try something like this at the console:


r = MyRecord.new
r.save!
r.change_logs

ChangeLog.find_recent.first.to_s
ChangeLog.find_recent.first.record

Not bad. Now, to display to your users, create this partial:

app/views/change_log/_changes.rhtml


<h3>Change Log</h3>
<table>
<tbody>
   <% record.change_logs.each do |log| %>
      <tr>
         <td><%=h log.user || "System" %></td>
         <td><%=h log.verb %></td>
         <td><%=h log.created_at.relative_time %></td>
      </tr>
   <% end %>
</tbody>
</table>

If you’ve got a dashboard view of some sort, render this partial on it:

app/views/change_log/_recent.rhtml


<table>
<tbody>
   <% ChangeLog.recent.each do |log| %>
      <tr>
         <td>
            <%=h log.user || "System" %> <%=h log.verb %> <%=
            link_to_record log.record %>
         </td>
         <td><%=h log.created_at.relative_time %></td>
      </tr>
   <% end %>
</tbody>
</table>

To store the user making the changes through the interface, you will need to set the change log user in your base controller class. If you have the standard before_filter :authorize type of setup, something like this should work:


class ApplicationController < ActionController::Base
   before_filter :authorize
   def authorize
      ...
      @user = ...
      ChangeLogger.user = @user
   end
end

The views shown above use on two extra helper methods. First, relative_time is part of Bitscribe’s standard plugin library. You can use the code below, or just remove “.relative_time” from the created_at display if you want to display a standard date.


class Time
   def relative_time(from_time=Time.now)
   from_time = from_time.to_time if from_time.respond_to?(:to_time)
   distance_in_minutes = (((self - from_time).abs)/60).round

   case distance_in_minutes
   when 0..1
      return (distance_in_minutes==0) ? 'just seconds ago' : '1 min ago'
      when 2..45        then "#{distance_in_minutes} mins ago"
      when 46..90       then '1 hr ago'
      when 90..1440     then "#{(distance_in_minutes.to_f / 60.0).round} hrs ago"
      when 1441..2880   then 'Yesterday'
      when 2881..8640   then "#{(distance_in_minutes / 1440).round} days ago"
      when 8641..10080  then '1 week ago'
      else
         strftime year == from_time.year ? '%b %d' : '%b %d, %Y'
      end
   end
end

Second, also optional, is a helper for creating a link to any record. You may need to do this on a per-class basis, but if you use a standard CRUD scheme (which I endeavor to do wherever possible), then this will be sufficient:


   def link_to_record(r)
      type = r.class.to_s.gsub(/([A-Z])/, " \\1")
      link_to "#{type} #{r}", :controller => r.class.to_s.underscore, :action => 'show', :id => r.id
   end

This also assumes that all of your records have to_s defined. If they don’t,
you could change this to use “#{r.id}” as a stopgap, though the display will
then be much less useful to your users.

Also, all of your records now have a log_change method that takes one
argument, a verb, for custom change logs. A common example might be closing
an invoice, something like:


class Invoice
   def close
      transaction do
         closed_on = Time.now
         save!
         log_change 'closed'
      end
   end
end

Yes, this will create two change log entries: “updated” and “closed”. One way around this might be a set_next_change_verb method which will override “updated”, but this doesn’t strike me as incredibly elegant. If you’ve got a good idea for this, leave a comment.

A couple of notes on what this doesn’t do. It doesn’t track what changed, only when and by who. I’ve worked on a few apps that track the name of every field that changed, including what the value was before the change and what it was afterward. Although there are times you need this level of detail, my experience has been that this approach is way more difficult to create and maintain, and leads to a massive explosion of mostly unused data in your database. Not to mention that it’s very difficult to find a way to display the audit data to the user in a way that isn’t overwhelming.

It also doesn’t track deletions, which I left out for two reasons. For one, enterprise apps very rarely allow deletes, but instead mark things inactive and remove them from view. This keeps all the data relations intact, and also keeps records of historic data around indefinitely. For example if a customer cancels their account, the app still needs to store all their billing data for the company’s tax purposes. So log_change(’deactivated’) would be in order.

The second reason is that deletions will break the (polymorphic) association on the change logs, making them unable to display. You could get around this by creating a text field on the change log to store the to_s of the record such that, even if change_log.record is nil, you can still print out the record’s name. (You’d probably only want to do this on destroy logs, rather than all the time, because the name of the record might have changed.) If you need this functionality it wouldn’t be too hard to add.

5 Responses to “Rails Change Logger”

  1. Brent Fitzgerald Says:

    Thanks for sharing this code. I’m working on a project that requires some auditing as well, and this is a great starting point.

  2. mike Says:

    I have extended it to log deletions and the actual updates to the records im just starting rails so if anyone spots any improvements please add a comment:

    Changes to schema:
    class AddLoginfoColumnToChangeLogs :record, :order => ‘created_at’

    @old_record;
    def log_change(verb)
    log = ChangeLog.new
    log.record = self
    log.verb = verb

    if verb == ‘updated’
    if @old_record != nil
    diff_arr = @old_record.attributes.to_a - self.attributes.to_a
    if diff_arr.size > 0
    log.loginfo = ”;
    diff_arr.each { |key, val| log.loginfo = log.loginfo + key + ‘:’ + val.to_s + “\n” }
    log.loginfo.strip!
    end
    else
    #nothing to log - ie no record change
    #do we return and not add empty log record here?
    end
    else
    log.loginfo = ”
    self.attributes.each_pair {|key, val| log.loginfo = log.loginfo + key + ‘:’ + val.to_s + “\n” }
    log.loginfo.strip!
    end
    log.user = ChangeLogger.user
    log.save!
    end

    after_create :log_create
    def log_create
    log_change(’created’)
    end

    before_update :log_before_update
    def log_before_update
    @old_record = self.class.find_by_id(self.id)
    end

    after_update :log_update
    def log_update
    log_change(’updated’)
    end

    after_destroy :log_destroy
    def log_destroy
    log_change(’destroyed’)
    end
    end
    end

    def ChangeLogger.user=(user)
    $changelog_user = user
    end
    def ChangeLogger.user
    $changelog_user
    end
    end

    Btw can anyone explain why rails performs an update on a record which has not been modified on a form submit? Is this not a waste of db server resource?

  3. mike Says:

    sry schema didnt come through too well heres the meat of it:

    def self.up
    add_column :change_logs, :loginfo, :text
    end
    def self.down
    remove_column :change_logs, :loginfo
    end

  4. Kurt Says:

    This is working fine for me, but on one of my sites, the client wants detailed change records, e.g. “jane status at datetime (priority: 1 changed to 2, owner: joe changed to jeff)”. Have any tips for me? I’m thinking of a ‘before_save’ method that compares the text of the before and after records and stores the diff. Or maybe it would be easier to version all rows, keeping all changes forever (acts_as_versioned, I think).

  5. adam Says:

    Keeping detailed audit records of what rows have changed is a whole other ball of wax - you’ll probably need several tables (i.e. change_log_rows, with columns like field_name, old_value, new_value). I’ve done this on some enterprise projects before (not written in Rails) and the data gets huge and unmanageable VERY quickly. I’d think carefully about how much this is really needed.

    If you decide you need it, I recommend checking out acts_as_audited. They use a serialized hash in place of a table - less flexible and transparent, but a little lower impact on your schema.