Fivell, I just saw this question and don't have time to work up alterations this evening before the bounty expires, so I'll give you my auditing code that works with ActiveRecord and should work with ActiveResource, perhaps with a few tweaks (I don't use ARes often enough to know offhand). I know the callbacks we use are there, but I'm not sure if ARes has ActiveRecord's dirty attribute changes
tracking.
This code logs each CREATE/UPDATE/DELETE on all models (excepting CREATEs on the audit log model and any other exceptions you specify) with the changes stored as JSON. A cleaned backtrace is also stored so you can determine what code made the change (this captures any point in your MVC as well as rake tasks and console usage).
This code works for console usage, rake tasks, and http requests, although generally only the last one logs the current user. (If I recall correctly, the ActiveRecord observer that this replaced did not work in rake tasks or the console.) Oh, this code comes from a Rails 2.3 app - I have a couple Rails 3 apps, but I haven't needed this kind of auditing for them yet.
I don't have code that builds a nice display of this information (we only dig into the data when we need to look into an issue), but since the changes are stored as JSON it should be fairly straightforward.
First, we store the current user in User.current so it is accessible everywhere, so in app/models/user.rb
:
Class User < ActiveRecord::Base
cattr_accessor :current
...
end
The current user is set in the application controller for each request like so (and does not cause concurrency issues):
def current_user
User.current = session[:user_id] ? User.find_by_id(session[:user_id]) : nil
end
You could set User.current
in your rake tasks if it made sense to.
Next, we define the model to store the audit info app/models/audit_log_entry.rb
- you'll want to customize IgnoreClassesRegEx
to fit any models you don't want audited:
# == Schema Information
#
# Table name: audit_log_entries
#
# id :integer not null, primary key
# class_name :string(255)
# entity_id :integer
# user_id :integer
# action :string(255)
# data :text
# call_chain :text
# created_at :datetime
# updated_at :datetime
#
class AuditLogEntry < ActiveRecord::Base
IgnoreClassesRegEx = /^ActiveRecord::Acts::Versioned|ActiveRecord.*::Session|Session|Sequence|SchemaMigration|CronRun|CronRunMessage|FontMetric$/
belongs_to :user
def entity (reload = false)
@entity = nil if reload
begin
@entity ||= Kernel.const_get(class_name).find_by_id(entity_id)
rescue
nil
end
end
def call_chain
return if call_chain_before_type_cast.blank?
if call_chain_before_type_cast.instance_of?(Array)
call_chain_before_type_cast
else
JSON.parse(call_chain_before_type_cast)
end
end
def data
return if data_before_type_cast.blank?
if data_before_type_cast.instance_of?(Hash)
data_before_type_cast
else
JSON.parse(data_before_type_cast)
end
end
def self.debug_entity(class_name, entity_id)
require 'fastercsv'
FasterCSV.generate do |csv|
csv << %w[class_name entity_id date action first_name last_name data]
find_all_by_class_name_and_entity_id(class_name, entity_id,
:order => 'created_at').each do |a|
csv << [a.class_name, a.entity_id, a.created_at, a.action,
(a.user && a.user.first_name), (a.user && a.user.last_name), a.data]
end
end
end
end
Next we add some methods to ActiveRecord::Base
to make the audits work. You'll want to look at the audit_log_clean_backtrace
method and modify for your needs. (FWIW, we put additions to existing classes in lib/extensions/*.rb
which are loaded in an initializer.) In lib/extensions/active_record.rb
:
class ActiveRecord::Base
cattr_accessor :audit_log_backtrace_cleaner
after_create :audit_log_on_create
before_update :save_audit_log_update_diff
after_update :audit_log_on_update
after_destroy :audit_log_on_destroy
def audit_log_on_create
return if self.class.name =~ /AuditLogEntry/
return if self.class.name =~ AuditLogEntry::IgnoreClassesRegEx
audit_log_create 'CREATE', self, caller
end
def save_audit_log_update_diff
@audit_log_update_diff = changes.reject{ |k,v| 'updated_at' == k }
end
def audit_log_on_update
return if self.class.name =~ AuditLogEntry::IgnoreClassesRegEx
return if @audit_log_update_diff.empty?
audit_log_create 'UPDATE', @audit_log_update_diff, caller
end
def audit_log_on_destroy
return if self.class.name =~ AuditLogEntry::IgnoreClassesRegEx
audit_log_create 'DESTROY', self, caller
end
def audit_log_create (action, data, call_chain)
AuditLogEntry.create :user => User.current,
:action => action,
:class_name => self.class.name,
:entity_id => id,
:data => data.to_json,
:call_chain => audit_log_clean_backtrace(call_chain).to_json
end
def audit_log_clean_backtrace (backtrace)
if !ActiveRecord::Base.audit_log_backtrace_cleaner
ActiveRecord::Base.audit_log_backtrace_cleaner = ActiveSupport::BacktraceCleaner.new
ActiveRecord::Base.audit_log_backtrace_cleaner.add_silencer { |line| line =~ /\/lib\/rake\.rb/ }
ActiveRecord::Base.audit_log_backtrace_cleaner.add_silencer { |line| line =~ /\/bin\/rake/ }
ActiveRecord::Base.audit_log_backtrace_cleaner.add_silencer { |line| line =~ /\/lib\/(action_controller|active_(support|record)|hoptoad_notifier|phusion_passenger|rack|ruby|sass)\// }
ActiveRecord::Base.audit_log_backtrace_cleaner.add_filter { |line| line.gsub(RAILS_ROOT, '') }
end
ActiveRecord::Base.audit_log_backtrace_cleaner.clean backtrace
end
end
Finally, here are the tests we have on this - you'll need to modify the actual test actions of course. test/integration/audit_log_test.rb
require File.dirname(__FILE__) + '/../test_helper'
class AuditLogTest < ActionController::IntegrationTest
def setup
end
def test_audit_log
u = users(:manager)
log_in u
a = Alert.first :order => 'id DESC'
visit 'alerts/new'
fill_in 'alert_note'
click_button 'Send Alert'
a = Alert.first :order => 'id DESC', :conditions => ['id > ?', a ? a.id : 0]
ale = AuditLogEntry.first :conditions => {:class_name => 'Alert', :entity_id => a.id }
assert_equal 'Alert', ale.class_name
assert_equal 'CREATE', ale.action
end
private
def log_in (user, password = 'test', initial_url = home_path)
visit initial_url
assert_contain 'I forgot my password'
fill_in 'email', :with => user.email
fill_in 'password', :with => password
click_button 'Log In'
end
def log_out
visit logout_path
assert_contain 'I forgot my password'
end
end
And test/unit/audit_log_entry_test.rb
:
# == Schema Information
#
# Table name: audit_log_entries
#
# id :integer not null, primary key
# class_name :string(255)
# action :string(255)
# data :text
# user_id :integer
# created_at :datetime
# updated_at :datetime
# entity_id :integer
# call_chain :text
#
require File.dirname(__FILE__) + '/../test_helper'
class AuditLogEntryTest < ActiveSupport::TestCase
test 'should handle create update and delete' do
record = Alert.new :note => 'Test Alert'
assert_difference 'Alert.count' do
assert_difference 'AuditLogEntry.count' do
record.save
ale = AuditLogEntry.first :order => 'created_at DESC'
assert ale
assert_equal 'CREATE', ale.action, 'AuditLogEntry.action should be CREATE'
assert_equal record.class.name, ale.class_name, 'AuditLogEntry.class_name should match record.class.name'
assert_equal record.id, ale.entity_id, 'AuditLogEntry.entity_id should match record.id'
end
end
assert_difference 'AuditLogEntry.count' do
record.update_attribute 'note', 'Test Update'
ale = AuditLogEntry.first :order => 'created_at DESC'
expected_data = {'note' => ['Test Alert', 'Test Update']}
assert ale
assert_equal 'UPDATE', ale.action, 'AuditLogEntry.action should be UPDATE'
assert_equal expected_data, ale.data
assert_equal record.class.name, ale.class_name, 'AuditLogEntry.class_name should match record.class.name'
assert_equal record.id, ale.entity_id, 'AuditLogEntry.entity_id should match record.id'
end
assert_difference 'AuditLogEntry.count' do
record.destroy
ale = AuditLogEntry.first :order => 'created_at DESC'
assert ale
assert_equal 'DESTROY', ale.action, 'AuditLogEntry.action should be CREATE'
assert_equal record.class.name, ale.class_name, 'AuditLogEntry.class_name should match record.class.name'
assert_equal record.id, ale.entity_id, 'AuditLogEntry.entity_id should match record.id'
assert_nil Alert.find_by_id(record.id), 'Alert should be deleted'
end
end
test 'should not log AuditLogEntry create entry and block on update and delete' do
record = Alert.new :note => 'Test Alert'
assert_difference 'Alert.count' do
assert_difference 'AuditLogEntry.count' do
record.save
end
end
ale = AuditLogEntry.first :order => 'created_at DESC'
assert_equal 'CREATE', ale.action, 'AuditLogEntry.action should be CREATE'
assert_equal record.class.name, ale.class_name, 'AuditLogEntry.class_name should match record.class.name'
assert_equal record.id, ale.entity_id, 'AuditLogEntry.entity_id should match record.id'
assert_nil AuditLogEntry.first(:conditions => { :class_name => 'AuditLogEntry', :entity_id => ale.id })
if ale.user_id.nil?
u = User.first
else
u = User.first :conditions => ['id != ?', ale.user_id]
end
ale.user_id = u.id
assert !ale.save
assert !ale.destroy
end
end