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,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module EzLogsAgent
6
+ # Rails Railtie for automatic zero-config runtime integration.
7
+ #
8
+ # This Railtie is the ONLY orchestrator in EzLogs Agent. It handles:
9
+ # - Starting/stopping the FlushScheduler
10
+ # - Auto-registering HTTP middleware
11
+ # - Auto-registering Sidekiq middleware (client + server)
12
+ # - Auto-installing ActiveJob capturer
13
+ # - Auto-installing Database capturer
14
+ #
15
+ # All integrations are:
16
+ # - Defensive (wrapped in begin/rescue, never crash host app)
17
+ # - Configuration-gated (respect capture_http, capture_jobs, capture_database)
18
+ # - Framework-aware (check defined?(Sidekiq), defined?(ActiveRecord), etc.)
19
+ # - Idempotent (safe to boot multiple times)
20
+ # - Explicitly logged (log what's enabled/skipped and why)
21
+ #
22
+ class Railtie < Rails::Railtie
23
+ # Track registration state for idempotency
24
+ @sidekiq_client_registered = false
25
+ @sidekiq_server_registered = false
26
+ @activejob_installed = false
27
+ @database_capturer_installed = false
28
+
29
+ class << self
30
+ attr_accessor :sidekiq_client_registered, :sidekiq_server_registered,
31
+ :activejob_installed, :database_capturer_installed
32
+
33
+ # Reset registration state (for testing)
34
+ def reset_registration_state!
35
+ @sidekiq_client_registered = false
36
+ @sidekiq_server_registered = false
37
+ @activejob_installed = false
38
+ @database_capturer_installed = false
39
+ end
40
+ end
41
+
42
+ # =========================================================================
43
+ # HTTP Middleware Registration
44
+ # =========================================================================
45
+ #
46
+ # Registers HTTP request capture middleware into the Rails middleware stack.
47
+ # Runs during Rails initialization (before after_initialize).
48
+ #
49
+ initializer "ez_logs_agent.http_middleware" do |app|
50
+ begin
51
+ if EzLogsAgent.configuration.capture_http
52
+ app.middleware.use EzLogsAgent::Middleware::HttpRequest
53
+ EzLogsAgent::Logger.debug("[Railtie] HTTP capture enabled")
54
+ else
55
+ EzLogsAgent::Logger.debug("[Railtie] HTTP capture disabled (capture_http = false)")
56
+ end
57
+ rescue StandardError => e
58
+ EzLogsAgent::Logger.error("[Railtie] Failed to register HTTP middleware: #{e.class} - #{e.message}")
59
+ end
60
+ end
61
+
62
+ # =========================================================================
63
+ # After Initialize: Register Capturers + Setup FlushScheduler
64
+ # =========================================================================
65
+ #
66
+ # This hook runs after Rails has fully initialized. We:
67
+ # 1. Register Sidekiq middleware (if Sidekiq present + capture_jobs enabled)
68
+ # 2. Install ActiveJob capturer (if ActiveJob present + capture_jobs enabled)
69
+ # 3. Install Database capturer (if ActiveRecord present + capture_database enabled)
70
+ # 4. Start FlushScheduler (with special handling for forking servers like Puma)
71
+ #
72
+ # IMPORTANT: FlushScheduler uses a background thread. In forking servers
73
+ # (Puma cluster mode, Unicorn), threads don't survive fork(). We must:
74
+ # - Start FlushScheduler AFTER fork in each worker process
75
+ # - Use server-specific hooks (Puma on_worker_boot, etc.)
76
+ # - Fall back to starting here for non-forking servers (Puma single mode, WEBrick)
77
+ #
78
+ config.after_initialize do
79
+ # Validate configuration before doing anything
80
+ validation_result = validate_configuration
81
+ next unless validation_result
82
+
83
+ # Register Sidekiq middleware
84
+ register_sidekiq_middleware
85
+
86
+ # Install ActiveJob capturer
87
+ install_activejob_capturer
88
+
89
+ # Install Database capturer
90
+ install_database_capturer
91
+
92
+ # Setup FlushScheduler with fork-aware initialization
93
+ setup_flush_scheduler
94
+
95
+ log_configuration_summary
96
+ EzLogsAgent::Logger.debug("[Railtie] Agent initialized successfully")
97
+ end
98
+
99
+ # =========================================================================
100
+ # Shutdown Hook: Stop FlushScheduler
101
+ # =========================================================================
102
+ at_exit do
103
+ begin
104
+ EzLogsAgent::FlushScheduler.stop
105
+ EzLogsAgent::Logger.debug("[Railtie] Agent stopped")
106
+ rescue StandardError => e
107
+ EzLogsAgent::Logger.error("[Railtie] Failed to stop: #{e.class} - #{e.message}")
108
+ end
109
+ end
110
+
111
+ # =========================================================================
112
+ # Private Registration Methods
113
+ # =========================================================================
114
+
115
+ private
116
+
117
+ # Register Sidekiq client and server middleware
118
+ #
119
+ # @return [void]
120
+ def self.register_sidekiq_middleware
121
+ # Check if Sidekiq is present
122
+ unless defined?(Sidekiq)
123
+ EzLogsAgent::Logger.debug("[Railtie] Sidekiq not detected, skipping job capture middleware")
124
+ return
125
+ end
126
+
127
+ # Check if job capture is enabled
128
+ unless EzLogsAgent.configuration.capture_jobs
129
+ EzLogsAgent::Logger.debug("[Railtie] Job capture disabled (capture_jobs = false)")
130
+ return
131
+ end
132
+
133
+ # Register client middleware (correlation propagation)
134
+ register_sidekiq_client_middleware
135
+
136
+ # Register server middleware (job execution capture)
137
+ register_sidekiq_server_middleware
138
+ rescue StandardError => e
139
+ EzLogsAgent::Logger.error("[Railtie] Failed to register Sidekiq middleware: #{e.class} - #{e.message}")
140
+ end
141
+
142
+ # Register Sidekiq client middleware
143
+ #
144
+ # Client middleware runs in ALL processes (web, worker, console) and
145
+ # injects correlation_id into job payloads at enqueue-time.
146
+ #
147
+ # @return [void]
148
+ def self.register_sidekiq_client_middleware
149
+ return if @sidekiq_client_registered
150
+
151
+ Sidekiq.configure_client do |config|
152
+ config.client_middleware do |chain|
153
+ chain.add EzLogsAgent::Capturers::JobCapturer::ClientMiddleware
154
+ end
155
+ end
156
+
157
+ @sidekiq_client_registered = true
158
+ EzLogsAgent::Logger.debug("[Railtie] Sidekiq client middleware registered")
159
+ rescue StandardError => e
160
+ EzLogsAgent::Logger.error("[Railtie] Failed to register Sidekiq client middleware: #{e.class} - #{e.message}")
161
+ end
162
+
163
+ # Register Sidekiq server middleware
164
+ #
165
+ # Server middleware runs ONLY in Sidekiq worker processes and
166
+ # captures job execution as background_job events.
167
+ #
168
+ # @return [void]
169
+ def self.register_sidekiq_server_middleware
170
+ return if @sidekiq_server_registered
171
+
172
+ Sidekiq.configure_server do |config|
173
+ # Also register client middleware in server process (for job-enqueues-job scenarios)
174
+ config.client_middleware do |chain|
175
+ chain.add EzLogsAgent::Capturers::JobCapturer::ClientMiddleware
176
+ end
177
+
178
+ config.server_middleware do |chain|
179
+ chain.add EzLogsAgent::Capturers::JobCapturer::ServerMiddleware
180
+ end
181
+ end
182
+
183
+ @sidekiq_server_registered = true
184
+ EzLogsAgent::Logger.debug("[Railtie] Sidekiq server middleware registered")
185
+ rescue StandardError => e
186
+ EzLogsAgent::Logger.error("[Railtie] Failed to register Sidekiq server middleware: #{e.class} - #{e.message}")
187
+ end
188
+
189
+ # Install ActiveJob capturer
190
+ #
191
+ # ActiveJob capturer handles correlation propagation and job capture
192
+ # for non-Sidekiq adapters (async, inline, etc.).
193
+ #
194
+ # @return [void]
195
+ def self.install_activejob_capturer
196
+ # Check if ActiveJob is present
197
+ unless defined?(ActiveJob)
198
+ EzLogsAgent::Logger.debug("[Railtie] ActiveJob not detected, skipping ActiveJob capturer")
199
+ return
200
+ end
201
+
202
+ # Check if job capture is enabled
203
+ unless EzLogsAgent.configuration.capture_jobs
204
+ # Already logged in register_sidekiq_middleware if Sidekiq present
205
+ # Only log here if Sidekiq is NOT present
206
+ unless defined?(Sidekiq)
207
+ EzLogsAgent::Logger.debug("[Railtie] Job capture disabled (capture_jobs = false)")
208
+ end
209
+ return
210
+ end
211
+
212
+ return if @activejob_installed
213
+
214
+ EzLogsAgent::Capturers::ActiveJobCapturer.install
215
+ @activejob_installed = true
216
+ EzLogsAgent::Logger.debug("[Railtie] ActiveJob capturer installed")
217
+ rescue StandardError => e
218
+ EzLogsAgent::Logger.error("[Railtie] Failed to install ActiveJob capturer: #{e.class} - #{e.message}")
219
+ end
220
+
221
+ # Install Database capturer
222
+ #
223
+ # Database capturer installs ActiveRecord lifecycle callbacks
224
+ # (after_create, after_update, after_destroy) for all models.
225
+ #
226
+ # @return [void]
227
+ def self.install_database_capturer
228
+ # Check if ActiveRecord is present
229
+ unless defined?(ActiveRecord)
230
+ EzLogsAgent::Logger.debug("[Railtie] ActiveRecord not detected, skipping database capture")
231
+ return
232
+ end
233
+
234
+ # Check if database capture is enabled
235
+ unless EzLogsAgent.configuration.capture_database
236
+ EzLogsAgent::Logger.debug("[Railtie] Database capture disabled (capture_database = false)")
237
+ return
238
+ end
239
+
240
+ return if @database_capturer_installed
241
+
242
+ EzLogsAgent::Capturers::DatabaseCapturer.install
243
+ @database_capturer_installed = true
244
+ EzLogsAgent::Logger.debug("[Railtie] Database capture installed")
245
+ rescue StandardError => e
246
+ EzLogsAgent::Logger.error("[Railtie] Failed to install database capturer: #{e.class} - #{e.message}")
247
+ end
248
+
249
+ # Setup FlushScheduler with fork-aware initialization
250
+ #
251
+ # Background threads don't survive fork(). In forking servers like Puma
252
+ # cluster mode or Unicorn, we must start FlushScheduler AFTER the fork
253
+ # in each worker process.
254
+ #
255
+ # Strategy:
256
+ # 1. Always register ActiveSupport::ForkTracker hook (handles all forking cases)
257
+ # 2. Also start immediately (for non-forking servers or single-process mode)
258
+ # 3. FlushScheduler.start is idempotent, so calling it multiple times is safe
259
+ #
260
+ # @return [void]
261
+ def self.setup_flush_scheduler
262
+ # Register fork hook for Puma cluster mode, Unicorn, etc.
263
+ # ActiveSupport::ForkTracker (Rails 6.1+) is the most reliable way
264
+ if defined?(ActiveSupport::ForkTracker)
265
+ ActiveSupport::ForkTracker.after_fork do
266
+ start_flush_scheduler
267
+ end
268
+ EzLogsAgent::Logger.debug("[Railtie] Registered ActiveSupport::ForkTracker hook for FlushScheduler")
269
+ end
270
+
271
+ # Always start FlushScheduler now (for non-forking servers or master process)
272
+ # In forking servers, this runs in master (will be killed on fork)
273
+ # then ForkTracker restarts it in each worker
274
+ start_flush_scheduler
275
+ rescue StandardError => e
276
+ EzLogsAgent::Logger.error("[Railtie] Failed to setup FlushScheduler: #{e.class} - #{e.message}")
277
+ end
278
+
279
+ # Start the FlushScheduler
280
+ #
281
+ # @return [void]
282
+ def self.start_flush_scheduler
283
+ EzLogsAgent::FlushScheduler.start
284
+ EzLogsAgent::Logger.debug("[Railtie] FlushScheduler started (PID: #{Process.pid})")
285
+ rescue StandardError => e
286
+ EzLogsAgent::Logger.error("[Railtie] Failed to start FlushScheduler: #{e.class} - #{e.message}")
287
+ end
288
+
289
+ # Validate configuration at boot time
290
+ #
291
+ # Logs errors and warnings from configuration validation.
292
+ # If configuration is invalid, logs errors and returns false to skip initialization.
293
+ # If configuration has warnings, logs them but continues.
294
+ #
295
+ # @return [Boolean] true if valid or has only warnings, false if invalid
296
+ def self.validate_configuration
297
+ result = EzLogsAgent::ConfigurationValidator.validate(EzLogsAgent.configuration)
298
+
299
+ # Log errors
300
+ if result.errors.any?
301
+ EzLogsAgent::Logger.error("[Railtie] Configuration validation failed:")
302
+ result.errors.each do |error|
303
+ EzLogsAgent::Logger.error("[Railtie] - #{error}")
304
+ end
305
+ EzLogsAgent::Logger.error("[Railtie] Agent initialization skipped. Please fix configuration errors.")
306
+ return false
307
+ end
308
+
309
+ # Log warnings
310
+ if result.warnings.any?
311
+ result.warnings.each do |warning|
312
+ EzLogsAgent::Logger.warn("[Railtie] ⚠ #{warning}")
313
+ end
314
+ end
315
+
316
+ true
317
+ rescue StandardError => e
318
+ EzLogsAgent::Logger.error("[Railtie] Failed to validate configuration: #{e.class} - #{e.message}")
319
+ false
320
+ end
321
+
322
+ # Log configuration summary at boot time
323
+ #
324
+ # Shows what's enabled/disabled to help with debugging and visibility.
325
+ #
326
+ # @return [void]
327
+ def self.log_configuration_summary
328
+ config = EzLogsAgent.configuration
329
+
330
+ EzLogsAgent::Logger.info("[Railtie] Configuration:")
331
+ EzLogsAgent::Logger.info("[Railtie] Server: #{config.server_url}")
332
+ EzLogsAgent::Logger.info("[Railtie] Capture HTTP: #{config.capture_http}")
333
+ EzLogsAgent::Logger.info("[Railtie] Capture Jobs: #{config.capture_jobs}")
334
+ EzLogsAgent::Logger.info("[Railtie] Capture Database: #{config.capture_database}")
335
+
336
+ # Show optional configuration if present
337
+ if config.actor_from_request
338
+ EzLogsAgent::Logger.info("[Railtie] Actor Extraction: enabled")
339
+ end
340
+
341
+ if config.display_name_for && config.display_name_for.any?
342
+ EzLogsAgent::Logger.info("[Railtie] Display Names: configured for #{config.display_name_for.keys.join(', ')}")
343
+ end
344
+ rescue StandardError => e
345
+ EzLogsAgent::Logger.error("[Railtie] Failed to log configuration summary: #{e.class} - #{e.message}")
346
+ end
347
+
348
+ # Load rake tasks
349
+ rake_tasks do
350
+ load "tasks/ez_logs_agent.rake"
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EzLogsAgent
4
+ # ResourceExtractor provides explicit, opt-in helpers for extracting resource_ids from objects.
5
+ #
6
+ # This module is a small, boring utility that:
7
+ # - Converts ActiveRecord model instances to resource_id hashes
8
+ # - Extracts resource_ids from arrays of models
9
+ # - Passes through existing resource_ids from hashes
10
+ # - Returns empty array for unsupported inputs (defensive)
11
+ #
12
+ # ResourceExtractor does NOT:
13
+ # - Automatically hook into anything
14
+ # - Infer resources from SQL or context
15
+ # - Guess actor or ownership
16
+ # - Read RequestStore or global state
17
+ # - Modify EventBuilder or other components
18
+ # - Raise exceptions for unsupported input
19
+ #
20
+ # Missing resource_ids is acceptable.
21
+ # Wrong or guessed resource_ids is NOT acceptable.
22
+ #
23
+ # @example Extract from ActiveRecord model
24
+ # user = User.find(123)
25
+ # EzLogsAgent::ResourceExtractor.from_record(user)
26
+ # # => [{ resource_type: "user_id", resource_id: "123" }]
27
+ #
28
+ # @example Extract from array of models
29
+ # orders = [Order.find(1), Order.find(2)]
30
+ # EzLogsAgent::ResourceExtractor.from_records(orders)
31
+ # # => [
32
+ # # { resource_type: "order_id", resource_id: "1" },
33
+ # # { resource_type: "order_id", resource_id: "2" }
34
+ # # ]
35
+ #
36
+ # @example Extract from hash
37
+ # hash = { resource_ids: [{ resource_type: "product_id", resource_id: "456" }] }
38
+ # EzLogsAgent::ResourceExtractor.from_hash(hash)
39
+ # # => [{ resource_type: "product_id", resource_id: "456" }]
40
+ #
41
+ # @example Unsupported input returns empty array
42
+ # EzLogsAgent::ResourceExtractor.from_record("not a model")
43
+ # # => []
44
+ #
45
+ module ResourceExtractor
46
+ # Extracts resource_id from a single ActiveRecord model instance
47
+ #
48
+ # @param record [Object] Potentially an ActiveRecord model instance
49
+ # @return [Array<Hash>] Array containing single resource_id hash, or empty array
50
+ #
51
+ # @example Valid ActiveRecord model
52
+ # user = User.find(123)
53
+ # ResourceExtractor.from_record(user)
54
+ # # => [{ resource_type: "user_id", resource_id: "123" }]
55
+ #
56
+ # @example Unsupported input
57
+ # ResourceExtractor.from_record("not a model")
58
+ # # => []
59
+ #
60
+ # @example Nil input
61
+ # ResourceExtractor.from_record(nil)
62
+ # # => []
63
+ #
64
+ def self.from_record(record)
65
+ return [] if record.nil?
66
+ return [] unless active_record_model?(record)
67
+
68
+ [{
69
+ resource_type: resource_type_from_class(record.class),
70
+ resource_id: record.id.to_s
71
+ }]
72
+ rescue => e
73
+ # Defensive: never raise exceptions, return empty array
74
+ []
75
+ end
76
+
77
+ # Extracts resource_ids from an array of ActiveRecord model instances
78
+ #
79
+ # @param records [Array] Array of potentially ActiveRecord model instances
80
+ # @return [Array<Hash>] Array of resource_id hashes, or empty array
81
+ #
82
+ # @example Valid ActiveRecord models
83
+ # orders = [Order.find(1), Order.find(2)]
84
+ # ResourceExtractor.from_records(orders)
85
+ # # => [
86
+ # # { resource_type: "order_id", resource_id: "1" },
87
+ # # { resource_type: "order_id", resource_id: "2" }
88
+ # # ]
89
+ #
90
+ # @example Mixed array (filters invalid entries)
91
+ # ResourceExtractor.from_records([user, "not a model", nil])
92
+ # # => [{ resource_type: "user_id", resource_id: "123" }]
93
+ #
94
+ # @example Empty array
95
+ # ResourceExtractor.from_records([])
96
+ # # => []
97
+ #
98
+ def self.from_records(records)
99
+ return [] unless records.is_a?(Array)
100
+
101
+ records.flat_map { |record| from_record(record) }.compact
102
+ rescue => e
103
+ # Defensive: never raise exceptions, return empty array
104
+ []
105
+ end
106
+
107
+ # Extracts resource_ids from a hash containing a :resource_ids key
108
+ #
109
+ # @param hash [Hash] Hash potentially containing :resource_ids key
110
+ # @return [Array<Hash>] Array of resource_id hashes, or empty array
111
+ #
112
+ # @example Valid hash with resource_ids
113
+ # hash = { resource_ids: [{ resource_type: "product_id", resource_id: "456" }] }
114
+ # ResourceExtractor.from_hash(hash)
115
+ # # => [{ resource_type: "product_id", resource_id: "456" }]
116
+ #
117
+ # @example Hash without resource_ids key
118
+ # ResourceExtractor.from_hash({ foo: "bar" })
119
+ # # => []
120
+ #
121
+ # @example Non-hash input
122
+ # ResourceExtractor.from_hash("not a hash")
123
+ # # => []
124
+ #
125
+ def self.from_hash(hash)
126
+ return [] unless hash.is_a?(Hash)
127
+ return [] unless hash.key?(:resource_ids)
128
+
129
+ resource_ids = hash[:resource_ids]
130
+ return [] unless resource_ids.is_a?(Array)
131
+
132
+ resource_ids
133
+ rescue => e
134
+ # Defensive: never raise exceptions, return empty array
135
+ []
136
+ end
137
+
138
+ # Checks if an object is an ActiveRecord model instance
139
+ #
140
+ # @param object [Object] Object to check
141
+ # @return [Boolean] True if object is an ActiveRecord::Base instance
142
+ # @private
143
+ def self.active_record_model?(object)
144
+ defined?(ActiveRecord::Base) && object.is_a?(ActiveRecord::Base)
145
+ end
146
+ private_class_method :active_record_model?
147
+
148
+ # Converts a class to a resource_type string
149
+ #
150
+ # @param klass [Class] ActiveRecord model class
151
+ # @return [String] Snake-cased class name with "_id" suffix
152
+ #
153
+ # @example
154
+ # resource_type_from_class(User) # => "user_id"
155
+ # resource_type_from_class(OrderItem) # => "order_item_id"
156
+ #
157
+ # @private
158
+ def self.resource_type_from_class(klass)
159
+ # Convert class name to snake_case and append "_id"
160
+ class_name = klass.name
161
+ snake_case = class_name
162
+ .gsub(/::/, "/")
163
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
164
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
165
+ .tr("-", "_")
166
+ .downcase
167
+
168
+ "#{snake_case}_id"
169
+ end
170
+ private_class_method :resource_type_from_class
171
+ end
172
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EzLogsAgent
4
+ # Retry logic with exponential backoff
5
+ #
6
+ # Wraps Transport.send(events) with automatic retry logic.
7
+ # Implements exponential backoff with a maximum delay cap.
8
+ #
9
+ # Responsibilities:
10
+ # - Decides IF and WHEN to retry
11
+ # - Sleeps between retries using exponential backoff
12
+ # - Stops after max attempts
13
+ # - Never crashes the host application
14
+ #
15
+ # Does NOT:
16
+ # - Modify events
17
+ # - Inspect HTTP status codes
18
+ # - Interpret errors
19
+ # - Spawn threads
20
+ # - Read from or flush Buffer
21
+ class RetrySender
22
+ # Maximum sleep duration between retries (hard cap)
23
+ MAX_SLEEP_SECONDS = 5
24
+
25
+ # Base delay for exponential backoff calculation
26
+ BASE_DELAY_SECONDS = 0.5
27
+
28
+ class << self
29
+ # Send events with retry logic
30
+ #
31
+ # @param events [Array<Hash>] Array of event hashes
32
+ # @return [Symbol] :success if sent successfully, :failure otherwise
33
+ def send(events)
34
+ # Empty events is a success (no work to do)
35
+ return :success if events.nil? || events.empty?
36
+
37
+ max_attempts = retry_attempts + 1 # Initial attempt + retries
38
+ attempt = 1
39
+
40
+ while attempt <= max_attempts
41
+ result = attempt_send(events, attempt, max_attempts)
42
+ return :success if result == :success
43
+
44
+ # If this was the last attempt, return failure
45
+ break if attempt >= max_attempts
46
+
47
+ # Sleep before next retry
48
+ sleep_before_retry(attempt)
49
+ attempt += 1
50
+ end
51
+
52
+ # All attempts exhausted
53
+ log_final_failure(events.size)
54
+ :failure
55
+ rescue => error
56
+ # Defensive: if anything unexpected happens, log and return failure
57
+ log_error("[RetrySender] send failed with exception: #{error.class} - #{error.message}")
58
+ :failure
59
+ end
60
+
61
+ private
62
+
63
+ # Attempt to send events once
64
+ def attempt_send(events, attempt, max_attempts)
65
+ Transport.send(events)
66
+ rescue => error
67
+ # Transport shouldn't raise, but be defensive
68
+ log_error("[RetrySender] Transport raised exception (attempt #{attempt}/#{max_attempts}): #{error.message}")
69
+ :failure
70
+ end
71
+
72
+ # Sleep with exponential backoff before next retry
73
+ def sleep_before_retry(attempt)
74
+ # Formula: base_delay * (2 ** (attempt - 1))
75
+ # attempt=1: 0.5s, attempt=2: 1.0s, attempt=3: 2.0s, attempt=4: 4.0s, attempt=5: 8.0s
76
+ delay = BASE_DELAY_SECONDS * (2**(attempt - 1))
77
+
78
+ # Cap at MAX_SLEEP_SECONDS
79
+ delay = [delay, MAX_SLEEP_SECONDS].min
80
+
81
+ sleep(delay)
82
+ rescue => error
83
+ # Defensive: if sleep fails somehow, continue anyway
84
+ log_error("[RetrySender] sleep failed: #{error.message}")
85
+ end
86
+
87
+ # Get configured retry attempts, with fallback
88
+ def retry_attempts
89
+ attempts = EzLogsAgent.configuration.retry_attempts
90
+
91
+ # Validate and fallback to 3 if invalid
92
+ return 3 if attempts.nil?
93
+ return 3 if !attempts.is_a?(Integer)
94
+ return 3 if attempts < 0
95
+
96
+ attempts
97
+ rescue => error
98
+ # Defensive: if configuration fails, use default
99
+ log_error("[RetrySender] Failed to read retry_attempts configuration: #{error.message}")
100
+ 3
101
+ end
102
+
103
+ # Log final failure after all retries exhausted
104
+ def log_final_failure(event_count)
105
+ Logger.error("[RetrySender] Failed after #{retry_attempts + 1} attempts, dropping #{event_count} events")
106
+ rescue => error
107
+ # Defensive: logging must never crash
108
+ nil
109
+ end
110
+
111
+ # Log error message
112
+ def log_error(message)
113
+ Logger.error(message)
114
+ rescue => error
115
+ # Defensive: logging must never crash
116
+ nil
117
+ end
118
+ end
119
+ end
120
+ end