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.
@@ -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