Why audit trails matter in production
When a customer's order status changes unexpectedly, or a team member reports that their account permissions were modified without their knowledge, you need answers fast. Auditing changes in Rails—tracking who changed what, when, and why—is no longer optional for systems handling sensitive data or business-critical workflows. We'll explore practical approaches to implementing audit trails, weighing the trade-offs between popular gems, storage strategies, and operational overhead.
PaperTrail: the dominant choice
PaperTrail is the most widely adopted auditing gem in the Rails ecosystem. It tracks every change to your models by storing versions—snapshots of record state before and after modifications. Each version captures the object's attributes, the type of change (create, update, destroy), who made it, and when.
Setting it up takes minutes. Add the gem to your Gemfile:
gem 'paper_trail', '~> 15.0'Run migrations to create the versions table, then declare which models to track:
class User < ApplicationRecord
has_paper_trail
endNow every save automatically creates a version record. Access the history via user.versions, and revert changes with user.paper_trail.previous_version. You can even restore a destroyed record:
version = PaperTrail::Version.find(123)
version.reify # Returns the record as it wasPaperTrail shines when you need to answer "what was the user's email on January 15th?" or quickly undo accidental deletions. The downside: the versions table grows fast. A busy e-commerce platform might accumulate millions of version records in a year, slowing queries and inflating database size.
Audited: a lighter alternative
Audited provides similar functionality with a different philosophy. It's less feature-rich but often sufficient for straightforward audit needs and can be gentler on your database.
Install and configure it:
gem 'audited', '~> 5.0'Then declare auditable models:
class Order < ApplicationRecord
audited
endAudited creates an audits table that logs changes without storing the full object snapshot. By default, it records the user making the change (if you set audit_comment before saving). The trade-off: you get "who did what" but lose the "previous value" detail without extra configuration.
For many teams, Audited is sufficient. It's simpler, faster to query, and consumes less storage. If you need only compliance-level logs ("user X modified record Y at time Z") rather than complete version history, Audited is a pragmatic choice.
Storing versions: database versus external systems
By default, both gems store versions in your application database. This is convenient but creates tight coupling: your audit trail grows with your operational data, and querying large version tables can impact production queries.
Some teams move audit storage elsewhere:
- Dedicated audit database: A separate PostgreSQL instance holding only versions. Decouples from transactional load but adds operational complexity.
- Time-series database: InfluxDB or similar for high-volume audit streams. Better for analytics but overkill for most Rails apps.
- Message queue: Publish change events to Kafka or RabbitMQ, with a separate consumer writing to an audit log. Provides decoupling and makes it easy to replay or reprocess audits.
- Cloud audit logs: AWS CloudTrail, Google Cloud Audit Logs. Valuable if your infrastructure is cloud-native, but doesn't capture application-layer business logic.
For a typical Rails app, keep versions in your main database unless you hit performance problems. The added complexity of remote storage rarely pays off early.
Building an audit history view in admin dashboards
Audit trails are only useful if someone can access them. Most teams build a dedicated admin interface to browse change history.
Here's a simple Rails controller that displays versions for a given model:
class Admin::AuditTrailsController < AdminController
def index
@auditable_type = params[:auditable_type]
@auditable_id = params[:auditable_id]
@versions = PaperTrail::Version.where(
item_type: @auditable_type,
item_id: @auditable_id
).order(created_at: :desc).limit(100)
end
endIn the view, loop through versions and display the changes:
<% @versions.each do |version| %>
<div class="audit-entry">
<p><strong><%= version.event %></strong> at <%= version.created_at %></p>
<p>Whodunnit: <%= version.whodunnit %></p>
<% if version.changeset %>
<ul>
<% version.changeset.each do |key, (old_val, new_val)| %>
<li><%= key %>: <em><%= old_val %></em> → <strong><%= new_val %></strong></li>
<% end %>
</ul>
<% end %>
</div>
<% end %>Make sure you authenticate this view behind admin-only access. Never expose audit trails to end users without careful consideration of what information they should see.
Implementing retention policies
Audit tables are write-heavy and rarely read-heavy. Over time, they become a storage liability. Retention policies—automatically purging old versions after a defined period—are essential for long-lived systems.
Most compliance frameworks require keeping audit logs for 1–7 years. Beyond that, delete them to reduce database bloat. Add a scheduled job (using whenever, good_job, or your preferred background job library):
class PurgeOldAuditTrailsJob < ApplicationJob
queue_as :default
def perform
# Delete versions older than 2 years
cutoff_date = 2.years.ago
PaperTrail::Version.where('created_at < ?', cutoff_date).delete_all
end
endSchedule it to run monthly or quarterly. If compliance requires longer retention, consider archiving old versions to cold storage (S3, etc.) before deletion.
For high-volume systems, be aware that delete_all on a large table locks it temporarily. Use batch deletion instead:
PaperTrail::Version.where('created_at < ?', cutoff_date)
.find_in_batches(batch_size: 5000) { |batch| batch.delete_all }This reduces lock time but takes longer overall. Test on staging first.
Tracking who made the change
PaperTrail and Audited can capture the user ID via whodunnit. Set it in a controller before-action:
class ApplicationController < ActionController::Base
before_action :set_paper_trail_whodunnit
def user_for_paper_trail
current_user&.id || 'system'
end
endNow every version stores the user ID. If you need the actual user name in audit reports, query the users table when displaying history, or store the name denormalized in the version record (trade: space for query speed).
Common mistakes
- Auditing too much: Tracking every attribute change on high-traffic tables (like
last_seen_attimestamps) bloats the versions table. Use theonlyorexceptoption to filter:has_paper_trail only: [:email, :role]. - Forgetting to set whodunnit: Without user context, your audit trail shows "system" changed everything. Invest in proper authentication setup.
- Querying versions without indexes: Add indexes on
(item_type, item_id, created_at)to keep audit queries fast as the table grows. - No retention policy: Audit tables that grow forever become a performance drag. Set a retention period and stick to it.
- Exposing audit data carelessly: Audit logs often contain sensitive info (old passwords, SSNs, etc.). Always mask or redact before showing in UI.
Key takeaways
- PaperTrail is the default Rails auditing choice; it stores full version snapshots and enables fast lookups and reversions.
- Audited is a lighter alternative if you only need compliance-level "who did what" logs without full change history.
- Keep audit storage in your main database unless performance issues force a change; remote storage adds complexity.
- Build an admin UI to make audit trails accessible and useful; gated audit data is dead weight.
- Implement retention policies immediately; purge old versions on a schedule to prevent unbounded table growth.
- Always capture user identity (whodunnit) and be selective about which attributes you audit to avoid noise.






Comments
Loading comments…