On 2 September 2025, we implemented a comprehensive audit trail system that automatically tracks every significant action in the platform: property updates, photo changes, status modifications, and user activities. This wasn't an afterthought—for letting agencies subject to increasingly stringent regulations, detailed audit logs are legally required and operationally critical.
Regulatory frameworks like right to rent checks, deposit protection schemes, and safety certificate compliance all require documented evidence of when actions occurred, who performed them, and what changed. Insurance claims, tenancy disputes, and regulatory inspections demand this paper trail. The audit system transforms these compliance requirements from burdensome manual recordkeeping into automatic background logging.
What Your Team Will Notice
Staff see audit history throughout the application. On property detail pages, the activity feed shows recent changes: "Sarah updated rental price from £1,200 to £1,250 on 2 Sep at 14:35." On the global activities page (/activities), a chronological timeline shows all agency activity with time-based grouping (today, yesterday, this week, earlier).
When compliance officers need reports—say, for a council inspection or insurance claim—the audit logs provide evidence. Every certificate upload, every property status change, every tenant document is timestamped and attributed to specific users. The system tracks not just what changed, but the before/after values, enabling precise reconstruction of events.
The interface is deliberately unobtrusive. Staff don't need to remember to "log" actions—the system handles it automatically through Rails callbacks. The only visible element is the orange notification dot in the header when new activity occurs, providing awareness without interrupting workflow.
For agencies with multiple offices or franchise networks, audit trails provide accountability and visibility. Head office can review what actions regional negotiators performed. Franchise owners can monitor their teams. Quality assurance can spot patterns (e.g., one user consistently skipping required fields).
Under the Bonnet: The AuditLog Model
The audit trail uses a dedicated AuditLog model with polymorphic associations, enabling it to track changes across any model (properties, users, tenancies, certificates):
# app/models/audit_log.rb
class AuditLog < ApplicationRecord
acts_as_tenant :agency
belongs_to :agency
belongs_to :user, optional: true # System actions may not have users
belongs_to :auditable, polymorphic: true, optional: true
validates :action, presence: true
validates :message, presence: true
scope :recent, -> { order(created_at: :desc).limit(100) }
scope :for_auditable, ->(auditable) { where(auditable: auditable) }
scope :by_action, ->(action) { where(action: action) }
# Format details as pretty JSON for display
def formatted_details
return nil unless details.present?
JSON.pretty_generate(details)
end
# Human-readable timestamp
def display_time
if created_at.today?
created_at.strftime("Today at %H:%M")
elsif created_at.yesterday?
created_at.strftime("Yesterday at %H:%M")
else
created_at.strftime("%d %b %Y at %H:%M")
end
end
end
The polymorphic auditable association allows one AuditLog record to reference any model. When auditing a Property update, auditable_type = "Property" and auditable_id = 42. When auditing a User change, auditable_type = "User" and auditable_id = 17. This flexibility means adding audit trails to new models requires no schema changes.
The acts_as_tenant :agency scoping ensures audit logs are tenant-isolated—Agency A cannot see Agency B's audit logs, maintaining multi-tenancy security.
The database migration creates the necessary tables and indexes:
# db/migrate/20250902122106_create_audit_logs.rb
class CreateAuditLogs < ActiveRecord::Migration[8.0]
def change
create_table :audit_logs do |t|
t.references :agency, null: false, foreign_key: true
t.references :user, null: true, foreign_key: true
t.string :action, null: false
t.references :auditable, polymorphic: true, null: true
t.jsonb :details, default: {}
t.text :message, null: false
t.timestamps
end
add_index :audit_logs, [:agency_id, :created_at]
add_index :audit_logs, [:auditable_type, :auditable_id]
add_index :audit_logs, [:user_id, :created_at]
end
end
The jsonb column type (PostgreSQL-specific) stores structured details (field changes, before/after values) efficiently while allowing queries against JSON content. The indexes optimize common queries:
- "Show recent activity for this agency"
- "Show activity for this property"
- "Show activity by this user"
The Auditable Concern: Automatic Change Tracking
Rather than manually inserting audit log code into every controller action, we use the Auditable concern that models include:
# app/models/concerns/auditable.rb (excerpt)
module Auditable
extend ActiveSupport::Concern
included do
after_create :audit_creation
after_update :audit_update
after_destroy :audit_destruction
end
module ClassMethods
def audit_activity(action:, auditable: nil, details: {}, message:, user: nil, async: false)
current_agency = ActsAsTenant.current_tenant
current_user = user || Current.user
return unless current_agency
if async
AuditLogJob.perform_later(
agency_id: current_agency.id,
user_id: current_user&.id,
action: action.to_s,
auditable: auditable,
details: details,
message: message
)
else
AuditLog.create!(
agency: current_agency,
user_id: current_user&.id,
action: action.to_s,
auditable: auditable,
details: details,
message: message
)
end
end
end
private
def audit_creation
self.class.audit_activity(
action: :create,
auditable: self,
message: "#{self.class.name} created",
async: true
)
end
def audit_update
return unless saved_changes.any?
fields_changed = saved_changes.keys - %w[updated_at]
return unless fields_changed.any?
details = {}
fields_changed.each do |field|
old_value, new_value = saved_changes[field]
details[field] = { from: old_value, to: new_value }
end
self.class.audit_activity(
action: :update,
auditable: self,
details: details,
message: generate_update_message(fields_changed),
async: true
)
end
def audit_destruction
self.class.audit_activity(
action: :destroy,
auditable: self,
message: "#{self.class.name} deleted",
async: false # Immediate, before record disappears
)
end
def generate_update_message(fields_changed)
key_fields = fields_changed & %w[headline price status reference]
if key_fields.any?
changes = key_fields.map { |f| "#{f.humanize}: #{send(f)}" }.join(", ")
"#{self.class.name} updated: #{changes}"
else
"#{self.class.name} updated (#{fields_changed.count} fields changed)"
end
end
end
Models include this concern:
# app/models/property.rb
class Property < ApplicationRecord
include Auditable
# ... rest of model definition
end
Now, whenever a Property is created, updated, or destroyed, the callbacks fire automatically, creating audit log entries. The saved_changes method (provided by Rails) contains the before/after values of changed fields, which we store in the details JSON column.
The async: true flag delegates audit log creation to background jobs, preventing audit overhead from slowing down user-facing requests. Destruction audits use async: false because we need the log created before the record disappears.
Background Job Processing with AuditLogJob
The background job handles audit log creation asynchronously:
# app/jobs/audit_log_job.rb
class AuditLogJob < ApplicationJob
queue_as :default
def perform(agency_id:, user_id:, action:, auditable:, details:, message:)
# Set tenant context for multi-tenancy
ActsAsTenant.current_tenant = Agency.find(agency_id)
AuditLog.create!(
agency_id: agency_id,
user_id: user_id,
action: action,
auditable: auditable,
details: details,
message: message
)
rescue ActiveRecord::RecordNotFound => e
# Agency was deleted between queuing and processing
Rails.logger.error("Audit log job failed: #{e.message}")
end
end
Using SolidQueue (configured in Week 35), these jobs process in the background. If audit log creation takes 50ms (database writes, JSON serialization), that's 50ms saved from the user's request/response cycle. For high-traffic periods, this improves perceived performance significantly.
User Context Tracking with Current Model
To attribute actions to specific users, we need the current user accessible throughout the request cycle. The Current model provides thread-safe storage:
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user
end
The ApplicationController sets this at the start of each request:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_current_user
private
def set_current_user
Current.user = current_user if user_signed_in?
end
end
Now, anywhere in the codebase—models, concerns, jobs—Current.user returns the user making the request. This enables automatic user attribution without passing user objects through method parameters.
ActiveSupport::CurrentAttributes uses thread-local storage, ensuring concurrent requests don't interfere. Request A's Current.user doesn't leak into Request B, even in multi-threaded application servers.
The Activities Controller: Displaying Audit Logs
The ActivitiesController presents audit logs to users:
# app/controllers/activities_controller.rb (excerpt)
class ActivitiesController < ApplicationController
before_action :authenticate_user!
def index
@audit_logs = AuditLog.includes(:user, :auditable)
.order(created_at: :desc)
.page(params[:page])
.per(50)
# Group by time periods for better UX
@grouped_logs = group_logs_by_time(@audit_logs)
end
private
def group_logs_by_time(logs)
{
today: logs.select { |log| log.created_at.today? },
yesterday: logs.select { |log| log.created_at.yesterday? },
this_week: logs.select { |log| log.created_at > 7.days.ago && !log.created_at.today? && !log.created_at.yesterday? },
earlier: logs.select { |log| log.created_at <= 7.days.ago }
}
end
end
The .includes(:user, :auditable) eager loading prevents N+1 queries when displaying user names and auditable objects. Time-based grouping (today, yesterday, this week, earlier) makes the feed easier to scan—a UX detail inspired by messaging apps.
The view presents this data cleanly:
<!-- app/views/activities/index.html.erb -->
<div class="activity-feed">
<% @grouped_logs.each do |period, logs| %>
<% next if logs.empty? %>
<h3 class="text-lg font-semibold text-black dark:text-white mb-4">
<%= period.to_s.titleize %>
</h3>
<% logs.each do |log| %>
<div class="activity-item flex items-start gap-4 p-4 bg-white dark:bg-boxdark rounded-lg shadow mb-4">
<div class="activity-icon">
<%= activity_icon(log.action) %>
</div>
<div class="activity-content flex-1">
<p class="text-sm text-black dark:text-white font-medium">
<%= log.message %>
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<%= log.user&.display_name || "System" %> • <%= log.display_time %>
</p>
<% if log.auditable %>
<%= link_to "View", polymorphic_path(log.auditable), class: "text-primary hover:underline text-xs" %>
<% end %>
</div>
</div>
<% end %>
<% end %>
</div>
The polymorphic_path helper generates correct URLs regardless of auditable type—property_path(property) for properties, user_path(user) for users, etc.
Performance Considerations
Audit logging introduces performance concerns—every write operation now creates an additional database record. Our mitigations:
Background processing: Audit log creation happens asynchronously (except deletions), removing overhead from the request cycle.
Selective auditing: Not every field change needs auditing. We exclude trivial fields like updated_at that change on every save.
Batch operations: For bulk imports or batch updates, we can disable auditing temporarily:
Property.without_auditing do
Property.import(csv_data)
end
Index optimization: Database indexes on [agency_id, created_at] make recent activity queries fast, even with millions of audit log records.
Retention policies: We can implement automatic pruning of old audit logs (e.g., archive logs older than 7 years) to control database size, though regulatory requirements often mandate longer retention.
Testing Audit Functionality
The comprehensive test suite (98.8% coverage achieved this week) includes audit trail tests:
# spec/models/concerns/auditable_spec.rb
RSpec.describe Auditable do
let(:agency) { create(:agency) }
let(:user) { create(:user, agency: agency) }
let(:property) { create(:property, agency: agency) }
before do
ActsAsTenant.current_tenant = agency
Current.user = user
end
describe "audit_creation" do
it "creates audit log when property is created" do
expect {
create(:property, agency: agency)
}.to have_enqueued_job(AuditLogJob).with(
hash_including(action: "create")
)
end
end
describe "audit_update" do
it "captures field changes" do
property.update(price: 1500, headline: "Updated Property")
expect(AuditLogJob).to have_been_enqueued.with(
hash_including(
action: "update",
details: hash_including("price", "headline")
)
)
end
it "skips audit if only updated_at changed" do
property.touch
expect(AuditLogJob).not_to have_been_enqueued
end
end
end
These tests verify auditing works correctly, captures appropriate data, and skips trivial changes.
What's Next
The audit trail foundation enables future enhancements:
- Compliance reports: Generate regulatory reports from audit logs
- Change notifications: Email alerts when critical fields change
- Audit log exports: CSV/PDF exports for insurance claims or legal proceedings
- Advanced filtering: Search audit logs by user, date range, or action type
- Restoration: Use audit logs to restore deleted records or revert changes
But the core system—automatic, comprehensive, multi-tenant audit logging with user attribution—was established on 2 September, providing the compliance foundation letting agencies require.
Related articles: