ez_logs_agent 0.1.3
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 +7 -0
- data/CHANGELOG.md +80 -0
- data/LICENSE.txt +21 -0
- data/README.md +1023 -0
- data/lib/ez_logs_agent/actor.rb +57 -0
- data/lib/ez_logs_agent/actor_validator.rb +58 -0
- data/lib/ez_logs_agent/buffer.rb +83 -0
- data/lib/ez_logs_agent/capturers/active_job_capturer.rb +300 -0
- data/lib/ez_logs_agent/capturers/database_capturer.rb +467 -0
- data/lib/ez_logs_agent/capturers/job_capturer.rb +261 -0
- data/lib/ez_logs_agent/configuration.rb +184 -0
- data/lib/ez_logs_agent/configuration_validator.rb +139 -0
- data/lib/ez_logs_agent/correlation.rb +40 -0
- data/lib/ez_logs_agent/event_builder.rb +281 -0
- data/lib/ez_logs_agent/flush_scheduler.rb +99 -0
- data/lib/ez_logs_agent/logger.rb +62 -0
- data/lib/ez_logs_agent/middleware/http_request.rb +992 -0
- data/lib/ez_logs_agent/railtie.rb +353 -0
- data/lib/ez_logs_agent/resource_extractor.rb +172 -0
- data/lib/ez_logs_agent/retry_sender.rb +120 -0
- data/lib/ez_logs_agent/sanitizer.rb +150 -0
- data/lib/ez_logs_agent/transport.rb +91 -0
- data/lib/ez_logs_agent/user_agent_detector.rb +51 -0
- data/lib/ez_logs_agent/version.rb +5 -0
- data/lib/ez_logs_agent.rb +44 -0
- data/lib/generators/ez_logs_agent/install/install_generator.rb +94 -0
- data/lib/generators/ez_logs_agent/install/templates/ez_logs_agent.rb.tt +128 -0
- data/lib/tasks/ez_logs_agent.rake +110 -0
- metadata +172 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EzLogsAgent
|
|
4
|
+
module Capturers
|
|
5
|
+
# Captures database operations via ActiveRecord model lifecycle callbacks.
|
|
6
|
+
#
|
|
7
|
+
# This capturer:
|
|
8
|
+
# - Installs after_create, after_update, after_destroy callbacks on ActiveRecord::Base
|
|
9
|
+
# - Captures model class, record id, and operation type
|
|
10
|
+
# - Extracts resource_ids from the model instance
|
|
11
|
+
# - For updates, extracts curated business-relevant change context
|
|
12
|
+
# - Preserves correlation_id from current context
|
|
13
|
+
# - Never crashes the host application (fail-open)
|
|
14
|
+
# - Respects capture_database configuration flag
|
|
15
|
+
#
|
|
16
|
+
# == What This Capturer Does NOT Do
|
|
17
|
+
#
|
|
18
|
+
# - Parse SQL queries
|
|
19
|
+
# - Dump full attribute diffs
|
|
20
|
+
# - Include sensitive data
|
|
21
|
+
# - Guess actors
|
|
22
|
+
# - Act as an audit log
|
|
23
|
+
#
|
|
24
|
+
# == Event Shape
|
|
25
|
+
#
|
|
26
|
+
# Produces events with:
|
|
27
|
+
# - source_type: :database_callback
|
|
28
|
+
# - source_data: { model_class: "User", operation: "create|update|destroy" }
|
|
29
|
+
# - outcome: :success
|
|
30
|
+
# - correlation_id: EzLogsAgent::Correlation.current (if present)
|
|
31
|
+
# - resource_ids: [{ resource_type: "User", resource_id: "123" }]
|
|
32
|
+
# - context: { changes: [{ attribute: "status", from: "pending", to: "shipped" }, ...] } (updates only, if meaningful)
|
|
33
|
+
#
|
|
34
|
+
class DatabaseCapturer
|
|
35
|
+
# Attributes to always ignore when detecting business changes
|
|
36
|
+
IGNORED_ATTRIBUTES = %w[
|
|
37
|
+
id
|
|
38
|
+
created_at
|
|
39
|
+
updated_at
|
|
40
|
+
lock_version
|
|
41
|
+
encrypted_password
|
|
42
|
+
reset_password_token
|
|
43
|
+
reset_password_sent_at
|
|
44
|
+
remember_created_at
|
|
45
|
+
confirmation_token
|
|
46
|
+
confirmed_at
|
|
47
|
+
confirmation_sent_at
|
|
48
|
+
unconfirmed_email
|
|
49
|
+
unlock_token
|
|
50
|
+
locked_at
|
|
51
|
+
sign_in_count
|
|
52
|
+
current_sign_in_at
|
|
53
|
+
last_sign_in_at
|
|
54
|
+
current_sign_in_ip
|
|
55
|
+
last_sign_in_ip
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
# Foreign key changes are now captured because they represent meaningful
|
|
59
|
+
# relationship changes (e.g., profile_id, user_id).
|
|
60
|
+
# Previously we filtered them out, but this loses important context.
|
|
61
|
+
# FOREIGN_KEY_PATTERN = /_id\z/ # Removed January 2026
|
|
62
|
+
|
|
63
|
+
# Patterns for sensitive data to ignore
|
|
64
|
+
SENSITIVE_PATTERNS = %w[
|
|
65
|
+
password
|
|
66
|
+
token
|
|
67
|
+
secret
|
|
68
|
+
api_key
|
|
69
|
+
credit_card
|
|
70
|
+
ssn
|
|
71
|
+
social_security
|
|
72
|
+
encrypted
|
|
73
|
+
].freeze
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@installed = false
|
|
77
|
+
@callbacks_registered = false
|
|
78
|
+
|
|
79
|
+
class << self
|
|
80
|
+
# Installs ActiveRecord lifecycle callbacks for database capture.
|
|
81
|
+
#
|
|
82
|
+
# This method is idempotent and can be called multiple times safely.
|
|
83
|
+
# Only installs if ActiveRecord is present.
|
|
84
|
+
#
|
|
85
|
+
# @return [void]
|
|
86
|
+
def install
|
|
87
|
+
return unless defined?(ActiveRecord::Base)
|
|
88
|
+
return if @installed
|
|
89
|
+
|
|
90
|
+
# Only register callbacks once per Ruby process
|
|
91
|
+
unless @callbacks_registered
|
|
92
|
+
ActiveRecord::Base.class_eval do
|
|
93
|
+
after_create { |model| EzLogsAgent::Capturers::DatabaseCapturer.handle_create(model) }
|
|
94
|
+
after_update { |model| EzLogsAgent::Capturers::DatabaseCapturer.handle_update(model) }
|
|
95
|
+
after_destroy { |model| EzLogsAgent::Capturers::DatabaseCapturer.handle_destroy(model) }
|
|
96
|
+
end
|
|
97
|
+
@callbacks_registered = true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
@installed = true
|
|
101
|
+
EzLogsAgent::Logger.debug("[DatabaseCapturer] Installed")
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
EzLogsAgent::Logger.error("[DatabaseCapturer] Installation failed: #{e.class} - #{e.message}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Handles after_create callback
|
|
107
|
+
#
|
|
108
|
+
# @param model [ActiveRecord::Base] The created model instance
|
|
109
|
+
# @return [void]
|
|
110
|
+
def handle_create(model)
|
|
111
|
+
return unless capture_enabled?
|
|
112
|
+
|
|
113
|
+
context = extract_initial_attributes(model) || {}
|
|
114
|
+
context[:display_name] = resolve_display_name(model)
|
|
115
|
+
capture_event(model, "create", context: context.presence)
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
EzLogsAgent::Logger.error("[DatabaseCapturer] handle_create failed: #{e.class} - #{e.message}")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Handles after_update callback
|
|
121
|
+
#
|
|
122
|
+
# @param model [ActiveRecord::Base] The updated model instance
|
|
123
|
+
# @return [void]
|
|
124
|
+
def handle_update(model)
|
|
125
|
+
return unless capture_enabled?
|
|
126
|
+
|
|
127
|
+
context = extract_change_context(model) || {}
|
|
128
|
+
context[:display_name] = resolve_display_name(model)
|
|
129
|
+
capture_event(model, "update", context: context.presence)
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
EzLogsAgent::Logger.error("[DatabaseCapturer] handle_update failed: #{e.class} - #{e.message}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Handles after_destroy callback
|
|
135
|
+
#
|
|
136
|
+
# @param model [ActiveRecord::Base] The destroyed model instance
|
|
137
|
+
# @return [void]
|
|
138
|
+
def handle_destroy(model)
|
|
139
|
+
return unless capture_enabled?
|
|
140
|
+
|
|
141
|
+
context = { display_name: resolve_display_name(model) }
|
|
142
|
+
capture_event(model, "destroy", context: context.presence)
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
EzLogsAgent::Logger.error("[DatabaseCapturer] handle_destroy failed: #{e.class} - #{e.message}")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# Checks if database capture is enabled
|
|
150
|
+
#
|
|
151
|
+
# @return [Boolean]
|
|
152
|
+
def capture_enabled?
|
|
153
|
+
EzLogsAgent.configuration.capture_database
|
|
154
|
+
rescue StandardError
|
|
155
|
+
false
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Checks if the model's table is in the excluded_tables list
|
|
159
|
+
# Uses all_excluded_tables which combines defaults with user-configured
|
|
160
|
+
#
|
|
161
|
+
# @param model [ActiveRecord::Base] The model instance
|
|
162
|
+
# @return [Boolean]
|
|
163
|
+
def table_excluded?(model)
|
|
164
|
+
return false unless model.class.respond_to?(:table_name)
|
|
165
|
+
|
|
166
|
+
EzLogsAgent.configuration.all_excluded_tables.include?(model.class.table_name)
|
|
167
|
+
rescue StandardError
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Captures a database event and pushes to buffer
|
|
172
|
+
#
|
|
173
|
+
# @param model [ActiveRecord::Base] The model instance
|
|
174
|
+
# @param operation [String] The operation type ("create", "update", "destroy")
|
|
175
|
+
# @param context [Hash, nil] Optional context with change information
|
|
176
|
+
# @return [void]
|
|
177
|
+
def capture_event(model, operation, context: nil)
|
|
178
|
+
return if table_excluded?(model)
|
|
179
|
+
|
|
180
|
+
event = EzLogsAgent::EventBuilder.build(
|
|
181
|
+
source_type: :database_callback,
|
|
182
|
+
source_data: {
|
|
183
|
+
model_class: model.class.name,
|
|
184
|
+
operation: operation
|
|
185
|
+
},
|
|
186
|
+
outcome: :success,
|
|
187
|
+
correlation_id: EzLogsAgent::Correlation.current,
|
|
188
|
+
resource_ids: extract_resource_ids(model),
|
|
189
|
+
context: context,
|
|
190
|
+
duration_ms: nil
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
EzLogsAgent::Buffer.push(event)
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
EzLogsAgent::Logger.error("[DatabaseCapturer] capture_event failed: #{e.class} - #{e.message}")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Extracts resource_ids from model
|
|
199
|
+
#
|
|
200
|
+
# @param model [ActiveRecord::Base] The model instance
|
|
201
|
+
# @return [Array<Hash>] Array with single resource identifier
|
|
202
|
+
def extract_resource_ids(model)
|
|
203
|
+
[
|
|
204
|
+
{
|
|
205
|
+
resource_type: model.class.name,
|
|
206
|
+
resource_id: model.id.to_s
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
rescue StandardError
|
|
210
|
+
[]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Resolves a human-readable display name for a model instance
|
|
214
|
+
#
|
|
215
|
+
# Uses configuration if provided, otherwise falls back to common patterns:
|
|
216
|
+
# 1. Custom field from config.display_name_for[ModelClass]
|
|
217
|
+
# 2. model.name (if responds)
|
|
218
|
+
# 3. model.title (if responds)
|
|
219
|
+
# 4. model.number (if responds)
|
|
220
|
+
#
|
|
221
|
+
# Returns nil if no meaningful name found. The frontend can decide
|
|
222
|
+
# to show the resource ID as a fallback if needed.
|
|
223
|
+
#
|
|
224
|
+
# IMPORTANT: This method only reads attributes already loaded in memory.
|
|
225
|
+
# It does NOT trigger any database queries.
|
|
226
|
+
# Configured fields should be direct attributes, not associations.
|
|
227
|
+
#
|
|
228
|
+
# @param model [ActiveRecord::Base] The model instance
|
|
229
|
+
# @return [String, nil] The display name, or nil if no meaningful name found
|
|
230
|
+
def resolve_display_name(model)
|
|
231
|
+
# Check for configured custom field
|
|
232
|
+
display_name_config = EzLogsAgent.configuration.display_name_for || {}
|
|
233
|
+
custom_field = display_name_config[model.class.name]
|
|
234
|
+
|
|
235
|
+
if custom_field && model.respond_to?(custom_field)
|
|
236
|
+
value = model.public_send(custom_field)
|
|
237
|
+
return value.to_s if value.present?
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Fallback chain: name → title → number
|
|
241
|
+
if model.respond_to?(:name) && model.name.present?
|
|
242
|
+
return model.name.to_s
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
if model.respond_to?(:title) && model.title.present?
|
|
246
|
+
return model.title.to_s
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
if model.respond_to?(:number) && model.number.present?
|
|
250
|
+
return model.number.to_s
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# No meaningful name found - return nil
|
|
254
|
+
# Frontend can show resource ID as fallback if needed
|
|
255
|
+
nil
|
|
256
|
+
rescue StandardError => e
|
|
257
|
+
EzLogsAgent::Logger.error("[DatabaseCapturer] resolve_display_name failed: #{e.class} - #{e.message}")
|
|
258
|
+
nil
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Extracts curated business change context from model's saved_changes
|
|
262
|
+
#
|
|
263
|
+
# Rules:
|
|
264
|
+
# - Only for updates
|
|
265
|
+
# - Pick meaningful attributes (ignore technical fields)
|
|
266
|
+
# - Ignore foreign keys
|
|
267
|
+
# - Ignore sensitive data
|
|
268
|
+
# - Only scalar values (String, Integer, Float, Boolean, Symbol, NilClass)
|
|
269
|
+
# - Value must have actually changed (from != to, not both nil)
|
|
270
|
+
#
|
|
271
|
+
# @param model [ActiveRecord::Base] The updated model instance
|
|
272
|
+
# @return [Hash, nil] Context hash with change, or nil if no meaningful change
|
|
273
|
+
def extract_change_context(model)
|
|
274
|
+
return nil unless model.respond_to?(:saved_changes)
|
|
275
|
+
|
|
276
|
+
changes = model.saved_changes
|
|
277
|
+
return nil if changes.nil? || changes.empty?
|
|
278
|
+
|
|
279
|
+
# Find meaningful changes
|
|
280
|
+
meaningful_changes = filter_meaningful_changes(changes)
|
|
281
|
+
return nil if meaningful_changes.empty?
|
|
282
|
+
|
|
283
|
+
# Build context with all meaningful changes
|
|
284
|
+
build_change_context(meaningful_changes)
|
|
285
|
+
rescue StandardError => e
|
|
286
|
+
EzLogsAgent::Logger.error("[DatabaseCapturer] extract_change_context failed: #{e.class} - #{e.message}")
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Extracts initial attributes from a newly created model
|
|
291
|
+
#
|
|
292
|
+
# Uses the same filtering rules as update changes:
|
|
293
|
+
# - Only meaningful attributes (not technical/ignored)
|
|
294
|
+
# - Only scalar values
|
|
295
|
+
# - Skip nil values (no point recording "attribute: nil")
|
|
296
|
+
# - Skip foreign keys and sensitive data
|
|
297
|
+
#
|
|
298
|
+
# @param model [ActiveRecord::Base] The created model instance
|
|
299
|
+
# @return [Hash, nil] Context hash with initial_attributes, or nil if none
|
|
300
|
+
def extract_initial_attributes(model)
|
|
301
|
+
return nil unless model.respond_to?(:attributes)
|
|
302
|
+
|
|
303
|
+
attributes = model.attributes
|
|
304
|
+
return nil if attributes.nil? || attributes.empty?
|
|
305
|
+
|
|
306
|
+
# Filter to meaningful, non-nil scalar attributes
|
|
307
|
+
meaningful_attrs = attributes.select do |attribute, value|
|
|
308
|
+
meaningful_attribute?(attribute) &&
|
|
309
|
+
scalar?(value) &&
|
|
310
|
+
!value.nil?
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
return nil if meaningful_attrs.empty?
|
|
314
|
+
|
|
315
|
+
# Format values for JSON
|
|
316
|
+
formatted_attrs = meaningful_attrs.transform_values { |v| format_value_for_json(v) }
|
|
317
|
+
|
|
318
|
+
{ initial_attributes: formatted_attrs }
|
|
319
|
+
rescue StandardError => e
|
|
320
|
+
EzLogsAgent::Logger.error("[DatabaseCapturer] extract_initial_attributes failed: #{e.class} - #{e.message}")
|
|
321
|
+
nil
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Filters changes to only meaningful business attributes
|
|
325
|
+
#
|
|
326
|
+
# @param changes [Hash] The saved_changes hash
|
|
327
|
+
# @return [Array<Array>] Array of [attribute, [from, to]] pairs
|
|
328
|
+
def filter_meaningful_changes(changes)
|
|
329
|
+
changes.select do |attribute, (from, to)|
|
|
330
|
+
meaningful_attribute?(attribute) &&
|
|
331
|
+
scalar_values?(from, to) &&
|
|
332
|
+
values_actually_changed?(from, to)
|
|
333
|
+
end.to_a
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Checks if an attribute is meaningful (not technical/ignored)
|
|
337
|
+
#
|
|
338
|
+
# @param attribute [String] The attribute name
|
|
339
|
+
# @return [Boolean]
|
|
340
|
+
def meaningful_attribute?(attribute)
|
|
341
|
+
attr_str = attribute.to_s
|
|
342
|
+
|
|
343
|
+
# Skip explicitly ignored attributes
|
|
344
|
+
return false if IGNORED_ATTRIBUTES.include?(attr_str)
|
|
345
|
+
|
|
346
|
+
# Foreign keys (_id) are now captured because they represent meaningful
|
|
347
|
+
# relationship changes (e.g., assigned_to_id changing from user A to user B)
|
|
348
|
+
# Previously filtered via FOREIGN_KEY_PATTERN - removed January 2026
|
|
349
|
+
|
|
350
|
+
# Skip sensitive data
|
|
351
|
+
return false if sensitive_attribute?(attr_str)
|
|
352
|
+
|
|
353
|
+
true
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Checks if attribute name contains sensitive patterns
|
|
357
|
+
#
|
|
358
|
+
# @param attribute [String] The attribute name
|
|
359
|
+
# @return [Boolean]
|
|
360
|
+
def sensitive_attribute?(attribute)
|
|
361
|
+
attr_lower = attribute.downcase
|
|
362
|
+
SENSITIVE_PATTERNS.any? { |pattern| attr_lower.include?(pattern) }
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Checks if both values are scalar types
|
|
366
|
+
#
|
|
367
|
+
# @param from [Object] The old value
|
|
368
|
+
# @param to [Object] The new value
|
|
369
|
+
# @return [Boolean]
|
|
370
|
+
def scalar_values?(from, to)
|
|
371
|
+
scalar?(from) && scalar?(to)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Checks if a value is a scalar type (simple, serializable values)
|
|
375
|
+
#
|
|
376
|
+
# Includes Date/Time types which are meaningful business values
|
|
377
|
+
# (e.g., discarded_at for soft deletes, published_at, expires_at)
|
|
378
|
+
#
|
|
379
|
+
# Includes BigDecimal which Rails uses for decimal columns
|
|
380
|
+
# (e.g., prices, percentages, rates)
|
|
381
|
+
#
|
|
382
|
+
# Includes arrays of scalar values (e.g., PostgreSQL array columns
|
|
383
|
+
# like email lists)
|
|
384
|
+
#
|
|
385
|
+
# @param value [Object] The value to check
|
|
386
|
+
# @return [Boolean]
|
|
387
|
+
def scalar?(value)
|
|
388
|
+
return true if value.nil? ||
|
|
389
|
+
value.is_a?(String) ||
|
|
390
|
+
value.is_a?(Integer) ||
|
|
391
|
+
value.is_a?(Float) ||
|
|
392
|
+
value.is_a?(BigDecimal) ||
|
|
393
|
+
value.is_a?(TrueClass) ||
|
|
394
|
+
value.is_a?(FalseClass) ||
|
|
395
|
+
value.is_a?(Symbol) ||
|
|
396
|
+
value.is_a?(Date) ||
|
|
397
|
+
value.is_a?(Time)
|
|
398
|
+
# Note: DateTime inherits from Date, so Date check covers it
|
|
399
|
+
|
|
400
|
+
# Arrays of scalars are also allowed (e.g., email arrays)
|
|
401
|
+
return value.all? { |v| scalar?(v) } if value.is_a?(Array)
|
|
402
|
+
|
|
403
|
+
false
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Checks if values actually changed (not both nil)
|
|
407
|
+
#
|
|
408
|
+
# @param from [Object] The old value
|
|
409
|
+
# @param to [Object] The new value
|
|
410
|
+
# @return [Boolean]
|
|
411
|
+
def values_actually_changed?(from, to)
|
|
412
|
+
# Both nil means no real change
|
|
413
|
+
return false if from.nil? && to.nil?
|
|
414
|
+
|
|
415
|
+
# Values must be different
|
|
416
|
+
from != to
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Builds context hash with all meaningful changes
|
|
420
|
+
#
|
|
421
|
+
# Captures ALL meaningful changes for better visibility:
|
|
422
|
+
# - Shows what actually changed in the UI
|
|
423
|
+
# - Enables better event descriptions
|
|
424
|
+
# - Still excludes sensitive/technical fields
|
|
425
|
+
# - Formats Date/Time values as ISO strings for JSON serialization
|
|
426
|
+
#
|
|
427
|
+
# @param meaningful_changes [Array<Array>] Array of [attribute, [from, to]] pairs
|
|
428
|
+
# @return [Hash] Context hash with changes array
|
|
429
|
+
def build_change_context(meaningful_changes)
|
|
430
|
+
changes = meaningful_changes.map do |attribute, (from, to)|
|
|
431
|
+
{
|
|
432
|
+
attribute: attribute.to_s,
|
|
433
|
+
from: format_value_for_json(from),
|
|
434
|
+
to: format_value_for_json(to)
|
|
435
|
+
}
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
{ changes: changes }
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Formats a value for JSON serialization
|
|
442
|
+
#
|
|
443
|
+
# Date/Time values are converted to ISO 8601 strings
|
|
444
|
+
# BigDecimal values are converted to floats (JSON doesn't support BigDecimal)
|
|
445
|
+
# Arrays are recursively formatted
|
|
446
|
+
# Other values pass through unchanged
|
|
447
|
+
#
|
|
448
|
+
# @param value [Object] The value to format
|
|
449
|
+
# @return [Object] The formatted value
|
|
450
|
+
def format_value_for_json(value)
|
|
451
|
+
case value
|
|
452
|
+
when Time, DateTime
|
|
453
|
+
value.iso8601
|
|
454
|
+
when Date
|
|
455
|
+
value.to_s
|
|
456
|
+
when BigDecimal
|
|
457
|
+
value.to_f
|
|
458
|
+
when Array
|
|
459
|
+
value.map { |v| format_value_for_json(v) }
|
|
460
|
+
else
|
|
461
|
+
value
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|