rails_audit_log 0.8.0 → 1.0.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +294 -55
  3. data/Rakefile +5 -0
  4. data/app/assets/stylesheets/rails_audit_log/_01_base.css +32 -0
  5. data/app/assets/stylesheets/rails_audit_log/_02_layout.css +34 -0
  6. data/app/assets/stylesheets/rails_audit_log/_03_table.css +39 -0
  7. data/app/assets/stylesheets/rails_audit_log/_04_badges.css +13 -0
  8. data/app/assets/stylesheets/rails_audit_log/_05_diff.css +94 -0
  9. data/app/assets/stylesheets/rails_audit_log/_06_timeline.css +21 -0
  10. data/app/assets/stylesheets/rails_audit_log/_07_detail.css +15 -0
  11. data/app/assets/stylesheets/rails_audit_log/_08_pagination.css +56 -0
  12. data/app/assets/stylesheets/rails_audit_log/_09_filters.css +54 -0
  13. data/app/assets/stylesheets/rails_audit_log/application.css +1 -15
  14. data/app/concerns/rails_audit_log/auditable.rb +57 -0
  15. data/app/concerns/rails_audit_log/controller.rb +29 -0
  16. data/app/controllers/rails_audit_log/application_controller.rb +13 -0
  17. data/app/controllers/rails_audit_log/audit_log_entries_controller.rb +32 -0
  18. data/app/controllers/rails_audit_log/resources_controller.rb +14 -0
  19. data/app/helpers/rails_audit_log/application_helper.rb +14 -0
  20. data/app/javascript/rails_audit_log/application.js +8 -0
  21. data/app/javascript/rails_audit_log/diff_controller.js +20 -0
  22. data/app/javascript/rails_audit_log/search_controller.js +12 -0
  23. data/app/jobs/rails_audit_log/application_job.rb +1 -0
  24. data/app/models/rails_audit_log/application_record.rb +1 -0
  25. data/app/models/rails_audit_log/audit_log_entry.rb +127 -25
  26. data/app/views/layouts/rails_audit_log/application.html.erb +16 -10
  27. data/app/views/rails_audit_log/audit_log_entries/_diff.html.erb +55 -0
  28. data/app/views/rails_audit_log/audit_log_entries/index.html.erb +76 -0
  29. data/app/views/rails_audit_log/audit_log_entries/show.html.erb +50 -0
  30. data/app/views/rails_audit_log/resources/show.html.erb +35 -0
  31. data/config/importmap.rb +5 -0
  32. data/config/routes.rb +3 -0
  33. data/lib/generators/rails_audit_log/initializer/templates/rails_audit_log.rb +10 -0
  34. data/lib/generators/rails_audit_log/migrate_from_paper_trail/migrate_from_paper_trail_generator.rb +21 -0
  35. data/lib/generators/rails_audit_log/migrate_from_paper_trail/templates/migrate_from_paper_trail.rb +99 -0
  36. data/lib/rails_audit_log/engine.rb +29 -0
  37. data/lib/rails_audit_log/matchers.rb +56 -0
  38. data/lib/rails_audit_log/minitest_assertions.rb +36 -0
  39. data/lib/rails_audit_log/paper_trail_compat.rb +76 -0
  40. data/lib/rails_audit_log/test_helpers.rb +20 -0
  41. data/lib/rails_audit_log/version.rb +1 -1
  42. data/lib/rails_audit_log.rb +173 -5
  43. metadata +70 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5cd71899d109aa978910a72ef144aeafb601dc70790e044ef723b49d1d560f65
4
- data.tar.gz: 8884deec2a0abf63f14639794d6ac614689c9799e09b375ffc0cc7d788943e7d
3
+ metadata.gz: aa624cc1cc0ba6984f41de2f19b22891f9a7c376aae0625f94ebe86f2c48ef78
4
+ data.tar.gz: 75bfb038bb1fe170bdf1a893ec87c40b3e7711602ad355a9c0788d20558ef337
5
5
  SHA512:
6
- metadata.gz: c32ada5189eda30461ea1dbc10a67982fb0ff6c4244da4339aa3376082b38a5272bb3a1b91fe50110c08ae9ee0ae3ede52d2a43701c0f7260a4d5a6581c0f42d
7
- data.tar.gz: 448becbb80428dd754bca4d1ab43625de5ec85a2c9d712e9e4a28f7c14b0c18ce6865b25dfd9ea6c46f7b1814988d0eea77ae8b6aee7a230927cc37a80f60cf3
6
+ metadata.gz: fd59c42a1522f4301e8c46c5b25186784847f5e56529fb7baf4d7b3d87d0f5391fb0bbcb33b8abac53bbf47a85a7529eae02d878c0484148a496b28950573541
7
+ data.tar.gz: 7b2be961c2b1f6091c1426d7be966ceb99647a7415faa4a540f3876d63ea6ff2a10fab515b61074b9538a7f43b63d99481c1743e35aeb09e313d2e76c4c3107f
data/README.md CHANGED
@@ -6,10 +6,45 @@
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
+ - [Selective tracking](#selective-tracking)
27
+ - [Disabling auditing](#disabling-auditing)
28
+ - [Object reconstruction](#object-reconstruction)
29
+ - [Attaching a reason](#attaching-a-reason)
30
+ - [Arbitrary metadata](#arbitrary-metadata)
31
+ - [Request metadata capture](#request-metadata-capture)
32
+ - [Actor display name snapshot](#actor-display-name-snapshot)
33
+ - [Object snapshot storage](#object-snapshot-storage)
34
+ - [Separate audit database](#separate-audit-database)
35
+ - [Test helpers](#test-helpers)
36
+ - [Stability and versioning](#stability-and-versioning)
37
+ - [Migrating from PaperTrail](#migrating-from-papertrail)
38
+ - [Performance](#performance)
39
+ - [Companion gems](#companion-gems)
40
+ - [Requirements](#requirements)
41
+ - [Contributing](#contributing)
42
+ - [License](#license)
10
43
 
11
44
  ## Installation
12
45
 
46
+ [↑ Table of contents](#table-of-contents)
47
+
13
48
  Add to your `Gemfile`:
14
49
 
15
50
  ```ruby
@@ -23,16 +58,79 @@ bin/rails generate rails_audit_log:install
23
58
  bin/rails db:migrate
24
59
  ```
25
60
 
26
- Optionally scaffold a commented initializer with every configuration option:
61
+ ## Configuration
62
+
63
+ [↑ Table of contents](#table-of-contents)
64
+
65
+ Run the initializer generator to create `config/initializers/rails_audit_log.rb` with every option documented as a commented example:
27
66
 
28
67
  ```bash
29
68
  bin/rails generate rails_audit_log:initializer
30
69
  ```
31
70
 
32
- This creates `config/initializers/rails_audit_log.rb` with all settings documented as commented examples inside a `RailsAuditLog.configure` block.
71
+ 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:
72
+
73
+ ```ruby
74
+ # config/initializers/rails_audit_log.rb
75
+
76
+ RailsAuditLog.configure do |config|
77
+ # Global columns excluded from all audited models.
78
+ # Default: ["updated_at"]
79
+ # config.ignored_attributes = %w[updated_at cached_at]
80
+
81
+ # Store a full attribute snapshot alongside object_changes.
82
+ # Default: true
83
+ # Disable to save storage; reify and version_at fall back to diff-only reconstruction.
84
+ # config.store_snapshot = false
85
+
86
+ # Capture remote_ip and user_agent into each entry's metadata column.
87
+ # Default: false — requires RailsAuditLog::Controller in ApplicationController.
88
+ # config.capture_request_metadata = true
89
+
90
+ # Customise how the actor's display name is stored at write time.
91
+ # Default: actor.name if available, otherwise actor.to_s
92
+ # config.whodunnit_display = ->(actor) { actor.email }
93
+
94
+ # Global cap on entries retained per tracked record. nil = no limit.
95
+ # Per-model `audit_log version_limit: N` takes precedence.
96
+ # config.version_limit = 100
97
+
98
+ # Write all audit entries asynchronously via WriteAuditLogJob.
99
+ # Default: false — per-model `audit_log async: true` also works.
100
+ # config.async = true
101
+
102
+ # Route AuditLogEntry to a dedicated database (Rails multi-DB).
103
+ # config.connects_to = { database: { writing: :audit_log, reading: :audit_log } }
104
+
105
+ # Entries per page in the web dashboard. Default: 25
106
+ # config.page_size = 50
107
+
108
+ # Gate web dashboard access. Block runs in controller context so controller
109
+ # helpers like current_user are available directly. Falls back to HTTP Basic
110
+ # auth when the block returns falsy. Leave unset for unauthenticated access.
111
+ # config.authenticate { current_user&.admin? }
112
+ # config.authenticate { |c| c.current_user&.admin? }
113
+ end
114
+ ```
115
+
116
+ Each option is documented in detail in its own Usage section below.
117
+
118
+ ## Web dashboard
119
+
120
+ [↑ Table of contents](#table-of-contents)
121
+
122
+ Mount the engine in `config/routes.rb` to enable the built-in audit trail browser:
123
+
124
+ ```ruby
125
+ mount RailsAuditLog::Engine, at: "/audit"
126
+ ```
127
+
128
+ Then visit `/audit` to browse all audit entries. The dashboard is delivered via propshaft, importmaps, and CDN-pinned Turbo and Stimulus — no asset pipeline configuration required in the host app.
33
129
 
34
130
  ## Usage
35
131
 
132
+ [↑ Table of contents](#table-of-contents)
133
+
36
134
  ### Tracking a model
37
135
 
38
136
  Include `RailsAuditLog::Auditable` in any ActiveRecord model:
@@ -110,19 +208,6 @@ entry.diff
110
208
  # => { "title" => { from: "Hello", to: "World" } }
111
209
  ```
112
210
 
113
- ### Separate audit database
114
-
115
- Route all audit writes to a dedicated database by setting `connects_to` in an initializer. The engine applies it to `AuditLogEntry` at boot:
116
-
117
- ```ruby
118
- # config/initializers/rails_audit_log.rb
119
- RailsAuditLog.connects_to = {
120
- database: { writing: :audit_log, reading: :audit_log }
121
- }
122
- ```
123
-
124
- 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.
125
-
126
211
  ### Lightweight queries
127
212
 
128
213
  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):
@@ -283,22 +368,20 @@ article.skip_audit_log { article.update!(cached_at: Time.current) }
283
368
 
284
369
  ### Object reconstruction
285
370
 
286
- #### Reify a single entry
287
-
288
- `AuditLogEntry#reify` returns an unsaved ActiveRecord instance reflecting the record's state **before** the entry was recorded:
371
+ **Reify a single entry** — `AuditLogEntry#reify` returns an unsaved ActiveRecord instance reflecting the record's state **before** the entry was recorded:
289
372
 
290
373
  ```ruby
291
374
  article.update!(title: "v2")
292
375
  entry = article.audit_log_entries.updated_events.last
293
376
 
294
377
  previous = entry.reify
295
- previous.title # => "v1" (the pre-update state)
378
+ previous.title # => "v1" (the pre-update state)
296
379
  previous.persisted? # => false
297
380
  ```
298
381
 
299
382
  Returns `nil` for `create` entries (nothing existed before).
300
383
 
301
- #### Reconstruct state at any point in time
384
+ **Reconstruct state at any point in time:**
302
385
 
303
386
  ```ruby
304
387
  snapshot = RailsAuditLog.version_at(article, 1.week.ago)
@@ -307,7 +390,7 @@ snapshot.title # => whatever the title was a week ago
307
390
 
308
391
  Returns `nil` if the record had no history at that time or was already destroyed.
309
392
 
310
- #### Navigate the version chain
393
+ **Navigate the version chain:**
311
394
 
312
395
  ```ruby
313
396
  entry = article.audit_log_entries.updated_events.last
@@ -392,31 +475,58 @@ To save storage at the cost of reduced reification accuracy, switch to diff-only
392
475
  RailsAuditLog.store_snapshot = false
393
476
  ```
394
477
 
395
- ### Test helper
478
+ ### Separate audit database
396
479
 
397
- `without_audit_log` silences audit tracking inside the block useful in FactoryBot factories and seed data to avoid noise in the audit trail:
480
+ Route all audit writes to a dedicated database by setting `connects_to` in an initializer. The engine applies it to `AuditLogEntry` at boot:
398
481
 
399
482
  ```ruby
400
- # spec/rails_helper.rb (or spec/support/factory_helpers.rb)
483
+ # config/initializers/rails_audit_log.rb
484
+ RailsAuditLog.connects_to = {
485
+ database: { writing: :audit_log, reading: :audit_log }
486
+ }
487
+ ```
488
+
489
+ 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.
490
+
491
+ ### Test helpers
492
+
493
+ **`RailsAuditLog::TestHelpers`** — silences audit tracking inside a block; useful in FactoryBot factories and seed data:
494
+
495
+ ```ruby
496
+ # spec/rails_helper.rb
401
497
  require "rails_audit_log/test_helpers"
402
498
 
403
499
  RSpec.configure do |config|
404
500
  config.include RailsAuditLog::TestHelpers
405
501
  end
502
+ ```
406
503
 
407
- # Or include directly in FactoryBot definitions:
408
- FactoryBot.define do
409
- factory :post do
410
- after(:create) { |p| without_audit_log { p.update!(cached_at: Time.current) } }
411
- end
412
- end
504
+ ```ruby
505
+ # Or in a FactoryBot factory:
506
+ after(:create) { |p| without_audit_log { p.update!(cached_at: Time.current) } }
413
507
  ```
414
508
 
415
509
  `without_audit_log` is a prefix-free wrapper around `RailsAuditLog.disable` — thread-safe and restores tracking even if the block raises.
416
510
 
417
- ### Minitest assertions
511
+ **`RailsAuditLog::Matchers`** (RSpec) — add to `spec/rails_helper.rb`:
512
+
513
+ ```ruby
514
+ require "rails_audit_log/matchers"
418
515
 
419
- Add to your `test/test_helper.rb`:
516
+ RSpec.configure do |config|
517
+ config.include RailsAuditLog::Matchers
518
+ end
519
+ ```
520
+
521
+ ```ruby
522
+ expect(post).to have_audit_log_entry
523
+ expect(post).to have_audit_log_entry(:update)
524
+ expect(post).to have_audit_log_entry(:update).touching(:title)
525
+
526
+ expect { post.update!(title: "New") }.to create_audit_log_entry(event: :update).touching(:title)
527
+ ```
528
+
529
+ **`RailsAuditLog::MinitestAssertions`** — add to `test/test_helper.rb`:
420
530
 
421
531
  ```ruby
422
532
  require "rails_audit_log/minitest_assertions"
@@ -426,51 +536,180 @@ class ActiveSupport::TestCase
426
536
  end
427
537
  ```
428
538
 
429
- Then use the assertions in any test:
430
-
431
539
  ```ruby
432
- assert_audit_log_entry post # any entry
433
- assert_audit_log_entry post, event: :update # update entry
434
- assert_audit_log_entry post, event: :update, touching: :title # touching title
435
- refute_audit_log_entry post, event: :update # no update entry
436
- assert_audit_log_entry post, event: :update, message: "custom" # custom failure message
540
+ assert_audit_log_entry post, event: :update, touching: :title
541
+ refute_audit_log_entry post, event: :destroy
542
+ ```
543
+
544
+ ## Stability and versioning
545
+
546
+ [↑ Table of contents](#table-of-contents)
547
+
548
+ `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.
549
+
550
+ | Version bump | Meaning |
551
+ |---|---|
552
+ | **Patch** (1.0.x) | Bug fixes only — no API changes |
553
+ | **Minor** (1.x.0) | New features, backward-compatible |
554
+ | **Major** (x.0.0) | Breaking changes with a documented migration path |
555
+
556
+ ### Public API surface
557
+
558
+ **Module-level** — `RailsAuditLog`
559
+
560
+ | Method / accessor | Purpose |
561
+ |---|---|
562
+ | `.configure { \|config\| }` | Block-style configuration |
563
+ | `.with_actor(actor) { }` | Set whodunnit context for a block |
564
+ | `.disable { }` | Suppress all audit writes for a block |
565
+ | `.enabled?` | Whether audit writes are active |
566
+ | `.audit_log_reason(value) { }` | Attach a free-text reason for a block |
567
+ | `.batch_audit { }` | Buffer writes and flush with a single `insert_all!` |
568
+ | `.version_at(record, time)` | Reconstruct record state at a point in time |
569
+ | `.authenticate { }` | Gate web dashboard access |
570
+ | `.ignored_attributes=` | Global columns excluded from all models |
571
+ | `.store_snapshot=` | Store full object snapshot alongside diffs |
572
+ | `.capture_request_metadata=` | Capture `remote_ip` and `user_agent` |
573
+ | `.version_limit=` | Global entry cap per record |
574
+ | `.async=` | Write audit entries via `WriteAuditLogJob` |
575
+ | `.connects_to=` | Route `AuditLogEntry` to a separate database |
576
+ | `.page_size=` | Entries per page in the web dashboard |
577
+ | `.whodunnit_display=` | Proc for actor display name snapshot |
578
+ | `.retention_period=` | _(1.1.0)_ Global time-based TTL |
579
+
580
+ **Concerns**
581
+
582
+ | Class | Include in | Key methods |
583
+ |---|---|---|
584
+ | `RailsAuditLog::Auditable` | ActiveRecord models | `audit_log(only:, ignore:, meta:, associations:, version_limit:, async:)`, `skip_audit_log { }` |
585
+ | `RailsAuditLog::Controller` | ActionController | `audit_log_actor { }` |
586
+
587
+ **Model — `RailsAuditLog::AuditLogEntry`**
588
+
589
+ Scopes: `created_events`, `updated_events`, `destroyed_events`, `by_actor`, `for_resource`, `since`, `until`, `touching`, `slim`, `for_period`
590
+
591
+ Instance methods: `reify`, `previous`, `next`, `diff`, `changed_attributes`, `actor`
592
+
593
+ Constants: `EVENTS`, `BLOB_COLUMNS`, `PERIODS`
594
+
595
+ **Testing helpers**
596
+
597
+ | Module | Require | Usage |
598
+ |---|---|---|
599
+ | `RailsAuditLog::Matchers` | `require "rails_audit_log/matchers"` | RSpec: `have_audit_log_entry`, `create_audit_log_entry` |
600
+ | `RailsAuditLog::MinitestAssertions` | `require "rails_audit_log/minitest_assertions"` | Minitest: `assert_audit_log_entry`, `refute_audit_log_entry` |
601
+ | `RailsAuditLog::TestHelpers` | `require "rails_audit_log/test_helpers"` | `without_audit_log { }` |
602
+
603
+ **Jobs** (enqueued internally; configure via ActiveJob)
604
+
605
+ `RailsAuditLog::WriteAuditLogJob` — do not instantiate directly; enqueued when `async: true` is set.
606
+
607
+ **Generators**
608
+
609
+ `rails generate rails_audit_log:install` · `rails generate rails_audit_log:initializer`
610
+
611
+ ---
612
+
613
+ ## Migrating from PaperTrail
614
+
615
+ [↑ Table of contents](#table-of-contents)
616
+
617
+ Run the migration generator to produce a timestamped data migration:
618
+
619
+ ```bash
620
+ bin/rails generate rails_audit_log:migrate_from_paper_trail
621
+ bin/rails db:migrate
437
622
  ```
438
623
 
439
- ### RSpec matchers
624
+ The generated migration reads every row from PaperTrail's `versions` table and inserts it into `audit_log_entries`.
440
625
 
441
- Add to your `spec/rails_helper.rb` (or `spec_helper.rb`):
626
+ ### Column mapping
627
+
628
+ | PaperTrail `versions` | `audit_log_entries` | Notes |
629
+ |---|---|---|
630
+ | `item_type` | `item_type` | Direct copy |
631
+ | `item_id` | `item_id` | Direct copy |
632
+ | `event` | `event` | `create` / `update` / `destroy` only; other values are skipped |
633
+ | `object_changes` | `object_changes` | YAML or JSON → JSON (see below) |
634
+ | `object` | `object` | YAML or JSON → JSON (see below) |
635
+ | `whodunnit` | `whodunnit_snapshot` | PaperTrail stores actor as a plain string |
636
+ | `created_at` | `created_at` | Direct copy |
637
+ | — | `actor_type` / `actor_id` | **Not populated** — cannot be inferred from a string `whodunnit` |
638
+
639
+ ### YAML and JSON serialization
640
+
641
+ 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).
642
+
643
+ ### Compatibility shim for gradual migration
644
+
645
+ If you need your codebase to keep using PaperTrail's API while migrating, include `RailsAuditLog::PaperTrailCompat` alongside `Auditable`:
442
646
 
443
647
  ```ruby
444
- require "rails_audit_log/matchers"
648
+ require "rails_audit_log/paper_trail_compat"
445
649
 
446
- RSpec.configure do |config|
447
- config.include RailsAuditLog::Matchers
650
+ class Article < ApplicationRecord
651
+ include RailsAuditLog::Auditable
652
+ include RailsAuditLog::PaperTrailCompat
448
653
  end
449
654
  ```
450
655
 
451
- Then use the matchers in any spec:
656
+ This adds the familiar PaperTrail surface:
452
657
 
453
658
  ```ruby
454
- # Assert a record has a matching audit entry
455
- expect(post).to have_audit_log_entry
456
- expect(post).to have_audit_log_entry(:update)
457
- expect(post).to have_audit_log_entry(:update).touching(:title)
458
-
459
- # Assert a block creates a matching audit entry
460
- expect { post.update!(title: "New") }.to create_audit_log_entry
461
- expect { post.update!(title: "New") }.to create_audit_log_entry(event: :update)
462
- expect { post.update!(title: "New") }.to create_audit_log_entry(event: :update).touching(:title)
659
+ article.versions # audit_log_entries ordered oldest-first
660
+ article.paper_trail.version # most recent AuditLogEntry
661
+ article.paper_trail.previous_version # reconstructed previous state (reify)
662
+ article.paper_trail.originator # whodunnit_snapshot string
663
+ article.paper_trail.version_at(1.week.ago) # reconstructed state at a point in time
463
664
  ```
464
665
 
666
+ `AuditLogEntry#reify` already matches PaperTrail's `Version#reify` — no additional alias needed.
667
+
668
+ ### What is not migrated
669
+
670
+ - `versions` rows with an `event` value outside `create`, `update`, `destroy` (custom events added by some apps)
671
+ - `actor_type` and `actor_id` — PaperTrail's `whodunnit` is a plain string and does not identify the actor model
672
+
673
+ ---
674
+
675
+ ## Companion gems
676
+
677
+ [↑ Table of contents](#table-of-contents)
678
+
679
+ > Coming in the 1.x series
680
+
681
+ | Gem | What it adds |
682
+ |---|---|
683
+ | `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 |
684
+
685
+ Each companion gem declares `rails_audit_log` as a dependency so you only add what you use.
686
+
465
687
  ## Requirements
466
688
 
689
+ [↑ Table of contents](#table-of-contents)
690
+
467
691
  - Ruby >= 3.3
468
692
  - Rails >= 7.2
469
693
 
694
+ ## Performance
695
+
696
+ [↑ Table of contents](#table-of-contents)
697
+
698
+ 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:
699
+
700
+ ```bash
701
+ bundle exec rake dev:setup
702
+ bundle exec rake benchmark
703
+ ```
704
+
470
705
  ## Contributing
471
706
 
707
+ [↑ Table of contents](#table-of-contents)
708
+
472
709
  Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/rails_audit_log).
473
710
 
474
711
  ## License
475
712
 
713
+ [↑ Table of contents](#table-of-contents)
714
+
476
715
  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" }
@@ -0,0 +1,32 @@
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+
3
+ :root {
4
+ --ral-bg: #f5f5f5;
5
+ --ral-surface: #ffffff;
6
+ --ral-border: #e5e5e5;
7
+ --ral-border-subtle: #f0f0f0;
8
+ --ral-text: #1a1a1a;
9
+ --ral-muted: #666666;
10
+ --ral-primary: #1d4ed8;
11
+ --ral-danger: #991b1b;
12
+ --ral-success: #166534;
13
+ --ral-radius: 6px;
14
+ --ral-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
15
+ --ral-surface-hover: #fafafa;
16
+ }
17
+
18
+ body {
19
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
20
+ font-size: 14px;
21
+ line-height: 1.5;
22
+ color: var(--ral-text);
23
+ background: var(--ral-bg);
24
+ min-height: 100vh;
25
+ }
26
+
27
+ .ral-link { color: var(--ral-primary); text-decoration: none; }
28
+ .ral-link:hover { text-decoration: underline; }
29
+
30
+ .ral-muted { color: var(--ral-muted); font-size: 13px; }
31
+ .ral-timestamp { color: var(--ral-muted); font-size: 13px; white-space: nowrap; }
32
+ .ral-reason { font-size: 12px; color: var(--ral-muted); font-style: italic; }
@@ -0,0 +1,34 @@
1
+ .ral-header {
2
+ background: var(--ral-text);
3
+ color: #fff;
4
+ height: 48px;
5
+ display: flex;
6
+ align-items: center;
7
+ padding: 0 24px;
8
+ }
9
+
10
+ .ral-header__inner {
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 16px;
14
+ width: 100%;
15
+ max-width: 1200px;
16
+ margin: 0 auto;
17
+ }
18
+
19
+ .ral-header__logo {
20
+ color: #fff;
21
+ text-decoration: none;
22
+ font-weight: 600;
23
+ font-size: 15px;
24
+ }
25
+ .ral-header__logo:hover { opacity: 0.8; }
26
+
27
+ .ral-main { max-width: 1200px; margin: 0 auto; padding: 24px; }
28
+
29
+ .ral-page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
30
+ .ral-page-header__title { font-size: 20px; font-weight: 600; }
31
+ .ral-page-header__meta { font-size: 13px; color: var(--ral-muted); }
32
+
33
+ .ral-breadcrumb { font-size: 12px; color: var(--ral-muted); margin-bottom: 4px; }
34
+ .ral-section-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #333; }
@@ -0,0 +1,39 @@
1
+ .ral-table-wrap {
2
+ background: var(--ral-surface);
3
+ border: 1px solid var(--ral-border);
4
+ border-radius: var(--ral-radius);
5
+ overflow: hidden;
6
+ box-shadow: var(--ral-shadow);
7
+ }
8
+
9
+ .ral-table { width: 100%; border-collapse: collapse; }
10
+
11
+ .ral-table th {
12
+ background: var(--ral-bg);
13
+ padding: 10px 16px;
14
+ text-align: left;
15
+ font-size: 12px;
16
+ font-weight: 600;
17
+ text-transform: uppercase;
18
+ letter-spacing: 0.04em;
19
+ color: var(--ral-muted);
20
+ border-bottom: 1px solid var(--ral-border);
21
+ }
22
+
23
+ .ral-table td {
24
+ padding: 10px 16px;
25
+ border-bottom: 1px solid var(--ral-border-subtle);
26
+ vertical-align: middle;
27
+ }
28
+
29
+ .ral-table tbody tr:last-child td { border-bottom: none; }
30
+ .ral-table tbody tr:hover { background: var(--ral-surface-hover); }
31
+
32
+ .ral-empty {
33
+ background: var(--ral-surface);
34
+ border: 1px solid var(--ral-border);
35
+ border-radius: var(--ral-radius);
36
+ padding: 40px;
37
+ text-align: center;
38
+ color: var(--ral-muted);
39
+ }
@@ -0,0 +1,13 @@
1
+ .ral-badge {
2
+ display: inline-block;
3
+ padding: 2px 8px;
4
+ border-radius: 10px;
5
+ font-size: 11px;
6
+ font-weight: 600;
7
+ text-transform: uppercase;
8
+ letter-spacing: 0.04em;
9
+ }
10
+
11
+ .ral-badge--create { background: #dcfce7; color: var(--ral-success); }
12
+ .ral-badge--update { background: #dbeafe; color: var(--ral-primary); }
13
+ .ral-badge--destroy { background: #fee2e2; color: var(--ral-danger); }