rails_audit_log 0.6.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 +4 -4
- data/README.md +79 -0
- data/app/concerns/rails_audit_log/auditable.rb +40 -12
- data/app/jobs/rails_audit_log/write_audit_log_job.rb +20 -0
- data/app/models/rails_audit_log/audit_log_entry.rb +10 -0
- data/lib/rails_audit_log/engine.rb +6 -0
- data/lib/rails_audit_log/version.rb +1 -1
- data/lib/rails_audit_log.rb +21 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3323e92d5f10ecd832a198a65bf6e0fd65c7bf0ff35069230bc8b7791d14e7fa
|
|
4
|
+
data.tar.gz: 4cd882a647d273ee3fe8a6c7b7f89a3b241ee0fa9b22b84aed93b8aea2a30c6d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c8d577a7b027d8c300b62545930e94798967f5cdc7f57d4d84a06b3f1968d1357b6ea1db9ae8abf1cb3e36bda8e6fd12be6c2da0798cf7d6d6a02e60849621c
|
|
7
|
+
data.tar.gz: 69a905ee623f28403c53d280a56009cd81696406e22ff2f258ef2d0ec56fc4d7a154f72a0a345557ec8a39e0b36b655b3fba7c1a128bee954834e73d537946b6
|
data/README.md
CHANGED
|
@@ -102,6 +102,32 @@ 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
|
+
|
|
105
131
|
### Association tracking
|
|
106
132
|
|
|
107
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:
|
|
@@ -151,6 +177,59 @@ end
|
|
|
151
177
|
|
|
152
178
|
`belongs_to` foreign-key changes are already tracked as regular column updates and require no extra configuration.
|
|
153
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
|
+
|
|
154
233
|
### Selective tracking
|
|
155
234
|
|
|
156
235
|
Track only specific attributes, or exclude noisy ones:
|
|
@@ -3,10 +3,12 @@ module RailsAuditLog
|
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
5
|
included do
|
|
6
|
-
class_attribute :_audit_log_only,
|
|
7
|
-
class_attribute :_audit_log_ignore,
|
|
8
|
-
class_attribute :_audit_log_meta,
|
|
9
|
-
class_attribute :_audit_log_associations,
|
|
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
|
|
10
12
|
|
|
11
13
|
has_many :audit_log_entries,
|
|
12
14
|
class_name: "RailsAuditLog::AuditLogEntry",
|
|
@@ -50,11 +52,13 @@ module RailsAuditLog
|
|
|
50
52
|
end
|
|
51
53
|
|
|
52
54
|
class_methods do
|
|
53
|
-
def audit_log(only: nil, ignore: nil, meta: nil, associations: nil)
|
|
54
|
-
self._audit_log_only
|
|
55
|
-
self._audit_log_ignore
|
|
56
|
-
self._audit_log_meta
|
|
57
|
-
self._audit_log_associations
|
|
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?
|
|
58
62
|
end
|
|
59
63
|
end
|
|
60
64
|
|
|
@@ -84,11 +88,12 @@ module RailsAuditLog
|
|
|
84
88
|
|
|
85
89
|
actor = RailsAuditLog.actor
|
|
86
90
|
meta = build_audit_metadata
|
|
87
|
-
|
|
91
|
+
write_audit_entry(
|
|
88
92
|
event: "update",
|
|
89
93
|
item_type: self.class.name,
|
|
90
94
|
item_id: id,
|
|
91
95
|
object_changes: { assoc_name => [before, after] },
|
|
96
|
+
object: nil,
|
|
92
97
|
reason: RailsAuditLog.reason,
|
|
93
98
|
metadata: meta.presence,
|
|
94
99
|
whodunnit_snapshot: actor ? RailsAuditLog.whodunnit_display.call(actor) : nil,
|
|
@@ -104,8 +109,8 @@ module RailsAuditLog
|
|
|
104
109
|
return if filtered.empty? && event == "update"
|
|
105
110
|
|
|
106
111
|
actor = RailsAuditLog.actor
|
|
107
|
-
meta
|
|
108
|
-
|
|
112
|
+
meta = build_audit_metadata
|
|
113
|
+
write_audit_entry(
|
|
109
114
|
event: event,
|
|
110
115
|
item_type: self.class.name,
|
|
111
116
|
item_id: id,
|
|
@@ -119,6 +124,29 @@ module RailsAuditLog
|
|
|
119
124
|
)
|
|
120
125
|
end
|
|
121
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
|
+
|
|
122
150
|
def build_audit_metadata
|
|
123
151
|
meta = {}
|
|
124
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
|
|
@@ -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
|
data/lib/rails_audit_log.rb
CHANGED
|
@@ -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)
|
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.
|
|
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
|