Omaship

March 22, 2026 . 13 min read

How to Add Audit Logs to Your Rails 8 SaaS in 2026

Jeronim Morina

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_type and actor_id identify who performed the action. Usually User, but keep it generic in case system jobs or admins also act.
  • action is a stable string like billing.plan_changed, admin.impersonation_started, or team.role_revoked.
  • subject_type and subject_id identify what the action was performed on.
  • subject_label stores a human-readable reference, because support people should not have to decode IDs at 2am.
  • metadata holds 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, and user_agent give you incident response context.
  • occurred_at lets 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.

Continue reading

We use analytics and session recordings to learn which parts of Omaship help and which need work. Accept all, or customize what you share.

Privacy policy