rails_audit_log 0.3.0 → 0.5.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: adf2bbd729f0c0a53c0016389d8b8af4ca66cedede3343bb3376cf880edc447c
4
- data.tar.gz: 640764fdbe148a2f8d0309350ae74358256b7cc9b530f0c35d0bc045ef736b4c
3
+ metadata.gz: cf93211ea95d97c01b541195ade4517d221d39035896e06a6432c512e5f284c0
4
+ data.tar.gz: f1bce85319393eaca9db2e078a0087e1f0818c1025fdd23e472fb8381a322b91
5
5
  SHA512:
6
- metadata.gz: 938374f49a4385c038124abb6aeb95eaa8369dccc20d8eb125e96071954c3b8858cb22dc94781bdfa31e96d006645df5bb07f989c83d4ca35d1792451cb82f07
7
- data.tar.gz: 82f6ca3524c11b954705d62b576d11c8ae5008f49b26c973789d43466a6d1ab3d4462fd012885a0b83177220c8ceda83c381af5db07e0b371deafca1db14badc
6
+ metadata.gz: f0f9def2049dc5ca95e13fad9746bc84a79c7e8081d8e84dd0f4fbfd3915b57b9dd35aaa600687970475f49b395e15c6725d3c558b1272f7afda6ba3b18054c0
7
+ data.tar.gz: 1699b3f01dbfd55377144207ff36c987a83419adf318671a16676e5513fd31fc98f908e5b2f83cef3a9f7b597bdf7ac8fe2cf4da2c275f569bdcd4837a3a1727
data/README.md CHANGED
@@ -39,7 +39,7 @@ Every `create`, `update`, and `destroy` is now recorded automatically:
39
39
 
40
40
  ```ruby
41
41
  article = Article.create!(title: "Hello")
42
- article.audit_log_entries.count # => 1
42
+ article.audit_log_entries.count # => 1
43
43
  article.audit_log_entries.first.event # => "create"
44
44
 
45
45
  article.update!(title: "World")
@@ -77,6 +77,185 @@ RailsAuditLog.with_actor(current_user) do
77
77
  end
78
78
  ```
79
79
 
80
+ ### Querying the audit log
81
+
82
+ ```ruby
83
+ # Event scopes
84
+ article.audit_log_entries.created_events
85
+ article.audit_log_entries.updated_events
86
+ article.audit_log_entries.destroyed_events
87
+
88
+ # Filter by actor, resource, or time
89
+ RailsAuditLog::AuditLogEntry.by_actor(current_user)
90
+ RailsAuditLog::AuditLogEntry.for_resource(Article)
91
+ RailsAuditLog::AuditLogEntry.for_resource(article)
92
+ RailsAuditLog::AuditLogEntry.since(1.week.ago)
93
+ RailsAuditLog::AuditLogEntry.until(Date.yesterday)
94
+
95
+ # Find entries that touched a specific attribute
96
+ RailsAuditLog::AuditLogEntry.touching(:title)
97
+
98
+ # Inspect what changed
99
+ entry = article.audit_log_entries.last
100
+ entry.changed_attributes # => ["title"]
101
+ entry.diff
102
+ # => { "title" => { from: "Hello", to: "World" } }
103
+ ```
104
+
105
+ ### Selective tracking
106
+
107
+ Track only specific attributes, or exclude noisy ones:
108
+
109
+ ```ruby
110
+ class Article < ApplicationRecord
111
+ include RailsAuditLog::Auditable
112
+
113
+ # Track only these columns
114
+ audit_log only: [:title, :status]
115
+
116
+ # Or exclude specific columns
117
+ audit_log ignore: [:cached_at, :views_count]
118
+ end
119
+ ```
120
+
121
+ Configure global defaults in an initializer:
122
+
123
+ ```ruby
124
+ # config/initializers/rails_audit_log.rb
125
+ RailsAuditLog.ignored_attributes = %w[updated_at cached_at]
126
+ ```
127
+
128
+ Updates that produce no tracked changes after filtering are silently skipped.
129
+
130
+ ### Disabling auditing
131
+
132
+ Suppress all audit writes inside a block — thread-safe, works in jobs and imports:
133
+
134
+ ```ruby
135
+ RailsAuditLog.disable do
136
+ Article.insert_all!(bulk_rows)
137
+ end
138
+
139
+ RailsAuditLog.enabled? # => true (restored after the block)
140
+ ```
141
+
142
+ Disable on a specific record instance:
143
+
144
+ ```ruby
145
+ article.skip_audit_log { article.update!(cached_at: Time.current) }
146
+ ```
147
+
148
+ ### Object reconstruction
149
+
150
+ #### Reify a single entry
151
+
152
+ `AuditLogEntry#reify` returns an unsaved ActiveRecord instance reflecting the record's state **before** the entry was recorded:
153
+
154
+ ```ruby
155
+ article.update!(title: "v2")
156
+ entry = article.audit_log_entries.updated_events.last
157
+
158
+ previous = entry.reify
159
+ previous.title # => "v1" (the pre-update state)
160
+ previous.persisted? # => false
161
+ ```
162
+
163
+ Returns `nil` for `create` entries (nothing existed before).
164
+
165
+ #### Reconstruct state at any point in time
166
+
167
+ ```ruby
168
+ snapshot = RailsAuditLog.version_at(article, 1.week.ago)
169
+ snapshot.title # => whatever the title was a week ago
170
+ ```
171
+
172
+ Returns `nil` if the record had no history at that time or was already destroyed.
173
+
174
+ #### Navigate the version chain
175
+
176
+ ```ruby
177
+ entry = article.audit_log_entries.updated_events.last
178
+ entry.previous # => the entry before this one
179
+ entry.next # => the entry after this one (nil if last)
180
+ ```
181
+
182
+ ### Attaching a reason
183
+
184
+ Record a free-text rationale alongside any write using a thread-local block — safe to nest, cleared automatically:
185
+
186
+ ```ruby
187
+ RailsAuditLog.audit_log_reason("Approved by legal") do
188
+ contract.update!(status: "approved")
189
+ end
190
+
191
+ entry.reason # => "Approved by legal"
192
+ ```
193
+
194
+ ### Arbitrary metadata
195
+
196
+ The `metadata` JSON column accepts any key/value hash. Populate it automatically with `audit_log meta:`:
197
+
198
+ ```ruby
199
+ class Article < ApplicationRecord
200
+ include RailsAuditLog::Auditable
201
+
202
+ # Zero-arg lambda — evaluated at write time (good for thread-locals)
203
+ # One-arg lambda — receives the record instance
204
+ audit_log meta: {
205
+ tenant_id: -> { Current.tenant.id },
206
+ title_length: ->(record) { record.title.length }
207
+ }
208
+ end
209
+
210
+ entry.metadata # => { "tenant_id" => "acme", "title_length" => 12 }
211
+ ```
212
+
213
+ ### Request metadata capture
214
+
215
+ Enable opt-in capture of `remote_ip` and `user_agent` from the current request in an initializer:
216
+
217
+ ```ruby
218
+ # config/initializers/rails_audit_log.rb
219
+ RailsAuditLog.capture_request_metadata = true
220
+ ```
221
+
222
+ When enabled, the `Controller` concern automatically stores these into each entry's `metadata` column:
223
+
224
+ ```ruby
225
+ entry.metadata # => { "remote_ip" => "203.0.113.1", "user_agent" => "Mozilla/5.0 ..." }
226
+ ```
227
+
228
+ Request metadata and `audit_log meta:` values are merged together automatically.
229
+
230
+ ### Actor display name snapshot
231
+
232
+ `whodunnit_snapshot` stores the actor's display name at write time so entries remain meaningful even after the actor record is deleted:
233
+
234
+ ```ruby
235
+ entry.whodunnit_snapshot # => "Alice" (even if the User record is later deleted)
236
+ ```
237
+
238
+ The default display proc uses `actor.name` if available, otherwise `actor.to_s`. Override globally in an initializer:
239
+
240
+ ```ruby
241
+ RailsAuditLog.whodunnit_display = ->(actor) { actor.email }
242
+ ```
243
+
244
+ ### Object snapshot storage
245
+
246
+ By default every entry stores a full `object` snapshot of the pre-change state alongside `object_changes`. This makes `reify` and `version_at` reliable without any database lookups:
247
+
248
+ ```ruby
249
+ entry.object # => { "id" => 1, "title" => "v1", ... }
250
+ entry.object_changes # => { "title" => ["v1", "v2"] }
251
+ ```
252
+
253
+ To save storage at the cost of reduced reification accuracy, switch to diff-only mode:
254
+
255
+ ```ruby
256
+ RailsAuditLog.store_snapshot = false
257
+ ```
258
+
80
259
  ## Requirements
81
260
 
82
261
  - Ruby >= 3.3
@@ -5,6 +5,7 @@ module RailsAuditLog
5
5
  included do
6
6
  class_attribute :_audit_log_only, default: nil
7
7
  class_attribute :_audit_log_ignore, default: nil
8
+ class_attribute :_audit_log_meta, default: nil
8
9
 
9
10
  has_many :audit_log_entries,
10
11
  class_name: "RailsAuditLog::AuditLogEntry",
@@ -17,9 +18,10 @@ module RailsAuditLog
17
18
  end
18
19
 
19
20
  class_methods do
20
- def audit_log(only: nil, ignore: nil)
21
+ def audit_log(only: nil, ignore: nil, meta: nil)
21
22
  self._audit_log_only = only.map(&:to_s) if only
22
23
  self._audit_log_ignore = ignore.map(&:to_s) if ignore
24
+ self._audit_log_meta = meta if meta
23
25
  end
24
26
  end
25
27
 
@@ -30,35 +32,53 @@ module RailsAuditLog
30
32
  private
31
33
 
32
34
  def record_audit_create
33
- record_audit_entry("create", saved_changes)
35
+ record_audit_entry("create", saved_changes, nil)
34
36
  end
35
37
 
36
38
  def record_audit_update
37
- record_audit_entry("update", saved_changes)
39
+ snapshot = attributes.merge(saved_changes.transform_values { |v| v[0] }) if RailsAuditLog.store_snapshot
40
+ record_audit_entry("update", saved_changes, snapshot)
38
41
  end
39
42
 
40
43
  def record_audit_destroy
44
+ snapshot = attributes.dup if RailsAuditLog.store_snapshot
41
45
  changes = attributes.transform_values { |v| [v, nil] }
42
- record_audit_entry("destroy", changes)
46
+ record_audit_entry("destroy", changes, snapshot)
43
47
  end
44
48
 
45
- def record_audit_entry(event, changes)
49
+ def record_audit_entry(event, changes, snapshot = nil)
46
50
  return unless RailsAuditLog.enabled?
47
51
 
48
52
  filtered = filter_changes(changes)
49
53
  return if filtered.empty? && event == "update"
50
54
 
51
55
  actor = RailsAuditLog.actor
56
+ meta = build_audit_metadata
52
57
  RailsAuditLog::AuditLogEntry.create!(
53
- event: event,
54
- item_type: self.class.name,
55
- item_id: id,
56
- object_changes: filtered,
57
- actor_type: actor&.class&.name,
58
- actor_id: actor.respond_to?(:id) ? actor.id : nil
58
+ event: event,
59
+ item_type: self.class.name,
60
+ item_id: id,
61
+ object_changes: filtered,
62
+ object: snapshot,
63
+ reason: RailsAuditLog.reason,
64
+ metadata: meta.presence,
65
+ whodunnit_snapshot: actor ? RailsAuditLog.whodunnit_display.call(actor) : nil,
66
+ actor_type: actor&.class&.name,
67
+ actor_id: actor.respond_to?(:id) ? actor.id : nil
59
68
  )
60
69
  end
61
70
 
71
+ def build_audit_metadata
72
+ meta = {}
73
+ if self.class._audit_log_meta
74
+ self.class._audit_log_meta.each do |key, callable|
75
+ meta[key.to_s] = callable.arity == 0 ? callable.call : callable.call(self)
76
+ end
77
+ end
78
+ meta.merge!(RailsAuditLog.request_metadata || {})
79
+ meta
80
+ end
81
+
62
82
  def filter_changes(changes)
63
83
  result = changes.dup
64
84
 
@@ -5,6 +5,8 @@ module RailsAuditLog
5
5
  included do
6
6
  before_action :set_audit_log_actor
7
7
  after_action :clear_audit_log_actor
8
+ before_action :capture_audit_log_request_metadata
9
+ after_action :clear_audit_log_request_metadata
8
10
  end
9
11
 
10
12
  class_methods do
@@ -27,5 +29,20 @@ module RailsAuditLog
27
29
  def clear_audit_log_actor
28
30
  RailsAuditLog.actor = nil
29
31
  end
32
+
33
+ def capture_audit_log_request_metadata
34
+ return unless RailsAuditLog.capture_request_metadata
35
+
36
+ RailsAuditLog.request_metadata = {
37
+ "remote_ip" => request.remote_ip,
38
+ "user_agent" => request.user_agent
39
+ }.compact
40
+ end
41
+
42
+ def clear_audit_log_request_metadata
43
+ return unless RailsAuditLog.capture_request_metadata
44
+
45
+ RailsAuditLog.request_metadata = nil
46
+ end
30
47
  end
31
48
  end
@@ -10,6 +10,7 @@ module RailsAuditLog
10
10
  validates :event, presence: true, inclusion: { in: EVENTS }
11
11
  validates :item_type, presence: true
12
12
  validates :item_id, presence: true
13
+ validate :metadata_must_be_a_hash
13
14
 
14
15
  # Event scopes
15
16
  scope :created_events, -> { where(event: "create") }
@@ -38,6 +39,40 @@ module RailsAuditLog
38
39
 
39
40
  # Instance methods
40
41
 
42
+ def reify
43
+ return nil if event == "create"
44
+
45
+ klass = item_type.constantize
46
+
47
+ if object.present?
48
+ instance = klass.new
49
+ instance.assign_attributes(object.except("id"))
50
+ instance.id = object.fetch("id") { item_id }
51
+ return instance
52
+ end
53
+
54
+ # Fallback: diff-only mode or entries recorded before snapshot support
55
+ from_attrs = (object_changes || {}).transform_values { |from_to| from_to[0] }
56
+
57
+ if event == "update"
58
+ record = klass.find_by(id: item_id)
59
+ from_attrs = record.attributes.merge(from_attrs) if record
60
+ end
61
+
62
+ instance = klass.new
63
+ instance.assign_attributes(from_attrs.except("id"))
64
+ instance.id = from_attrs.fetch("id") { item_id }
65
+ instance
66
+ end
67
+
68
+ def previous
69
+ self.class.where(item_type: item_type, item_id: item_id).where("id < ?", id).order(id: :desc).first
70
+ end
71
+
72
+ def next
73
+ self.class.where(item_type: item_type, item_id: item_id).where("id > ?", id).order(id: :asc).first
74
+ end
75
+
41
76
  def changed_attributes
42
77
  object_changes&.keys || []
43
78
  end
@@ -48,11 +83,19 @@ module RailsAuditLog
48
83
  object_changes.transform_values { |from_to| { from: from_to[0], to: from_to[1] } }
49
84
  end
50
85
 
51
- # Attribute scope — uses json_extract (SQLite/MySQL) or json ? key (PostgreSQL)
86
+ private
87
+
88
+ def metadata_must_be_a_hash
89
+ errors.add(:metadata, "must be a Hash") if metadata.present? && !metadata.is_a?(Hash)
90
+ end
91
+
92
+ public
93
+
94
+ # Attribute scope — uses json_extract (SQLite/MySQL) or ->> (PostgreSQL)
52
95
  scope :touching, ->(attribute) {
53
96
  if connection.adapter_name =~ /PostgreSQL/i
54
97
  # :nocov:
55
- where("object_changes ? :key", key: attribute.to_s)
98
+ where("object_changes->>? IS NOT NULL", attribute.to_s)
56
99
  # :nocov:
57
100
  else
58
101
  where("json_extract(object_changes, ?) IS NOT NULL", "$.#{attribute}")
@@ -5,6 +5,10 @@ class CreateAuditLogEntries < ActiveRecord::Migration[<%= ActiveRecord::Migratio
5
5
  t.string :item_type, null: false
6
6
  t.bigint :item_id, null: false
7
7
  t.json :object_changes
8
+ t.json :object
9
+ t.json :metadata
10
+ t.string :reason
11
+ t.string :whodunnit_snapshot
8
12
  t.string :actor_type
9
13
  t.bigint :actor_id
10
14
  t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
@@ -1,3 +1,3 @@
1
1
  module RailsAuditLog
2
- VERSION = "0.3.0"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -5,6 +5,19 @@ module RailsAuditLog
5
5
  # Global default columns to ignore across all audited models.
6
6
  # Override in an initializer: RailsAuditLog.ignored_attributes = %w[updated_at cached_at]
7
7
  mattr_accessor :ignored_attributes, default: %w[updated_at]
8
+ mattr_accessor :store_snapshot, default: true
9
+ mattr_accessor :capture_request_metadata, default: false
10
+ mattr_accessor :whodunnit_display, default: ->(actor) {
11
+ actor.respond_to?(:name) ? actor.name.to_s : actor.to_s
12
+ }
13
+
14
+ def self.request_metadata
15
+ Thread.current[:rails_audit_log_request_metadata]
16
+ end
17
+
18
+ def self.request_metadata=(value)
19
+ Thread.current[:rails_audit_log_request_metadata] = value
20
+ end
8
21
 
9
22
  def self.actor
10
23
  Thread.current[:rails_audit_log_actor]
@@ -33,4 +46,39 @@ module RailsAuditLog
33
46
  ensure
34
47
  Thread.current[:rails_audit_log_disabled] = previous
35
48
  end
49
+
50
+ def self.reason
51
+ Thread.current[:rails_audit_log_reason]
52
+ end
53
+
54
+ def self.reason=(value)
55
+ Thread.current[:rails_audit_log_reason] = value
56
+ end
57
+
58
+ def self.audit_log_reason(value)
59
+ previous = self.reason
60
+ self.reason = value
61
+ yield
62
+ ensure
63
+ self.reason = previous
64
+ end
65
+
66
+ def self.version_at(record, time)
67
+ entry = AuditLogEntry
68
+ .where(item_type: record.class.name, item_id: record.id)
69
+ .where(created_at: ..time)
70
+ .order(created_at: :desc, id: :desc)
71
+ .first
72
+
73
+ return nil if entry.nil? || entry.event == "destroy"
74
+
75
+ klass = record.class
76
+ to_attrs = (entry.object_changes || {}).transform_values { |v| v[1] }
77
+ attrs = entry.object.present? ? entry.object.merge(to_attrs) : to_attrs
78
+
79
+ instance = klass.new
80
+ instance.assign_attributes(attrs.except("id"))
81
+ instance.id = attrs.fetch("id") { entry.item_id }
82
+ instance
83
+ end
36
84
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_audit_log
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith