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.
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.
- Summary
- Requirements
- The two layers
- Demo Rails App
- Getting started
- 1. Add the gem
- 2. Install
- 3. Attach a trigger to each audited table
- 4. Prove nothing was missed
- 5. Schedule the daily task
- 6. Read the initializer before deploying
- 7. Recommended: Register and emit events for significant business actions
- 8. Optional: Put a history on your own pages
- What the generator wrote
- What a model needs
- Registering and emitting events
- Reading one record's history
- Building an activity history in your own app
- Styling the auditor UI (optional)
- Timestamps
- Making association ids readable (optional)
- Dimensions: querying by your own associations (optional)
- Configuration
- Generator options
- Rake tasks
- Advanced
- Attaching to a table that already exists
- Re-attaching, and changing a table's exclusions
- Redacting values under an erasure request
- Bypassing the log for a bulk load
- Stopping auditing, and starting again
- Installing into a schema other than
public - Transaction control in
audited - Multi-database apps
- Why objects and not relations
- Documentation for coding agents
- Why this one, and not a callback-based gem
- Why not one of the popular gems?
- Working on this library
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_idper 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_idor adepartment_idonto 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_zonecannot 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.
| 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.
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.
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.
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.
# 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.
bin/rails generate audit_log:install
bin/rails db:migrateWrites 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 putsinclude AuditLog::ControllerContextafter the lastbefore_actionit can find inApplicationController. If it lands ahead of your authentication, it reads acurrent_userthat is not resolved yet and every audit row gets a NULL actor, silently. Look at the file.
bin/rails generate audit_log:trigger orders --model=Order
bin/rails generate audit_log:trigger products --model=Product --exclude=search_vector
bin/rails db:migrateOne 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.
bin/rails audit_log:coverageFails 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.
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.
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.
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.
bin/rails generate audit_log:views:activity Order Product LineItemThen 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.
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 |
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.
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.
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.
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]})" }| 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.eventsubscriber, 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:reconcilelists it.
This is the first thing to check when an action does not show up on /audit.
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
endPut 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
endTwo 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.
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
endCalling 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
endWhen 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.
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."
endThe 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.
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."
endAn event emitted here would claim the order shipped at the moment somebody clicked a button, which is not what happened.
- Pass primitives — ids, strings, numbers, arrays. The payload is stored
verbatim in the
metadatajsonb 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:, neverid:— including on an action whose subject is that record. A bareidcannot be declared as a dimension:dimensions: %i[id]records{"id": "17487"}, which nojob_idfilter 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, whereid: 17487besidenumber: "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.
metadatarenders on the action screen as the structured backing for the summary. - Never put a secret, token or password in a payload.
config.default_excluded_columnskeepsencrypted_passwordand 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::Redactionempties an event'smetadatawholesale and replaces itssummarywith the marker, so one careless key costs that subject its entire narrative. It also matches onsubject_type/subject_id, which means an action registered without asubject:cannot be reached by a record-level erasure at all. nilvalues are dropped (payload.compact), so a key that is sometimes absent will be absent frommetadata, not present asnull.- A missing key is silent unless you declare it. See Declaring a payload contract below.
- Do not rescue around
notify. The engine setsRails.event.raise_on_error = trueon purpose: a failed audit write must not vanish while the change it described commits anyway.
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
nilvalue counts as supplied —metadatais 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.
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.
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 aRegistryentry and asubject: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 matchingrequest_idagainst 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 threeevents 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.
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.
rails generate audit_log:views:activity Order Product CustomerAny 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 ShipmentThat 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.
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.
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
endAnd 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.
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.
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
endAuthorization 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.
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 dateTwo 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.
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.
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
@mediablock 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.
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 EDTIt 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.
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}"
endproduct_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.
| 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 |
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" } }
endReflection, 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.
- 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
diffcolumn 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.
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.
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.
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.
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.
- 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
invoicescombine freely.customer_idfrom an order plusproduct_idfrom 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.
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.
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.
New installs get the storage and the index automatically. An existing deployment runs:
bin/rails generate audit_log:dimensions
bin/rails db:migrateThe 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.
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.
| 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. |
| 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. |
| 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. |
| 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. |
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.
| 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 |
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, alast_seen_attouched on every request. Left in, an auditor reading "what changed on this order" wades through a column nobody asked about, and the jsonbdiffgrows for no benefit. - Values you do not want a second copy of.
config.default_excluded_columnsalready covers the usual suspects —created_at,updated_at,lock_version,password_digest,encrypted_password, and Devise's reset tokens.--excludeis 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::Redactionis 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.
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 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 LineItemA 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.
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
retentionandrollupcommit per partition, so a mid-run failure leaves the earlier ones already done — keep the output, not just the exit status.
| 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. |
| 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. |
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 | 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.
| 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. |
redacttakesFIELDS=, neverCOLUMNS=— 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.
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.
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)
endcoverage_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 TRIGGERresolvesaudit_row_changeat creation time, so a missing install fails the migration loudly. This is the harmless one. - The table needs a
bigint-compatibleid. The trigger function assignsrec_id bigint := NEW.id, andaudit_changes.record_idisbigint NOT NULL. Acreate_table id: falsejoin table, auuidprimary key, or a primary key not namedidtherefore 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 TRIGGERtakesSHARE ROW EXCLUSIVEon 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 alock_timeoutand retry, rather than letting the migration wait behind one long transaction. Same reasoning asconfig.maintenance_lock_timeoutfor 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
UPDATEof a pre-existing row still yields a complete[old, new]pair, because the diff readsto_jsonb(OLD)off the live row. What is missing is the changes before the attach, not the values before the change. - A
DELETEsnapshots 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.
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]
endChanging 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.
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_columnsis 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,phoneREASON 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.
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.
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")- 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 arake audit_log:partitionsrun 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.
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)
endby: 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.notifyandAuditLog.auditedstill writeaudit_eventsrows, 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.bypassandaudit.bypass_completed, whichaudit_log:installnow 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.
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:migrateThat 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.
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.
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.
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 readsto_jsonb(OLD)off the live row. - A
DELETEafter 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.
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 markerCheck 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.
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
publicthat is written while another schema'ssearch_pathis active still files its audit rows inpublic, 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:coveragewill 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 inconfig.unaudited_tableswith a reason.
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.
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
endaudited 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
endLeft 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?
endMeasured 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.
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
endon: 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.
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.
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 thereaudit_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.
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.
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.
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.
| 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. |
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_DIRresolvesdb/sqlagainst it rather than againstEngine.root, becauseSchema.install!runs from a migration and a migration must not depend on a booted engine.Engine.find_rootdoes not exist, on purpose. It used to, while this library lived inside a host app'slib/, where Rails' default root-walk would have resolved to the host app's root and pulled in itsapp/directories. A gem root is unambiguous. Do not reintroduce it.
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.
Per DESIGN.md §12, §13, and the open questions in the reference
app's ROLLOUT.md:
- Database-level append-only enforcement.
REVOKE UPDATE, DELETEplus a rejecting trigger. Additive, needs no schema change — but it requiresSECURITY DEFINERand 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_hashchain 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.