March 22, 2026 . 13 min read
How to Add Audit Logs to Your Rails 8 SaaS in 2026
Jeronim Morina
Founder, Omaship
Audit logs are one of those features founders delay until a customer asks an uncomfortable question: who changed the billing plan, who exported the customer list, who granted admin access, and why can nobody prove it? By then you are not adding a nice-to-have. You are patching a trust problem.
The good news: Rails makes audit logging straightforward if you stop overthinking it. Most SaaS products do not need a sprawling observability platform or magical event sourcing story. They need an append-only audit trail for sensitive actions, enough context to investigate incidents, and tests that prove the trail cannot quietly disappear.
This guide covers the practical version: what to log, what not to log, the table design that holds up in support and procurement conversations, how to wire it into a Rails 8 app, when PaperTrail helps, and when a custom audit log is the cleaner move.
Why audit logs matter earlier than founders think
Audit logs are not just for banks and healthcare companies. They matter as soon as your app has staff accounts, permissions, billing controls, exports, or anything a support person might do on behalf of a customer.
- B2B buyers ask for them. Procurement and security questionnaires reliably ask how you track privileged actions and data access.
- Support needs them. When a customer says something changed unexpectedly, the first useful question is "what happened?" not "can we reproduce it?"
- Permissions without logging are half-built. If admins can impersonate users, change roles, or export data without an audit trail, you created risk without accountability.
- Exits get easier. Buyers like systems that are boring, explicit, and inspectable. Audit logs are boring in exactly the right way.
This is why Omaship leans toward explicit Rails patterns over clever abstractions. A clean audit_logs table with a few well-defined service entry points beats a black box every time.
What you should log
Log actions that matter for security, money, access, support, and compliance. Do not try to log every model update in the entire system. That turns your audit trail into sludge.
The core events for most SaaS apps
- Authentication events: sign in, sign out, password reset requested, password changed, MFA enabled or disabled.
- Authorization changes: role granted, role revoked, team member invited, account ownership transferred.
- Privileged support actions: admin impersonation started, admin impersonation ended, account unlocked, account suspended.
- Data exports and destructive operations: CSV export, personal data export, account deletion requested, account deletion completed.
- Billing and subscription changes: plan upgraded, downgraded, subscription cancelled, invoice manually forgiven, payment method updated by staff.
- High-risk settings changes: domain changed, API key rotated, webhook secret regenerated, SSO settings updated.
If an action can lose money, leak data, change access, or trigger a support escalation, it probably belongs in the audit log.
What you should not log
Founders screw this up in two opposite ways: they log nothing useful, or they log everything including secrets. Both are bad.
- Do not log raw passwords, tokens, API keys, session cookies, OAuth secrets, or webhook secrets.
- Do not dump entire request bodies by default. You will collect far more personal data than you intended.
- Do not log routine low-risk CRUD noise. Every text field edit by every user is not an audit event.
- Do not make the metadata schema free-for-all JSON garbage. If every event payload looks different, your trail becomes unreadable fast.
The rule is simple: log enough to explain the action and its impact, but not so much that the log becomes a second copy of your production database.
The audit log table that actually works
You want one append-only table with boring columns. Boring wins here.
# db/migrate/XXXXXX_create_audit_logs.rb
class CreateAuditLogs < ActiveRecord::Migration[8.1]
def change
create_table :audit_logs do |t|
t.string :actor_type
t.bigint :actor_id
t.string :action, null: false
t.string :subject_type
t.bigint :subject_id
t.string :subject_label
t.json :metadata, null: false, default: {}
t.string :request_id
t.string :ip_address
t.string :user_agent
t.datetime :occurred_at, null: false
t.timestamps
end
add_index :audit_logs, [ :actor_type, :actor_id ]
add_index :audit_logs, [ :subject_type, :subject_id ]
add_index :audit_logs, :action
add_index :audit_logs, :occurred_at
add_index :audit_logs, :request_id
end
end
Here is what each column is doing:
actor_typeandactor_ididentify who performed the action. UsuallyUser, but keep it generic in case system jobs or admins also act.actionis a stable string likebilling.plan_changed,admin.impersonation_started, orteam.role_revoked.subject_typeandsubject_ididentify what the action was performed on.subject_labelstores a human-readable reference, because support people should not have to decode IDs at 2am.metadataholds small, action-specific context like old plan and new plan, old role and new role, export format, or which admin triggered the action.request_id,ip_address, anduser_agentgive you incident response context.occurred_atlets you record the event time explicitly, which matters when system jobs write entries after the triggering request.
Append-only means append-only
The whole point of an audit log is that it tells the truth later. If admins or background jobs can casually update or delete rows, you built decorative fiction.
At the application level, the simplest move is to never expose update or destroy paths for AuditLog at all.
# app/models/audit_log.rb
class AuditLog < ApplicationRecord
belongs_to :actor, polymorphic: true, optional: true
belongs_to :subject, polymorphic: true, optional: true
validates :action, :occurred_at, presence: true
before_update :readonly_record
before_destroy :readonly_record
private
def readonly_record
raise ActiveRecord::ReadOnlyRecord, "Audit logs are append-only"
end
end
If you have stricter compliance requirements, move the immutability guarantee down a layer too: database permissions, partitioning, write-once storage exports, or streaming the same events to a separate log pipeline. But for most early SaaS apps, application-level append-only behavior plus restricted database access is already a massive step up from nothing.
A small service beats callbacks everywhere
Do not scatter audit logging across random model callbacks. It becomes impossible to reason about and even harder to keep context-rich. Put writes behind an explicit service.
# app/models/audit_logger.rb
class AuditLogger
def self.log!(action:, actor: nil, subject: nil, subject_label: nil, metadata: {}, request: nil, occurred_at: Time.current)
AuditLog.create!(
actor: actor,
action: action,
subject: subject,
subject_label: subject_label || default_subject_label(subject),
metadata: metadata,
request_id: request&.request_id,
ip_address: request&.remote_ip,
user_agent: request&.user_agent,
occurred_at: occurred_at
)
end
def self.default_subject_label(subject)
return unless subject
subject.try(:name) || subject.try(:email_address) || "#{subject.class.name}##{subject.id}"
end
end
Then call it from the place where the business action actually happens: a controller action, model method, or service object that already knows the old state and new state.
# app/controllers/account_roles_controller.rb
class AccountRolesController < ApplicationController
def update
membership = Current.team.memberships.find(params[:id])
old_role = membership.role
membership.update!(role: params[:role])
AuditLogger.log!(
action: "team.role_changed",
actor: Current.user,
subject: membership.user,
metadata: {
old_role: old_role,
new_role: membership.role,
team_id: Current.team.id
},
request: request
)
redirect_to team_settings_path, notice: "Role updated"
end
end
This is explicit, readable, and easy for AI coding agents to extend without inventing weird callback chains. Exactly what you want.
The events most founders forget
These are the omissions that come back to bite later:
- Admin impersonation. Log both the start and the end, and record who initiated it and which account was impersonated.
- Exports. CSV export, account export, invoice export, and anything that moves a lot of customer data out of the system.
- Role changes. Access changes are often more important than content edits.
- Billing overrides. Manual discounts, complimentary plans, invoice forgiveness, and plan changes by staff.
- Credential rotation. API keys regenerated, SSO certificates replaced, webhook secrets rotated.
Retention, pruning, and exports
Audit logs grow forever unless you make a retention call. The answer depends on your buyers and regulatory context, but here is the practical default: keep security and admin audit logs longer than product noise, and make the retention rule explicit in code and policy.
If you decide to prune low-value events after a fixed period, do it with a background job and only for categories you are comfortable losing.
If you are treating audit_logs as append-only, do not turn around and delete from that same table in application code. That undercuts the guarantee you just created. A better pattern is to keep security-sensitive audit logs immutable, archive them to object storage on a schedule, and apply retention at the archive or partition level instead of through ad hoc DELETE jobs.
If you know some events are intentionally short-lived, split them into a separate, lower-trust event stream instead of mixing them into the same append-only audit table. Login success noise and support-grade audit trails do not need identical retention rules.
Better yet, export before pruning. Security-sensitive customers often care more about recoverability than infinite in-app retention. A monthly archive to S3-compatible object storage is a sane middle ground.
PaperTrail vs a custom audit log
Use PaperTrail when you specifically need record version history: who changed this row, what the previous values were, and maybe the ability to reify old versions. It is good at model-level versioning.
Use a custom audit_logs table when you care about business events across the app: impersonation started, export triggered, team invitation accepted, invoice marked paid manually, SSO config changed. Those are not just row diffs. They are application events.
Many teams end up with both: PaperTrail for a few heavily edited records and a custom audit log for security and support actions. If you can only afford one to start, pick the custom audit log. It answers broader operational questions.
How to test audit logging without writing nonsense tests
The wrong test checks that AuditLog.count increased by one. That proves almost nothing. Test the behavior that matters:
- The correct action name is written.
- The actor and subject are correct.
- The critical metadata is present.
- Readonly behavior prevents updates and deletes.
- High-risk controller flows create entries exactly once.
test "role change writes an audit log with old and new role" do
admin = users(:admin)
membership = memberships(:editor_membership)
sign_in_as(admin)
patch membership_role_path(membership), params: { role: "viewer" }
audit_log = AuditLog.order(:id).last
assert_equal "team.role_changed", audit_log.action
assert_equal admin, audit_log.actor
assert_equal membership.user, audit_log.subject
assert_equal "editor", audit_log.metadata.fetch("old_role")
assert_equal "viewer", audit_log.metadata.fetch("new_role")
end
Then add a model test for append-only behavior. If someone "helpfully" removes the readonly guard six months from now, you want the test suite to slap their hand immediately.
The common mistakes
- Only logging model updates. Support and compliance questions are about business actions, not just row diffs.
- Logging secrets. Nothing says "security theater" like leaking API keys into your audit trail.
- Hiding everything in callbacks. You lose business context and future readers hate you.
- Making action names inconsistent. Pick a namespace pattern and stick to it.
- No request context. Without request ID and IP, incident investigation gets much weaker.
- No UI for reading the logs. If only the database can tell the story, support still cannot use it.
The practical default
If you are a small SaaS team, here is the move: add a custom append-only audit_logs table, write through an explicit AuditLogger service, log the handful of actions that actually matter, and test the high-risk paths. That gets you 80 percent of the value with 20 percent of the complexity.
Add PaperTrail later if you need record-level version history for a few models. Do not start by spraying callbacks over the whole app and pretending that is an audit strategy. It is not. It is a future cleanup project wearing a compliance hat.
Build the boring trail now. Your support team, your future customers, and whoever buys your SaaS later will all thank you.