ez_logs_agent 0.1.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 +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +57 -0
- data/CONFIGURATION.md +752 -0
- data/FAQ.md +574 -0
- data/LICENSE.txt +21 -0
- data/QUICKSTART.md +390 -0
- data/README.md +1021 -0
- data/RELEASING.md +55 -0
- data/Rakefile +8 -0
- data/lib/ez_logs_agent/actor.rb +57 -0
- data/lib/ez_logs_agent/actor_validator.rb +51 -0
- data/lib/ez_logs_agent/buffer.rb +83 -0
- data/lib/ez_logs_agent/capturers/active_job_capturer.rb +270 -0
- data/lib/ez_logs_agent/capturers/database_capturer.rb +467 -0
- data/lib/ez_logs_agent/capturers/job_capturer.rb +238 -0
- data/lib/ez_logs_agent/configuration.rb +186 -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 +1094 -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/transport.rb +91 -0
- data/lib/ez_logs_agent/version.rb +5 -0
- data/lib/ez_logs_agent.rb +42 -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
- data/script/publish-to-public.sh +113 -0
- data/sig/ez_logs_agent.rbs +4 -0
- metadata +178 -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
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EzLogsAgent
|
|
4
|
+
module Capturers
|
|
5
|
+
class JobCapturer
|
|
6
|
+
# Sidekiq client middleware that propagates correlation_id
|
|
7
|
+
# from the current request context into enqueued job payloads.
|
|
8
|
+
#
|
|
9
|
+
# This middleware runs at enqueue-time (when a job is scheduled),
|
|
10
|
+
# NOT at execution-time.
|
|
11
|
+
#
|
|
12
|
+
# == Behavior
|
|
13
|
+
#
|
|
14
|
+
# - Reads EzLogsAgent::Correlation.current
|
|
15
|
+
# - If present, injects it into job["correlation_id"]
|
|
16
|
+
# - Never overwrites existing job["correlation_id"]
|
|
17
|
+
# - Never raises exceptions
|
|
18
|
+
# - Always yields to next middleware
|
|
19
|
+
#
|
|
20
|
+
# == Registration
|
|
21
|
+
#
|
|
22
|
+
# Note: This middleware is automatically registered by Railtie.
|
|
23
|
+
# Users do NOT need to manually configure it.
|
|
24
|
+
#
|
|
25
|
+
# For manual setup (if not using Rails), add to your Sidekiq initializer:
|
|
26
|
+
#
|
|
27
|
+
# Sidekiq.configure_client do |config|
|
|
28
|
+
# config.client_middleware do |chain|
|
|
29
|
+
# chain.add EzLogsAgent::Capturers::JobCapturer::ClientMiddleware
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
class ClientMiddleware
|
|
34
|
+
# Sidekiq client middleware hook.
|
|
35
|
+
#
|
|
36
|
+
# @param worker_class [Class] The worker class being enqueued
|
|
37
|
+
# @param job [Hash] The Sidekiq job payload (mutable)
|
|
38
|
+
# @param queue [String] The queue name
|
|
39
|
+
# @param redis_pool [ConnectionPool] Sidekiq's Redis connection pool
|
|
40
|
+
# @yield Passes control to the next middleware in the chain
|
|
41
|
+
# @return [void]
|
|
42
|
+
def call(worker_class, job, queue, redis_pool)
|
|
43
|
+
# Defensive: ensure job is a Hash
|
|
44
|
+
return yield unless job.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
# Read current correlation_id from context
|
|
48
|
+
correlation_id = EzLogsAgent::Correlation.current
|
|
49
|
+
|
|
50
|
+
# Only inject if:
|
|
51
|
+
# 1. correlation_id is present and not empty
|
|
52
|
+
# 2. job doesn't already have one
|
|
53
|
+
if correlation_id && correlation_id.respond_to?(:empty?) && !correlation_id.empty? && !job.key?("correlation_id")
|
|
54
|
+
job["correlation_id"] = correlation_id
|
|
55
|
+
end
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
# Never crash the host application during correlation injection
|
|
58
|
+
# Log error, then continue
|
|
59
|
+
EzLogsAgent::Logger.error("[JobCapturer::ClientMiddleware] Error during correlation injection: #{e.class} - #{e.message}")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Always yield to next middleware
|
|
63
|
+
yield
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Sidekiq server middleware that captures background job execution
|
|
68
|
+
# as background_job events.
|
|
69
|
+
#
|
|
70
|
+
# This middleware runs at execution-time (when the job runs),
|
|
71
|
+
# NOT at enqueue-time.
|
|
72
|
+
#
|
|
73
|
+
# == Behavior
|
|
74
|
+
#
|
|
75
|
+
# - Restores correlation_id from job["correlation_id"] (or generates new one)
|
|
76
|
+
# - Measures job execution duration
|
|
77
|
+
# - Captures success or failure outcome
|
|
78
|
+
# - Re-raises exceptions after capturing failure
|
|
79
|
+
# - Always clears correlation_id in ensure block
|
|
80
|
+
# - Respects capture_jobs configuration flag
|
|
81
|
+
#
|
|
82
|
+
# == Registration
|
|
83
|
+
#
|
|
84
|
+
# Note: This middleware is automatically registered by Railtie.
|
|
85
|
+
# Users do NOT need to manually configure it.
|
|
86
|
+
#
|
|
87
|
+
# For manual setup (if not using Rails), add to your Sidekiq initializer:
|
|
88
|
+
#
|
|
89
|
+
# Sidekiq.configure_server do |config|
|
|
90
|
+
# config.server_middleware do |chain|
|
|
91
|
+
# chain.add EzLogsAgent::Capturers::JobCapturer::ServerMiddleware
|
|
92
|
+
# end
|
|
93
|
+
# end
|
|
94
|
+
#
|
|
95
|
+
class ServerMiddleware
|
|
96
|
+
# Sidekiq server middleware hook.
|
|
97
|
+
#
|
|
98
|
+
# @param worker [Object] The worker instance executing the job
|
|
99
|
+
# @param job [Hash] The Sidekiq job payload
|
|
100
|
+
# @param queue [String] The queue name
|
|
101
|
+
# @yield Executes the job
|
|
102
|
+
# @return [void]
|
|
103
|
+
def call(worker, job, queue)
|
|
104
|
+
# Skip capture if disabled
|
|
105
|
+
return yield unless EzLogsAgent.configuration.capture_jobs
|
|
106
|
+
|
|
107
|
+
# Skip excluded job classes (health checks, infrastructure jobs).
|
|
108
|
+
# Match against the underlying ActiveJob class too, not just the wrapper.
|
|
109
|
+
if excluded_job_class?(worker, job)
|
|
110
|
+
EzLogsAgent::Logger.debug("[JobCapturer::ServerMiddleware] Skipping capture (excluded job class: #{resolve_job_class(worker, job)})")
|
|
111
|
+
return yield
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Restore correlation_id from job payload (or generate new one)
|
|
115
|
+
correlation_id = job["correlation_id"] || EzLogsAgent::Correlation.generate
|
|
116
|
+
EzLogsAgent::Correlation.current = correlation_id
|
|
117
|
+
|
|
118
|
+
# Measure execution time
|
|
119
|
+
start_time = Time.now
|
|
120
|
+
result = yield
|
|
121
|
+
duration_ms = ((Time.now - start_time) * 1000).to_i
|
|
122
|
+
|
|
123
|
+
# Capture success event
|
|
124
|
+
capture_success(worker, job, queue, correlation_id, duration_ms, start_time)
|
|
125
|
+
|
|
126
|
+
result
|
|
127
|
+
rescue StandardError => error
|
|
128
|
+
# Capture failure event, then re-raise
|
|
129
|
+
capture_failure(worker, job, queue, correlation_id, error, start_time)
|
|
130
|
+
raise
|
|
131
|
+
ensure
|
|
132
|
+
# Always clear correlation_id
|
|
133
|
+
EzLogsAgent::Correlation.clear
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
# Captures successful job execution.
|
|
139
|
+
#
|
|
140
|
+
# @param worker [Object] The worker instance
|
|
141
|
+
# @param job [Hash] The Sidekiq job payload
|
|
142
|
+
# @param queue [String] The queue name
|
|
143
|
+
# @param correlation_id [String] The correlation ID
|
|
144
|
+
# @param duration_ms [Integer] Job execution duration in milliseconds
|
|
145
|
+
# @param start_time [Time] Job start time
|
|
146
|
+
# @return [void]
|
|
147
|
+
def capture_success(worker, job, queue, correlation_id, duration_ms, start_time)
|
|
148
|
+
event = EzLogsAgent::EventBuilder.build(
|
|
149
|
+
source_type: :background_job,
|
|
150
|
+
source_data: extract_job_data(worker, job, queue),
|
|
151
|
+
outcome: :success,
|
|
152
|
+
correlation_id: correlation_id,
|
|
153
|
+
duration_ms: duration_ms,
|
|
154
|
+
timestamp: start_time
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
EzLogsAgent::Buffer.push(event)
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
# Defensive: never crash the host application during event capture
|
|
160
|
+
EzLogsAgent::Logger.error("[JobCapturer::ServerMiddleware] Failed to capture success event: #{e.class} - #{e.message}")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Captures failed job execution.
|
|
164
|
+
#
|
|
165
|
+
# @param worker [Object] The worker instance
|
|
166
|
+
# @param job [Hash] The Sidekiq job payload
|
|
167
|
+
# @param queue [String] The queue name
|
|
168
|
+
# @param correlation_id [String] The correlation ID
|
|
169
|
+
# @param error [StandardError] The error that caused the failure
|
|
170
|
+
# @param start_time [Time] Job start time
|
|
171
|
+
# @return [void]
|
|
172
|
+
def capture_failure(worker, job, queue, correlation_id, error, start_time)
|
|
173
|
+
event = EzLogsAgent::EventBuilder.build(
|
|
174
|
+
source_type: :background_job,
|
|
175
|
+
source_data: extract_job_data(worker, job, queue),
|
|
176
|
+
outcome: :failure,
|
|
177
|
+
correlation_id: correlation_id,
|
|
178
|
+
error_message: "#{error.class}: #{error.message}",
|
|
179
|
+
timestamp: start_time
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
EzLogsAgent::Buffer.push(event)
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
# Defensive: never crash the host application during event capture
|
|
185
|
+
EzLogsAgent::Logger.error("[JobCapturer::ServerMiddleware] Failed to capture failure event: #{e.class} - #{e.message}")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Extracts relevant job data for event source_data.
|
|
189
|
+
#
|
|
190
|
+
# When the worker is the ActiveJob+Sidekiq wrapper, we surface the
|
|
191
|
+
# underlying ActiveJob class name (e.g., "ChargeCardJob") rather
|
|
192
|
+
# than the wrapper class. This mirrors how Sidekiq itself resolves
|
|
193
|
+
# the display class — see Sidekiq::JobLogger and Sidekiq::JobUtil:
|
|
194
|
+
#
|
|
195
|
+
# job_hash["display_class"] || job_hash["wrapped"] || job_hash["class"]
|
|
196
|
+
#
|
|
197
|
+
# @param worker [Object] The worker instance
|
|
198
|
+
# @param job [Hash] The Sidekiq job payload
|
|
199
|
+
# @param queue [String] The queue name
|
|
200
|
+
# @return [Hash] Job metadata for source_data
|
|
201
|
+
def extract_job_data(worker, job, queue)
|
|
202
|
+
{
|
|
203
|
+
job_class: resolve_job_class(worker, job),
|
|
204
|
+
job_id: job["jid"],
|
|
205
|
+
queue: queue,
|
|
206
|
+
retry_count: job["retry_count"]
|
|
207
|
+
}.compact
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Resolves the human-meaningful job class name, unwrapping the
|
|
211
|
+
# ActiveJob+Sidekiq adapter when present.
|
|
212
|
+
#
|
|
213
|
+
# @param worker [Object] The worker instance Sidekiq is running
|
|
214
|
+
# @param job [Hash] The Sidekiq job payload
|
|
215
|
+
# @return [String] The class name to record on the event
|
|
216
|
+
def resolve_job_class(worker, job)
|
|
217
|
+
job["display_class"] || job["wrapped"] || worker.class.name
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Checks if worker class is in the excluded list.
|
|
221
|
+
#
|
|
222
|
+
# Exclusion is matched against the same name that lands in
|
|
223
|
+
# source_data, so users can list either the Sidekiq worker class
|
|
224
|
+
# or the underlying ActiveJob class — whichever they actually own.
|
|
225
|
+
#
|
|
226
|
+
# @param worker [Object] The worker instance
|
|
227
|
+
# @param job [Hash] The Sidekiq job payload (optional; defaults to {})
|
|
228
|
+
# @return [Boolean] true if excluded, false otherwise
|
|
229
|
+
def excluded_job_class?(worker, job = {})
|
|
230
|
+
excluded = EzLogsAgent.configuration.all_excluded_job_classes
|
|
231
|
+
[worker.class.name, job["wrapped"], job["display_class"]].compact.any? { |name| excluded.include?(name) }
|
|
232
|
+
rescue StandardError
|
|
233
|
+
false
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|