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.
February 19th, 2007 at 1:50 pm
Thanks for sharing this code. I’m working on a project that requires some auditing as well, and this is a great starting point.
February 20th, 2007 at 2:37 pm
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?
February 20th, 2007 at 2:39 pm
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
July 26th, 2007 at 8:26 pm
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).
July 27th, 2007 at 12:13 pm
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.