dead_bro 0.2.1 → 0.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb42d69f24067aad16e5b9287d018590cd4876d5d37d40c4903d4216af721054
4
- data.tar.gz: 27ce42b49dd15c548cebbbf8f95cb294c0479da8a3c41a1a8413e855fe929908
3
+ metadata.gz: 64333e774f85e0505014b6a0a6170e6721c0be30f1d3103b214fabdc7faacd8f
4
+ data.tar.gz: 82d6fc30651fcc2ee54904c6dff7b7cc36bd56c1a27a58ee726429c8b592b50a
5
5
  SHA512:
6
- metadata.gz: daeeb6923d46b072535cff1e03d89b7f9cb7f5d3373bfdcb36c36f876c3460dbc612885c65940f2b65f99ce3929cefb41b9db1204ce22e8601eee9987b22a73c
7
- data.tar.gz: 59335da8149d74c7e1e00be6ba7dc700a09eb261bb4e5322fd22914c57f57f468ee34733973ab08125c79072c9ff59bba42990bd9cc5721aff5484e38fea3243
6
+ metadata.gz: f7fc637b60a2990c3e3cfd6c39a5adbbebde198b11c1da5ff1127199ee05e900c6b971bccc740b1552f53156717511c2244dc7e70f736ac2b2110a2e0291d577
7
+ data.tar.gz: 1bed47b5f0d4dabc1e851631c00701802207769280b82d2809afd7cf75fa3e5be5dfaf5940ed853234dfdf659911512f56a383020b2bb027bce6eea8d1288637
@@ -5,6 +5,7 @@ require "active_support/notifications"
5
5
  module DeadBro
6
6
  class CacheSubscriber
7
7
  THREAD_LOCAL_KEY = :dead_bro_cache_events
8
+ MAX_TRACKED_EVENTS = 1000
8
9
 
9
10
  EVENTS = [
10
11
  "cache_read.active_support",
@@ -24,7 +25,9 @@ module DeadBro
24
25
 
25
26
  duration_ms = ((finished - started) * 1000.0).round(2)
26
27
  event = build_event(name, data, duration_ms)
27
- Thread.current[THREAD_LOCAL_KEY] << event if event
28
+ if event && should_continue_tracking?
29
+ Thread.current[THREAD_LOCAL_KEY] << event
30
+ end
28
31
  end
29
32
  rescue
30
33
  end
@@ -42,6 +45,24 @@ module DeadBro
42
45
  events || []
43
46
  end
44
47
 
48
+ # Check if we should continue tracking based on count and time limits
49
+ def self.should_continue_tracking?
50
+ events = Thread.current[THREAD_LOCAL_KEY]
51
+ return false unless events
52
+
53
+ # Check count limit
54
+ return false if events.length >= MAX_TRACKED_EVENTS
55
+
56
+ # Check time limit
57
+ start_time = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
58
+ if start_time
59
+ elapsed_seconds = Time.now - start_time
60
+ return false if elapsed_seconds >= DeadBro::MAX_TRACKING_DURATION_SECONDS
61
+ end
62
+
63
+ true
64
+ end
65
+
45
66
  def self.build_event(name, data, duration_ms)
46
67
  return nil unless data.is_a?(Hash)
47
68
 
@@ -37,6 +37,29 @@ module DeadBro
37
37
  nil
38
38
  end
39
39
 
40
+ def post_job_stats(payload)
41
+ return if @configuration.api_key.nil?
42
+ return unless @configuration.enabled
43
+ return unless @configuration.job_queue_monitoring_enabled
44
+
45
+ # Check circuit breaker before making request
46
+ if @circuit_breaker && @configuration.circuit_breaker_enabled
47
+ if @circuit_breaker.state == :open
48
+ # Check if we should attempt a reset to half-open state
49
+ if @circuit_breaker.should_attempt_reset?
50
+ @circuit_breaker.transition_to_half_open!
51
+ else
52
+ return
53
+ end
54
+ end
55
+ end
56
+
57
+ # Make the HTTP request (async) to jobs endpoint
58
+ make_job_stats_request(payload, @configuration.api_key)
59
+
60
+ nil
61
+ end
62
+
40
63
  private
41
64
 
42
65
  def create_circuit_breaker
@@ -99,6 +122,56 @@ module DeadBro
99
122
  nil
100
123
  end
101
124
 
125
+ def make_job_stats_request(payload, api_key)
126
+ use_staging = ENV["USE_STAGING_ENDPOINT"] && !ENV["USE_STAGING_ENDPOINT"].empty?
127
+ production_url = use_staging ? "https://deadbro.aberatii.com/apm/v1/jobs" : "https://www.deadbro.com/apm/v1/jobs"
128
+ endpoint_url = @configuration.ruby_dev ? "http://localhost:3100/apm/v1/jobs" : production_url
129
+ uri = URI.parse(endpoint_url)
130
+ http = Net::HTTP.new(uri.host, uri.port)
131
+ http.use_ssl = (uri.scheme == "https")
132
+ http.open_timeout = @configuration.open_timeout
133
+ http.read_timeout = @configuration.read_timeout
134
+
135
+ request = Net::HTTP::Post.new(uri.request_uri)
136
+ request["Content-Type"] = "application/json"
137
+ request["Authorization"] = "Bearer #{api_key}"
138
+ body = {payload: payload, sent_at: Time.now.utc.iso8601, revision: @configuration.resolve_deploy_id}
139
+ request.body = JSON.dump(body)
140
+
141
+ # Fire-and-forget using a short-lived thread to avoid blocking
142
+ Thread.new do
143
+ response = http.request(request)
144
+
145
+ if response
146
+ # Update circuit breaker based on response
147
+ if @circuit_breaker && @configuration.circuit_breaker_enabled
148
+ if response.is_a?(Net::HTTPSuccess)
149
+ @circuit_breaker.send(:on_success)
150
+ else
151
+ @circuit_breaker.send(:on_failure)
152
+ end
153
+ end
154
+ elsif @circuit_breaker && @configuration.circuit_breaker_enabled
155
+ # Treat nil response as failure for circuit breaker
156
+ @circuit_breaker.send(:on_failure)
157
+ end
158
+
159
+ response
160
+ rescue Timeout::Error
161
+ # Update circuit breaker on timeout
162
+ if @circuit_breaker && @configuration.circuit_breaker_enabled
163
+ @circuit_breaker.send(:on_failure)
164
+ end
165
+ rescue
166
+ # Update circuit breaker on exception
167
+ if @circuit_breaker && @configuration.circuit_breaker_enabled
168
+ @circuit_breaker.send(:on_failure)
169
+ end
170
+ end
171
+
172
+ nil
173
+ end
174
+
102
175
  def log_debug(message)
103
176
  if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
104
177
  Rails.logger.debug(message)
@@ -5,7 +5,8 @@ module DeadBro
5
5
  attr_accessor :api_key, :open_timeout, :read_timeout, :enabled, :ruby_dev, :memory_tracking_enabled,
6
6
  :allocation_tracking_enabled, :circuit_breaker_enabled, :circuit_breaker_failure_threshold, :circuit_breaker_recovery_timeout,
7
7
  :circuit_breaker_retry_timeout, :sample_rate, :excluded_controllers, :excluded_jobs,
8
- :exclusive_controllers, :exclusive_jobs, :deploy_id, :slow_query_threshold_ms, :explain_analyze_enabled
8
+ :exclusive_controllers, :exclusive_jobs, :deploy_id, :slow_query_threshold_ms, :explain_analyze_enabled,
9
+ :job_queue_monitoring_enabled
9
10
 
10
11
  def initialize
11
12
  @api_key = nil
@@ -28,6 +29,7 @@ module DeadBro
28
29
  @deploy_id = resolve_deploy_id
29
30
  @slow_query_threshold_ms = 500 # Default: 500ms
30
31
  @explain_analyze_enabled = false # Enable EXPLAIN ANALYZE for slow queries by default
32
+ @job_queue_monitoring_enabled = false # Disabled by default
31
33
  end
32
34
 
33
35
  def resolve_deploy_id
@@ -6,6 +6,8 @@ require "net/http"
6
6
  module DeadBro
7
7
  module HttpInstrumentation
8
8
  EVENT_NAME = "outgoing.http"
9
+ THREAD_LOCAL_KEY = :dead_bro_http_events
10
+ MAX_TRACKED_EVENTS = 1000
9
11
 
10
12
  def self.install!(client: Client.new)
11
13
  install_net_http!(client)
@@ -52,8 +54,8 @@ module DeadBro
52
54
  exception: error && error.class.name
53
55
  }
54
56
  # Accumulate per-request; only send with controller metric
55
- if Thread.current[:dead_bro_http_events]
56
- Thread.current[:dead_bro_http_events] << payload
57
+ if Thread.current[THREAD_LOCAL_KEY] && should_continue_tracking?
58
+ Thread.current[THREAD_LOCAL_KEY] << payload
57
59
  end
58
60
  end
59
61
  rescue
@@ -96,8 +98,8 @@ module DeadBro
96
98
  duration_ms: duration_ms
97
99
  }
98
100
  # Accumulate per-request; only send with controller metric
99
- if Thread.current[:dead_bro_http_events]
100
- Thread.current[:dead_bro_http_events] << payload
101
+ if Thread.current[THREAD_LOCAL_KEY] && should_continue_tracking?
102
+ Thread.current[THREAD_LOCAL_KEY] << payload
101
103
  end
102
104
  end
103
105
  rescue
@@ -109,5 +111,23 @@ module DeadBro
109
111
  ::Typhoeus::Request.prepend(mod) unless ::Typhoeus::Request.ancestors.include?(mod)
110
112
  rescue
111
113
  end
114
+
115
+ # Check if we should continue tracking based on count and time limits
116
+ def self.should_continue_tracking?
117
+ events = Thread.current[THREAD_LOCAL_KEY]
118
+ return false unless events
119
+
120
+ # Check count limit
121
+ return false if events.length >= MAX_TRACKED_EVENTS
122
+
123
+ # Check time limit
124
+ start_time = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
125
+ if start_time
126
+ elapsed_seconds = Time.now - start_time
127
+ return false if elapsed_seconds >= DeadBro::MAX_TRACKING_DURATION_SECONDS
128
+ end
129
+
130
+ true
131
+ end
112
132
  end
113
133
  end
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeadBro
4
+ class JobQueueMonitor
5
+ def initialize(client: DeadBro.client)
6
+ @client = client
7
+ @thread = nil
8
+ @running = false
9
+ end
10
+
11
+ def start
12
+ return if @running
13
+ return unless DeadBro.configuration.job_queue_monitoring_enabled
14
+ return unless DeadBro.configuration.enabled
15
+
16
+ @running = true
17
+ @thread = Thread.new do
18
+ Thread.current.abort_on_exception = false
19
+ loop do
20
+ break unless @running
21
+
22
+ begin
23
+ stats = collect_queue_stats
24
+ @client.post_job_stats(stats) if stats
25
+ rescue => e
26
+ log_error("Error collecting job queue stats: #{e.message}")
27
+ end
28
+
29
+ # Sleep for 60 seconds (1 minute)
30
+ sleep(120)
31
+ end
32
+ end
33
+
34
+ @thread
35
+ end
36
+
37
+ def stop
38
+ @running = false
39
+ @thread&.join(5) # Wait up to 5 seconds for thread to finish
40
+ @thread = nil
41
+ end
42
+
43
+ private
44
+
45
+ def collect_queue_stats
46
+ stats = {
47
+ timestamp: Time.now.utc.iso8601,
48
+ queue_system: detect_queue_system,
49
+ environment: Rails.env,
50
+ queues: {}
51
+ }
52
+
53
+ case stats[:queue_system]
54
+ when :sidekiq
55
+ stats[:queues] = collect_sidekiq_stats
56
+ when :solid_queue
57
+ stats[:queues] = collect_solid_queue_stats
58
+ when :delayed_job
59
+ stats[:queues] = collect_delayed_job_stats
60
+ when :good_job
61
+ stats[:queues] = collect_good_job_stats
62
+ else
63
+ return nil # Unknown queue system, don't send stats
64
+ end
65
+
66
+ stats
67
+ end
68
+
69
+ def detect_queue_system
70
+ return :sidekiq if defined?(Sidekiq)
71
+ return :solid_queue if defined?(SolidQueue)
72
+ return :delayed_job if defined?(Delayed::Job)
73
+ return :good_job if defined?(GoodJob)
74
+ :unknown
75
+ end
76
+
77
+ def collect_sidekiq_stats
78
+ return {} unless defined?(Sidekiq)
79
+
80
+ stats = {
81
+ total_queued: 0,
82
+ total_busy: 0,
83
+ queues: {}
84
+ }
85
+
86
+ begin
87
+ # Get queue sizes - try to access Queue class (will trigger autoload if needed)
88
+ begin
89
+ queue_class = Sidekiq.const_get(:Queue)
90
+ if queue_class.respond_to?(:all)
91
+ queue_class.all.each do |queue|
92
+ queue_name = queue.name
93
+ size = queue.size
94
+ stats[:queues][queue_name] = {
95
+ queued: size,
96
+ busy: 0,
97
+ scheduled: 0,
98
+ retries: 0
99
+ }
100
+ stats[:total_queued] += size
101
+ end
102
+ end
103
+ rescue NameError => e
104
+ log_error("Sidekiq::Queue not available: #{e.message}")
105
+ rescue => e
106
+ log_error("Error accessing Sidekiq::Queue: #{e.message}")
107
+ end
108
+
109
+ # Get busy workers
110
+ begin
111
+ workers_class = Sidekiq.const_get(:Workers)
112
+ workers = workers_class.new
113
+ workers.each do |process_id, thread_id, work|
114
+ next unless work
115
+ queue_name = work["queue"] || "default"
116
+ stats[:queues][queue_name] ||= { queued: 0, busy: 0, scheduled: 0, retries: 0 }
117
+ stats[:queues][queue_name][:busy] += 1
118
+ stats[:total_busy] += 1
119
+ end
120
+ rescue NameError
121
+ # Workers class not available, try fallback
122
+ if Sidekiq.respond_to?(:workers)
123
+ # Fallback for older Sidekiq versions
124
+ begin
125
+ workers = Sidekiq.workers
126
+ if workers.respond_to?(:each)
127
+ workers.each do |worker|
128
+ queue_name = worker.respond_to?(:queue) ? worker.queue : "default"
129
+ stats[:queues][queue_name] ||= { queued: 0, busy: 0, scheduled: 0, retries: 0 }
130
+ stats[:queues][queue_name][:busy] += 1
131
+ stats[:total_busy] += 1
132
+ end
133
+ end
134
+ rescue => e
135
+ log_error("Error getting Sidekiq workers (fallback): #{e.message}")
136
+ end
137
+ end
138
+ rescue => e
139
+ log_error("Error getting Sidekiq workers: #{e.message}")
140
+ end
141
+
142
+ # Get scheduled jobs
143
+ begin
144
+ scheduled_set_class = Sidekiq.const_get(:ScheduledSet)
145
+ scheduled_set = scheduled_set_class.new
146
+ stats[:total_scheduled] = scheduled_set.size
147
+ rescue NameError
148
+ # ScheduledSet not available, skip
149
+ rescue => e
150
+ log_error("Error getting Sidekiq scheduled jobs: #{e.message}")
151
+ end
152
+
153
+ # Get retries
154
+ begin
155
+ retry_set_class = Sidekiq.const_get(:RetrySet)
156
+ retry_set = retry_set_class.new
157
+ stats[:total_retries] = retry_set.size
158
+ rescue NameError
159
+ # RetrySet not available, skip
160
+ rescue => e
161
+ log_error("Error getting Sidekiq retries: #{e.message}")
162
+ end
163
+
164
+ # Get dead jobs
165
+ begin
166
+ dead_set_class = Sidekiq.const_get(:DeadSet)
167
+ dead_set = dead_set_class.new
168
+ stats[:total_dead] = dead_set.size
169
+ rescue NameError
170
+ # DeadSet not available, skip
171
+ rescue => e
172
+ log_error("Error getting Sidekiq dead jobs: #{e.message}")
173
+ end
174
+
175
+ # Get process info
176
+ begin
177
+ process_set_class = Sidekiq.const_get(:ProcessSet)
178
+ process_set = process_set_class.new
179
+ stats[:processes] = process_set.size
180
+ rescue NameError
181
+ # ProcessSet not available, skip
182
+ rescue => e
183
+ log_error("Error getting Sidekiq processes: #{e.message}")
184
+ end
185
+ rescue => e
186
+ log_error("Error collecting Sidekiq stats: #{e.message}")
187
+ log_error("Backtrace: #{e.backtrace.first(5).join("\n")}")
188
+ end
189
+
190
+ stats
191
+ end
192
+
193
+ def collect_solid_queue_stats
194
+ return {} unless defined?(SolidQueue)
195
+
196
+ stats = {
197
+ total_queued: 0,
198
+ total_busy: 0,
199
+ queues: {}
200
+ }
201
+
202
+ begin
203
+ # Solid Queue uses ActiveJob and stores jobs in a database table
204
+ if defined?(ActiveRecord) && ActiveRecord::Base.connected? && ActiveRecord::Base.connection.table_exists?("solid_queue_jobs")
205
+ # Get queued jobs grouped by queue
206
+ result = ActiveRecord::Base.connection.execute(
207
+ "SELECT queue_name, COUNT(*) as count FROM solid_queue_jobs WHERE finished_at IS NULL GROUP BY queue_name"
208
+ )
209
+
210
+ parse_query_result(result).each do |row|
211
+ queue_name = (row["queue_name"] || row[:queue_name] || "default").to_s
212
+ count = (row["count"] || row[:count] || 0).to_i
213
+ stats[:queues][queue_name] = {
214
+ queued: count,
215
+ busy: 0,
216
+ scheduled: 0,
217
+ retries: 0
218
+ }
219
+ stats[:total_queued] += count
220
+ end
221
+
222
+ # Get busy jobs (claimed but not finished)
223
+ result = ActiveRecord::Base.connection.execute(
224
+ "SELECT queue_name, COUNT(*) as count FROM solid_queue_jobs WHERE finished_at IS NULL AND claimed_at IS NOT NULL GROUP BY queue_name"
225
+ )
226
+
227
+ parse_query_result(result).each do |row|
228
+ queue_name = (row["queue_name"] || row[:queue_name] || "default").to_s
229
+ count = (row["count"] || row[:count] || 0).to_i
230
+ stats[:queues][queue_name] ||= { queued: 0, busy: 0, scheduled: 0, retries: 0 }
231
+ stats[:queues][queue_name][:busy] = count
232
+ stats[:total_busy] += count
233
+ end
234
+
235
+ # Get scheduled jobs
236
+ result = ActiveRecord::Base.connection.execute(
237
+ "SELECT COUNT(*) as count FROM solid_queue_jobs WHERE scheduled_at > NOW()"
238
+ )
239
+ scheduled_count = parse_query_result(result).first
240
+ stats[:total_scheduled] = (scheduled_count&.dig("count") || scheduled_count&.dig(:count) || 0).to_i
241
+
242
+ # Get failed jobs
243
+ if ActiveRecord::Base.connection.table_exists?("solid_queue_failed_jobs")
244
+ result = ActiveRecord::Base.connection.execute(
245
+ "SELECT COUNT(*) as count FROM solid_queue_failed_jobs"
246
+ )
247
+ failed_count = parse_query_result(result).first
248
+ stats[:total_failed] = (failed_count&.dig("count") || failed_count&.dig(:count) || 0).to_i
249
+ end
250
+ end
251
+ rescue => e
252
+ log_error("Error collecting Solid Queue stats: #{e.message}")
253
+ end
254
+
255
+ stats
256
+ end
257
+
258
+ def collect_delayed_job_stats
259
+ return {} unless defined?(Delayed::Job)
260
+
261
+ stats = {
262
+ total_queued: 0,
263
+ total_busy: 0,
264
+ queues: {}
265
+ }
266
+
267
+ begin
268
+ # Delayed Job uses a single table
269
+ if defined?(ActiveRecord) && ActiveRecord::Base.connected? && ActiveRecord::Base.connection.table_exists?("delayed_jobs")
270
+ # Get queued jobs
271
+ queued = Delayed::Job.where("locked_at IS NULL AND attempts < max_attempts").count
272
+ stats[:total_queued] = queued
273
+ stats[:queues]["default"] = {
274
+ queued: queued,
275
+ busy: 0,
276
+ scheduled: 0,
277
+ retries: 0
278
+ }
279
+
280
+ # Get busy jobs (locked)
281
+ busy = Delayed::Job.where("locked_at IS NOT NULL AND locked_by IS NOT NULL").count
282
+ stats[:total_busy] = busy
283
+ stats[:queues]["default"][:busy] = busy
284
+
285
+ # Get failed jobs
286
+ failed = Delayed::Job.where("attempts >= max_attempts").count
287
+ stats[:total_failed] = failed
288
+ end
289
+ rescue => e
290
+ log_error("Error collecting Delayed Job stats: #{e.message}")
291
+ end
292
+
293
+ stats
294
+ end
295
+
296
+ def collect_good_job_stats
297
+ return {} unless defined?(GoodJob)
298
+
299
+ stats = {
300
+ total_queued: 0,
301
+ total_busy: 0,
302
+ queues: {}
303
+ }
304
+
305
+ begin
306
+ # Good Job uses ActiveJob and stores jobs in a database table
307
+ if defined?(ActiveRecord) && ActiveRecord::Base.connected? && ActiveRecord::Base.connection.table_exists?("good_jobs")
308
+ # Get queued jobs grouped by queue
309
+ result = ActiveRecord::Base.connection.execute(
310
+ "SELECT queue_name, COUNT(*) as count FROM good_jobs WHERE finished_at IS NULL GROUP BY queue_name"
311
+ )
312
+
313
+ parse_query_result(result).each do |row|
314
+ queue_name = (row["queue_name"] || row[:queue_name] || "default").to_s
315
+ count = (row["count"] || row[:count] || 0).to_i
316
+ stats[:queues][queue_name] = {
317
+ queued: count,
318
+ busy: 0,
319
+ scheduled: 0,
320
+ retries: 0
321
+ }
322
+ stats[:total_queued] += count
323
+ end
324
+
325
+ # Get busy jobs (running)
326
+ result = ActiveRecord::Base.connection.execute(
327
+ "SELECT queue_name, COUNT(*) as count FROM good_jobs WHERE finished_at IS NULL AND performed_at IS NOT NULL GROUP BY queue_name"
328
+ )
329
+
330
+ parse_query_result(result).each do |row|
331
+ queue_name = (row["queue_name"] || row[:queue_name] || "default").to_s
332
+ count = (row["count"] || row[:count] || 0).to_i
333
+ stats[:queues][queue_name] ||= { queued: 0, busy: 0, scheduled: 0, retries: 0 }
334
+ stats[:queues][queue_name][:busy] = count
335
+ stats[:total_busy] += count
336
+ end
337
+
338
+ # Get scheduled jobs
339
+ result = ActiveRecord::Base.connection.execute(
340
+ "SELECT COUNT(*) as count FROM good_jobs WHERE scheduled_at > NOW()"
341
+ )
342
+ scheduled_count = parse_query_result(result).first
343
+ stats[:total_scheduled] = (scheduled_count&.dig("count") || scheduled_count&.dig(:count) || 0).to_i
344
+
345
+ # Get failed jobs
346
+ result = ActiveRecord::Base.connection.execute(
347
+ "SELECT COUNT(*) as count FROM good_jobs WHERE finished_at IS NOT NULL AND error IS NOT NULL"
348
+ )
349
+ failed_count = parse_query_result(result).first
350
+ stats[:total_failed] = (failed_count&.dig("count") || failed_count&.dig(:count) || 0).to_i
351
+ end
352
+ rescue => e
353
+ log_error("Error collecting Good Job stats: #{e.message}")
354
+ end
355
+
356
+ stats
357
+ end
358
+
359
+ def parse_query_result(result)
360
+ # Handle different database adapter result formats
361
+ if result.respond_to?(:each)
362
+ # PostgreSQL PG::Result or similar
363
+ if result.respond_to?(:values)
364
+ # Convert to array of hashes
365
+ columns = result.fields rescue result.column_names rescue []
366
+ result.values.map do |row|
367
+ columns.each_with_index.each_with_object({}) do |(col, idx), hash|
368
+ hash[col.to_s] = row[idx]
369
+ hash[col.to_sym] = row[idx]
370
+ end
371
+ end
372
+ elsif result.is_a?(Array)
373
+ # Already an array
374
+ result
375
+ else
376
+ # Try to convert to array
377
+ result.to_a
378
+ end
379
+ else
380
+ []
381
+ end
382
+ rescue => e
383
+ log_error("Error parsing query result: #{e.message}")
384
+ []
385
+ end
386
+
387
+ def log_error(message)
388
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
389
+ Rails.logger.error("[DeadBro::JobQueueMonitor] #{message}")
390
+ else
391
+ $stderr.puts("[DeadBro::JobQueueMonitor] #{message}")
392
+ end
393
+ end
394
+ end
395
+ end
@@ -7,6 +7,10 @@ module DeadBro
7
7
  ActiveSupport::Notifications.subscribe("perform_start.active_job") do |name, started, finished, _unique_id, data|
8
8
  # Clear logs for this job
9
9
  DeadBro.logger.clear
10
+
11
+ # Set tracking start time once for all subscribers (before starting any tracking)
12
+ Thread.current[DeadBro::TRACKING_START_TIME_KEY] = Time.now
13
+
10
14
  DeadBro::SqlSubscriber.start_request_tracking
11
15
 
12
16
  # Start lightweight memory tracking for this job
@@ -28,6 +28,14 @@ module DeadBro
28
28
 
29
29
  duration_ms = ((finished - started) * 1000.0).round(2)
30
30
 
31
+ # Ensure tracking was started (fallback if perform_start.active_job didn't fire)
32
+ # This handles job backends that don't emit perform_start events
33
+ unless Thread.current[DeadBro::SqlSubscriber::THREAD_LOCAL_KEY]
34
+ DeadBro.logger.clear
35
+ Thread.current[DeadBro::TRACKING_START_TIME_KEY] = Time.now
36
+ DeadBro::SqlSubscriber.start_request_tracking
37
+ end
38
+
31
39
  # Get SQL queries executed during this job
32
40
  sql_queries = DeadBro::SqlSubscriber.stop_request_tracking
33
41
 
@@ -96,6 +104,14 @@ module DeadBro
96
104
  duration_ms = ((finished - started) * 1000.0).round(2)
97
105
  exception = data[:exception_object]
98
106
 
107
+ # Ensure tracking was started (fallback if perform_start.active_job didn't fire)
108
+ # This handles job backends that don't emit perform_start events
109
+ unless Thread.current[DeadBro::SqlSubscriber::THREAD_LOCAL_KEY]
110
+ DeadBro.logger.clear
111
+ Thread.current[DeadBro::TRACKING_START_TIME_KEY] = Time.now
112
+ DeadBro::SqlSubscriber.start_request_tracking
113
+ end
114
+
99
115
  # Get SQL queries executed during this job
100
116
  sql_queries = DeadBro::SqlSubscriber.stop_request_tracking
101
117
 
@@ -55,6 +55,13 @@ if defined?(Rails) && defined?(Rails::Railtie)
55
55
  DeadBro::JobSqlTrackingMiddleware.subscribe!
56
56
  DeadBro::JobSubscriber.subscribe!(client: shared_client)
57
57
  end
58
+
59
+ # Start job queue monitoring if enabled
60
+ if DeadBro.configuration.job_queue_monitoring_enabled
61
+ require "dead_bro/job_queue_monitor"
62
+ DeadBro.job_queue_monitor = DeadBro::JobQueueMonitor.new(client: shared_client)
63
+ DeadBro.job_queue_monitor.start
64
+ end
58
65
  rescue
59
66
  # Never raise in Railtie init
60
67
  end
@@ -3,6 +3,7 @@
3
3
  module DeadBro
4
4
  class RedisSubscriber
5
5
  THREAD_LOCAL_KEY = :dead_bro_redis_events
6
+ MAX_TRACKED_EVENTS = 1000
6
7
 
7
8
  def self.subscribe!
8
9
  install_redis_instrumentation!
@@ -86,7 +87,7 @@ module DeadBro
86
87
  error: error ? error.class.name : nil
87
88
  }
88
89
 
89
- if Thread.current[RedisSubscriber::THREAD_LOCAL_KEY]
90
+ if Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] && RedisSubscriber.should_continue_tracking?
90
91
  Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] << event
91
92
  end
92
93
  rescue
@@ -114,7 +115,7 @@ module DeadBro
114
115
  db: safe_db(@db)
115
116
  }
116
117
 
117
- if Thread.current[RedisSubscriber::THREAD_LOCAL_KEY]
118
+ if Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] && RedisSubscriber.should_continue_tracking?
118
119
  Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] << event
119
120
  end
120
121
  rescue
@@ -142,7 +143,7 @@ module DeadBro
142
143
  db: safe_db(@db)
143
144
  }
144
145
 
145
- if Thread.current[RedisSubscriber::THREAD_LOCAL_KEY]
146
+ if Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] && RedisSubscriber.should_continue_tracking?
146
147
  Thread.current[RedisSubscriber::THREAD_LOCAL_KEY] << event
147
148
  end
148
149
  rescue
@@ -201,7 +202,9 @@ module DeadBro
201
202
  next unless Thread.current[THREAD_LOCAL_KEY]
202
203
  duration_ms = ((finished - started) * 1000.0).round(2)
203
204
  event = build_event(name, data, duration_ms)
204
- Thread.current[THREAD_LOCAL_KEY] << event if event
205
+ if event && should_continue_tracking?
206
+ Thread.current[THREAD_LOCAL_KEY] << event
207
+ end
205
208
  end
206
209
  rescue
207
210
  end
@@ -218,6 +221,24 @@ module DeadBro
218
221
  events || []
219
222
  end
220
223
 
224
+ # Check if we should continue tracking based on count and time limits
225
+ def self.should_continue_tracking?
226
+ events = Thread.current[THREAD_LOCAL_KEY]
227
+ return false unless events
228
+
229
+ # Check count limit
230
+ return false if events.length >= MAX_TRACKED_EVENTS
231
+
232
+ # Check time limit
233
+ start_time = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
234
+ if start_time
235
+ elapsed_seconds = Time.now - start_time
236
+ return false if elapsed_seconds >= DeadBro::MAX_TRACKING_DURATION_SECONDS
237
+ end
238
+
239
+ true
240
+ end
241
+
221
242
  def self.build_event(name, data, duration_ms)
222
243
  cmd = extract_command(data)
223
244
  {
@@ -14,6 +14,25 @@ module DeadBro
14
14
  THREAD_LOCAL_ALLOC_RESULTS_KEY = :dead_bro_sql_alloc_results
15
15
  THREAD_LOCAL_BACKTRACE_KEY = :dead_bro_sql_backtraces
16
16
  THREAD_LOCAL_EXPLAIN_PENDING_KEY = :dead_bro_explain_pending
17
+ MAX_TRACKED_QUERIES = 1000
18
+
19
+ # Check if we should continue tracking based on count and time limits
20
+ def self.should_continue_tracking?(thread_local_key, max_count)
21
+ events = Thread.current[thread_local_key]
22
+ return false unless events
23
+
24
+ # Check count limit
25
+ return false if events.length >= max_count
26
+
27
+ # Check time limit
28
+ start_time = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
29
+ if start_time
30
+ elapsed_seconds = Time.now - start_time
31
+ return false if elapsed_seconds >= DeadBro::MAX_TRACKING_DURATION_SECONDS
32
+ end
33
+
34
+ true
35
+ end
17
36
 
18
37
  def self.subscribe!
19
38
  # Subscribe with a start/finish listener to measure allocations per query
@@ -63,8 +82,10 @@ module DeadBro
63
82
  start_explain_analyze_background(original_sql, data[:connection_id], query_info, binds)
64
83
  end
65
84
 
66
- # Add to thread-local storage
67
- Thread.current[THREAD_LOCAL_KEY] << query_info
85
+ # Add to thread-local storage, but only if we haven't exceeded the limits
86
+ if should_continue_tracking?(THREAD_LOCAL_KEY, MAX_TRACKED_QUERIES)
87
+ Thread.current[THREAD_LOCAL_KEY] << query_info
88
+ end
68
89
  end
69
90
  end
70
91
 
@@ -354,8 +375,12 @@ module DeadBro
354
375
  # Fallback to string representation
355
376
  result.to_s
356
377
  when "mysql", "mysql2", "trilogy"
357
- # MySQL returns rows
358
- if result.is_a?(Array)
378
+ # MySQL returns Mysql2::Result object which needs to be converted to array
379
+ if result.respond_to?(:to_a)
380
+ # Convert Mysql2::Result to array of hashes
381
+ rows = result.to_a
382
+ rows.map { |row| row.is_a?(Hash) ? row.values.join(" | ") : row.to_s }.join("\n")
383
+ elsif result.is_a?(Array)
359
384
  result.map { |row| row.is_a?(Hash) ? row.values.join(" | ") : row.to_s }.join("\n")
360
385
  else
361
386
  result.to_s
@@ -42,6 +42,9 @@ module DeadBro
42
42
 
43
43
  # Start outgoing HTTP accumulation for this request
44
44
  Thread.current[:dead_bro_http_events] = []
45
+
46
+ # Set tracking start time once for all subscribers (before starting any tracking)
47
+ Thread.current[DeadBro::TRACKING_START_TIME_KEY] = Time.now
45
48
 
46
49
  @app.call(env)
47
50
  ensure
@@ -71,8 +74,9 @@ module DeadBro
71
74
  Thread.current[:dead_bro_lightweight_memory] = nil
72
75
  end
73
76
 
74
- # Clean up HTTP events
77
+ # Clean up HTTP events and tracking start time
75
78
  Thread.current[:dead_bro_http_events] = nil
79
+ Thread.current[DeadBro::TRACKING_START_TIME_KEY] = nil
76
80
  end
77
81
  end
78
82
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadBro
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.2"
5
5
  end
@@ -10,6 +10,7 @@ module DeadBro
10
10
  RENDER_COLLECTION_EVENT = "render_collection.action_view"
11
11
 
12
12
  THREAD_LOCAL_KEY = :dead_bro_view_events
13
+ MAX_TRACKED_EVENTS = 1000
13
14
 
14
15
  def self.subscribe!(client: Client.new)
15
16
  # Track template rendering
@@ -78,11 +79,29 @@ module DeadBro
78
79
  end
79
80
 
80
81
  def self.add_view_event(view_info)
81
- if Thread.current[THREAD_LOCAL_KEY]
82
+ if Thread.current[THREAD_LOCAL_KEY] && should_continue_tracking?
82
83
  Thread.current[THREAD_LOCAL_KEY] << view_info
83
84
  end
84
85
  end
85
86
 
87
+ # Check if we should continue tracking based on count and time limits
88
+ def self.should_continue_tracking?
89
+ events = Thread.current[THREAD_LOCAL_KEY]
90
+ return false unless events
91
+
92
+ # Check count limit
93
+ return false if events.length >= MAX_TRACKED_EVENTS
94
+
95
+ # Check time limit
96
+ start_time = Thread.current[DeadBro::TRACKING_START_TIME_KEY]
97
+ if start_time
98
+ elapsed_seconds = Time.now - start_time
99
+ return false if elapsed_seconds >= DeadBro::MAX_TRACKING_DURATION_SECONDS
100
+ end
101
+
102
+ true
103
+ end
104
+
86
105
  def self.safe_identifier(identifier)
87
106
  return "" unless identifier.is_a?(String)
88
107
 
data/lib/dead_bro.rb CHANGED
@@ -18,6 +18,7 @@ module DeadBro
18
18
  autoload :MemoryHelpers, "dead_bro/memory_helpers"
19
19
  autoload :JobSubscriber, "dead_bro/job_subscriber"
20
20
  autoload :JobSqlTrackingMiddleware, "dead_bro/job_sql_tracking_middleware"
21
+ autoload :JobQueueMonitor, "dead_bro/job_queue_monitor"
21
22
  autoload :Logger, "dead_bro/logger"
22
23
  begin
23
24
  require "dead_bro/railtie"
@@ -66,4 +67,18 @@ module DeadBro
66
67
  ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development"
67
68
  end
68
69
  end
70
+
71
+ # Returns the job queue monitor instance
72
+ def self.job_queue_monitor
73
+ @job_queue_monitor
74
+ end
75
+
76
+ # Sets the job queue monitor instance
77
+ def self.job_queue_monitor=(monitor)
78
+ @job_queue_monitor = monitor
79
+ end
80
+
81
+ # Shared constant for tracking start time (used by all subscribers)
82
+ TRACKING_START_TIME_KEY = :dead_bro_tracking_start_time
83
+ MAX_TRACKING_DURATION_SECONDS = 3600 # 1 hour
69
84
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dead_bro
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa
@@ -27,6 +27,7 @@ files:
27
27
  - lib/dead_bro/configuration.rb
28
28
  - lib/dead_bro/error_middleware.rb
29
29
  - lib/dead_bro/http_instrumentation.rb
30
+ - lib/dead_bro/job_queue_monitor.rb
30
31
  - lib/dead_bro/job_sql_tracking_middleware.rb
31
32
  - lib/dead_bro/job_subscriber.rb
32
33
  - lib/dead_bro/lightweight_memory_tracker.rb
@@ -60,7 +61,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
60
61
  - !ruby/object:Gem::Version
61
62
  version: '0'
62
63
  requirements: []
63
- rubygems_version: 3.6.7
64
+ rubygems_version: 4.0.3
64
65
  specification_version: 4
65
66
  summary: Minimal APM for Rails apps.
66
67
  test_files: []