Skip to content
web-ascenderPublic

About

Two-layer audit log for Rails 8 + PostgreSQL

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Copyright (c) 2026 Web Ascender. All rights reserved. CONFIDENTIAL AND PROPRIETARY PROPERTY. This software is for internal company use on company projects only. Unauthorized copying, modification, or distribution via the public internet or any cloud environment is strictly prohibited. See LICENSE.txt.

AuditLog

CI

A two-layer, compliance-grade audit log for Rails 8 + PostgreSQL. Implements DESIGN.md — the design record, which sits next to this file and is the authority on why any of this is shaped the way it is.

Contents


Summary

An audit log that cannot be bypassed, because it does not run in Ruby. PostgreSQL triggers write a field-level diff of every INSERT, UPDATE and DELETE, so update_all, delete_all, insert_all, upsert_all, raw SQL, a database cascade, a rake task and a console session are all captured — with the actor attached — and no model has to opt in or even know.

  • Nothing in a model class. No concern, no callback, no base class. The entire per-model cost is one line in a migration.
  • A callback-based gem cannot see update_all. This one has no callbacks to bypass.
  • One request_id per unit of work. A form submit that writes a parent and forty children reads as one action with forty children, not forty unrelated rows.
  • The actor comes along for free — including into background jobs, which also record the request that enqueued them.
  • Coverage is a forcing function. The build fails for any table that is neither audited nor exempted with a written reason. You cannot forget a table.
  • Two layers. Field-level diffs (complete by construction) plus named business events with human sentences, joined by the same correlation id.
  • A finished auditor UI at /audit, served by the gem — actor activity, record history, action reports, out-of-band review, drill-down, CSV export. It is not copied into your app and you do not maintain it; it upgrades with the gem.
  • Filterable by your own facets. Record a customer_id or a department_id onto audit rows and ask "everything that happened for this customer" — including the writes no callback ever saw. Optional, and an app that declares none pays nothing.
  • Optional starter views for your own pages, generated into your app and yours to rewrite. Plain CSS, Tailwind or Bootstrap.
  • Built for volume from day one. Monthly range partitions, automatic rotation, retention, yearly rollup, verified export, VACUUM FREEZE.
  • GDPR erasure that keeps the evidence. Redaction removes values and keeps the structure — "the email changed at 14:02, by Jane" stays provable after the address is gone.
  • Uncorrelated writes are surfaced, not hidden. A console edit gets its own screen rather than blending in.
  • No silent truncation, anywhere. Keyset paging, disclosed date bounds, uncapped exports. Every screen says what it searched.
  • Timestamps are UTC by construction, not by convention — the app's time_zone cannot reach them.
  • A reconciler tells you what you have not named yet, so the readable layer fills in over time instead of being an up-front project.
  • Ids in a diff read as records. product_id → Grommet 10mm (id: 51), with the recorded id never dropped.
  • Zero application constants. Every coupling point is a lambda on AuditLog.config, so one library serves every app.

Already weighing this against paper_trail, audited or logidze? Why this one and Why not one of the popular gems? are at the end, along with the cases where this gem is the wrong choice.

Requirements

Why it is a floor and not a preference
Ruby >= 3.3 SecureRandom.uuid_v7, which is Context.new_request_id. On 3.2 every correlated write raises. UUIDv7 gives the audit_changes(request_id) index insert locality, and its embedded timestamp is what bounds the drill-down. DESIGN §2.1.
Rails ~> 8.0 8.0 floor for Rails.event (with a fallback, and CI runs the suite on 8.0 so the fallback is exercised rather than assumed); ceiling below 9.0 because TransactionStamp prepends the private raw_execute. DESIGN §2.2.
PostgreSQL >= 16 Layer 1 is a plpgsql trigger writing jsonb into range-partitioned tables, so this is not swappable for another database — but nothing here needs a recent Postgres. 16, 17 and 18 are all supported; CI runs the suite on 16 and 18. DESIGN §20.

pg is deliberately not a dependency, so your app picks its own build. Nor is pagy, or any other pagination gem: the audit screens are keyset-paginated by AuditLog::Pagination, which is this library's own and depends on nothing, so your app paginates however it already does — see Use AuditLog::Pagination. The one runtime dependency is csv, for the export.

Ruby 3.3.0 exactly is unusable with Rails 8.1, for a reason unrelated to this gem: actionview 8.1.3.1 contains yield(*, **) inside a block, which 3.3.0's parser rejects, while Rails still declares >= 3.2.0. Any later 3.3 patch is fine.

The two layers

                         Current.request_id = <uuidv7>
   web request           Current.actor      = User#17
   background job                 │
   console / rake                 │
                     ┌────────────┴────────────┐
                     │                         │
        LAYER 2 (application)      LAYER 1 (PostgreSQL)
        AuditLog.notify(...)       AFTER INSERT/UPDATE/DELETE
                │                  FOR EACH ROW triggers
                ▼                         │
          audit_events   ◄─request_id─►   ▼
          1 row per ACTION           audit_changes
          who / what / summary       1 row per ROW CHANGE
                                     jsonb field diff

Layer 1 cannot be bypassed. Not by update_all, delete_all, insert_all, upsert_all, dependent: :delete_all, a database cascade, raw SQL, a rake task, or a console session — because it lives in the database rather than in an Active Record callback. This is the whole reason for the design.

Layer 2 is opt-in per action, because no database can infer that saving six rows constituted "submitting an order".

The join is request_id. One form submit → one audit_events row → N audit_changes rows sharing one UUIDv7.


Demo Rails App

audit-log-demo is a small Rails app that installs this gem the way the next section describes — seeded data, emitted events, and the generated activity views on real pages. It is the demo app the rest of this file refers to as the reference app.


Getting started

An existing Rails app with existing models. Work down the list; every step is a command, and the reasoning for any of it is linked rather than inline.

1. Add the gem

# Gemfile
gem "audit_log", git: "https://github.com/web-ascender/audit-log", tag: "v0.6.3"

A private repo, so bundle needs credentials for the company GitHub org. Pin to a tag — without one, bundle update tracks main and moves the library under a running app. Use path: "../audit-log" for local co-development.

2. Install

bin/rails generate audit_log:install
bin/rails db:migrate

Writes the initializer, the schema migration, the two includes, the engine mount and the coverage spec — see What the generator wrote, which also lists what it reports rather than does.

⚠️ Confirm one thing before moving on. The generator puts include AuditLog::ControllerContext after the last before_action it can find in ApplicationController. If it lands ahead of your authentication, it reads a current_user that is not resolved yet and every audit row gets a NULL actor, silently. Look at the file.

3. Attach a trigger to each audited table

bin/rails generate audit_log:trigger orders   --model=Order
bin/rails generate audit_log:trigger products --model=Product --exclude=search_vector
bin/rails db:migrate

One line per table, and the entire per-model cost of the design — nothing goes in the model class. Which tables are worth auditing is a judgement about your domain, so nothing can infer it for you.

Two things to know, both covered in Attaching to a table that already exists: the table needs a bigint primary key named id or the first write after attaching fails, and there is no backfill — rows that predate the trigger have no history, so write the attach date down.

Flags: audit_log:trigger options.

4. Prove nothing was missed

bin/rails audit_log:coverage

Fails until every table is either audited or listed in config.unaudited_tables with a written reason. The generator also wrote a spec asserting the same thing, so the decision cannot be skipped instead of made.

5. Schedule the daily task

0 2 * * *   bin/rails audit_log:partitions

A missing future partition is a write-path outage, not a degraded report. This is the one task that belongs in a cron; the rest are in Rake tasks.

6. Read the initializer before deploying

config/initializers/audit_log.rb. config.authorize defaults to a no-op, which is right for a demo and wrong for you. Everything else is in Configuration.

At this point every change to an audited table is recorded, with an actor and a correlation id, and readable at /audit. Neither step below is required for that.

7. Recommended: Register and emit events for significant business actions

Two lines in two files — a declaration and a call — for each action worth a sentence:

# config/initializers/audit_log.rb
AuditLog::Registry.register "order.cancelled",
  subject: ->(p) { ["Order", p[:order_id]] },
  summary: ->(p) { "Cancelled order #{p[:number]} (#{p[:reason]})" }

# the controller, model or job — after the write succeeds
AuditLog.notify("order.cancelled", order_id: @order.id, number: number,
                reason: params[:reason])

This is what turns a complete log into a readable one: layer 2, the sentences an auditor reads instead of a field diff. When the action spans several writes, AuditLog.audited is the same emit with the transaction handled for you. bin/rails audit_log:reconcile tells you which actions you have not named yet, so it fills in over time rather than up front. See Registering and emitting events.

8. Optional: Put a history on your own pages

bin/rails generate audit_log:views:activity Order Product LineItem

Then edit RecordActivity#audit_activity_visible?, which the generator prints in red because it denies everyone until you do. Running it again later adds a model and leaves your edits alone. See Building an activity history.

What the generator wrote

Step 2 does all of this. Worth a look rather than a read — it reports anything it could not do, and two of these need a decision from you.

What Check
config/application.rb config.active_record.schema_format = :sql Required, and required before your first migration — schema.rb cannot represent partitioned tables or triggers. On an app that already has a db/schema.rb the generator refuses and tells you, rather than flipping it silently.
config/initializers/audit_log.rb every coupling point, as a lambda The only file that knows anything about your app. Configuration is the full list.
db/migrate/…_install_audit_log.rb AuditLog::Schema.install! The two partitioned tables, their indexes, and the trigger function.
ApplicationController include AuditLog::ControllerContext ⚠️ Must sit after whatever sets current_user — see the warning in step 2.
ApplicationJob include AuditLog::JobContext The entire job-side integration.
config/routes.rb mount AuditLog::Engine => "/audit" Gate it. config.authorize is a no-op by default.
.claude/skills/audit-log/SKILL.md a pointer to this gem's own docs, for coding agents Yours to edit, never regenerated. Documentation for coding agents.
app/assets/stylesheets/audit_log.css a starter stylesheet for /audit, commented out Inert until you enable it, which is one sed the generator prints. The engine ships no CSS on purpose. Styling the auditor UI.
spec/audit_log/coverage_spec.rb three lines, using a shared example The forcing function. Shares AuditLog::Coverage with the rake task, so the two cannot disagree about what counts as covered. Do not weaken it to make a build pass.

Re-running is safe: every step detects work already done and reports skip rather than injecting twice. Flags: --mount-at=/audit, --skip-migration, --skip-routes, --skip-controller, --skip-job, --skip-spec, --skip-skill, --skip-css.

What a model needs

Nothing! No include, no concern, no callback, no base class. An audited model is an ordinary ApplicationRecord. The one line of per-model cost lives in the migration, next to the table it audits.

Registering and emitting events

Once the trigger is attached and ControllerContext is included, every row your controllers touch is already being recorded — field by field, with no code in the controller at all. This section is optional: layer 2 is the sentence over the top of that, and skipping it costs you readability, never completeness.

It takes two pieces, in two files:

Lives in Does
AuditLog::Registry.register config/initializers/audit_log.rb declares the action and renders its human summary
AuditLog.notify the controller, model or job emits it, carrying the payload that summary reads
AuditLog.audited the model or service the same emit, with the transaction opened for you — see below

You never pass the actor, IP, source, timestamp or request_id. All five come from AuditLog::Current, which ControllerContext populated in a before_action — the payload is only the domain detail.

Registering actions

Register the actions with business significance in config/initializers/audit_log.rb. An entry gives the action three things it does not otherwise have: a human sentence, the record it was about, and a name an auditor can filter and group by.

What you gain is readability — the difference between an auditor reading "Cancelled order SO-4471 (duplicate)" and reading four column diffs to infer it.

AuditLog::Registry.register "order.created",
  description: "An order was placed for a customer.",
  requires: %i[order_id],
  subject: ->(p) { ["Order", p[:order_id]] },
  summary: lambda { |p|
    "Placed order #{p[:number]} for #{p[:customer]} — " \
      "#{ActiveSupport::NumberHelper.number_to_currency(p[:total_cents].to_i / 100.0)}"
  }

AuditLog::Registry.register "order.updated",
  subject: ->(p) { ["Order", p[:order_id]] },
  summary: ->(p) { "Edited order #{p[:number]} (#{Array(p[:fields]).join(', ')})" }

AuditLog::Registry.register "order.cancelled",
  description: "An order was destroyed, cascading to its line items.",
  subject: ->(p) { ["Order", p[:order_id]] },
  summary: ->(p) { "Cancelled order #{p[:number]} (#{p[:reason]})" }

.register options:

Shape Purpose Example Stored on the row?
summary:

(required)
lambda → String Describe a specific occurrence. Should usually include a noun, a verb and some kind of human-friendly record descriptor "Submitted Order #{p[:number]}" yes — audit_events.summary, rendered at emit and frozen
subject:

(optional - recommended)
lambda → [type, id], optional Track the model type and id (each occurrence) ["Order", p[:order_id]] yes — subject_type / subject_id, indexed
description:

(optional - recommended)
String What this action means, in general "An order was submitted for fulfillment."
(for an action registered as "order.submitted")
no — it lives only in this initializer
requires:

(optional)
Array<Symbol> Payload keys this entry cannot render without. Emitting it without one raises instead of storing a sentence with a hole in it — see Declaring a payload contract %i[order_id number reason] no — it is a check, not data

summary: is the evidence sentence, and it is what every screen shows. Interpolate the payload so each row says something specific: Placed order SO-4471 for Acme — $1,240.00, not "an order was placed". It is rendered once, at emit time, and stored, so editing the lambda changes what future rows say and never what past rows said — a copy edit must not alter the historical record.

subject: names the record the action was about — a pointer, not prose; nothing renders it as text. Read a prefixed payload key — p[:order_id], not p[:id], even here where the subject is the order (see Payload rules). It is what puts the action on that record's history screen, and what a later erasure request follows, so set it on any entry whose summary could carry personal data, or that erasure will not reach it. Omit it only for an action with no single subject, such as a bulk price change; those still show up in a record's correlated section.

description: is the glossary entry an auditor reads at the top of /audit/actions/order.cancelled when they need to know what that name signifies in your app. Write it once, in the present tense, about the action rather than any occurrence of it. Because it is not stored, editing it changes what the glossary says everywhere — which is right: it documents what the name means now, not a historical claim about any event.

Note

A call to AuditLog.notify(...) (or AuditLog.audited) for an action that is not registered is a silent no-op:

  • the event still reaches any other Rails.event subscriber, which is how analytics events stay out of the audit tables;
  • the change rows land as they always would, so the record layer stays complete;
  • the activity simply has no headline, and a timeline renders it as :change_only — the diff, with no sentence over the top of it;
  • bin/rails audit_log:reconcile lists it.

This is the first thing to check when an action does not show up on /audit.

Emitting it: create, update, destroy

The payload keys below and the p[...] reads in the entry above are the contract between the two files. A typo on either side renders an empty gap in a sentence, so once an action settles down, declare its keys with requires: and the gap becomes an exception instead.

class OrdersController < ApplicationController
  before_action :set_order, only: %i[update cancel]

  def create
    @order = Order.new(order_params)

    # Emit INSIDE the success branch. An event for a save that failed
    # validation is a lie the audit log cannot take back.
    if @order.save
      AuditLog.notify("order.created",
        order_id:   @order.id,
        number:     @order.number,
        customer:   @order.customer.name,
        total_cents: @order.total_cents)
      redirect_to @order, notice: "Order created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @order.update(order_params)
      # `saved_changes` is a good payload: it says WHICH fields moved without
      # duplicating layer 1's before/after values, which audit_changes already
      # holds against this same request_id.
      AuditLog.notify("order.updated",
        order_id:   @order.id,
        number:     @order.number,
        fields:     @order.saved_changes.keys - %w[updated_at])
      redirect_to @order, notice: "Order updated."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def cancel
    # Read anything the summary needs BEFORE the row goes away.
    number = @order.number

    @order.destroy!
    AuditLog.notify("order.cancelled",
      order_id: @order.id, number: number,
      reason: params[:reason].presence || "no reason given")
    redirect_to orders_path, notice: "Order cancelled."
  end
end

An action that spans several writes

Put the notify in the model or service, inside the same transaction as the work, and let the controller stay a controller:

# app/controllers/orders_controller.rb
def submit
  @order.submit!(by: current_user)
  redirect_to @order, notice: "Order submitted."
end

# app/models/order.rb
def submit!(by:)
  transaction do
    update!(status: "submitted", submitted_at: Time.current)
    line_items.each { |item| item.update!(unit_price_cents: item.product.price_cents) }
    customer.update!(balance_cents: customer.balance_cents + total_cents)

    # One notify for the whole action, not one per row: layer 1 already wrote a
    # row per row. Inside the transaction, so a rollback discards the sentence
    # along with the changes it describes.
    AuditLog.notify("order.submitted",
      order_id: id, number: number, line_count: line_items.size,
      total_cents: total_cents, approver: by.to_label)
  end
end

Two reasons it belongs there rather than in the controller: the same action invoked from a console session or a rake task still gets its narrative, and the event cannot commit without the writes it claims happened.

approver: is in the payload only because it may differ from the actor — the person who clicked is already on the row. Do not re-send current_user as a payload key; it is duplication that can later disagree with actor_label.

Letting audited open the transaction

AuditLog.audited is sugar for exactly the shape above — it opens the transaction, runs your block, and emits the event, same guarantees:

# app/models/order.rb
def submit!(by:)
  # pass identity data in as keyword arguments (e.g. order_id)
  AuditLog.audited("order.submitted",
    order_id: id, number: number, approver: by.to_label) do |audit|
    # audited opens or joins a transaction
    update!(status: "submitted", submitted_at: Time.current)
    line_items.each { |item| item.update!(unit_price_cents: item.product.price_cents) }
    customer.update!(balance_cents: customer.balance_cents + total_cents)

    # assign outcome data using the `audit` block variable
    # (e.g total_cents wasn't known until code inside the block was executed)
    audit[:line_count]  = line_items.size
    audit[:total_cents] = total_cents

    # audit data is committed (or rolled back) with the transaction
  end
end
Identity and inputs
(values that will not change inside the block)
Outcomes
(values only known inside the block)
pass as keyword arguments to .audited(...) assign through the audit block variable

Caution

A key passed as a keyword and set in the block raises — it does not overwrite. Keyword arguments are evaluated before the block runs, so the keyword holds the pre-write value; if the block changes it, it belonged in the block. Nothing can prove a value is an input, so this collision is the one guard that can exist.

Within the block there is no such guard: a key the keywords never carried can be assigned twice and the last write wins, silently. Same for merge!.

The audit collector takes keys three ways, all equivalent:

# assignment
audit[:line_count] = line_items.size                        
# keywords    (note .merge! and not .merge)
audit.merge!(line_count: line_items.size, total_cents: n)   
# a hash      (note .merge! and not .merge)
audit.merge!({line_count: line_items.size})                 

audited returns the block's value, so a method can still return what it built:

def ship!(carrier:)
  AuditLog.audited("order.shipped", order_id: id, carrier: carrier) do |audit|
    shipment = shipments.create!(carrier: carrier)
    update!(status: "shipped")
    audit[:tracking_number] = shipment.tracking_number

    # block returns `shipment` like you'd expect
    shipment
  end
end

Calling it inside a transaction you already opened works, and is the normal case. audited joins an open transaction on the same connection rather than nesting one, so the event commits and rolls back with your unit of work.

You can use Rails' transaction callbacks without opening a transaction of your own by using the second tx block argument:

AuditLog.audited("order.shipped", order_id: id) do |audit, tx|
  tx.after_commit { NotifyCustomerJob.perform_later(id) }

  shipment = shipments.create!(carrier: carrier)
  audit[:tracking_number] = shipment.tracking_number
end

When joined, that is your transaction object, so the callback fires on your outermost commit rather than on ours. Blocks naming one argument or none are unaffected.

That is the whole of the everyday API, and it assumes a single database. Savepoints, ActiveRecord::Rollback inside a joined transaction, and the transaction callbacks Rails documents but does not have are in Transaction control in audited; apps using connects_to need Multi-database apps.

An action whose writes skip Active Record

Nothing changes. Emit the event exactly as above — layer 1 catches the rows from the database side:

def bulk_adjust
  percent = params[:percent].to_i.clamp(-50, 50)
  # No callbacks, no instantiation, no Active Record involvement at all.
  count = Product.where(active: true)
                 .update_all("price_cents = (price_cents * #{100 + percent}) / 100")

  AuditLog.notify("price.bulk_adjusted", percent: percent, count: count)
  redirect_to products_path, notice: "Adjusted #{count} prices."
end

The audit_changes rows and this audit_events row share the request's request_id, so the drill-down shows the sentence with all count diffs under it.

An action that only enqueues work

Do not emit anything for the enqueue. Once ApplicationJob includes AuditLog::JobContext (step 2), the job inherits this request's actor and records this request as its caused_by_request_id; the job emits its own event when the work actually happens:

def ship
  OrderShipmentJob.perform_later(@order)
  redirect_to @order, notice: "Shipment queued."
end

An event emitted here would claim the order shipped at the moment somebody clicked a button, which is not what happened.

Payload rules

  • Pass primitives — ids, strings, numbers, arrays. The payload is stored verbatim in the metadata jsonb column. Passing an Active Record object serialises every one of its attributes into the audit log, PII included.
  • Name every id key for its type — order_id:, never id: — including on an action whose subject is that record. A bare id cannot be declared as a dimension: dimensions: %i[id] records {"id": "17487"}, which no job_id filter matches, so the record's own events drop off its own facet feed while a record timeline still shows them — two screens disagreeing about one history. It also renders as evidence, where id: 17487 beside number: "117487" does not say which number the log recorded. Payloads are frozen at emit time, so neither is repairable afterwards. DESIGN.md §7.
  • Include what the sentence needs plus the evidence behind it, and nothing else. metadata renders on the action screen as the structured backing for the summary.
  • Never put a secret, token or password in a payload. config.default_excluded_columns keeps encrypted_password and the reset tokens out of layer 1's diffs. It does not filter a layer 2 payload — that is exactly what the call site passed, and nothing else inspects it.
  • Getting something back out is blunt. AuditLog::Redaction empties an event's metadata wholesale and replaces its summary with the marker, so one careless key costs that subject its entire narrative. It also matches on subject_type / subject_id, which means an action registered without a subject: cannot be reached by a record-level erasure at all.
  • nil values are dropped (payload.compact), so a key that is sometimes absent will be absent from metadata, not present as null.
  • A missing key is silent unless you declare it. See Declaring a payload contract below.
  • Do not rescue around notify. The engine sets Rails.event.raise_on_error = true on purpose: a failed audit write must not vanish while the change it described commits anyway.

Declaring a payload contract

The payload keys a call site passes and the p[...] reads in the registry entry are a contract between two files, and by default nothing checks it. A typo on either side renders a gap in a stored sentence — and summaries are frozen at emit time, so that gap can never be repaired.

requires: is the third point that makes the two agree:

AuditLog::Registry.register "order.submitted",
  requires: %i[order_id reference customer_name line_count total_cents],
  subject: ->(p) { ["Order", p[:order_id]] },
  summary: ->(p) { "Submitted order #{p[:reference]} — #{p[:line_count]} line items" }

Emit that action without line_count — from notify, from audited, or from a bare Rails.event.notify — and it raises AuditLog::MissingPayloadKeys naming the key, inside your transaction, so the change rolls back with it.

Four things to know:

  • Opt-in per entry. An entry with no requires: is unchecked — any payload passes, including an empty one.
  • Extra keys pass, and are still stored.
  • A key present with a nil value counts as supplied — metadata is stored .compacted, so this is the only place that distinction survives.
  • List what the entry cannot render without, not every key it reads.

The reasoning behind each is in DESIGN.md §7.

Finding the actions you have not registered yet

bin/rails audit_log:reconcile reports correlated changes with no registered action — the writes that happened under one request_id and have no sentence over them. Run it after adding controllers, and let it tell you which narratives are still missing.


Reading one record's history

Three tabs on /audit/records/:record_type/:record_id/history. The first two are one per layer, because they answer different questions and neither substitutes for the other; the third puts them together.

Changes (the default) is audit_changes — every INSERT, UPDATE and DELETE against this record, field by field, complete regardless of how the write was issued. This is the compliance-grade answer and the reason it is the landing tab.

Actions is audit_events — the same history as sentences. It has two sections, and the split is deliberate:

  • The actions that named this record as their subject. Indexed, keyset-paged and uncapped — complete for actions that have a Registry entry and a subject: lambda.
  • "Also touched this record" — actions that wrote to it under a different subject or none at all: a bulk update, a save whose subject was the parent, an entry registered with no subject:. There is no column linking these to the record, so they are found by matching request_id against the record's own change rows.

The second section is capped and says so: it reads a bounded number of the record's most recent change rows, prints how many it read, and offers ?scan= to widen it. That is the same treatment the request drill-down gives its date window — a narrowed query must never be mistaken for a complete one.

Timeline is both layers interleaved, at the grain a person reads: one activity per unit of work rather than one row per audit row, so a save that wrote this record and forty children is one card and not forty. It is rendered entirely from AuditLog::Timeline's value objects — the same published contract described in the next section — so the auditor UI cannot drift from what a host app gets. ?days= bounds it; unbounded is the default.

Both sections are reachable as query objects if you would rather build your own view than link to the engine's:

timeline = AuditLog::RecordTimeline.new(record_type: "Order", record_id: order.id)

timeline.events                    # subject-matched, ordered, UNLIMITED — you paginate
timeline.changes_for(page_of_events)  # the change rows behind a page, grouped by request_id
timeline.correlated(limit: 50)     # .events, .scanned, .truncated? — render all three

events returns an unlimited relation on purpose: a limit applied below the controller is invisible to the screen rendering it. If you cap it, say so on the page. And if you render correlated, render scanned and truncated? with it — a "recent activity" list that quietly stops short is worse than no list.

The engine sets isolate_namespace, so its helpers and route helpers are not available in your own views. Reuse the query objects, not the partials — write the markup that matches your app, or link to the engine screen.


Building an activity history in your own app

Optional, and starter code. The gem is complete without any of this — /audit is a finished auditor UI served by the engine, and nothing depends on what the generator writes. What it produces lands in your app and belongs to you: plain ERB, no markup lock-in, never re-generated, never upgraded. If you would rather write the view yourself, AuditLog::Timeline's value objects below are the real contract, and the generated files are one worked answer to it.

The auditor UI is for auditors. For an "activity history" on your own orders/show, in your own markup, use AuditLog::Timeline — a paginated list of units of work, each one carrying its narrative, that record's field changes, and the other records the same action touched.

Start with the generator. Everything after it — the worked example, the view written by hand, the value objects — is what it produces and the contract underneath, for when you want to change it or replace it.

Generate it

rails generate audit_log:views:activity Order Product Customer

Any number of models, in one call or several. That produces a controller, a concern, a helper, three views, a route, a locale file and a stylesheet — the reference app's implementation, extracted into templates. It is yours: plain Rails, no gem-side indirection, never re-generated or upgraded later.

--css=plain (default) ships audit_log_activity.css, no framework needed
--css=tailwind Tailwind utility classes in the markup, no stylesheet
--css=bootstrap Bootstrap classes in the markup, no stylesheet

The markup structure is identical across all three — only class= changes, so switching later is rewriting strings rather than re-deriving the view. Neither framework option installs anything; both assume you already have it working.

It denies everyone until you edit one method. RecordActivity#audit_activity_visible? is generated as false, and the generator says so in red. That default is deliberate: Timeline exposes previous values of every audited column and the other records each action touched — which on a shared action can be another customer's row. Defaulting to visible would publish all of it to every signed-in user of an app whose roles this gem cannot see, and nothing would report it.

The models you name become ActivityController::VIEWABLE, an allowlist checked before constantize — /activity/User/1 is a URL anyone can type. The generator refuses to run without them rather than emitting an empty one.

It wires up each model's show page too, where it safely can: the recent_activity call into #show, and the render into the view. Where it can't — no def show, an ivar it cannot infer, a namespaced model — it declines and prints the two exact lines for that model rather than guessing. Guessing @order when the controller calls it @sales_order produces a page that renders an empty feed and reports nothing, which reads as the audit log having no data. --skip-show-pages opts out.

Adding a model later is the same command again:

rails generate audit_log:views:activity Invoice Shipment

That second run adds both to the allowlist, wires up their show pages, and leaves every generated file alone — they are yours the moment they land, and a generator that quietly reverses an edited authorization rule is worse than no generator. --force re-baselines everything against the current templates when you actually want that.

A worked example

The reference app renders this on its order, product and customer pages, and on a paginated history of its own at /activity/:record_type/:record_id — its own markup, its own i18n for the sentence this library refuses to invent, its own record_url lambda, its own role check. Nothing but the contract above:

app/controllers/concerns/record_activity.rb the show-page widget: the cap, the extra key that discloses it, the role check
app/controllers/activity_controller.rb the paginated page: a record-type allowlist, ?days=, and include AuditLog::Pagination
app/helpers/activity_helper.rb the sentence, the actor, the touched records, the three nil shapes
app/views/shared/_activity_feed.html.erb how one activity renders, deliberately not this engine's markup
config/locales/en.yml activity.created / updated / deleted

That app also shows the shape worth copying: a manager reads one record's history there without holding the auditor role, because the split from /audit is by scope — one record, an allowlist of types — and not by fidelity. Same value objects, same detail.

Worth reading activity_value there before writing your own: it must return exactly one element, because the field list is a CSS grid whose <li> is display: contents. Returning a label and its id as two elements gives valid markup, correct values and a scrambled page — the kind of thing only rendering finds.

Writing the view yourself

class OrdersController < ApplicationController
  include AuditLog::Pagination      # the gem's keyset pager — see below

  def show
    @order      = Order.find(params[:id])
    timeline    = AuditLog::Timeline.for(@order)
    @page       = paginate(timeline.activity_keys, limit: 20)
    @activities = timeline.activities(@page.records)
  end
end

And the view it feeds:

<% @activities.each do |activity| %>
  <li>
    <time><%= l activity.occurred_at, format: :short %></time>

    <%# A registered action stored this sentence at emit time. nil when none did. %>
    <% if activity.headline %>
      <%= activity.headline %>
    <% else %>
      <%= t(".#{activity.operations.first}", model: Order.model_name.human) %>
      <%= activity.changed_columns.map { |c| Order.human_attribute_name(c) }.to_sentence %>
    <% end %>

    <span><%= activity.actor.display %></span>

    <% activity.field_changes.each do |fc| %>
      <div><%= fc.column %>: <%= fc.from %> → <%= fc.to %></div>
    <% end %>

    <% if activity.also_touched.any? %>
      <details>
        <summary><%= activity.also_touched.size %> other records</summary>
        <% activity.also_touched.each do |touched| %>
          <div><%= link_to touched.to_s, touched.url || "#" %></div>
          <details>
            <summary><%= pluralize(touched.field_changes.size, "value") %></summary>
            <% touched.field_changes.each do |fc| %>
              <div><%= fc.column %>: <%= fc.from %> → <%= fc.to %></div>
            <% end %>
          </details>
        <% end %>
      </details>
    <% end %>
  </li>
<% end %>

AuditLog::Timeline.new(record_type:, record_id:) is the same thing without a record in hand — which is what you want for a deleted record, since an audit trail outlives what it describes and that is exactly when somebody reads it.

Use AuditLog::Pagination, do not hand-roll one

include AuditLog::Pagination gives you paginate(scope, limit:), reading the cursor from params[:page]. It is not a convenience.

A hand-rolled keyset cursor serialises occurred_at at ActiveSupport's default millisecond precision, while the column is clock_timestamp() — microseconds. The cursor then names an instant just before the row it came from, and the next page skips everything in the gap: rows vanish between pages, silently, as a rare flake rather than an error. This module carries the fix, and falls back to the first page on a cursor minted for a different screen rather than applying it and dropping rows.

It brings no dependency with it, so paginate the rest of your app however you already do. DESIGN.md §11.0 has the measurement, and why this is hand-rolled rather than built on Pagy.

Five things to know

headline is nil when no registered action covered the write, and the library will not invent one — a generated sentence would be this gem's phrasing rather than yours, and would be indistinguishable on the page from a summary frozen at emit time. kind tells you which you are holding; register more actions and more entries become :narrative.

Never drop the id from a TouchedRecord. to_s renders Grommet 10mm (Product id: 51) on purpose: the label is resolved live, the id is what the log recorded. Showing only the label lets a rename rewrite what your timeline says happened.

The other records carry their own before-and-after. touched.field_changes is the same FieldChange list as the anchor record's, already loaded and already labelled with the page — no extra query. Without it a reader is told a line item's quantity changed and never what it changed to, and on your own page there is usually nowhere else to look: a line item has no show page to link to. Render it collapsed, and nested inside the "other records" disclosure — one form submit can touch forty of them.

Set config.record_url if you want links. It is nil by default and that is not a placeholder — this gem does not know your routes, and it will not guess product_path from "Product". Return nil for a type you have no page for.

config.record_url = lambda do |type, id|
  case type
  when "Order"   then Rails.application.routes.url_helpers.order_path(id)
  when "Product" then Rails.application.routes.url_helpers.product_path(id)
  end
end

Authorization is yours. The timeline exposes everything the log holds — diffs, actors, other customers' records touched by the same action. That is a staff-grade view. config.authorize gates the auditor UI; this is your screen, so gate it with your own policy layer.

Bounding it

range: narrows both halves of the union and is the biggest lever on cost. An unbounded timeline plans against every partition your retention horizon holds; a 30-day window plans against a handful, whatever that horizon is. Measured with EXPLAIN against a 36-month horizon — 72 monthly partitions across the two tables:

Bound Partitions in the plan
unbounded 72
30.days.ago.. (endless) 12
30.days.ago..Time.current 4

The full table, and why pruning survives the union and the GROUP BY, are in DESIGN.md §11.2b, The date bound.

AuditLog::Timeline.for(@order, range: 90.days.ago..Time.current)   # max age
AuditLog::Timeline.for(@order, range: (cutoff - 1.year)..cutoff)   # up to a date

Two rules for writing the range:

  • Close it at the top, even when the top is "now." That is the difference between the second and third rows above, for the same span. Pass an endless range anyway and the library closes it for you.
  • Pass Ruby times, not SQL. now() - interval '30 days' defers pruning until after the planner has already opened every partition.

The default is unbounded on purpose — a bound nobody asked for is invisible truncation. If you do bound it, say so; bounded? and scope_description exist for that, and are in as_json too:

<p>Showing <%= timeline.scope_description %>.</p>

older_than_window? answers "is there history before this window" with one indexed check per table — the difference between "end of results" and "end of the window". Call it once, at the bottom of the last page. It is never called for you, because it deliberately looks below the bound.

What the timeline covers

The index is a union, so an entry appears if the unit of work either wrote this record or was about it (an audit_events row whose subject is this record). That second half is what catches an action that wrote only children, one whose write landed in another table, one that wrote nothing at all, and every action on a record whose table is in unaudited_tables.

The one thing it does not reach is an unregistered action that only wrote children — no event, and no change row here. That is a registry gap rather than a query one, and bin/rails audit_log:reconcile is what reports it. DESIGN §11.2b explains why chasing it through a child's foreign key would break more than it fixes.

The engine's own Timeline tab is rendered from these same objects, so the contract cannot drift from what the auditor UI does.

Styling the auditor UI (optional)

The screens the engine mounts at /audit ship unstyled, and that is not an oversight. They render inside your layout — that is what config.parent_controller is for — so a stylesheet the gem loaded would arrive uninvited on a page you designed, and you would spend your time overriding it. The markup carries semantic class names instead.

audit_log:install writes a starting point, commented out:

app/assets/stylesheets/audit_log.css

As generated it is a no-op: an explainer, then the whole stylesheet inside one block comment. To turn it on, delete two lines — the bare /* under the explainer, and the file's last line. In most editors you can instead select that block and hit the toggle-block-comment key.

Then load it however this app loads stylesheets:

Pipeline The line
Propshaft <%= stylesheet_link_tag "audit_log" %> in your layout
Sprockets *= require audit_log in application.css
Sass (dartsass, cssbundling) @import "audit_log"; in application.scss — one line, and what an importmap app usually wants

Nothing loads it merely for being present, and nothing in the gem ever checks whether it is there or current: it is yours the moment it lands, like the generated activity views.

The stylesheet is the reference app's own, ported and scoped — it was arrived at by rendering these screens and fixing what broke, so five of its rules look odd and are load-bearing. The explainer at the top says which and why, because there are deliberately no comments inside the block: one would close it early and leave the rest of the stylesheet live. That explainer also carries the map of what is in there — palette, frame, text helpers, tables, badges, diffs, association labels, nav, filters, cards, payload, timeline, responsive, dark mode. Two of those are worth a decision rather than a glance:

  • The palette. Fifteen custom properties on .audit-log — fourteen colours and a font stack — and every other rule reads them. Rethemeing is those, not a rewrite.
  • Dark mode. The @media block at the very end, so it is easy to drop. Keeping it makes these screens follow the reader's system setting rather than your application's — which on a light-only app shows as a dark panel inside a light page. Delete that one block and the screens stay light for everyone.

Every selector is scoped under .audit-log, the element each screen is wrapped in, so nothing here can reach your own .card or .note — the engine's class names are deliberately generic and would otherwise collide. Four properties on that element — background, max-width, margin and padding — are the first thing to change if your layout already provides a frame: they are there so the screens look finished with no help from you, which means they paint a panel your page did not ask for. Colour is never the only signal: before and after values carry a left border as well as a tint, badges carry their own text, and a redacted payload says so in words. Keep that if you retheme. An auditor may be colour blind, and these screens are read as evidence.

Timestamps

Every timestamp on the auditor screens is rendered by one helper, and three things about it are deliberate.

The zone is always named, and the date is ISO-ordered. 2026-09-10 13:06 UTC. An unlabelled timestamp on an audit screen is ambiguous, and two readers seeing different unlabelled numbers is worse than everyone seeing UTC. Year-month-day rather than a month name because Sep is English, and this is the one rendering a reader with no JavaScript ever sees — a language dependency there would be the coupling this library removed, in the other direction.

The reader's own zone by default, through their browser. The server renders UTC; a small inline script re-renders each <time> in the reader's zone using the platform's Intl.DateTimeFormat, with no date library and no dependency added to your app. With JavaScript off, a blocked script, or a Content Security Policy that rejects it, the screen still shows a complete labelled UTC timestamp — the enhancement only ever replaces one correct rendering with another. Set config.display_time_zone = :utc for UTC everywhere and no script; the engine refuses to boot on any other value rather than falling back silently.

The conventions are yours; the field set is not. Date, year, time and zone are always all present — that is the point of the section above. Field order, month name, digit shape and the 12-or-24-hour clock come from the reader's own locale by default, or from config.timestamp_locale when you want one house style for everyone:

config.timestamp_locale = nil                # the reader's own locale (default)
config.timestamp_locale = "en-US"            # Sep 10, 2026, 1:06 PM EDT
config.timestamp_locale = "en-GB"            # 10 Sep 2026, 18:06 GMT+1
config.timestamp_locale = "en-US-u-hc-h23"   # Sep 10, 2026, 13:06 EDT

It is a BCP-47 language tag and not a format string, deliberately: a strftime string is what this library just stopped taking from your I18n, and it can drop the year or the zone label with nothing reporting it. A locale tag says "American or international" precisely and cannot express "no year". The unicode extensions cover the combinations a house style actually wants — the -u-hc-h23 above is American field order on a 24-hour clock. The engine rejects a malformed tag at boot, and a tag the reader's browser dislikes anyway falls back to their own locale rather than to no conversion at all.

The two settings answer different questions and neither overrides the other. display_time_zone decides which zone; timestamp_locale decides whose conventions. :viewer with "en-GB" gives a reader in New York 10 Sep 2026, 09:06 EDT — their zone, British conventions.

The one combination that does nothing is :utc with a locale: :utc renders no script, so the locale reaches nobody and every reader sees the canonical 2026-09-10 13:06 UTC. The engine logs a warning saying exactly that rather than ignoring you silently — and warns rather than refusing to boot, because flipping to :utc for a compliance review is legitimate and shouldn't need a second edit.

Either way the server-side fallback stays 2026-09-10 13:06 UTC — the same for every reader, in every language.

The recorded instant is always one hover away. datetime and title carry the stored value at microsecond precision whatever the visible text says, so a display in someone's local zone never becomes the only version of when something happened. The CSV export is untouched: it ships the recorded UTC values, with no display layer in it at all.

The format is the library's own and is not l(time, format: :short). That read your application's time.formats.short, which meant the audit screens' timestamps were formatted by one of your I18n keys — an app that had set it to a time-only format got audit screens showing no date at all, and Rails' own default omits the year, which is wrong on a log kept for seven years.

Making association ids readable (optional)

A field-level diff records what the database recorded, which is an id:

product_id     (not set)  →  51
customer_id    (not set)  →  25

Define to_audit_label on a model and every id pointing at it gains a caption:

class Product < ApplicationRecord
  def to_audit_label = "#{sku} — #{name}"
end
product_id     (not set)  →  WID-100 — Widget, standard (id: 51)

That is the whole opt-in. No configuration, no per-column declaration: belongs_to reflection on the changed model finds which columns are foreign keys and what they point at, and the label chain is tried in this order —

to_audit_label first, so a model can show auditors something other than what it shows the rest of the UI
to_label the same hook actor labels use, and in the same order
to_s only when the model deliberately overrode it
nothing no label. The cell renders the bare id, exactly as it did before

There is deliberately no fallback that reads a name or title column. Guessing which column reads as a label is how a screen ends up confidently captioning an id with the wrong string; to_audit_label is the seam for saying it explicitly.

The id is never replaced. It is what the audit log actually stores, so the label annotates it, and the screen says once that names are resolved when the page loads.

The four things a cell can say

Means
WID-100 — Widget (id: 51) resolved
51 (not found) nothing with that id exists now — it was almost certainly deleted, which on an audit screen is information
51 (label unavailable) the lookup itself failed. Not the same as "no label configured", and never a blank cell
51 no label available. Every screen renders exactly as it did before this feature existed

Configuring the label lookup

Both attributes are optional and both have working defaults.

AuditLog.configure do |config|
  # ->(type, ids) { {id => label} }  Batch: called once per record type per page.
  # Return nil for a type you do not label; {} for a type you do label none of
  # whose ids still exist. The screen renders those two differently.
  #
  # nil disables association labelling entirely.
  config.record_label_resolver = lambda do |type, ids|
    klass = type.safe_constantize
    klass ? klass.where(id: ids).index_by(&:id).transform_values(&:to_audit_label) : nil
  end

  # For the foreign keys reflection cannot see. Merged OVER the reflected map;
  # `false` suppresses a column reflection did find.
  config.association_targets = { "LineItem" => { "legacy_product_ref" => "Product" } }
end

Reflection, not convention. The belongs_to carries class_name:, so orders.created_by_id resolves to User — which no amount of de-suffixing the column name would.

Two things to know before turning it on

  • Scoping is your job. The default resolver is where(id: ids) with no tenant scope, reading live business tables on a screen an auditor is trusted with. In a multitenant application that reads perfectly safe and is not — scope it inside the lambda.
  • CSV export is untouched, deliberately. It is the evidence artifact; the diff column ships the ids that were recorded, with no display decoration.

Cost is one primary-key lookup per record type per page, batched before the table renders. A type that cannot produce a label is skipped with no query at all, so an application that has opted nothing in pays nothing.

Why a label annotates a recorded id here while an actor label is snapshotted at write time: DESIGN.md §11.8.

Dimensions: querying by your own associations (optional)

The audit log answers questions about actors, records and requests. It cannot answer "everything that happened to invoices in department 5" — department_id is yours, and this library never sees your models.

Dimensions are facets you attach to audit rows so that it can.

Declare them beside the trigger

The names are columns on the table being audited:

attach_audit_trigger :invoices, model: "Invoice",
  dimensions: %i[organization_id customer_id department_id
                 shipping_location_id payment_provider_id]

Every write to invoices now records those five values beside the diff — including update_all, a database cascade, raw SQL and a console session, because they are read from the row by the same trigger that writes the diff.

Declare only what you will filter on. Each facet costs index maintenance on every write to that table. A value you want to read on a screen belongs in the action's payload, which is free.

A name that is not a column on that table raises in the migration. That is the only enforcement in this feature, and it is there because the alternative is a facet that records nothing forever and a filter that returns nothing without saying why.

Changing the list is detach-then-attach, the same as changing a table's exclusions.

Query them

timeline = AuditLog::DimensionTimeline.new(
  dimensions: { department_id: 5, shipping_location_id: 12, payment_provider_id: 3 },
  range:      30.days.ago..
)

page       = paginate(timeline.activity_keys)     # AuditLog::Pagination
activities = timeline.activities(page.records)

Any combination of the declared facets, in one index scan — you do not add an index per combination. The result is the same Activity objects a record timeline yields, so anything you already render for one works here unchanged.

Values are normalised for you, so department_id: 5 and department_id: "5" are the same query. On the relations directly, AuditLog::Change.where_dimensions(...) and AuditLog::Event.where_dimensions(...) are the same normalisation.

Unlike a record timeline, this one is bounded by default — 30 days, because an unfiltered facet scan across a long retention horizon is genuinely slow. Pass range: to widen it, or range: nil for all retained history. scope_description tells the reader which they are looking at.

Facets that are not columns

A tenant, a deploy version, a tag — values your application has but no audited row carries. These attach to events, so they need a registered action.

Per action, taken from the payload:

AuditLog::Registry.register "invoice.approved",
  dimensions: %i[department_id region],
  requires:   %i[invoice_id],
  subject:    ->(p) { ["Invoice", p[:invoice_id]] },
  summary:    ->(p) { "Invoice #{p[:invoice_id]} approved" }

The key stays in the payload, where it renders as evidence, and is copied onto the event's facets, where it is an index. dimensions: does not imply requires: — an entry that wants a facet enforced lists it in both.

Or on every event, taken from application state:

config.default_dimensions = -> { { tenant_id: Current.tenant&.id, app_version: AppVersion.current } }

Applied to every event, so no call site repeats them; a registry entry declaring the same key wins. The lambda takes no arguments on purpose: it supplies what is true of the unit of work, never of the action. Anything that varies per action belongs in the registry entry, where it is visible beside the summary. It must not raise — if it does, the event is still written, without them.

Between them, the two halves cover each other: the trigger's facets reach every write, including the ones no callback sees, and an action's facets reach a unit of work whose writes landed in a table that declares none. A unit qualifies if either matched.

Three things that affect what a filter returns

  • It is not retroactive. A facet declared today says nothing about yesterday. A filter returns results from that migration forward; older rows do not match.
  • A conjunction has to fit on one row. Five facets on invoices combine freely. customer_id from an order plus product_id from a line item matches nothing — no single row carries both. Declare the facet on the table you will filter by.
  • A record is filed under the value it held after the change. An invoice moving from department 5 to department 9 appears under 9, so department 5's feed shows it up to but not including the move. The move itself is on the invoice's own timeline as an ordinary field change.

A filter in the auditor UI

Which facets a screen offers, at /audit/dimensions. Nothing here affects what is recorded — add it whenever, or never. options: reads from your own tables, never from the audit log, which cannot list them at volume:

config.dimension_filters = {
  department_id: { label: "Department",
                   options: -> { Department.order(:name).pluck(:name, :id) } },
  app_version:   { label: "App version" }   # no options -> free-text input
}

With this unset, the screen's nav link is hidden: an application that declares no facets has a complete audit log and no question that screen could answer.

Keep them low-cardinality, and keep values out of them

app_version (dozens), tag (hundreds), department_id (thousands) are all fine. A free-text note, a URL or an idempotency key drives the index toward one entry per row and belongs in the payload, which is already the right home for evidence somebody reads rather than filters on.

Facets survive redaction by design, which is correct for department_id and wrong for anything that is itself personal data. Dimensions are ids and scope labels, not values.

Turning it on in an app that already has audit data

New installs get the storage and the index automatically. An existing deployment runs:

bin/rails generate audit_log:dimensions
bin/rails db:migrate

The generated migration adds the column, re-installs the trigger function so it reads your facet lists, and builds the facet index one partition at a time with CONCURRENTLY, so it never takes a lock that blocks audit writes. It reports each partition as it goes, and it is safe to re-run if it is interrupted.

Applications that never declare a dimension pay nothing for this feature — the index excludes their rows by construction. The measurements are in DESIGN.md §23.


Configuration

The next three sections — Configuration, Generator options and Rake tasks — are lookup tables rather than reading. The guides above tell you which of these you need; these tell you what they all are.

Everything this gem needs to know about your application, in one file. The install generator writes config/initializers/audit_log.rb with the ones that matter commented in place; this is the whole list.

Nothing here names one of your constants. Every coupling point is a lambda or a string you supply, which is what lets one library serve every app without knowing anything about any of them.

The ones you should look at before deploying

Default Does
authorize no-op Gates the auditor UI at /audit. The default lets everyone in, which is right for a demo and wrong for you. Raise or redirect.
actor_resolver controller.try(:current_user) How to find the acting user. Works with Devise, the Rails generator, or anything exposing current_user.
actor_label_resolver to_audit_label → to_label → name/email → Class (id: n) The string snapshotted onto every audit row. Rendered once per entry point, so a later rename never rewrites history. to_audit_label comes first for the same reason it does on record labels — and it matters more here, because this string is stored rather than resolved at display time.
unaudited_tables a few internals Tables that legitimately have no trigger, each with a written reason. audit_log:coverage fails for anything neither audited nor listed here.
default_excluded_columns timestamps, lock_version, password and reset-token columns Columns kept out of every diff. Per-table extras go on the trigger via --exclude.
default_dimensions nil -> { {tenant_id: …, app_version: …} } — facets recorded onto every event, merged under whatever a registry entry declared. Takes no arguments on purpose: it supplies what is true of the unit of work, never of the action. It must not raise; if it does the event is still written without them. See Dimensions.
retention 7.years How long partitions are kept before retention will detach them. nil disables it.

Rendering and screens

Default Does
parent_controller "ApplicationController" What the engine's controllers inherit, which is how they pick up your layout and authentication.
record_url nil ->(type, id) returning a path in your app, for a history you render yourself. nil means labels render unlinked, ids intact — it will not guess a route.
display_time_zone :viewer Which zone the auditor screens show a timestamp in — :viewer for the reader's own, resolved in their browser, or :utc for everyone. Stored values are always UTC either way and nothing here can change that. The zone is always named on screen, and title/datetime carry the exact recorded instant whatever the visible text says. See Timestamps.
timestamp_locale nil Whose conventions the reader-local timestamp follows — field order, month name, 12-or-24-hour clock. nil is the reader's own locale; "en-US" is that house style for everyone. A BCP-47 tag, not a format string, so it cannot drop the year or the zone. "en-US-u-hc-h23" is American order on a 24-hour clock. See Timestamps.
page_size 50 Rows per page on the auditor screens. Keyset-paginated, so there is no cost curve behind it.
actor_picker [] Populates the actor search on /audit/actors. Source it from your users table, not from the log.
actor_finder type.constantize.find_by(id:) Looks up an actor for display when the log holds no snapshot.
record_label_resolver RecordLabel.batch Turns ids in a diff into labels. nil disables labelling entirely. Scope it in a multitenant app — the default reads business tables unscoped.
association_targets {} {"LineItem" => {"product_id" => "Product"}} for association columns belongs_to reflection cannot see. false suppresses one.
dimension_filters {} Which facets /audit/dimensions offers as a filter, and where each one's options come from. Inert — it decides what a screen offers and never what is recorded, which is why it is filters and not dimensions. Empty hides the screen's nav link. See Dimensions.
drill_down_slack 24.hours How wide the date window around a request_id drill-down is. Generous on purpose, and disclosed on screen.

Storage lifecycle

Default Does
partition_months_ahead 3 How far ahead the daily task provisions. A missing future partition is a write-path outage.
rollup_after 2.years How cold a year must be before rollup consolidates its months. nil disables it.
archive_dir nil Default DIR for the export tasks.
maintenance_lock_timeout "5s" How long the three ACCESS EXCLUSIVE operations wait before failing rather than blocking every audited write.

Rarely touched

Default Does
correlated_connections %w[primary] Which connections carry the correlation context — connection names as they appear in database.yml (primary, queue), not database names. The default is right for nearly every app, including one whose database.yml has no primary: key: Rails names a flat single-database config primary. Does not decide what is audited — a connection left out is still fully audited, its rows just arrive with no actor. The engine refuses to boot if this matches no connection, because that failure is otherwise silent. See Multi-database apps.
bypass_allowlist [] Classes permitted to call AuditLog.without_logging. Empty means the bypass is unavailable, which is the right default. See Bypassing the log for a bulk load.
raise_on_subscriber_error true Whether a failed layer-2 write raises. Leaving it true is what stops an audit failure vanishing while the change it described commits.

Generator options

Every flag the generators take. audit_log:install's are listed with the step-by-step in What the generator wrote; the ones below are those with decisions in them.

The generators

Does Run it
audit_log:install initializer, schema migration, ControllerContext and JobContext includes, mounts the engine, coverage spec, agent skill once
audit_log:trigger TABLE --model=Model a migration with one attach_audit_trigger line once per audited table
audit_log:trigger TABLE --replace detach-then-attach, to change a table's model or exclusions when those change
audit_log:views:activity Model [Model...] controller, concern, helper, views, route, locale, stylesheet — and wires each model's show page once, then again per new model
audit_log:views:css a starter stylesheet for the auditor UI, written commented out audit_log:install runs it; separately if you skipped it or deleted the file
audit_log:dimensions retrofits the dimensions column, re-installs the trigger function, and builds the facet index one partition at a time with CONCURRENTLY only on an app installed before dimensions existed
audit_log:disable --reason=... a reversible migration detaching every audit trigger, keeping the tables and rows to stop capture, or before removing the gem — see Stopping auditing
audit_log:enable rebuilds the attach lines from the marker, when the disable migration is gone recovery only; db:migrate:down is the ordinary way back

audit_log:trigger options

bin/rails generate audit_log:trigger orders \
  --model=Order \
  --exclude=internal_notes search_vector
--model=Order the model name recorded on every audit_changes row. Defaults to the table name classified — pass it when they differ, because this string is what every screen filters and groups on.
--exclude=a b c columns kept out of the diff, on top of config.default_excluded_columns. Space-separated, not comma-separated
--replace detach first. Required to change an existing trigger's model or exclusions — see Re-attaching.

What --exclude is for. The trigger writes a diff of every column that changed. Some columns change constantly and mean nothing to an auditor, and a few should never be copied anywhere at all:

  • Noise that would drown the signal. A search_vector, a denormalised counter, a last_seen_at touched on every request. Left in, an auditor reading "what changed on this order" wades through a column nobody asked about, and the jsonb diff grows for no benefit.
  • Values you do not want a second copy of. config.default_excluded_columns already covers the usual suspects — created_at, updated_at, lock_version, password_digest, encrypted_password, and Devise's reset tokens. --exclude is for the ones only your schema knows about: an API secret, a bearer token, a column holding something a customer can ask you to erase.

What it does not do. Excluding a column does not stop the row being audited. The change is still recorded — who, when, under which request_id, and every other column that moved. Only that column's before/after values are left out.

That distinction is the reason to reach for --exclude rather than unaudited_tables: the latter drops the whole table from the log and needs a written reason to pass audit_log:coverage.

Excluding is not retroactive, in either direction. A newly excluded column stops appearing from the re-attach forward and stays in the history written before it — AuditLog::Redaction is the tool for values already recorded. And un-excluding one does not recover the values that were never captured.

Changing exclusions later means --replace, because attaching is deliberately not idempotent: a second attach on the same table fails with 42710 rather than letting two triggers coexist and write two rows per change under different exclusion sets.

audit_log:dimensions options

None. It takes no arguments and makes no decisions — there is nothing to parameterise, because what gets recorded is declared per table in a migration and per action in the registry, not here. This only puts the storage in place.

It is not needed on an app installed after dimensions shipped: audit_tables.sql creates the column and the index with the tables. See Dimensions.

audit_log:views:activity options

audit_log:views:activity takes any number of models in one call, and calling it again later is how you add more. Both reach the same place:

bin/rails generate audit_log:views:activity Order Product LineItem
# ...is equivalent to:
bin/rails generate audit_log:views:activity Order
bin/rails generate audit_log:views:activity Product LineItem

A model with no show page — LineItem usually — is still added to the allowlist and still readable at /activity/LineItem/86; the generator just reports that it could not find line_items_controller.rb and prints the two lines for when you do have one. The allowlist and the show-page wiring are independent, which is right: a child record often has a history worth reading and no page of its own.

Options: --css=plain|tailwind|bootstrap, --path=activity, --skip-show-pages, --skip-views, --skip-css, --skip-locale, --skip-routes, and --force to re-baseline generated files against the current templates.


Rake tasks

Registered by the engine, so they appear in any host app's bin/rails -T.

One is mandatory in cron; two others belong there too, with conditions. audit_log:partitions is operationally required, and it now folds freezing in, so there is nothing to schedule for that. retention and rollup are the two an app with a compliance horizon will want scheduled — retention that depends on somebody remembering, monthly, for seven years, is not retention.

The condition on both is the same. They take ACCESS EXCLUSIVE on an audit table, which blocks every audited write in your application while it runs, so they belong in a low-traffic window. They fail fast rather than queueing (config.maintenance_lock_timeout, 5s) because a pending ACCESS EXCLUSIVE blocks every lock behind it — an unbounded wait behind one long reader would stall the write path.

A scheduler that discards output turns that design into a silent skip. Lock contention and lock timeouts raise, so a bad moment gives you a non-zero exit and a retry next cycle. That is only true if something is watching. And retention and rollup commit per partition, so a mid-run failure leaves the earlier ones already done — keep the output, not just the exit status.

Schedule this one

Task What it does Why, and when
audit_log:partitions Creates missing monthly partitions, freezes newly closed ones, and warns on default-partition overflow and retired leftovers Daily, in cron. Non-negotiable. A missing future partition is a write-path outage, not a degraded report — every audited write fails once the calendar passes the last partition. Keeps config.partition_months_ahead (3) provisioned. Creation commits before the freeze, so a slow VACUUM can never delay the half that matters.

Run when something needs it

Task What it does Why, and when
audit_log:coverage Lists tables in the primary database with no audit trigger In CI, not cron. The forcing function. Fails for any table that is neither audited nor in config.unaudited_tables with a written reason. Run it in CI — it is what stops a table added next month being quietly unaudited.
audit_log:reconcile Reports correlated changes with no registered action Tells you which narratives are still missing, so layer 2 fills in over time instead of being an up-front project. Run after adding controllers.
audit_log:partitions:drain_default Moves rows out of the default partition into the ones that should hold them When partitions reports default-partition overflow. Do not schedule this one. Needing it means a row landed in the default partition, which means the rotation task was not running — scheduling the repair hides the fault that caused it. Takes ACCESS EXCLUSIVE. Stages through a temp table in one transaction, so a failure leaves the rows where they started.
audit_log:redact Removes a record's values from the log, keeping the structure An erasure request. RECORD=Customer:42 REASON=DSR-1182 [FIELDS=email,phone] [DRY_RUN=1]. The only thing permitted to modify audit rows; it narrates itself in the same transaction. changed_columns survives, so "the email changed at 14:02, by Jane" stays provable. Full guide: Redacting values under an erasure request.

Retention: schedulable, in this order

Every state named below is defined in DESIGN.md §8, The partition lifecycle — including which states the gem can still see, and which are DBA-only.

Task What it does Why, and when
audit_log:partitions:rollup Consolidates closed years of monthly partitions into yearly ones Monthly or quarterly is reasonable. Fewer partitions to plan against once a year is cold. DRY_RUN=1 to preview. Only rolls up years past config.rollup_after (2y) — it coarsens retention, since a yearly partition can only be retired whole. Takes ACCESS EXCLUSIVE.
audit_log:partitions:retention Detaches partitions past the horizon and marks them retired Monthly is the obvious cadence, and scheduling it is the point of having a horizon. config.retention (7y). It cannot drop anything — there is no option to make it — so a scheduled run can only take data out of service, never destroy it. DRY_RUN=1 to preview. Takes ACCESS EXCLUSIVE.
audit_log:partitions:export_retired Streams every retired partition to DIR as gzipped CSV + manifest, verifying each DIR=/backups/audit. Exports everything, every run — it does not skip what it exported before, because a file existing in DIR is not evidence it is intact or that it ever reached durable storage. Writes through a temp file, so a re-export cannot destroy a good archive. Reports total bytes, which is what tells you whether to be dropping more aggressively.
audit_log:partitions:export_and_drop_retired Exports, verifies, then drops only what verified The recommended disposal path. DIR=/backups/audit, optional BEFORE=YYYY-MM-DD. Verifies by checksum and row count, and anything that fails is reported and left alone. Safe to re-run: export skips nothing, and the drop only takes what passed.
audit_log:partitions:drop_retired Drops retired partitions without checking for an export ⚠️ Irreversible, and does not look for a backup. DRY_RUN=1 first; optional BEFORE=YYYY-MM-DD. Offered because a CSV in a directory is not proof of preservation, so requiring one buys less safety than it appears to — and forcing everyone to produce archives they do not want is not this library's call. The judgement that mattered was made upstream by retention; this reclaims the disk.
audit_log:partitions:freeze VACUUM FREEZE closed partitions that are not already frozen You do not need to schedule this — audit_log:partitions does it daily. It is here as a manual catch-up, plus FORCE=1 to redo partitions already marked frozen.

BEFORE= compares the upper bound, which is what the retirement marker records — so BEFORE=2025-06-01 does not drop a 2025 yearly partition, because that partition holds data through 2025-12-31. A partition whose marker cannot be read is skipped by a date-bounded drop rather than guessed at, and the task says which.

Freezing is automatic and you should not have to think about it. PostgreSQL must eventually mark old rows frozen or an anti-wraparound vacuum will scan your largest table at a moment of its own choosing — and append-only audit tables are exactly the shape ordinary vacuuming ignores until then. audit_log:partitions pre-empts that by freezing each partition as its month closes: nothing on most days, one partition per table on the first run of a month.

A redaction un-freezes whatever partitions held the redacted rows, because it updates the parent table and dirties pages there; the next daily run re-freezes them. DESIGN.md §8 has the mechanism, and why the timing is the only part actually on offer.

Only partitions this gem retired are ever exported or dropped. A table merely named like a retired partition — a manual copy taken before a risky migration, say — carries no marker and is reported, never touched. That is the same rule that keeps rollup from dropping somebody's audit_events_2019.

Only on a scratch database

Task What it does Why, and when
audit_log:benchmark Generates volume and EXPLAINs the canonical auditor queries ROWS=100000. Writes synthetic rows into your real audit tables. Use a scratch database or clean up after.
audit_log:benchmark_cleanup Deletes the synthetic rows benchmark wrote Immediately after benchmark, unless the database is disposable.

redact takes FIELDS=, never COLUMNS= — and getting that wrong redacts nothing while reporting success. The trap, and the rest of what a redaction reaches, is in Redacting values under an erasure request.


Advanced

Everything above is enough to install this gem, use it, and put a history on your own pages. What follows is reached when you have a reason rather than on the way in, and it is two kinds of thing:

  • Jobs you will eventually have to do — attach a trigger to a table that already exists, change a table's exclusions, redact values under an erasure request, bypass the log for a bulk load, stop auditing altogether. Read these when the job lands.
  • The parts most likely to surprise you — multiple schemas, transaction control, multiple databases. Read these when one of them does.

The full design record lives in DESIGN.md, which is the authority on why anything here is shaped the way it is.

Attaching to a table that already exists

Supported, and no different mechanically. attach_audit_trigger is a bare CREATE TRIGGER: it reads nothing from the create_table beside it and carries no state between the two calls, so a standalone migration is equivalent.

class AuditExistingOrders < ActiveRecord::Migration[8.1]
  def up   = attach_audit_trigger(:orders, model: "Order")
  def down = detach_audit_trigger(:orders)
end

coverage_spec.rb is satisfied either way — it queries pg_trigger, not the migration history.

Three things to check first. None is about when the trigger is attached; all three are about the shape of the table.

  • Step 2 must already have run. CREATE TRIGGER resolves audit_row_change at creation time, so a missing install fails the migration loudly. This is the harmless one.
  • The table needs a bigint-compatible id. The trigger function assigns rec_id bigint := NEW.id, and audit_changes.record_id is bigint NOT NULL. A create_table id: false join table, a uuid primary key, or a primary key not named id therefore fails on the first write after attaching, not at migration time. Every table in this app is uniform, so the constraint stays invisible until you meet a legacy schema. Check the primary key before you attach.
  • CREATE TRIGGER takes SHARE ROW EXCLUSIVE on the table. Catalog-only, no rewrite, so it is fast — but it blocks writes while held, and a pending request queues every write behind it. On a busy table set a lock_timeout and retry, rather than letting the migration wait behind one long transaction. Same reasoning as config.maintenance_lock_timeout for the maintenance tasks.

What the history then looks like. Rows that existed before the attach have no back-history, and there is no backfill — the trigger records changes, and those changes did not pass through it. Two things narrow the gap:

  • The first UPDATE of a pre-existing row still yields a complete [old, new] pair, because the diff reads to_jsonb(OLD) off the live row. What is missing is the changes before the attach, not the values before the change.
  • A DELETE snapshots the whole final row, so even a row created long before the trigger leaves a full record behind when it goes.

What remains is epistemic: a record with no audit_changes rows is ambiguous between "never changed" and "predates the trigger". Record the attach date — the migration's own timestamp is the durable answer. An audit trail that cannot say which of the two it means is under-reporting without saying so, which is the one failure mode this whole design exists to prevent.

Re-attaching, and changing a table's exclusions

attach_audit_trigger is not idempotent, deliberately. A second attach on an already-audited table fails:

ERROR:  trigger "orders_audit" for relation "orders" already exists   -- SQLSTATE 42710

The trigger name is #{table}_audit — derived from the table alone, ignoring both model: and exclude: — so two attaches on one table always collide, whatever arguments they pass. Treat the failure as the answer, not as an obstacle. Were the name to carry the model or the exclusion list, the second attach would succeed, and the table would write two audit_changes rows per change under two different exclusion sets — invisible until somebody counts them.

detach_audit_trigger is idempotent (DROP TRIGGER IF EXISTS). The asymmetry is the point, and it makes detach-then-attach the supported way to change a table's exclusions or its model name — idempotent end to end:

def up
  detach_audit_trigger :orders
  attach_audit_trigger :orders, model: "Order", exclude: %w[internal_notes]
end

Changing the exclusion list is not retroactive: rows already in audit_changes keep the diffs they were written with. A newly excluded column stops appearing from the re-attach forward and stays in the history before it.

Do not reach for CREATE OR REPLACE TRIGGER to make attaching idempotent. It exists (PostgreSQL 14+) and it works, and it would also silently absorb a second attach carrying a different model or exclusion list — the one case worth hearing about. There is no CREATE TRIGGER IF NOT EXISTS at all.

Which paths actually reach the collision, and why the trade is what it is: DESIGN.md §5.2.

Redacting values under an erasure request

An audit log holds old values of fields that may be personal data, which puts immutability in direct tension with a right-to-erasure request. The resolution is not to delete rows.

  • The structural record survives: who changed which field, on which record, when, in which request. changed_columns is never touched, so "the email address changed at 14:02, by Jane" stays provable after the address is gone.
  • The values are replaced with a marker naming the authorization — [redacted 2026-09-01 per DSR-1182].
  • The redaction is itself an audited action, written in the same transaction.
# preview first -- it changes nothing and tells you what it would touch
bin/rails audit_log:redact RECORD=Customer:42 REASON=DSR-1182 FIELDS=email,phone DRY_RUN=1

bin/rails audit_log:redact RECORD=Customer:42 REASON=DSR-1182 FIELDS=email,phone

REASON is required and there is no default — a redaction without a written authorization is not auditable. FIELDS is optional and naming fields is the better habit: an erasure request is usually about an email address, not about the fact that a status changed. Omitting it redacts every recorded value for that record.

Warning

It is FIELDS=, never COLUMNS=. COLUMNS is a reserved shell variable holding your terminal width, so COLUMNS=email bin/rails audit_log:redact arrives as a number, matches no column, and redacts nothing while reporting success.

It is irreversible. The values are overwritten in place, which is the point. DRY_RUN=1 is the only preview you get.

What it reaches, and what it does not

audit_changes.diff targeted keys replaced with the marker. Every key survives, including untargeted ones with their values intact
audit_changes.changed_columns untouched. The structural record is what survives an erasure
audit_events.summary replaced with the marker — the summary is the payload rendered into a sentence, so it is the second place the same data sits
audit_events.metadata emptied to {}
audit_events.dimensions untouched. Facets are structure, like changed_columns — correct for department_id, and something to keep in mind before declaring a facet on anything that is itself personal data (DESIGN §23)
action, actor, occurred_at, request_id untouched, so the narrative still says something happened to this record, by whom, and when

The audit.redaction rows themselves are skipped, so the record of the erasure cannot erase itself.

From the console

Two things the rake task does not expose. Both are documented capabilities, not internals (DESIGN §13).

# what would it touch?
AuditLog::Redaction.preview(record_type: "Customer", record_id: 42)
# => { changes: 31, events: 4, columns: ["email", "name", "phone", "status"] }

# pseudonymize an ACTOR: replace the snapshotted label, keep actor_type/actor_id.
# Their activity stays attributable to a stable identifier and stays countable --
# it simply stops naming them.
AuditLog::Redaction.redact_actor!(actor_type: "User", actor_id: 7, reason: "DSR-1190")

Three operational notes

  • It is the only thing permitted to modify audit rows. Everything else treats them as append-only. Do not add a second mutation path, and do not reach for DELETE — a missing row is indistinguishable from a row that never existed.
  • It is deliberately not date-bounded. Every other query here carries a range so the planner can prune partitions; this one must reach every partition or the redaction is incomplete, which is a compliance failure rather than a slow screen. Run it in a maintenance window on a large log.
  • It un-freezes every frozen partition, because it UPDATEs the parent and so dirties pages in closed partitions the daily task believed were handled. The daily task re-freezes them. Nothing to do, but it explains a rake audit_log:partitions run that suddenly has work.

Keep it rare by keeping the worst fields out of the log entirely. config.default_excluded_columns and the per-table exclude: are the first line and they cost nothing; redaction is the tool for values already recorded.

What is still open about redaction is policy, not mechanism: who may authorize one, and what makes a REASON valid. Those are yours.

Bypassing the log for a bulk load

The one escape hatch from layer 1, for a bulk operation where a row per record is genuinely not wanted — a nightly ERP sync, a one-off backfill of a million rows. It is scoped to a block, and it logs itself.

# config/initializers/audit_log.rb
config.bypass_allowlist = %w[ErpSyncJob CatalogImportJob]

# and at the call site
AuditLog.without_logging(reason: "Nightly ERP sync", by: ErpSyncJob) do
  Product.upsert_all(rows)
end

by: must match an entry in config.bypass_allowlist or it raises AuditLog::BypassNotPermitted, and reason: cannot be blank. The allowlist is empty by default, so the bypass is unavailable until somebody adds a class to it — which is the right default for something that puts a hole in the audit log.

Important

The allowlist is an intent declaration, not a security boundary. Anything that can call without_logging can also edit the initializer. Its value is that enabling the bypass for a new caller shows up as a diff in one reviewable file, rather than as a line buried in a job.

It narrates the gap it creates. An audit.bypass event is written before anything is disabled — inside the same transaction, so a rollback discards the narration along with the work it described — and an audit.bypass_completed follows with the elapsed time. An un-narrated gap in an audit log is a finding; a narrated one is a control.

Four things to know:

  • It suppresses layer 1 only. AuditLog.notify and AuditLog.audited still write audit_events rows, so the action keeps its sentence and loses the field-level diffs beneath it.
  • It is restored when the block exits, including when the block raises — the toggle is transaction-local and reset in an ensure.
  • It returns the block's value, so it wraps an existing call without restructuring it.
  • Register audit.bypass and audit.bypass_completed, which audit_log:install now writes into your initializer. An unregistered action is a silent no-op in the subscriber, so without those entries the bypass does not log itself and the gap is the only evidence it ran.

Reach for this, not for stopping capture, when the scope is one operation. Detaching triggers is for a window measured in hours or longer; this is for a block, and it needs no migration and no schema change. If a bulk load is large enough that you were considering detaching, this is usually still the right tool — it costs one event row.

Stopping auditing, and starting again

Two different questions with one answer.

  • "I am removing the gem and do not want triggers left behind writing to tables nothing reads."
  • "I want capture to stop for a window and resume afterwards — a staging database, a cost decision, a migration too long to hold inside one bypassed block — with the log intact and a gap in it I have accounted for."

Both are the same mechanism: detach the triggers and keep everything else.

bin/rails generate audit_log:disable --reason="ERP backfill, ticket OPS-4412"
bin/rails db:migrate

That writes one reversible migration. up detaches every audit trigger in the schema; down re-attaches exactly what was there. The cycle is that one migration, indefinitely — db:migrate:down VERSION=… resumes capture, db:migrate:up VERSION=… stops it again. There is no separate re-disable generator, because Rails already has the verb.

The audit tables, every partition, every row and the auditor UI are untouched, and the screens go on reading the history you already have. (AuditLog::Schema.uninstall! is the other thing entirely — it DROP TABLE ... CASCADEs both audit tables. If you want the data gone, that is the call; this is not it.)

The re-attach is exact rather than approximate: the model name, the merged exclusion list and any declared dimensions: are read out of pg_trigger at generate time and written into the migration as literals you can review before running it. capture_spec pins that pg_get_triggerdef comes back byte-identical across a full cycle.

What stops, what does not

Layer 1 stops. No audit_changes row is written for any table.

Layer 2 keeps going. AuditLog.notify and AuditLog.audited still write audit_events rows, so the timeline keeps its narrative activities and loses the field changes beneath them. That asymmetry is deliberate — a screen reading "Jane submitted order 4821" with no diffs under it reports exactly what happened. There is no config.enabled = false; an app that wants layer 2 off too stops calling it, or clears the registry. DESIGN §25.

It narrates itself. An audit.capture_disabled event is written before the detach and an audit.capture_resumed after the re-attach, both carrying the reason. The generator refuses to run if those two actions are not registered in your initializer — an unnarrated gap would leave the hole itself as the only evidence anything was turned off.

rake audit_log:coverage will fail, and that is the design

While capture is disabled, coverage and the shared example both fail — saying capture is disabled, since this date, for this reason, rather than listing your tables and telling you to write attach migrations.

Important

Do not skip the spec to make the build green. A disabled audit log is not OK, and the reason this feature detaches triggers rather than setting a flag is precisely that a flag would pass this check while auditing nothing. The honest options are to resume capture, or to run red for as long as the pause lasts.

The gap

Everything in the window is a hole with visible edges, except one thing.

  • The first change after capture resumes still yields a complete [old, new] pair, because the diff reads to_jsonb(OLD) off the live row.
  • A DELETE after capture resumes snapshots the whole final row.
  • A record created and deleted inside the window leaves no trace that it ever existed. That is the one genuinely lossy case, and the one to know before accepting the gap.

There is no backfill, and there could not be one: the rows that would describe the window were never written. Record the dates — the two migration timestamps are the durable answer, and the two audit.capture_* events are the answer inside the log itself.

If the disable migration is gone

Squashed, deleted, or absent from the checkout you are holding. The triggers no longer exist, so the catalog cannot say what they were — but the marker audit_log:disable stamped on audit_changes carries the same snapshot.

bin/rails generate audit_log:enable    # rebuilds the attach lines from the marker

Check the model names before running it. They are what the triggers carried when capture was disabled; a model renamed since then needs its new name, or record_type will name a class your app no longer has. It refuses outright if there is no marker, rather than guessing model names from table names.

Installing into a schema other than public

Everything installs into the current schema — the first entry on the connection's search_path. For a normal Rails app that is public and there is nothing here to do.

It matters if your app puts data in more than one schema, because the audit tables, their partitions and the trigger function all have to agree on which one. They do: AuditLog::Schema.install! creates the tables and the function together in whatever schema is current, and attach_audit_trigger binds each trigger to the function copy sitting beside it. Run the install once per schema and each one gets an independent, self-contained audit log.

# In a schema-per-tenant app (ros-apartment and friends), migrations already run
# once per tenant with that tenant's search_path active -- so the ordinary
# install migration does the right thing per tenant with no changes.
#
# What does NOT sweep automatically is anything scheduled. The daily task is the
# one whose failure is a write-path outage, so it is the one to get right:
Apartment::Tenant.each { AuditLog::Partitions.ensure! }

The same wrapping applies to audit_log:coverage, audit_log:reconcile, audit_log:redact and the retention tasks — each acts on one schema per call. This gem has no tenancy configuration and names no tenancy library; it only declines to assume public.

Three things to know:

  • A trigger's destination is fixed when it is attached, not when it fires. A table in public that is written while another schema's search_path is active still files its audit rows in public, where the table lives. That is what you want for records deliberately kept outside per-tenant data.
  • If you provision a schema by cloning another one rather than by migrating it, call AuditLog::Schema.install_function! in that schema afterwards. A clone may carry a function still pointing at the schema it was copied from, and that failure is silent — rows land in the wrong table and everything reports success.
  • rake audit_log:coverage will ask about tables you consider dead. A schema cloned from a template contains every table in the template, including ones that schema never uses. Exempt them in config.unaudited_tables with a reason.

Transaction control in audited

Everything here is optional. AuditLog.audited joins or opens a transaction on its own and the defaults are right for a single-database app; this is what to reach for when they are not.

on: picks the connection the transaction is opened on, and defaults to ActiveRecord::Base. A single-database app never needs it. An app using connects_to does — see Multi-database apps below.

Need a savepoint instead of a join? transaction: is passed straight through to ActiveRecord::Base.transaction, so requires_new:, isolation: and the rest stay available:

AuditLog.audited("order.shipped", transaction: {requires_new: true}, ...)

on: and transaction: are the only two keywords reserved from the payload — every other keyword becomes payload.

The emit is inside the transaction, not after commit. "Only if the writes succeeded" comes free, because a raise never reaches the last statement — and the guarantee holds in the other direction too: if the event write fails, the business changes roll back with it. An after_commit emit would leave the changes standing with no narrative.

Warning

A joined transaction swallows ActiveRecord::Rollback — this is Rails' documented nested transaction behaviour, and audited hides the nesting, so the block looks like it owns a transaction it does not. Raising ActiveRecord::Rollback there would commit your writes, emit no event, and have audited return nil as though it had rolled back. audited detects that and raises instead. Use transaction: {requires_new: true} for a savepoint the rollback can actually discard, or raise a real exception to abort the enclosing transaction.

Nested transactions, worked through

This is Rails' own example of the surprise. The inner transaction joins the outer one rather than nesting, so ActiveRecord::Rollback there is a no-op and both posts are created:

ActiveRecord::Base.transaction do
  Post.create(title: "first")
  ActiveRecord::Base.transaction do
    Post.create(title: "second")
    raise ActiveRecord::Rollback     # does nothing
  end
end

audited opens a transaction, so it is the inner block in that picture — and it hides the nesting, which makes the surprise worse. Here it is with an order:

ActiveRecord::Base.transaction do
  @order.update!(status: "submitted")

  AuditLog.audited("order.shipped", order_id: @order.id) do |audit|
    shipment = @order.shipments.create!(carrier: carrier)
    audit[:tracking_number] = shipment.tracking_number

    raise ActiveRecord::Rollback if shipment.tracking_number.blank?
  end
end

Left alone, that would commit the shipment and emit no order.shipped event — a change row with nothing describing it, which is the failure this library exists to prevent. So audited detects it and raises AuditLog::Error instead. Unrescued, that propagates out of your transaction and the whole thing rolls back.

If the inner unit genuinely should be able to abort on its own, give it a savepoint and the rollback works as written:

AuditLog.audited("order.shipped", order_id: @order.id,
                 transaction: {requires_new: true}) do |audit|
  shipment = @order.shipments.create!(carrier: carrier)
  audit[:tracking_number] = shipment.tracking_number

  raise ActiveRecord::Rollback if shipment.tracking_number.blank?
end

Measured outcomes for the four spellings, after the outer block finishes:

the inner block raises ActiveRecord::Rollback order status shipment event
Rails' bare nested transaction (no audited) "submitted" created —
audited, joined — the error left to propagate "draft" discarded none
audited, joined — the error rescued "submitted" created none
audited with transaction: {requires_new: true} "submitted" discarded none

Row three is why the guard is worth having and why you should not rescue it: a committed shipment with no event is exactly the state row one produces silently. Row four is the one to reach for when a nested unit is genuinely optional — the outer work survives, the inner is discarded, and nothing claims the shipment happened.

Nothing changes when the block simply succeeds inside your transaction: the event joins your unit of work and commits with it, which is the everyday case above and needs none of this.

Caution

transaction.before_commit does not exist, despite appearing in Rails' own documented example for this API. ActiveRecord::Transaction defines only after_commit, after_rollback, open?, closed? and uuid — verified in the source of both 8.0.5.1 and 8.1.3.1 — so copying that example raises NoMethodError. before_commit exists on the internal transaction and as a model callback (ActiveRecord::Base.before_commit), neither of which is the object yielded here. Nothing before the commit needs a callback anyway: the end of your block already runs there.

Outside audited, the same callbacks are reachable through current_transaction, which is often what you actually want — with no transaction open it returns a null object whose after_commit runs the block immediately, so one spelling covers both cases:

Order.current_transaction.after_commit { NotifyCustomerJob.perform_later(id) }

It is a class method, so Order.current_transaction, not order.current_transaction.

And the explicit transaction do ... AuditLog.notify ... end form is not deprecated and never will be. Use it wherever several notifies belong in one transaction.

Multi-database apps

Everything else in this file assumes one database, which is the ordinary case and the one the defaults are tuned for. If your app uses connects_to — a separate writer and reader, a queue database, a shard — two things need attention. Neither affects whether a row is audited: layer 1 is a trigger, so every write to an audited table is captured on every connection regardless.

Set config.correlated_connections. It lists which connections carry the actor and request_id, by connection name as it appears in database.yml (primary, queue), never by database name. The default %w[primary] is right for a single-database app — including one whose database.yml has no primary: key at all, because Rails names a flat config primary.

config.correlated_connections = %w[primary shard_one]

A connection left out is still fully audited; its rows simply arrive with a NULL actor and NULL request_id, indistinguishable from a console session. That failure is silent, which is why the engine refuses to boot when the value matches no connection at all, and warns on a partial miss — %w[primary replica] is legitimate in an environment that has no replica.

Pass on: to AuditLog.audited. It names what opens the transaction, and it defaults to ActiveRecord::Base. For a model on a secondary connection that transaction wraps none of your writes: the block still runs, the rows still commit, and a rollback discards nothing while appearing to work.

class Order < SecondaryRecord      # connects_to database: { writing: :shard_one }
  def submit!
    AuditLog.audited("order.submitted", on: self, order_id: id) do |audit|
      update!(status: "submitted")
      audit[:total_cents] = total_cents
    end
  end
end

on: self inside an instance method, or the model class, is right by construction — it is the same connection the writes go to. There is no equivalent to worry about for AuditLog.notify, which opens no transaction and joins whatever the caller has.

A read replica needs nothing at all. Audit rows are only ever written, and config.correlated_connections naming a replica that some environments lack is the partial-miss case above: a warning, not a failure.

Why objects and not relations

The auditor screens encode rules that are invisible from outside the gem: a diff value's three nil shapes mean different things, a nil actor renders "System" but is never stored that way, a redacted payload and an absent one are the same empty jsonb, an association label annotates a recorded id and must never replace it. Handed a relation, every app re-derives those and some get them wrong on a screen that looks fine. The value objects make each one a method call.

Object Reads
Activity kind (:narrative / :change_only), headline, action, source, actor, occurred_at, operations, changed_columns, field_changes, also_touched, metadata, redacted?, out_of_band?
FieldChange column, from, to, cleared?, set?, from_label / to_label, association?
TouchedRecord type, id, identifier, label, label_failed?, operations, columns, field_changes, url, to_s
Actor type, id, label, display, system?, linkable?, url

Activity, FieldChange, TouchedRecord and Actor each have as_json, so a JSON API or a JS frontend gets the same contract.

Why two calls, and two types. activity_keys is an ActiveRecord relation of Timeline::ActivityKey — the identity of each activity (which unit of work, and when), and nothing else. It is an opaque handle: paginate it, hand the page straight back, never render it. activities turns that page into Timeline::Activity objects, loading the events, change rows and labels for the whole page in three queries rather than three per row.

They are separate because keyset paging needs a relation to build a cursor from, because hydration has to be batched, and because the limit belongs above the controller where you can see it (DESIGN §11.0 Rule 2) — so the library cannot paginate and load in one call.

Documentation for coding agents

An agent working in your app has this gem resolved in the bundle, so it already has these documents on disk — and no reason to look. The gem ships llms.txt as the entry point, in the packaged form of the llms.txt convention: a short summary and a routing table into README.md and DESIGN.md, written as file paths rather than URLs.

bundle info audit_log --path     # then read llms.txt there

audit_log:install writes .claude/skills/audit-log/SKILL.md into your app, so Claude Code finds that entry point on its own — plus the handful of facts only your installation knows, such as where the engine is mounted. It is a pointer, not a copy: everything version-specific stays in the gem, where it upgrades with the gem. The file is yours from the moment it is written — never regenerated, never overwritten by a later install, and nothing here depends on it existing. --skip-skill if you do not want it.

If you use a different tool, point it at llms.txt yourself; one line in an AGENTS.md is enough. Worth doing rather than leaving to chance, because the failure mode is specific and quiet: an agent that has not read these docs falls back on what it knows about paper_trail and writes a concern into a model class, where it records nothing at all.

Why this one, and not a callback-based gem

The comparison, kept to the end because the Summary already covers what this gem does — this is the part you want when deciding whether rather than how.

Most audit gems hook Active Record callbacks, which works until the first update_all, the first dependent: :delete_all, the first data-fix script — and then the log is missing exactly the writes somebody will later ask about, with nothing anywhere reporting the gap. Being mostly complete is the one property an audit log cannot trade away, and you discover you traded it at the worst possible moment.

Integration is genuinely small: a generator, one migration line per table, and two includes the generator writes for you. Nothing about your models changes. Bending it is small too — every hook into your app is a lambda you own, the auditor UI needs nothing from you, and the optional activity views are generated into your app rather than served from the gem, so you can rewrite them completely and nothing here will notice.

Why not one of the popular gems?

They are good gems. This exists because of one architectural difference and a few consequences of it.

Why not
paper_trail Model callbacks, so bulk writes and raw SQL never reach it, and every model must opt in — nothing tells you which one you forgot. Excellent at versioning: if you want reify to restore a record to a previous state, use it. This gem records what changed, and does not rebuild past objects.
audited Same callback architecture, same blind spots, same per-model opt-in. Simpler to adopt than this if your writes all go through Active Record and you do not need partitioning, retention or an auditor UI.
logidze Also trigger-based, and the closest relative here. It stores history in the audited row itself (a log_data column), which is elegant and fast — but it means deleting the record deletes its history, the row carries its own past forever, and there is no separate table to partition, retire or export. If what you need is "what did this row look like last Tuesday", it is a very good answer. If you need the record of a deletion to outlive the record, it structurally cannot be.
Rolling your own triggers Entirely reasonable, and roughly the first two days of this. The rest is what took the time: correlation through jobs, partition lifecycle, retention, redaction that survives an audit, and the forcing function that stops a new table being quietly unaudited.

Where this gem is the wrong choice, stated plainly:

  • PostgreSQL only. Layer 1 is a plpgsql trigger writing jsonb into range-partitioned tables. Any Postgres from 16 up, but there is no MySQL path and there will not be one.
  • It requires schema_format = :sql, which must be set before your first migration. An established app switching to it re-dumps its whole schema.
  • No object restoration. No reify, no "roll this record back". It answers what changed and who did it, not "give me the January version of this order".
  • Read-access logging is out of scope. This records changes, not views.

Working on this library

Only relevant if you are changing the gem itself rather than using it.

CLAUDE.md is the terse companion to this section: the same decisions as a list of things not to "fix", for anyone — human or otherwise — who will not read the whole design document first.

Files

Path Role
lib/audit_log/configuration.rb Every host-app coupling point. The only file to read before adopting.
lib/audit_log/current.rb CurrentAttributes holding the audit identity as primitives.
lib/audit_log/context.rb Writes the correlation GUCs onto a connection; mints UUIDv7 ids.
lib/audit_log/transaction_stamp.rb Adapter prepend. Read the comment — it explains why raw_execute and not begin_db_transaction.
lib/audit_log/controller_context.rb The whole web integration.
lib/audit_log/job_context.rb The whole background-job integration.
lib/audit_log/registry.rb The allowlist of auditable actions, and each one's human sentence.
lib/audit_log/event_subscriber.rb Rails.event → audit_events.
lib/audit_log/payload.rb The collector AuditLog.audited yields. Wraps a Hash rather than subclassing one, normalises keys to symbols, and raises on a key set in both the keyword and block slots.
lib/audit_log/actor_label.rb Renders the label snapshotted onto every row, and (display/linkable?) the one definition of how a stored actor reads on a screen.
lib/audit_log/record_label.rb The opt-in label chain (to_audit_label → to_label → overridden to_s → nothing) for the record an association id points at. Display-time only; nothing it returns is stored.
lib/audit_log/migration_helpers.rb attach_audit_trigger / detach_audit_trigger, and add_audit_dimension_index for a hot facet.
lib/audit_log/schema.rb install! / uninstall! for a migration.
lib/audit_log/dimension_index.rb The retrofit path for the facet index: parent index, then CONCURRENTLY per partition, with the catalog asserting completeness. Only for an app installed before dimensions existed.
lib/audit_log/partitions.rb Partition rotation, default-partition drain, yearly rollup, retention, freezing, UTC-boundary enforcement.
lib/audit_log/bypass.rb The one escape hatch from layer 1, scoped to a block, which logs itself before it opens.
lib/audit_log/redaction.rb The only thing allowed to modify audit rows. Values go, structure stays.
lib/audit_log/capture.rb Reads the trigger snapshot out of pg_trigger, and owns the marker and the narration for disabling capture. The DDL stays in the migration.
lib/audit_log/archive.rb Retired partitions → gzipped CSV + manifest; drops only what verifies.
lib/audit_log/pagination.rb Keyset paging, for the auditor screens and for host apps — include AuditLog::Pagination. No page numbers, no counts, and a microsecond cursor.
lib/audit_log/csv_export.rb Streaming CSV for the screens. No row cap.
lib/audit_log/engine.rb Initializers: the adapter prepend, the event subscriber, PGTZ.
lib/audit_log/console.rb Narrates console sessions.
llms.txt The packaged entry point for coding agents: a summary, then a routing table into this file and DESIGN.md. Guarded by readme_spec.
.github/workflows/release.yml Turns a pushed v* tag into a GitHub Release from the CHANGELOG section, refusing when the tag and version.rb disagree. Release notes only — it never runs gem push.
db/sql/audit_tables.sql The two partitioned tables and their indexes.
db/sql/audit_row_change.sql The trigger function. The heart of layer 1.
app/queries/ One object per auditor question (ActorActivity, RecordHistory, RecordTimeline, ActionReport, Reconciler, Coverage), plus LabelResolver — the per-request association-label cache.
app/queries/audit_log/timeline.rb The host-facing contract: one record's history as units of work, for an activity history in your own app.
app/queries/audit_log/dimension_timeline.rb Timeline with the record predicate swapped for a facet containment test — same union, same value objects. Bounded by default.
app/queries/audit_log/timeline/ Its value objects — Activity (one thing that happened, loaded), ActivityKey (its identity before loading), FieldChange, TouchedRecord, Actor.
app/controllers/, app/views/ The auditor UI. shared/_event_payload and records/_timeline_activities both render audit_events.metadata in three states — present, absent, redacted.
lib/audit_log/rspec.rb Shared examples a host app uses instead of copying a spec. Not loaded by lib/audit_log.rb — rspec is the host's test dependency.
lib/generators/audit_log/ audit_log:install, audit_log:trigger, audit_log:dimensions, audit_log:disable, audit_log:enable and audit_log:views:activity, with templates.
DESIGN.md Why every decision here is what it is. Cited by section number from source comments.
lib/audit_log/tasks/audit_log.rake partitions and the partitions: namespace, plus redact, reconcile, coverage, benchmark. Full list in Rake tasks.

What reloads and what does not

The gem loads its own files two ways, and only one of them reloads in a host app's development environment:

Path Loader Reloads?
app/** (queries, models, controllers, helpers, views) Zeitwerk, via the engine yes
lib/audit_log/*.rb (configuration, context, partitions, schema, …) Kernel#autoload, from lib/audit_log.rb no — once per process

Editing anything directly under lib/audit_log/ requires a server restart. This matters in practice when you consume the gem by path, as the reference app does: an app/** edit shows up on the next request, a lib/** edit does not.

It is deliberate rather than an oversight. TransactionStamp is prepended into the Postgres adapter at boot, which reloading would corrupt, and AuditLog.config memoizes its instance in @config on the module — so a reloaded Configuration class would not replace the object already built.

The failure mode is a half-updated library: a reloaded query object calling a stale Configuration. Adding a config attribute and using it in the same edit raises NoMethodError on the next request, which is the good case — if the calling code tolerates nil, the same staleness silently changes behaviour instead. Restart after touching the top level.

Two path constants, both deliberate:

  • AuditLog::GEM_ROOT — the gem root. Schema::SQL_DIR resolves db/sql against it rather than against Engine.root, because Schema.install! runs from a migration and a migration must not depend on a booted engine.
  • Engine.find_root does not exist, on purpose. It used to, while this library lived inside a host app's lib/, where Rails' default root-walk would have resolved to the host app's root and pulled in its app/ directories. A gem root is unambiguous. Do not reintroduce it.

Before you change anything

The reasoning behind every decision here lives in DESIGN.md, which is the single source of truth for it — this file does not restate it. The sections most likely to matter, and the shape of the mistake each one prevents:

If you are touching Read Because
transaction_stamp.rb §6.1 begin_db_transaction is the obvious hook and misses update_all — bulk writes land with a NULL actor
current.rb, job or controller context §6.2, §6.4 the origin is captured in serialize, not around_enqueue; perform_all_later skips enqueue callbacks entirely
event_subscriber.rb, record.rb §7, §12 readonly? keyed on true breaks inserts, silently disabling layer 2
partitions.rb, the SQL, migrations §8 every boundary is UTC midnight, and the three manual operations must not overlap
a query object or a screen §11 mandatory date bounds are what make the screens prune
timeline.rb or its value objects §11.2b it is a PUBLISHED contract host apps render — headline returning nil rather than a generated sentence is part of it, and so is the two-type split
record_timeline.rb, the record screen §11.2a where.not(subject_type:, subject_id:) is NULL-unsafe and silently drops every event with no subject — which is the exact population the correlated section exists to show
pagination.rb or a screen's scope §11.0 the cursor must carry microseconds, or rows vanish between pages — and a .limit below the controller is a silent truncation
csv_export.rb §11.4a an export with a row cap reintroduces exactly what the paging removed
redaction.rb §13 changed_columns must survive; it is what keeps "the email changed at 14:02" provable
capture.rb, the disable/enable generators §25 capture is disabled by DETACHING, never by a flag the trigger reads — a flag would pass audit_log:coverage while auditing nothing
redaction.rb's marker, shared/_event_payload §11.3, §13 a redacted payload and an absent one are the same empty jsonb — the marker is the only trace, and a screen that cannot tell them apart renders an erasure as an absence
archive.rb §8 drop_exported! may never drop a partition whose manifest does not verify
actor_label.rb, an actor cell on a screen §6.2 a GROUP BY rollup has a tuple, not a record — a hand-rolled fallback chain drops the nil branch and actor_path(nil) 500s the screen
anything storing a timestamp §4 occurred_at is filled by a column DEFAULT so config.time_zone cannot reach it — supplying it from Ruby breaks that silently

Section numbers are cited from source comments throughout the library, so they are stable. Sections 15, 18 and 19 were project rollout and now live in the reference app's ROLLOUT.md.

Not implemented (deliberately)

Per DESIGN.md §12, §13, and the open questions in the reference app's ROLLOUT.md:

  • Database-level append-only enforcement. REVOKE UPDATE, DELETE plus a rejecting trigger. Additive, needs no schema change — but it requires SECURITY DEFINER and an owner role, which is the one thing that complicates managed-Postgres deployment.
  • Cryptographic tamper evidence. If ever needed, do it as a nightly sealing job, never in the trigger: an in-trigger prev_hash chain serializes every write through one hot tuple.
  • Read-access logging. Explicitly out of scope — this records changes, not views.
  • Signed-PDF export. CSV is implemented; PDF was judged unnecessary. Revisit only if a compliance regime asks for it.

Note the interaction between the first item and redaction.rb: append-only grants would now have to carve out an exception for the one operation that is supposed to modify audit rows.

Two things that used to be on this list are now built — export of retired partitions (archive.rb, rake audit_log:partitions:export_retired) and PII redaction (redaction.rb, rake audit_log:redact). What remains open about redaction is policy, not mechanism: who may authorize one, and what makes a REASON valid.

About

Two-layer audit log for Rails 8 + PostgreSQL

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages