LaunchKit
← All posts
· 18 min read · by The LaunchKit team · 0 views

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.

#rails #active-record #postgresql

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.