Note that there are some explanatory texts on larger screens.

plurals
  1. PO
    text
    copied!<p>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 <code>changes</code> tracking.</p> <p>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).</p> <p>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.</p> <p>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.</p> <p>First, we store the current user in User.current so it is accessible everywhere, so in <code>app/models/user.rb</code>:</p> <pre><code>Class User &lt; ActiveRecord::Base cattr_accessor :current ... end </code></pre> <p>The current user is set in the application controller for each request like so (and does not cause concurrency issues):</p> <pre><code>def current_user User.current = session[:user_id] ? User.find_by_id(session[:user_id]) : nil end </code></pre> <p>You could set <code>User.current</code> in your rake tasks if it made sense to.</p> <p>Next, we define the model to store the audit info <code>app/models/audit_log_entry.rb</code> - you'll want to customize <code>IgnoreClassesRegEx</code> to fit any models you don't want audited:</p> <pre><code># == 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 &lt; 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 &lt;&lt; %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 =&gt; 'created_at').each do |a| csv &lt;&lt; [a.class_name, a.entity_id, a.created_at, a.action, (a.user &amp;&amp; a.user.first_name), (a.user &amp;&amp; a.user.last_name), a.data] end end end end </code></pre> <p>Next we add some methods to <code>ActiveRecord::Base</code> to make the audits work. You'll want to look at the <code>audit_log_clean_backtrace</code> method and modify for your needs. (FWIW, we put additions to existing classes in <code>lib/extensions/*.rb</code> which are loaded in an initializer.) In <code>lib/extensions/active_record.rb</code>:</p> <pre><code>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 =&gt; User.current, :action =&gt; action, :class_name =&gt; self.class.name, :entity_id =&gt; id, :data =&gt; data.to_json, :call_chain =&gt; 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 </code></pre> <p>Finally, here are the tests we have on this - you'll need to modify the actual test actions of course. <code>test/integration/audit_log_test.rb</code></p> <pre><code>require File.dirname(__FILE__) + '/../test_helper' class AuditLogTest &lt; ActionController::IntegrationTest def setup end def test_audit_log u = users(:manager) log_in u a = Alert.first :order =&gt; 'id DESC' visit 'alerts/new' fill_in 'alert_note' click_button 'Send Alert' a = Alert.first :order =&gt; 'id DESC', :conditions =&gt; ['id &gt; ?', a ? a.id : 0] ale = AuditLogEntry.first :conditions =&gt; {:class_name =&gt; 'Alert', :entity_id =&gt; 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 =&gt; user.email fill_in 'password', :with =&gt; password click_button 'Log In' end def log_out visit logout_path assert_contain 'I forgot my password' end end </code></pre> <p>And <code>test/unit/audit_log_entry_test.rb</code>:</p> <pre><code># == 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 &lt; ActiveSupport::TestCase test 'should handle create update and delete' do record = Alert.new :note =&gt; 'Test Alert' assert_difference 'Alert.count' do assert_difference 'AuditLogEntry.count' do record.save ale = AuditLogEntry.first :order =&gt; '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 =&gt; 'created_at DESC' expected_data = {'note' =&gt; ['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 =&gt; '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 =&gt; 'Test Alert' assert_difference 'Alert.count' do assert_difference 'AuditLogEntry.count' do record.save end end ale = AuditLogEntry.first :order =&gt; '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 =&gt; { :class_name =&gt; 'AuditLogEntry', :entity_id =&gt; ale.id }) if ale.user_id.nil? u = User.first else u = User.first :conditions =&gt; ['id != ?', ale.user_id] end ale.user_id = u.id assert !ale.save assert !ale.destroy end end </code></pre>
 

Querying!

 
Guidance

SQuiL has stopped working due to an internal error.

If you are curious you may find further information in the browser console, which is accessible through the devtools (F12).

Reload