rails_audit_log 0.5.0 → 0.7.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: cf93211ea95d97c01b541195ade4517d221d39035896e06a6432c512e5f284c0
4
- data.tar.gz: f1bce85319393eaca9db2e078a0087e1f0818c1025fdd23e472fb8381a322b91
3
+ metadata.gz: 3323e92d5f10ecd832a198a65bf6e0fd65c7bf0ff35069230bc8b7791d14e7fa
4
+ data.tar.gz: 4cd882a647d273ee3fe8a6c7b7f89a3b241ee0fa9b22b84aed93b8aea2a30c6d
5
5
  SHA512:
6
- metadata.gz: f0f9def2049dc5ca95e13fad9746bc84a79c7e8081d8e84dd0f4fbfd3915b57b9dd35aaa600687970475f49b395e15c6725d3c558b1272f7afda6ba3b18054c0
7
- data.tar.gz: 1699b3f01dbfd55377144207ff36c987a83419adf318671a16676e5513fd31fc98f908e5b2f83cef3a9f7b597bdf7ac8fe2cf4da2c275f569bdcd4837a3a1727
6
+ metadata.gz: 7c8d577a7b027d8c300b62545930e94798967f5cdc7f57d4d84a06b3f1968d1357b6ea1db9ae8abf1cb3e36bda8e6fd12be6c2da0798cf7d6d6a02e60849621c
7
+ data.tar.gz: 69a905ee623f28403c53d280a56009cd81696406e22ff2f258ef2d0ec56fc4d7a154f72a0a345557ec8a39e0b36b655b3fba7c1a128bee954834e73d537946b6
data/README.md CHANGED
@@ -102,6 +102,134 @@ entry.diff
102
102
  # => { "title" => { from: "Hello", to: "World" } }
103
103
  ```
104
104
 
105
+ ### Separate audit database
106
+
107
+ Route all audit writes to a dedicated database by setting `connects_to` in an initializer. The engine applies it to `AuditLogEntry` at boot:
108
+
109
+ ```ruby
110
+ # config/initializers/rails_audit_log.rb
111
+ RailsAuditLog.connects_to = {
112
+ database: { writing: :audit_log, reading: :audit_log }
113
+ }
114
+ ```
115
+
116
+ 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.
117
+
118
+ ### Lightweight queries
119
+
120
+ 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):
121
+
122
+ ```ruby
123
+ entries = AuditLogEntry.slim.for_resource(article).since(1.week.ago)
124
+ entries.first.event # => "update"
125
+ entries.first.actor_type # => "User"
126
+ entries.first.object_changes # => raises ActiveModel::MissingAttributeError
127
+ ```
128
+
129
+ > **Note:** Use `.count(:id)` instead of `.count` when chaining `.slim` with other scopes — Rails' `COUNT` with a multi-column `SELECT` is not supported by all databases.
130
+
131
+ ### Association tracking
132
+
133
+ Track `has_many` add and remove events by passing `associations: true` to `audit_log`. Call `audit_log` **before** the `has_many` declarations so the callbacks are wired at class load time:
134
+
135
+ ```ruby
136
+ class Post < ApplicationRecord
137
+ include RailsAuditLog::Auditable
138
+ audit_log associations: true
139
+ has_many :tags
140
+ has_many :comments, dependent: :destroy
141
+ end
142
+ ```
143
+
144
+ Each add or remove creates an `update` entry on the parent with the associated record's identity in `object_changes`:
145
+
146
+ ```ruby
147
+ post = Post.create!(title: "Hello")
148
+ tag = post.tags.create!(name: "Ruby")
149
+
150
+ entry = post.audit_log_entries.updated_events.last
151
+ entry.object_changes
152
+ # => { "tags" => [nil, { "id" => 1, "type" => "Tag" }] }
153
+
154
+ post.tags.delete(tag)
155
+ entry = post.audit_log_entries.updated_events.last
156
+ entry.object_changes
157
+ # => { "tags" => [{ "id" => 1, "type" => "Tag" }, nil] }
158
+ ```
159
+
160
+ Track only a named subset of associations:
161
+
162
+ ```ruby
163
+ audit_log associations: [:tags] # comments changes are not recorded
164
+ ```
165
+
166
+ `has_many :through` and `has_and_belongs_to_many` work the same way — no extra configuration:
167
+
168
+ ```ruby
169
+ class Post < ApplicationRecord
170
+ include RailsAuditLog::Auditable
171
+ audit_log associations: true
172
+ has_many :taggings
173
+ has_many :tags, through: :taggings # tracked automatically
174
+ has_and_belongs_to_many :categories # tracked automatically
175
+ end
176
+ ```
177
+
178
+ `belongs_to` foreign-key changes are already tracked as regular column updates and require no extra configuration.
179
+
180
+ ### Bulk audit writes
181
+
182
+ Wrap a block with `RailsAuditLog.batch_audit` to buffer all audit entries and flush them in a single `insert_all!` call at the end, eliminating N+1 inserts during imports:
183
+
184
+ ```ruby
185
+ RailsAuditLog.batch_audit do
186
+ records.each { |attrs| Post.create!(attrs) }
187
+ end
188
+ # All audit entries written in one INSERT
189
+ ```
190
+
191
+ Nested calls accumulate into the outermost batch — only one `insert_all!` fires. If the block raises, the buffer is discarded and no entries are written.
192
+
193
+ > **Note:** Version pruning (`version_limit`) is deferred in batch mode — the next write outside the batch will trigger pruning as usual.
194
+
195
+ ### Async audit writes
196
+
197
+ Offload audit writes to a background job by passing `async: true` to `audit_log`. The entry is enqueued via `RailsAuditLog::WriteAuditLogJob` (a subclass of `ActiveJob::Base`) so the request does not block on the database write:
198
+
199
+ ```ruby
200
+ class Post < ApplicationRecord
201
+ include RailsAuditLog::Auditable
202
+ audit_log async: true
203
+ end
204
+ ```
205
+
206
+ Enable async globally in an initializer — per-model `async: true` takes precedence:
207
+
208
+ ```ruby
209
+ # config/initializers/rails_audit_log.rb
210
+ RailsAuditLog.async = true
211
+ ```
212
+
213
+ Configure the queue adapter and queue name through standard ActiveJob settings. Version pruning also runs inside the job when `version_limit` is set.
214
+
215
+ ### Capping history per record
216
+
217
+ Limit how many audit entries are kept per record with `version_limit:`. Oldest entries are pruned automatically after each write once the cap is reached:
218
+
219
+ ```ruby
220
+ class Post < ApplicationRecord
221
+ include RailsAuditLog::Auditable
222
+ audit_log version_limit: 10 # keep only the 10 most recent entries
223
+ end
224
+ ```
225
+
226
+ Set a global default in an initializer — per-model values take precedence:
227
+
228
+ ```ruby
229
+ # config/initializers/rails_audit_log.rb
230
+ RailsAuditLog.version_limit = 50
231
+ ```
232
+
105
233
  ### Selective tracking
106
234
 
107
235
  Track only specific attributes, or exclude noisy ones:
@@ -3,9 +3,12 @@ module RailsAuditLog
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
- class_attribute :_audit_log_only, default: nil
7
- class_attribute :_audit_log_ignore, default: nil
8
- class_attribute :_audit_log_meta, default: nil
6
+ class_attribute :_audit_log_only, default: nil
7
+ class_attribute :_audit_log_ignore, default: nil
8
+ class_attribute :_audit_log_meta, default: nil
9
+ class_attribute :_audit_log_associations, default: nil
10
+ class_attribute :_audit_log_version_limit, default: nil
11
+ class_attribute :_audit_log_async, default: false
9
12
 
10
13
  has_many :audit_log_entries,
11
14
  class_name: "RailsAuditLog::AuditLogEntry",
@@ -15,13 +18,47 @@ module RailsAuditLog
15
18
  after_create :record_audit_create
16
19
  after_update :record_audit_update
17
20
  after_destroy :record_audit_destroy
21
+
22
+ # Intercept has_many (including :through) and has_and_belongs_to_many to
23
+ # inject after_add/after_remove callbacks when association tracking is
24
+ # enabled. Must be defined after has_many :audit_log_entries so that the
25
+ # internal association is not affected.
26
+ def self.has_many(name, scope = nil, **options, &extension)
27
+ if _audit_log_associations && name.to_s != "audit_log_entries"
28
+ options = _build_audit_association_options(name.to_s, options)
29
+ end
30
+ scope ? super(name, scope, **options, &extension) : super(name, **options, &extension)
31
+ end
32
+
33
+ def self.has_and_belongs_to_many(name, scope = nil, **options, &extension)
34
+ if _audit_log_associations
35
+ options = _build_audit_association_options(name.to_s, options)
36
+ end
37
+ scope ? super(name, scope, **options, &extension) : super(name, **options, &extension)
38
+ end
39
+
40
+ def self._build_audit_association_options(assoc_name, options)
41
+ tracked = _audit_log_associations == true ||
42
+ Array(_audit_log_associations).map(&:to_s).include?(assoc_name)
43
+ return options unless tracked
44
+
45
+ add_cb = ->(owner, rec) { owner.send(:record_audit_association_change, assoc_name, nil, { "id" => rec.id, "type" => rec.class.name }) }
46
+ remove_cb = ->(owner, rec) { owner.send(:record_audit_association_change, assoc_name, { "id" => rec.id, "type" => rec.class.name }, nil) }
47
+ options.merge(
48
+ after_add: [*options[:after_add]] + [add_cb],
49
+ after_remove: [*options[:after_remove]] + [remove_cb]
50
+ )
51
+ end
18
52
  end
19
53
 
20
54
  class_methods do
21
- def audit_log(only: nil, ignore: nil, meta: nil)
22
- self._audit_log_only = only.map(&:to_s) if only
23
- self._audit_log_ignore = ignore.map(&:to_s) if ignore
24
- self._audit_log_meta = meta if meta
55
+ def audit_log(only: nil, ignore: nil, meta: nil, associations: nil, version_limit: nil, async: nil)
56
+ self._audit_log_only = only.map(&:to_s) if only
57
+ self._audit_log_ignore = ignore.map(&:to_s) if ignore
58
+ self._audit_log_meta = meta if meta
59
+ self._audit_log_associations = associations unless associations.nil?
60
+ self._audit_log_version_limit = version_limit unless version_limit.nil?
61
+ self._audit_log_async = async unless async.nil?
25
62
  end
26
63
  end
27
64
 
@@ -46,6 +83,25 @@ module RailsAuditLog
46
83
  record_audit_entry("destroy", changes, snapshot)
47
84
  end
48
85
 
86
+ def record_audit_association_change(assoc_name, before, after)
87
+ return unless RailsAuditLog.enabled?
88
+
89
+ actor = RailsAuditLog.actor
90
+ meta = build_audit_metadata
91
+ write_audit_entry(
92
+ event: "update",
93
+ item_type: self.class.name,
94
+ item_id: id,
95
+ object_changes: { assoc_name => [before, after] },
96
+ object: nil,
97
+ reason: RailsAuditLog.reason,
98
+ metadata: meta.presence,
99
+ whodunnit_snapshot: actor ? RailsAuditLog.whodunnit_display.call(actor) : nil,
100
+ actor_type: actor&.class&.name,
101
+ actor_id: actor.respond_to?(:id) ? actor.id : nil
102
+ )
103
+ end
104
+
49
105
  def record_audit_entry(event, changes, snapshot = nil)
50
106
  return unless RailsAuditLog.enabled?
51
107
 
@@ -53,8 +109,8 @@ module RailsAuditLog
53
109
  return if filtered.empty? && event == "update"
54
110
 
55
111
  actor = RailsAuditLog.actor
56
- meta = build_audit_metadata
57
- RailsAuditLog::AuditLogEntry.create!(
112
+ meta = build_audit_metadata
113
+ write_audit_entry(
58
114
  event: event,
59
115
  item_type: self.class.name,
60
116
  item_id: id,
@@ -68,6 +124,29 @@ module RailsAuditLog
68
124
  )
69
125
  end
70
126
 
127
+ def write_audit_entry(entry_attrs)
128
+ if (buffer = RailsAuditLog.batch_audit_buffer)
129
+ buffer << entry_attrs.stringify_keys.merge("created_at" => Time.current)
130
+ elsif _audit_log_async || RailsAuditLog.async
131
+ limit = self.class._audit_log_version_limit || RailsAuditLog.version_limit
132
+ WriteAuditLogJob.perform_later(entry_attrs.stringify_keys, version_limit: limit)
133
+ else
134
+ RailsAuditLog::AuditLogEntry.create!(entry_attrs)
135
+ prune_audit_entries
136
+ end
137
+ end
138
+
139
+ def prune_audit_entries
140
+ limit = self.class._audit_log_version_limit || RailsAuditLog.version_limit
141
+ return unless limit
142
+
143
+ count = audit_log_entries.count
144
+ excess = count - limit
145
+ return unless excess > 0
146
+
147
+ audit_log_entries.order(id: :asc).limit(excess).delete_all
148
+ end
149
+
71
150
  def build_audit_metadata
72
151
  meta = {}
73
152
  if self.class._audit_log_meta
@@ -0,0 +1,20 @@
1
+ module RailsAuditLog
2
+ class WriteAuditLogJob < ApplicationJob
3
+ def perform(entry_attrs, version_limit: nil)
4
+ AuditLogEntry.create!(entry_attrs)
5
+
6
+ return unless version_limit
7
+
8
+ item_type = entry_attrs["item_type"]
9
+ item_id = entry_attrs["item_id"]
10
+ count = AuditLogEntry.where(item_type: item_type, item_id: item_id).count
11
+ excess = count - version_limit
12
+ return unless excess > 0
13
+
14
+ AuditLogEntry.where(item_type: item_type, item_id: item_id)
15
+ .order(id: :asc)
16
+ .limit(excess)
17
+ .delete_all
18
+ end
19
+ end
20
+ end
@@ -3,6 +3,13 @@ module RailsAuditLog
3
3
  self.table_name = "audit_log_entries"
4
4
 
5
5
  EVENTS = %w[create update destroy].freeze
6
+ BLOB_COLUMNS = %w[object_changes object metadata].freeze
7
+
8
+ def self.configure_connection!
9
+ return unless (opts = RailsAuditLog.connects_to)
10
+
11
+ connects_to(**opts)
12
+ end
6
13
 
7
14
  belongs_to :item, polymorphic: true, optional: true
8
15
  belongs_to :actor, polymorphic: true, optional: true
@@ -37,6 +44,9 @@ module RailsAuditLog
37
44
  scope :since, ->(time) { where(created_at: time..) }
38
45
  scope :until, ->(time) { where(created_at: ..time) }
39
46
 
47
+ # Projection scope — omits JSON blob columns for index/listing queries
48
+ scope :slim, -> { select(column_names - BLOB_COLUMNS) }
49
+
40
50
  # Instance methods
41
51
 
42
52
  def reify
@@ -51,8 +61,13 @@ module RailsAuditLog
51
61
  return instance
52
62
  end
53
63
 
54
- # Fallback: diff-only mode or entries recorded before snapshot support
55
- from_attrs = (object_changes || {}).transform_values { |from_to| from_to[0] }
64
+ # Fallback: diff-only mode or entries recorded before snapshot support.
65
+ # Filter to column names so association-change entries (e.g. tags, comments)
66
+ # don't get assigned to the record as if they were scalar attributes.
67
+ column_names = klass.column_names.map(&:to_s)
68
+ from_attrs = (object_changes || {})
69
+ .select { |k, _| column_names.include?(k) }
70
+ .transform_values { |from_to| from_to[0] }
56
71
 
57
72
  if event == "update"
58
73
  record = klass.find_by(id: item_id)
@@ -1,5 +1,11 @@
1
1
  module RailsAuditLog
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace RailsAuditLog
4
+
5
+ initializer "rails_audit_log.connect_audit_db" do
6
+ ActiveSupport.on_load(:active_record) do
7
+ RailsAuditLog::AuditLogEntry.configure_connection!
8
+ end
9
+ end
4
10
  end
5
11
  end
@@ -1,3 +1,3 @@
1
1
  module RailsAuditLog
2
- VERSION = "0.5.0"
2
+ VERSION = "0.7.0"
3
3
  end
@@ -7,6 +7,9 @@ module RailsAuditLog
7
7
  mattr_accessor :ignored_attributes, default: %w[updated_at]
8
8
  mattr_accessor :store_snapshot, default: true
9
9
  mattr_accessor :capture_request_metadata, default: false
10
+ mattr_accessor :version_limit, default: nil
11
+ mattr_accessor :async, default: false
12
+ mattr_accessor :connects_to, default: nil
10
13
  mattr_accessor :whodunnit_display, default: ->(actor) {
11
14
  actor.respond_to?(:name) ? actor.name.to_s : actor.to_s
12
15
  }
@@ -63,6 +66,24 @@ module RailsAuditLog
63
66
  self.reason = previous
64
67
  end
65
68
 
69
+ def self.batch_audit
70
+ return yield if Thread.current[:rails_audit_log_batch]
71
+
72
+ Thread.current[:rails_audit_log_batch] = []
73
+ begin
74
+ result = yield
75
+ batch = Thread.current[:rails_audit_log_batch]
76
+ AuditLogEntry.insert_all!(batch) if batch.any?
77
+ result
78
+ ensure
79
+ Thread.current[:rails_audit_log_batch] = nil
80
+ end
81
+ end
82
+
83
+ def self.batch_audit_buffer
84
+ Thread.current[:rails_audit_log_batch]
85
+ end
86
+
66
87
  def self.version_at(record, time)
67
88
  entry = AuditLogEntry
68
89
  .where(item_type: record.class.name, item_id: record.id)
@@ -73,7 +94,10 @@ module RailsAuditLog
73
94
  return nil if entry.nil? || entry.event == "destroy"
74
95
 
75
96
  klass = record.class
76
- to_attrs = (entry.object_changes || {}).transform_values { |v| v[1] }
97
+ column_names = klass.column_names.map(&:to_s)
98
+ to_attrs = (entry.object_changes || {})
99
+ .select { |k, _| column_names.include?(k) }
100
+ .transform_values { |v| v[1] }
77
101
  attrs = entry.object.present? ? entry.object.merge(to_attrs) : to_attrs
78
102
 
79
103
  instance = klass.new
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.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -41,6 +41,7 @@ files:
41
41
  - app/controllers/rails_audit_log/application_controller.rb
42
42
  - app/helpers/rails_audit_log/application_helper.rb
43
43
  - app/jobs/rails_audit_log/application_job.rb
44
+ - app/jobs/rails_audit_log/write_audit_log_job.rb
44
45
  - app/models/rails_audit_log/application_record.rb
45
46
  - app/models/rails_audit_log/audit_log_entry.rb
46
47
  - app/views/layouts/rails_audit_log/application.html.erb