rails_audit_log 0.9.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c9a9fcdb866f5300a3a2fd9bd6026ad93530d15b0b26d771ef1a28a47985280
4
- data.tar.gz: 01ac4b3f95fd1c2067a8aa5936122ff86dc264fe771881515dd31d3401377b66
3
+ metadata.gz: 26fdf8335990094a7278cbcf9921e123fbf44f6ec464b974b1c73e1f41047691
4
+ data.tar.gz: 9700fef384bb8f23eb929b0c62cb82880ba8217a44f64660f97823c4ffe6f1c0
5
5
  SHA512:
6
- metadata.gz: 4c155747f1219996a6fde0fcc0d00c764df0f296c7b099418b7020ef90984690400300174b5c1cc1dc4842451a3f53d35e4b710542693683c78d4706c0eca1c1
7
- data.tar.gz: 256ef73b739193443718481ef7fa1c0920ae4b4d424e3466b9ac3dbaf9a0bf8b7cf4823d0fd7d000652710985ac60fa6df6252e5997266bb40eda6bde9ba9941
6
+ metadata.gz: 8c0450b8f9228ec6ea98e7e026e0554d73004accf1a284433fa6260b5acd941d8314fd33b9bf873156e214f65f8bcd1d687613077f60c310dacd2a2b4e1f98e8
7
+ data.tar.gz: 733c560b234f96900a760cc76b74a18bd39303179e36ae8272c0f150560139272eb3fb0de9ee435883fbd2c128bd408b373601f407818ff9e5c72cb9c465e503
data/README.md CHANGED
@@ -6,10 +6,47 @@
6
6
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-ruby)](https://www.ruby-lang.org)
7
7
  [![codecov](https://codecov.io/gh/eclectic-coding/rails_audit_log/branch/main/graph/badge.svg)](https://codecov.io/gh/eclectic-coding/rails_audit_log)
8
8
 
9
- A modern, Zeitwerk-native Rails engine for auditing ActiveRecord changes. Tracks `create`, `update`, and `destroy` events with JSON-first storage, whodunnit actor context, and a clean query API.
9
+ Audit logging for Rails. Tracks `create`, `update`, and `destroy` events as structured JSON records with a mountable web dashboard, whodunnit actor context, batch writes, time-travel reconstruction, and test helpers — a clean, well-documented replacement for PaperTrail with a built-in migration path.
10
+
11
+ ## Table of contents
12
+
13
+ - [Installation](#installation)
14
+ - [Configuration](#configuration)
15
+ - [Web dashboard](#web-dashboard)
16
+ - [Usage](#usage)
17
+ - [Tracking a model](#tracking-a-model)
18
+ - [Recording who made the change](#recording-who-made-the-change)
19
+ - [Actor context outside of controllers](#actor-context-outside-of-controllers)
20
+ - [Querying the audit log](#querying-the-audit-log)
21
+ - [Lightweight queries](#lightweight-queries)
22
+ - [Association tracking](#association-tracking)
23
+ - [Bulk audit writes](#bulk-audit-writes)
24
+ - [Async audit writes](#async-audit-writes)
25
+ - [Capping history per record](#capping-history-per-record)
26
+ - [Time-based retention](#time-based-retention)
27
+ - [Scheduled and manual pruning](#scheduled-and-manual-pruning)
28
+ - [Selective tracking](#selective-tracking)
29
+ - [Disabling auditing](#disabling-auditing)
30
+ - [Object reconstruction](#object-reconstruction)
31
+ - [Attaching a reason](#attaching-a-reason)
32
+ - [Arbitrary metadata](#arbitrary-metadata)
33
+ - [Request metadata capture](#request-metadata-capture)
34
+ - [Actor display name snapshot](#actor-display-name-snapshot)
35
+ - [Object snapshot storage](#object-snapshot-storage)
36
+ - [Separate audit database](#separate-audit-database)
37
+ - [Test helpers](#test-helpers)
38
+ - [Stability and versioning](#stability-and-versioning)
39
+ - [Migrating from PaperTrail](#migrating-from-papertrail)
40
+ - [Performance](#performance)
41
+ - [Companion gems](#companion-gems)
42
+ - [Requirements](#requirements)
43
+ - [Contributing](#contributing)
44
+ - [License](#license)
10
45
 
11
46
  ## Installation
12
47
 
48
+ [↑ Table of contents](#table-of-contents)
49
+
13
50
  Add to your `Gemfile`:
14
51
 
15
52
  ```ruby
@@ -23,16 +60,72 @@ bin/rails generate rails_audit_log:install
23
60
  bin/rails db:migrate
24
61
  ```
25
62
 
26
- Optionally scaffold a commented initializer with every configuration option:
63
+ ## Configuration
64
+
65
+ [↑ Table of contents](#table-of-contents)
66
+
67
+ Run the initializer generator to create `config/initializers/rails_audit_log.rb` with every option documented as a commented example:
27
68
 
28
69
  ```bash
29
70
  bin/rails generate rails_audit_log:initializer
30
71
  ```
31
72
 
32
- This creates `config/initializers/rails_audit_log.rb` with all settings documented as commented examples inside a `RailsAuditLog.configure` block.
73
+ The generated file (shown below with all options) uses the block-style `configure` API. Every setting has a sensible default — uncomment only what you need:
74
+
75
+ ```ruby
76
+ # config/initializers/rails_audit_log.rb
77
+
78
+ RailsAuditLog.configure do |config|
79
+ # Global columns excluded from all audited models.
80
+ # Default: ["updated_at"]
81
+ # config.ignored_attributes = %w[updated_at cached_at]
82
+
83
+ # Store a full attribute snapshot alongside object_changes.
84
+ # Default: true
85
+ # Disable to save storage; reify and version_at fall back to diff-only reconstruction.
86
+ # config.store_snapshot = false
87
+
88
+ # Capture remote_ip and user_agent into each entry's metadata column.
89
+ # Default: false — requires RailsAuditLog::Controller in ApplicationController.
90
+ # config.capture_request_metadata = true
91
+
92
+ # Customise how the actor's display name is stored at write time.
93
+ # Default: actor.name if available, otherwise actor.to_s
94
+ # config.whodunnit_display = ->(actor) { actor.email }
95
+
96
+ # Global cap on entries retained per tracked record. nil = no limit.
97
+ # Per-model `audit_log version_limit: N` takes precedence.
98
+ # config.version_limit = 100
99
+
100
+ # Global time-based TTL — entries older than this duration are pruned after
101
+ # each write. Composes with version_limit: an entry is removed when it
102
+ # exceeds either constraint. Default: nil (no TTL)
103
+ # config.retention_period = 90.days
104
+
105
+ # Write all audit entries asynchronously via WriteAuditLogJob.
106
+ # Default: false — per-model `audit_log async: true` also works.
107
+ # config.async = true
108
+
109
+ # Route AuditLogEntry to a dedicated database (Rails multi-DB).
110
+ # config.connects_to = { database: { writing: :audit_log, reading: :audit_log } }
111
+
112
+ # Entries per page in the web dashboard. Default: 25
113
+ # config.page_size = 50
114
+
115
+ # Gate web dashboard access. Block runs in controller context so controller
116
+ # helpers like current_user are available directly. Falls back to HTTP Basic
117
+ # auth when the block returns falsy. Leave unset for unauthenticated access.
118
+ # config.authenticate { current_user&.admin? }
119
+ # config.authenticate { |c| c.current_user&.admin? }
120
+ end
121
+ ```
122
+
123
+ Each option is documented in detail in its own Usage section below.
33
124
 
34
125
  ## Web dashboard
35
126
 
127
+ [↑ Table of contents](#table-of-contents)
128
+
36
129
  Mount the engine in `config/routes.rb` to enable the built-in audit trail browser:
37
130
 
38
131
  ```ruby
@@ -43,6 +136,8 @@ Then visit `/audit` to browse all audit entries. The dashboard is delivered via
43
136
 
44
137
  ## Usage
45
138
 
139
+ [↑ Table of contents](#table-of-contents)
140
+
46
141
  ### Tracking a model
47
142
 
48
143
  Include `RailsAuditLog::Auditable` in any ActiveRecord model:
@@ -120,19 +215,6 @@ entry.diff
120
215
  # => { "title" => { from: "Hello", to: "World" } }
121
216
  ```
122
217
 
123
- ### Separate audit database
124
-
125
- Route all audit writes to a dedicated database by setting `connects_to` in an initializer. The engine applies it to `AuditLogEntry` at boot:
126
-
127
- ```ruby
128
- # config/initializers/rails_audit_log.rb
129
- RailsAuditLog.connects_to = {
130
- database: { writing: :audit_log, reading: :audit_log }
131
- }
132
- ```
133
-
134
- The key (e.g. `:audit_log`) must match a database key in `config/database.yml`. All reads and writes on `AuditLogEntry` — including `batch_audit` inserts and `WriteAuditLogJob` — use that connection automatically.
135
-
136
218
  ### Lightweight queries
137
219
 
138
220
  Use `.slim` to exclude the three JSON blob columns (`object_changes`, `object`, `metadata`) from the SQL projection. This is useful for index or listing views where you only need the entry header (who, what event, when):
@@ -248,6 +330,45 @@ Set a global default in an initializer — per-model values take precedence:
248
330
  RailsAuditLog.version_limit = 50
249
331
  ```
250
332
 
333
+ ### Time-based retention
334
+
335
+ Automatically prune entries older than a configured duration by setting `retention_period` in an initializer:
336
+
337
+ ```ruby
338
+ # config/initializers/rails_audit_log.rb
339
+ RailsAuditLog.retention_period = 90.days
340
+ ```
341
+
342
+ Entries whose `created_at` is older than the period are deleted after each write. `retention_period` and `version_limit` compose — an entry is pruned when it exceeds **either** constraint.
343
+
344
+ Override the global default per model with `retain_for:`:
345
+
346
+ ```ruby
347
+ class Post < ApplicationRecord
348
+ include RailsAuditLog::Auditable
349
+ audit_log retain_for: 30.days # takes precedence over RailsAuditLog.retention_period
350
+ end
351
+ ```
352
+
353
+ ### Scheduled and manual pruning
354
+
355
+ `RailsAuditLog::PruneAuditLogJob` prunes all audited models in one pass. It iterates over every `item_type` present in `audit_log_entries`, resolves the effective `retain_for` / `retention_period` and `version_limit` per model, and deletes entries that exceed either constraint.
356
+
357
+ Enqueue it on a recurring schedule via your job backend:
358
+
359
+ ```ruby
360
+ # config/recurring.yml (Solid Queue)
361
+ prune_audit_log:
362
+ class: RailsAuditLog::PruneAuditLogJob
363
+ schedule: every day at midnight
364
+ ```
365
+
366
+ Or run it once manually via the rake task:
367
+
368
+ ```bash
369
+ bin/rails rails_audit_log:prune
370
+ ```
371
+
251
372
  ### Selective tracking
252
373
 
253
374
  Track only specific attributes, or exclude noisy ones:
@@ -293,22 +414,20 @@ article.skip_audit_log { article.update!(cached_at: Time.current) }
293
414
 
294
415
  ### Object reconstruction
295
416
 
296
- #### Reify a single entry
297
-
298
- `AuditLogEntry#reify` returns an unsaved ActiveRecord instance reflecting the record's state **before** the entry was recorded:
417
+ **Reify a single entry** — `AuditLogEntry#reify` returns an unsaved ActiveRecord instance reflecting the record's state **before** the entry was recorded:
299
418
 
300
419
  ```ruby
301
420
  article.update!(title: "v2")
302
421
  entry = article.audit_log_entries.updated_events.last
303
422
 
304
423
  previous = entry.reify
305
- previous.title # => "v1" (the pre-update state)
424
+ previous.title # => "v1" (the pre-update state)
306
425
  previous.persisted? # => false
307
426
  ```
308
427
 
309
428
  Returns `nil` for `create` entries (nothing existed before).
310
429
 
311
- #### Reconstruct state at any point in time
430
+ **Reconstruct state at any point in time:**
312
431
 
313
432
  ```ruby
314
433
  snapshot = RailsAuditLog.version_at(article, 1.week.ago)
@@ -317,7 +436,7 @@ snapshot.title # => whatever the title was a week ago
317
436
 
318
437
  Returns `nil` if the record had no history at that time or was already destroyed.
319
438
 
320
- #### Navigate the version chain
439
+ **Navigate the version chain:**
321
440
 
322
441
  ```ruby
323
442
  entry = article.audit_log_entries.updated_events.last
@@ -402,31 +521,58 @@ To save storage at the cost of reduced reification accuracy, switch to diff-only
402
521
  RailsAuditLog.store_snapshot = false
403
522
  ```
404
523
 
405
- ### Test helper
524
+ ### Separate audit database
406
525
 
407
- `without_audit_log` silences audit tracking inside the block useful in FactoryBot factories and seed data to avoid noise in the audit trail:
526
+ Route all audit writes to a dedicated database by setting `connects_to` in an initializer. The engine applies it to `AuditLogEntry` at boot:
408
527
 
409
528
  ```ruby
410
- # spec/rails_helper.rb (or spec/support/factory_helpers.rb)
529
+ # config/initializers/rails_audit_log.rb
530
+ RailsAuditLog.connects_to = {
531
+ database: { writing: :audit_log, reading: :audit_log }
532
+ }
533
+ ```
534
+
535
+ The key (e.g. `:audit_log`) must match a database key in `config/database.yml`. All reads and writes on `AuditLogEntry` — including `batch_audit` inserts and `WriteAuditLogJob` — use that connection automatically.
536
+
537
+ ### Test helpers
538
+
539
+ **`RailsAuditLog::TestHelpers`** — silences audit tracking inside a block; useful in FactoryBot factories and seed data:
540
+
541
+ ```ruby
542
+ # spec/rails_helper.rb
411
543
  require "rails_audit_log/test_helpers"
412
544
 
413
545
  RSpec.configure do |config|
414
546
  config.include RailsAuditLog::TestHelpers
415
547
  end
548
+ ```
416
549
 
417
- # Or include directly in FactoryBot definitions:
418
- FactoryBot.define do
419
- factory :post do
420
- after(:create) { |p| without_audit_log { p.update!(cached_at: Time.current) } }
421
- end
422
- end
550
+ ```ruby
551
+ # Or in a FactoryBot factory:
552
+ after(:create) { |p| without_audit_log { p.update!(cached_at: Time.current) } }
423
553
  ```
424
554
 
425
555
  `without_audit_log` is a prefix-free wrapper around `RailsAuditLog.disable` — thread-safe and restores tracking even if the block raises.
426
556
 
427
- ### Minitest assertions
557
+ **`RailsAuditLog::Matchers`** (RSpec) — add to `spec/rails_helper.rb`:
428
558
 
429
- Add to your `test/test_helper.rb`:
559
+ ```ruby
560
+ require "rails_audit_log/matchers"
561
+
562
+ RSpec.configure do |config|
563
+ config.include RailsAuditLog::Matchers
564
+ end
565
+ ```
566
+
567
+ ```ruby
568
+ expect(post).to have_audit_log_entry
569
+ expect(post).to have_audit_log_entry(:update)
570
+ expect(post).to have_audit_log_entry(:update).touching(:title)
571
+
572
+ expect { post.update!(title: "New") }.to create_audit_log_entry(event: :update).touching(:title)
573
+ ```
574
+
575
+ **`RailsAuditLog::MinitestAssertions`** — add to `test/test_helper.rb`:
430
576
 
431
577
  ```ruby
432
578
  require "rails_audit_log/minitest_assertions"
@@ -436,51 +582,182 @@ class ActiveSupport::TestCase
436
582
  end
437
583
  ```
438
584
 
439
- Then use the assertions in any test:
440
-
441
585
  ```ruby
442
- assert_audit_log_entry post # any entry
443
- assert_audit_log_entry post, event: :update # update entry
444
- assert_audit_log_entry post, event: :update, touching: :title # touching title
445
- refute_audit_log_entry post, event: :update # no update entry
446
- assert_audit_log_entry post, event: :update, message: "custom" # custom failure message
586
+ assert_audit_log_entry post, event: :update, touching: :title
587
+ refute_audit_log_entry post, event: :destroy
588
+ ```
589
+
590
+ ## Stability and versioning
591
+
592
+ [↑ Table of contents](#table-of-contents)
593
+
594
+ `rails_audit_log` follows [Semantic Versioning](https://semver.org/) from 1.0.0. The public API is everything documented in this README. Anything not listed here is internal and may change between minor versions.
595
+
596
+ | Version bump | Meaning |
597
+ |---|---|
598
+ | **Patch** (1.0.x) | Bug fixes only — no API changes |
599
+ | **Minor** (1.x.0) | New features, backward-compatible |
600
+ | **Major** (x.0.0) | Breaking changes with a documented migration path |
601
+
602
+ ### Public API surface
603
+
604
+ **Module-level** — `RailsAuditLog`
605
+
606
+ | Method / accessor | Purpose |
607
+ |---|---|
608
+ | `.configure { \|config\| }` | Block-style configuration |
609
+ | `.with_actor(actor) { }` | Set whodunnit context for a block |
610
+ | `.disable { }` | Suppress all audit writes for a block |
611
+ | `.enabled?` | Whether audit writes are active |
612
+ | `.audit_log_reason(value) { }` | Attach a free-text reason for a block |
613
+ | `.batch_audit { }` | Buffer writes and flush with a single `insert_all!` |
614
+ | `.version_at(record, time)` | Reconstruct record state at a point in time |
615
+ | `.authenticate { }` | Gate web dashboard access |
616
+ | `.ignored_attributes=` | Global columns excluded from all models |
617
+ | `.store_snapshot=` | Store full object snapshot alongside diffs |
618
+ | `.capture_request_metadata=` | Capture `remote_ip` and `user_agent` |
619
+ | `.version_limit=` | Global entry cap per record |
620
+ | `.async=` | Write audit entries via `WriteAuditLogJob` |
621
+ | `.connects_to=` | Route `AuditLogEntry` to a separate database |
622
+ | `.page_size=` | Entries per page in the web dashboard |
623
+ | `.whodunnit_display=` | Proc for actor display name snapshot |
624
+ | `.retention_period=` | Global time-based TTL for audit entries |
625
+
626
+ **Concerns**
627
+
628
+ | Class | Include in | Key methods |
629
+ |---|---|---|
630
+ | `RailsAuditLog::Auditable` | ActiveRecord models | `audit_log(only:, ignore:, meta:, associations:, version_limit:, retain_for:, async:)`, `skip_audit_log { }` |
631
+ | `RailsAuditLog::Controller` | ActionController | `audit_log_actor { }` |
632
+
633
+ **Model — `RailsAuditLog::AuditLogEntry`**
634
+
635
+ Scopes: `created_events`, `updated_events`, `destroyed_events`, `by_actor`, `for_resource`, `since`, `until`, `touching`, `slim`, `for_period`
636
+
637
+ Instance methods: `reify`, `previous`, `next`, `diff`, `changed_attributes`, `actor`
638
+
639
+ Constants: `EVENTS`, `BLOB_COLUMNS`, `PERIODS`
640
+
641
+ **Testing helpers**
642
+
643
+ | Module | Require | Usage |
644
+ |---|---|---|
645
+ | `RailsAuditLog::Matchers` | `require "rails_audit_log/matchers"` | RSpec: `have_audit_log_entry`, `create_audit_log_entry` |
646
+ | `RailsAuditLog::MinitestAssertions` | `require "rails_audit_log/minitest_assertions"` | Minitest: `assert_audit_log_entry`, `refute_audit_log_entry` |
647
+ | `RailsAuditLog::TestHelpers` | `require "rails_audit_log/test_helpers"` | `without_audit_log { }` |
648
+
649
+ **Jobs** (configure via ActiveJob)
650
+
651
+ `RailsAuditLog::WriteAuditLogJob` — do not instantiate directly; enqueued when `async: true` is set.
652
+
653
+ `RailsAuditLog::PruneAuditLogJob` — enqueue on a schedule to prune all audited models; also invoked by `bin/rails rails_audit_log:prune`.
654
+
655
+ **Generators**
656
+
657
+ `rails generate rails_audit_log:install` · `rails generate rails_audit_log:initializer`
658
+
659
+ ---
660
+
661
+ ## Migrating from PaperTrail
662
+
663
+ [↑ Table of contents](#table-of-contents)
664
+
665
+ Run the migration generator to produce a timestamped data migration:
666
+
667
+ ```bash
668
+ bin/rails generate rails_audit_log:migrate_from_paper_trail
669
+ bin/rails db:migrate
447
670
  ```
448
671
 
449
- ### RSpec matchers
672
+ The generated migration reads every row from PaperTrail's `versions` table and inserts it into `audit_log_entries`.
673
+
674
+ ### Column mapping
675
+
676
+ | PaperTrail `versions` | `audit_log_entries` | Notes |
677
+ |---|---|---|
678
+ | `item_type` | `item_type` | Direct copy |
679
+ | `item_id` | `item_id` | Direct copy |
680
+ | `event` | `event` | `create` / `update` / `destroy` only; other values are skipped |
681
+ | `object_changes` | `object_changes` | YAML or JSON → JSON (see below) |
682
+ | `object` | `object` | YAML or JSON → JSON (see below) |
683
+ | `whodunnit` | `whodunnit_snapshot` | PaperTrail stores actor as a plain string |
684
+ | `created_at` | `created_at` | Direct copy |
685
+ | — | `actor_type` / `actor_id` | **Not populated** — cannot be inferred from a string `whodunnit` |
686
+
687
+ ### YAML and JSON serialization
688
+
689
+ PaperTrail serializes `object` and `object_changes` as **YAML** by default and as **JSON** when `PaperTrail.serializer = PaperTrail::Serializers::JSON` is configured. The migration handles both transparently — it tries JSON first, then falls back to YAML (with a permissive class list for the Ruby types PaperTrail commonly serializes).
690
+
691
+ ### Compatibility shim for gradual migration
450
692
 
451
- Add to your `spec/rails_helper.rb` (or `spec_helper.rb`):
693
+ If you need your codebase to keep using PaperTrail's API while migrating, include `RailsAuditLog::PaperTrailCompat` alongside `Auditable`:
452
694
 
453
695
  ```ruby
454
- require "rails_audit_log/matchers"
696
+ require "rails_audit_log/paper_trail_compat"
455
697
 
456
- RSpec.configure do |config|
457
- config.include RailsAuditLog::Matchers
698
+ class Article < ApplicationRecord
699
+ include RailsAuditLog::Auditable
700
+ include RailsAuditLog::PaperTrailCompat
458
701
  end
459
702
  ```
460
703
 
461
- Then use the matchers in any spec:
704
+ This adds the familiar PaperTrail surface:
462
705
 
463
706
  ```ruby
464
- # Assert a record has a matching audit entry
465
- expect(post).to have_audit_log_entry
466
- expect(post).to have_audit_log_entry(:update)
467
- expect(post).to have_audit_log_entry(:update).touching(:title)
468
-
469
- # Assert a block creates a matching audit entry
470
- expect { post.update!(title: "New") }.to create_audit_log_entry
471
- expect { post.update!(title: "New") }.to create_audit_log_entry(event: :update)
472
- expect { post.update!(title: "New") }.to create_audit_log_entry(event: :update).touching(:title)
707
+ article.versions # audit_log_entries ordered oldest-first
708
+ article.paper_trail.version # most recent AuditLogEntry
709
+ article.paper_trail.previous_version # reconstructed previous state (reify)
710
+ article.paper_trail.originator # whodunnit_snapshot string
711
+ article.paper_trail.version_at(1.week.ago) # reconstructed state at a point in time
473
712
  ```
474
713
 
714
+ `AuditLogEntry#reify` already matches PaperTrail's `Version#reify` — no additional alias needed.
715
+
716
+ ### What is not migrated
717
+
718
+ - `versions` rows with an `event` value outside `create`, `update`, `destroy` (custom events added by some apps)
719
+ - `actor_type` and `actor_id` — PaperTrail's `whodunnit` is a plain string and does not identify the actor model
720
+
721
+ ---
722
+
723
+ ## Companion gems
724
+
725
+ [↑ Table of contents](#table-of-contents)
726
+
727
+ > Coming in the 1.x series
728
+
729
+ | Gem | What it adds |
730
+ |---|---|
731
+ | `rails_audit_log-graphql` | Mountable GraphQL endpoint at `/audit/graphql` — queryable audit trail for API-first apps without forcing `graphql-ruby` on users who don't need it |
732
+
733
+ Each companion gem declares `rails_audit_log` as a dependency so you only add what you use.
734
+
475
735
  ## Requirements
476
736
 
737
+ [↑ Table of contents](#table-of-contents)
738
+
477
739
  - Ruby >= 3.3
478
740
  - Rails >= 7.2
479
741
 
742
+ ## Performance
743
+
744
+ [↑ Table of contents](#table-of-contents)
745
+
746
+ See [BENCHMARKS.md](BENCHMARKS.md) for write throughput, `batch_audit` gains, query performance, storage efficiency, and notes on comparing against PaperTrail. To run the suite locally:
747
+
748
+ ```bash
749
+ bundle exec rake dev:setup
750
+ bundle exec rake benchmark
751
+ ```
752
+
480
753
  ## Contributing
481
754
 
755
+ [↑ Table of contents](#table-of-contents)
756
+
482
757
  Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/rails_audit_log).
483
758
 
484
759
  ## License
485
760
 
761
+ [↑ Table of contents](#table-of-contents)
762
+
486
763
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -16,6 +16,11 @@ end
16
16
 
17
17
  task default: ["bundle:audit:update", "bundle:audit:check", :rubocop, :zeitwerk, :spec]
18
18
 
19
+ desc "Run the performance benchmark suite (requires dev:setup first)"
20
+ task :benchmark do
21
+ sh "bundle exec ruby benchmarks/suite.rb"
22
+ end
23
+
19
24
  # Development database tasks (operate on spec/dummy)
20
25
  namespace :dev do
21
26
  dummy_env = { "RAILS_ENV" => "development" }
@@ -1,4 +1,24 @@
1
1
  module RailsAuditLog
2
+ # Include in any ActiveRecord model to automatically track +create+, +update+,
3
+ # and +destroy+ events as {AuditLogEntry} records.
4
+ #
5
+ # == Basic usage
6
+ #
7
+ # class Article < ApplicationRecord
8
+ # include RailsAuditLog::Auditable
9
+ # end
10
+ #
11
+ # == Configuring tracking
12
+ #
13
+ # class Article < ApplicationRecord
14
+ # include RailsAuditLog::Auditable
15
+ # audit_log only: %i[title body],
16
+ # meta: { tenant_id: -> { Current.tenant_id } },
17
+ # version_limit: 50,
18
+ # async: true
19
+ # end
20
+ #
21
+ # Adds a polymorphic +has_many :audit_log_entries+ association to the model.
2
22
  module Auditable
3
23
  extend ActiveSupport::Concern
4
24
 
@@ -8,10 +28,13 @@ module RailsAuditLog
8
28
  class_attribute :_audit_log_meta, default: nil
9
29
  class_attribute :_audit_log_associations, default: nil
10
30
  class_attribute :_audit_log_version_limit, default: nil
31
+ class_attribute :_audit_log_retain_for, default: nil
11
32
  class_attribute :_audit_log_async, default: false
12
33
 
13
34
  _warn_if_audit_table_missing
14
35
 
36
+ # All {AuditLogEntry} records for this object, newest first by default.
37
+ # Destroyed when the object is destroyed.
15
38
  has_many :audit_log_entries,
16
39
  class_name: "RailsAuditLog::AuditLogEntry",
17
40
  as: :item,
@@ -54,6 +77,7 @@ module RailsAuditLog
54
77
  end
55
78
 
56
79
  class_methods do
80
+ # @api private
57
81
  def _warn_if_audit_table_missing
58
82
  return if connection.table_exists?("audit_log_entries")
59
83
 
@@ -66,16 +90,55 @@ module RailsAuditLog
66
90
  # DB not reachable during this phase (e.g. before db:create) — skip the check
67
91
  end
68
92
 
69
- def audit_log(only: nil, ignore: nil, meta: nil, associations: nil, version_limit: nil, async: nil)
93
+ # Configures auditing options for this model. Call once in the class body
94
+ # after +include RailsAuditLog::Auditable+.
95
+ #
96
+ # @param only [Array<Symbol>, nil] whitelist of attributes to track;
97
+ # when set, all other attributes are ignored regardless of +ignore:+
98
+ # @param ignore [Array<Symbol>, nil] additional attributes to exclude on
99
+ # top of {RailsAuditLog.ignored_attributes}; ignored when +only:+ is set
100
+ # @param meta [Hash{Symbol => Proc}, nil] per-entry metadata; each value
101
+ # is a lambda called at write time — zero-argument lambdas receive no
102
+ # arguments, one-argument lambdas receive the record instance
103
+ # @param associations [Boolean, Array<Symbol>, nil] when +true+, tracks
104
+ # all +has_many+ and +has_and_belongs_to_many+ associations; pass an
105
+ # array of association names to track only specific ones
106
+ # @param version_limit [Integer, nil] maximum number of entries to retain
107
+ # per record; oldest entries are pruned after each write; overrides
108
+ # {RailsAuditLog.version_limit} for this model
109
+ # @param retain_for [ActiveSupport::Duration, nil] per-model time-based TTL;
110
+ # entries older than this duration are pruned after each write; overrides
111
+ # {RailsAuditLog.retention_period} for this model
112
+ # @param async [Boolean, nil] when +true+, writes are dispatched via
113
+ # +WriteAuditLogJob+; overrides {RailsAuditLog.async} for this model
114
+ # @return [void]
115
+ # @example
116
+ # class Article < ApplicationRecord
117
+ # include RailsAuditLog::Auditable
118
+ # audit_log only: %i[title body published_at],
119
+ # meta: { tenant_id: -> { Current.tenant_id } },
120
+ # associations: %i[tags],
121
+ # version_limit: 100,
122
+ # retain_for: 30.days
123
+ # end
124
+ def audit_log(only: nil, ignore: nil, meta: nil, associations: nil, version_limit: nil, retain_for: nil, async: nil)
70
125
  self._audit_log_only = only.map(&:to_s) if only
71
126
  self._audit_log_ignore = ignore.map(&:to_s) if ignore
72
127
  self._audit_log_meta = meta if meta
73
128
  self._audit_log_associations = associations unless associations.nil?
74
129
  self._audit_log_version_limit = version_limit unless version_limit.nil?
130
+ self._audit_log_retain_for = retain_for unless retain_for.nil?
75
131
  self._audit_log_async = async unless async.nil?
76
132
  end
77
133
  end
78
134
 
135
+ # Executes the block with audit logging disabled for this record's writes.
136
+ # A convenience wrapper around {RailsAuditLog.disable}.
137
+ #
138
+ # @yield executes the block without recording any audit entries
139
+ # @return [Object] the return value of the block
140
+ # @example Skip auditing during a bulk update
141
+ # post.skip_audit_log { post.update!(cached_at: Time.current) }
79
142
  def skip_audit_log
80
143
  RailsAuditLog.disable { yield }
81
144
  end
@@ -142,8 +205,9 @@ module RailsAuditLog
142
205
  if (buffer = RailsAuditLog.batch_audit_buffer)
143
206
  buffer << entry_attrs.stringify_keys.merge("created_at" => Time.current)
144
207
  elsif _audit_log_async || RailsAuditLog.async
145
- limit = self.class._audit_log_version_limit || RailsAuditLog.version_limit
146
- WriteAuditLogJob.perform_later(entry_attrs.stringify_keys, version_limit: limit)
208
+ limit = self.class._audit_log_version_limit || RailsAuditLog.version_limit
209
+ period = self.class._audit_log_retain_for || RailsAuditLog.retention_period
210
+ WriteAuditLogJob.perform_later(entry_attrs.stringify_keys, version_limit: limit, retention_period: period)
147
211
  else
148
212
  RailsAuditLog::AuditLogEntry.create!(entry_attrs)
149
213
  prune_audit_entries
@@ -151,14 +215,19 @@ module RailsAuditLog
151
215
  end
152
216
 
153
217
  def prune_audit_entries
154
- limit = self.class._audit_log_version_limit || RailsAuditLog.version_limit
155
- return unless limit
218
+ limit = self.class._audit_log_version_limit || RailsAuditLog.version_limit
219
+ period = self.class._audit_log_retain_for || RailsAuditLog.retention_period
220
+ return unless limit || period
156
221
 
157
- count = audit_log_entries.count
158
- excess = count - limit
159
- return unless excess > 0
222
+ if period
223
+ audit_log_entries.where(created_at: ..period.ago).delete_all
224
+ end
160
225
 
161
- audit_log_entries.order(id: :asc).limit(excess).delete_all
226
+ if limit
227
+ count = audit_log_entries.count
228
+ excess = count - limit
229
+ audit_log_entries.order(id: :asc).limit(excess).delete_all if excess > 0
230
+ end
162
231
  end
163
232
 
164
233
  def build_audit_metadata