Rails audit log, and the writes that never reach it
An audit trail is not a log. A log is for you, it is append-only by convention, and nobody minds when a line is missing. An audit trail is for somebody who was not there: a support agent asking why this customer's plan changed, a security reviewer asking who touched the payout account, a regulator asking for the same answer in writing. The difference is that a gap in an audit trail is not a missing line. It is an answer of "nobody did this", which is false.
So the interesting question about a rails audit log is never how to write rows into a table. It is which writes reach the table at all, and every claim below was reproduced on activerecord 8.1.3.1 against PostgreSQL 17.7.
Four columns, and one of them is hard
A useful audit trail answers four questions, and the schema falls out of them directly.
What changed is the easy one, and it has two shapes. Store the diff, {"state": ["draft",
"sent"]}, and the row is small and reads like a sentence. Store the whole record before the change
and you can reconstruct any historical state, at a cost measured further down. audited stores the
diff. paper_trail stores the snapshot, in a column called object, and the diff is an optional
extra column you get only by passing --with-changes to the generator.
When is a timestamp, and the only thing to get right is that it belongs to the event and not to
the row, so it is created_at and nothing ever updates it.
From where is the request. audited's install migration gives you remote_address and
request_uuid out of the box, and the request_uuid is the one people underestimate: it is what
groups the eleven rows that one form submission touched into one action. paper_trail's default
versions table has neither. Its Controller module documents the workaround in place: override
info_for_paper_trail to return {:ip => request.remote_ip, :user_agent => request.user_agent},
and "The columns ip and user_agent must exist in your versions table", which is a migration
you write.
Who is the hard one, and it is hard for a structural reason. The model layer, where the change is observed, does not know who is making it. The controller knows, and the controller is not where the write is recorded. Every audit implementation is a scheme for getting an identity from the place that has it down to the place that needs it, and every one of them has a hole where no request exists.
Where the actor goes missing
ActiveSupport::CurrentAttributes is the standard carrier, and audited 5.6.0 switched to it from
the request_store gem. paper_trail 17.0.0 still depends on request_store, ~> 1.4. Either way the
storage is per thread:
Current.actor inside a fresh thread: nil
Current.actor on this thread: "user:42"
That one line is the whole background job problem. A Solid Queue worker running
Subscription#cancel! is a different thread in a different process, nothing set an actor there, and
the audit row records a change with an empty who. A rake task, a console session and a data
migration are the same story. The fix is not technical but editorial: name the job as the actor.
"job:SubscriptionSweeper" is a true answer and NULL is not.
The two gems differ here in a way worth knowing before you choose. audited ships a railtie that
installs ActionController::Base.around_action Audited::Sweeper.new for you, and the sweeper stores
current_user, request.remote_ip and request.uuid on every request without you writing a line.
paper_trail does not. Its PaperTrail::Rails::Controller declares
before_action :set_paper_trail_enabled_for_controller, :set_paper_trail_controller_info, and
set_paper_trail_whodunnit is deliberately not in that list. Its README says so in the comment next
to the example output: "ID of current_user. Requires set_paper_trail_whodunnit callback." Install
paper_trail, skip that line in ApplicationController, and you get a complete version history in
which nobody did anything.
For the console, audited has Audited.audit_class.as_user("console-user-#{ENV['SSH_USER']}") { ... }
and paper_trail has PaperTrail.request(whodunnit: "Dorian Marié") { ... }. Both are blocks you
have to remember to open, which is another way of saying the console is not audited.
The callback is the entire mechanism
Reading both gems takes about ten minutes and settles most arguments about them. In
lib/audited/auditor.rb, the private set_audit method is where the feature is installed:
after_create :audit_create if audited_options[:on].include?(:create)
before_update :audit_update if audited_options[:on].include?(:update)
after_touch :audit_touch if audited_options[:on].include?(:touch) && ::ActiveRecord::VERSION::MAJOR >= 6
before_destroy :audit_destroy if audited_options[:on].include?(:destroy)
paper_trail's lib/paper_trail/model_config.rb has the same four, arranged differently:
after_create for creates, after_update for updates, after_touch for touches, and for destroys
a before_destroy or after_destroy depending on the recording_order you pass. Its README states
the count plainly: "There are four potential callbacks, and the default is to install all four."
Note the one real difference in that list. audited records on before_update and reads
changes_to_save, the pending change. paper_trail records on after_update and reads what was
saved. The distinction matters when another before_update callback further down the chain modifies
an attribute, because audited has already decided what the change was and paper_trail has not.
This is the same shape as counter_cache, which
Counter caches by hand takes apart: two callbacks, _create_record
and destroy_row, and a column that drifts the moment anything writes outside them. An audit trail
built on callbacks inherits that property exactly, and an audit trail is the place where it costs
most.
What six bulk methods leave behind
Here is a model with a hand written audit callback on create, update and destroy, and an invoice that goes through the model layer twice:
--- through the model, with Current set
#1 create Invoice#1 actor="user:42" ip=203.0.113.7 {"cents" => [nil, 1000], "state" => [nil, "draft"]}
#2 update Invoice#1 actor="user:42" ip=203.0.113.7 {"cents" => [1000, 1200], "state" => ["draft", "sent"]}
invoices: [[1, "sent", 1200]]
Now the same invoice through six methods that every Rails application uses, in order:
update_all(state: "paid"), update_column(:cents, 9999), update_columns(state: "void"),
insert_all, upsert_all(id: 1, state: "reopened", cents: 7), delete_all.
--- after update_all / update_column / update_columns / insert_all / upsert_all / delete_all
(no audit rows)
invoices: [[1, "reopened", 7]]
Six state transitions, one row created and one destroyed, zero audit rows. The invoice is now
reopened at 7 cents and the rails audit trail says it was last seen as sent at 1200. Nothing
raised, nothing logged, and there is no failing test to write, because the methods are working
exactly as documented.
dependent: has the same edge. With dependent: :destroy a parent's destruction runs every child's
callbacks and the trail is complete. Switch one association to dependent: :delete_all for speed and
the children leave no record at all:
dependent: :destroy -> ["destroy LineItem 1", "destroy LineItem 2", "destroy Order 1"]
dependent: :delete_all -> ["destroy FastOrder 2"]
Two line items vanished between those runs and the second trail does not mention them.
The column name Active Record will not let you have
A small one, found the direct way. The obvious name for the jsonb column holding the diff is
changed, and Rails rejects it at class definition time:
ActiveRecord::DangerousAttributeError: changed is defined by Active Record.
Check to make sure that you don't have an attribute or method with the same name.
changes is taken for the same reason. Both are dirty-tracking methods on every model, and
instance_method_already_implemented? in active_record/attribute_methods.rb raises rather than
shadow them. Name it diff, or audited_changes the way audited does, or object_changes the way
paper_trail does. The failure is loud and happens at boot, which is the good kind.
Where the audit write goes, and what it costs there
Two placements are available and the choice is a real one with no free option. Put the audit write
in after_update and it is inside the save's transaction. Put it in after_commit and it is
outside.
Here is the same failing audit write, a NOT NULL on the actor column with no actor set, in both
positions. First after_update:
ActiveRecord::NotNullViolation: PG::NotNullViolation: ERROR: null value in column "actor"
of relation "audit_events" violates not-null constraint
invoice cents in the database: 100 audit rows: 0
Then after_commit, same model, same constraint, same missing actor:
ActiveRecord::NotNullViolation: PG::NotNullViolation: ERROR: null value in column "actor"
of relation "audit_events" violates not-null constraint
invoice cents in the database: 777 audit rows: 1
In the first, the audit table refused a row and the invoice stayed at 100. Nothing happened that was not recorded, and your checkout flow is now down because of a column on a table nobody looks at. In the second, the invoice committed at 777 and the trail does not contain it. Nothing broke, and the trail is now quietly wrong.
Both gems take the first option, which is the right default: an audit trail that can be skipped without anyone noticing is not one. But name the consequence out loud before you ship it, because the audits table is now on the critical path of every write to every audited model, and it is the fastest growing table in the schema.
A rolled-back transaction removes the audit row along with the change, which is correct, and it
leaves a fingerprint. In the trigger run further down, the last row before a deliberate rollback is
#6 and the first row after it is #8:
--- a rolled back transaction
audit rows added after rollback: 0
invoice cents now: 9999
--- the next write
#8 actor=nil {"cents" => [9999, 4242]}
Id 7 belonged to an audit row for a change that never happened. Sequence gaps in an audits table are normal and are not evidence of tampering, which is worth knowing before somebody reports one.
A trigger sees everything and knows nothing
Everything above happens in Ruby, which is why everything above can be walked past. A row trigger
lives under Active Record, so there is nothing for update_all to skip:
CREATE OR REPLACE FUNCTION audit_row() RETURNS trigger AS $$
DECLARE d jsonb;
BEGIN
IF TG_OP = 'UPDATE' THEN
SELECT jsonb_object_agg(n.key, jsonb_build_array(o.value, n.value))
INTO d
FROM jsonb_each(to_jsonb(NEW)) n
JOIN jsonb_each(to_jsonb(OLD)) o USING (key)
WHERE n.value IS DISTINCT FROM o.value AND n.key <> 'updated_at';
IF d IS NULL THEN RETURN NEW; END IF;
ELSIF TG_OP = 'INSERT' THEN d := to_jsonb(NEW);
ELSE d := to_jsonb(OLD);
END IF;
INSERT INTO audit_events (table_name, row_id, action, diff, actor, at)
VALUES (TG_TABLE_NAME, COALESCE(NEW.id, OLD.id), lower(TG_OP), d,
nullif(current_setting('audit.actor', true), ''), now());
RETURN NULL;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER invoices_audit AFTER INSERT OR UPDATE OR DELETE ON invoices
FOR EACH ROW EXECUTE FUNCTION audit_row();
The same table, written through create!, update!, update_all, update_column, insert_all
and delete_all, now produces this:
#1 insert row=1 actor="user:42" {"id" => 1, "cents" => 1000, "state" => "draft"}
#2 update row=1 actor="user:42" {"cents" => [1000, 1200], "state" => ["draft", "sent"]}
#3 update row=1 actor="user:42" {"state" => ["sent", "paid"]}
#4 update row=1 actor="user:42" {"cents" => [1200, 9999]}
#5 insert row=2 actor=nil {"id" => 2, "cents" => 5, "state" => "draft"}
#6 delete row=2 actor=nil {"id" => 2, "cents" => 5, "state" => "draft"}
Rows 3 and 4 are the update_all and the update_column that produced nothing at all in the
previous section. Row 6 is the delete_all. The to_jsonb(NEW) against to_jsonb(OLD) join also
means an update that changes nothing records nothing: Invoice.where(id: 1).update_all(state:
"paid") on a row already paid added zero audit rows, even though PostgreSQL wrote a new row version
for it.
Now read the actor column. Rows 1 to 4 ran inside a transaction that had called
SELECT set_config('audit.actor', 'user:42', true). Rows 5 and 6 did not, and the trigger recorded
nil, because a trigger has no idea what a current_user is. That is the trade in one output
block: the recorder that cannot be bypassed is the one furthest from the information you actually
want.
The third argument to set_config is load-bearing and gets skipped. With true the setting is
transaction-local and is gone afterwards; with false it stays for the life of the session:
after a transaction-local set: ""
after a session-level set: "user:42"
Rails hands connections back to a pool. A session-level actor is a value the next request to check
out that connection inherits, which attributes one user's changes to another, in the one table where
that is worst. Pass true.
What keeping the trail costs
Storage is the cost people discover late, and the shape of the what changed column decides it.
Here are 1000 updates to a single article with a 4 kB incompressible body, written twice: once as
paper_trail writes them, the full previous row serialized to YAML in a text column, and once as
audited writes them, a jsonb diff.
articles 48 kB
snapshots 5608 kB (of which 5448 kB is TOAST)
diffs 200 kB
one snapshot object column: 4128 bytes of YAML
one diff audited_changes: 31 bytes of JSON
The snapshot table is 28 times the diff table and 116 times the table it audits. Be careful with
that ratio, because it is the one number here that does not generalise: the body was deliberately
incompressible. The same test with a body of 4000 repeated x characters gave 312 kB instead of
5608 kB, since PostgreSQL compressed the TOASTed text nearly to nothing. The honest statement is
that full-snapshot versioning costs you the size of the row on every update and then whatever your
data's compressibility gives back.
Both gems offer a cap, and it is worth understanding what a cap means here. paper_trail has
PaperTrail.config.version_limit. audited has max_audits, and its implementation is
combine_audits, which merges the excess rows' changes with reduce(&:merge) into the newest one
and then runs delete_all on the rest. Merging diffs with Hash#merge keeps the last value per
key and discards every intermediate one, so a column that went draft, sent, disputed, paid
across four combined audits ends up recorded as having gone to paid with the disputed state gone.
That is a fine retention policy for a rollback buffer and a poor one for an audit trail, and the
option name does not tell you which you are getting.
The integer column in the install migration
rails generate audited:install writes a migration that begins:
create_table :audits, :force => true do |t|
t.column :auditable_id, :integer
t.column :auditable_type, :string
...
t.column :user_id, :<%= options[:audited_user_id_column_type] %>
:integer on PostgreSQL is int4, maximum 2147483647. Rails has created bigint primary keys by
default since 5.1. Create the audits table exactly as generated, push a table's id sequence past
int4, and the audit write is what fails:
audits.auditable_id -> integer
audits.user_id -> integer
widget id: 4000000001
ActiveModel::RangeError: 4000000001 is out of range for ActiveModel::Type::Integer with limit 4 bytes
Because audit_create is an after_create inside the save's transaction, that is not a missing
audit row. That is the Widget.create! failing. The user_id column has the same default and the
README names the consequence for one case only: "The standard Audited install assumes your User
model has an integer primary key type. If this isn't true (e.g. you're using UUID primary keys),
you'll need to create a migration." UUID keys are not the only way to exceed int4. Change both
columns to bigint in the generated migration before you run it.
paper_trail's generator already does this. Its item_id_type_options returns "bigint" unless you
pass --uuid, in which case it returns "string".
audited and paper_trail, as they stand today
Both are polymorphic tables, auditable_type plus auditable_id and item_type plus item_id, so
both inherit what Polymorphic associations is about, starting
with the foreign key PostgreSQL will not give you. For an audit trail that is the correct trade
rather than a compromise: the row recording a deletion has to outlive the row it describes, and a
foreign key would forbid exactly that.
paper_trail 17.0.0, released 2025-10-24, MIT, 147.9 million downloads, requires Ruby 3.2 or
newer. Rails 8.1 support is official: PR #1538, "Make support for Rails 8.1 official", merged
2025-10-22, two days before the release. The CI matrix is ['rails_7.1', 'rails_7.2', 'rails_8.0',
'rails_8.1'] against Ruby 3.2 and 4.0, and Ruby 4.0 went in on 2026-05-08. Its gemspec pins only
activerecord >= 7.1, with an upper bound of < 8.2 declared in PaperTrail::Compatibility and
enforced as a boot-time warning rather than a resolver failure, on the stated grounds that "It is
not safe to assume that a new version of rails will be compatible with PaperTrail."
audited 5.8.0, released 2024-11-08, MIT, 41.3 million downloads. Its gemspec allows Rails 8.1,
activerecord >= 5.2, < 8.2, which came from PR #738, "Relax gemspec to allow Rails 8.1". Allowing
is not testing. Its Appraisals file stops at rails80 plus a rails_main, its CI matrix runs Ruby
2.3 to 3.3, and its README's supported Ruby list ends at 3.3. The repository is not dead: the last
commit on master is from 2025-11-18. It has had no release in almost two years, and it still
supports Rails 5.2, which is the same fact seen from the other side.
The call, and what would change it
Install the audited gem when the model layer is the only thing that writes and "who" matters most.
The sweeper railtie fills in who, when and from where on every request with no code, the diff-shaped
audited_changes keeps the table small, and the schema is already the four columns above. Change
auditable_id and user_id to bigint in the generated migration first.
Reach for paper_trail when reconstructing a record as it was is a real requirement, and when a
maintained Rails 8.1 matrix matters. Both gems can rebuild a
past state, differently: reify deserializes one object column, while audited's Audit#revision
queries every audit up to that version and folds them together, starting from
clazz.find_by_id(auditable_id) || clazz.new, so anything you excluded from auditing comes back as
its value today rather than its value then. Add the before_action :set_paper_trail_whodunnit
yourself, add your own ip and request_id columns, and pass --with-changes unless you want to diff YAML blobs in
production.
Write the trigger when the answer has to be complete rather than convenient: money, permissions,
anything a person outside the team will read. It is the only recorder here that survives a
delete_all, a psql session and a second service sharing the database, and its price is plpgsql to
maintain, a schema dumper that has to be :sql rather than :ruby to keep it, and an actor that
exists only when something remembers to set it. The two are not exclusive, and the strongest setup I
can defend is a trigger for completeness with a gem for attribution, accepting that the two tables
will disagree and that the disagreement is itself information.
What would change it: an audited release naming Rails 8.1 and Ruby 4.0 in its CI would remove the
only real argument against that gem. A PaperTrail.request.whodunnit wired up by the railtie the way
audited's sweeper is would remove the sharpest footgun in the other one. And a Rails feature that
carried Current down to the database session, so update_all and a trigger could agree on who ran
them, would make the whole trade in this post go away.
The trigger nobody can see
The position has a cost worth stating. Preferring the trigger for high-value tables means an audit
trail that is not in your test suite, not in schema.rb unless somebody switched the dumper, and
invisible to a Rails developer reading the models. A has_paper_trail line at the top of a class
announces itself; a trigger created in a migration two years ago announces nothing, and the next
person to write a data backfill will not know their update_all is being recorded. That is a real
maintenance tax, and the only mitigation I know of is a comment on the model pointing at the
trigger, which is a convention rather than a mechanism and will rot like every other one.
What this post does not cover
The LaunchKit boilerplate has no audit trail, and neither gem is in its Gemfile. What it has is one
model with a version history: AiTemplate carries a versions jsonb column filled by a
before_update :snapshot_previous callback, capped at MAX_VERSIONS = 10, so an admin can roll a
broken prompt back. That is a rollback buffer and not an audit log, and the giveaway is that the
snapshot records system_prompt, user_prompt, variables and saved_at, with no actor anywhere
in it. ConfigTransfer excludes the column from its export for that reason: "the rollback history,
which is local to each environment". Recording the same information in the sales site would cost the
same four columns as anything else here.
Also absent: logical decoding and the wal2json route, which gets you a trail with no trigger and no
callback at the price of a replication slot and a consumer process; pgaudit, which audits
statements rather than rows and answers a different question; retention, partitioning and moving old
audit rows off the primary, which is where every one of these tables eventually goes; and any
timing figure, because the measurements that matter here are what each recorder catches and what it
stores, not how many microseconds it adds.
Comments
No comments yet. Be the first.