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,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
|