dead_bro 0.2.0 → 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 +4 -4
- data/README.md +4 -4
- data/lib/dead_bro/cache_subscriber.rb +22 -1
- data/lib/dead_bro/client.rb +73 -0
- data/lib/dead_bro/configuration.rb +4 -2
- data/lib/dead_bro/http_instrumentation.rb +24 -4
- data/lib/dead_bro/job_queue_monitor.rb +395 -0
- data/lib/dead_bro/job_sql_tracking_middleware.rb +4 -0
- data/lib/dead_bro/job_subscriber.rb +16 -0
- data/lib/dead_bro/railtie.rb +7 -0
- data/lib/dead_bro/redis_subscriber.rb +25 -4
- data/lib/dead_bro/sql_subscriber.rb +77 -9
- data/lib/dead_bro/sql_tracking_middleware.rb +5 -1
- data/lib/dead_bro/version.rb +1 -1
- data/lib/dead_bro/view_rendering_subscriber.rb +20 -1
- data/lib/dead_bro.rb +15 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 64333e774f85e0505014b6a0a6170e6721c0be30f1d3103b214fabdc7faacd8f
|
|
4
|
+
data.tar.gz: 82d6fc30651fcc2ee54904c6dff7b7cc36bd56c1a27a58ee726429c8b592b50a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f7fc637b60a2990c3e3cfd6c39a5adbbebde198b11c1da5ff1127199ee05e900c6b971bccc740b1552f53156717511c2244dc7e70f736ac2b2110a2e0291d577
|
|
7
|
+
data.tar.gz: 1bed47b5f0d4dabc1e851631c00701802207769280b82d2809afd7cf75fa3e5be5dfaf5940ed853234dfdf659911512f56a383020b2bb027bce6eea8d1288637
|
data/README.md
CHANGED
|
@@ -22,9 +22,9 @@ You can set via an initializer:
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
```ruby
|
|
25
|
-
DeadBro.configure do |
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
DeadBro.configure do |config|
|
|
26
|
+
config.api_key = ENV["DEAD_BRO_API_KEY"]
|
|
27
|
+
config.enabled = true
|
|
28
28
|
end
|
|
29
29
|
```
|
|
30
30
|
|
|
@@ -180,7 +180,7 @@ DeadBro can automatically run `EXPLAIN ANALYZE` on slow SQL queries to help you
|
|
|
180
180
|
|
|
181
181
|
```ruby
|
|
182
182
|
DeadBro.configure do |config|
|
|
183
|
-
config.api_key = ENV['
|
|
183
|
+
config.api_key = ENV['DEAD_BRO_API_KEY']
|
|
184
184
|
config.enabled = true
|
|
185
185
|
|
|
186
186
|
# Enable EXPLAIN ANALYZE for queries slower than 500ms
|
|
@@ -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
|
-
|
|
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
|
|
data/lib/dead_bro/client.rb
CHANGED
|
@@ -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
|
|
@@ -107,7 +109,7 @@ module DeadBro
|
|
|
107
109
|
def resolve_api_key
|
|
108
110
|
return @api_key unless @api_key.nil?
|
|
109
111
|
|
|
110
|
-
ENV["
|
|
112
|
+
ENV["DEAD_BRO_API_KEY"]
|
|
111
113
|
end
|
|
112
114
|
|
|
113
115
|
def sample_rate=(value)
|
|
@@ -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[
|
|
56
|
-
Thread.current[
|
|
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[
|
|
100
|
-
Thread.current[
|
|
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
|
|
data/lib/dead_bro/railtie.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -58,11 +77,15 @@ module DeadBro
|
|
|
58
77
|
if should_explain_query?(duration_ms, original_sql)
|
|
59
78
|
# Store reference to query_info so we can update it when EXPLAIN completes
|
|
60
79
|
query_info[:explain_plan] = nil # Placeholder
|
|
61
|
-
|
|
80
|
+
# Capture binds if available (type_casted_binds is preferred as they are ready for quoting)
|
|
81
|
+
binds = data[:type_casted_binds] || data[:binds]
|
|
82
|
+
start_explain_analyze_background(original_sql, data[:connection_id], query_info, binds)
|
|
62
83
|
end
|
|
63
84
|
|
|
64
|
-
# Add to thread-local storage
|
|
65
|
-
|
|
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
|
|
66
89
|
end
|
|
67
90
|
end
|
|
68
91
|
|
|
@@ -142,7 +165,7 @@ module DeadBro
|
|
|
142
165
|
true
|
|
143
166
|
end
|
|
144
167
|
|
|
145
|
-
def self.start_explain_analyze_background(sql, connection_id, query_info)
|
|
168
|
+
def self.start_explain_analyze_background(sql, connection_id, query_info, binds = nil)
|
|
146
169
|
return unless defined?(ActiveRecord)
|
|
147
170
|
return unless ActiveRecord::Base.respond_to?(:connection)
|
|
148
171
|
|
|
@@ -160,8 +183,11 @@ module DeadBro
|
|
|
160
183
|
connection = ActiveRecord::Base.connection
|
|
161
184
|
end
|
|
162
185
|
|
|
186
|
+
# Interpolate binds if present to ensure EXPLAIN works with placeholders
|
|
187
|
+
final_sql = interpolate_sql_with_binds(sql, binds, connection)
|
|
188
|
+
|
|
163
189
|
# Build EXPLAIN query based on database adapter
|
|
164
|
-
explain_sql = build_explain_query(
|
|
190
|
+
explain_sql = build_explain_query(final_sql, connection)
|
|
165
191
|
|
|
166
192
|
# Execute the EXPLAIN query
|
|
167
193
|
# For PostgreSQL, use select_all which returns ActiveRecord::Result
|
|
@@ -182,10 +208,8 @@ module DeadBro
|
|
|
182
208
|
# This updates the hash that's already in the queries array
|
|
183
209
|
if explain_plan && !explain_plan.to_s.strip.empty?
|
|
184
210
|
query_info[:explain_plan] = explain_plan
|
|
185
|
-
append_log_to_thread(main_thread, :debug, "Captured EXPLAIN ANALYZE for slow query (#{query_info[:duration_ms]}ms): #{explain_plan[0..1000]}...")
|
|
186
211
|
else
|
|
187
212
|
query_info[:explain_plan] = nil
|
|
188
|
-
append_log_to_thread(main_thread, :debug, "EXPLAIN ANALYZE returned empty result. Result type: #{result.class}, Result: #{result.inspect[0..200]}")
|
|
189
213
|
end
|
|
190
214
|
rescue => e
|
|
191
215
|
# Silently fail - don't let EXPLAIN break the application
|
|
@@ -267,6 +291,46 @@ module DeadBro
|
|
|
267
291
|
end
|
|
268
292
|
end
|
|
269
293
|
|
|
294
|
+
def self.interpolate_sql_with_binds(sql, binds, connection)
|
|
295
|
+
return sql if binds.nil? || binds.empty?
|
|
296
|
+
return sql unless connection
|
|
297
|
+
|
|
298
|
+
interpolated_sql = sql.dup
|
|
299
|
+
|
|
300
|
+
# Handle $1, $2 style placeholders (PostgreSQL)
|
|
301
|
+
if interpolated_sql.include?("$1")
|
|
302
|
+
binds.each_with_index do |val, index|
|
|
303
|
+
# Get value from bind param (handle both raw values and ActiveRecord::Relation::QueryAttribute)
|
|
304
|
+
value = val
|
|
305
|
+
if val.respond_to?(:value_for_database)
|
|
306
|
+
value = val.value_for_database
|
|
307
|
+
elsif val.respond_to?(:value)
|
|
308
|
+
value = val.value
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
quoted_value = connection.quote(value)
|
|
312
|
+
interpolated_sql = interpolated_sql.gsub("$#{index + 1}", quoted_value)
|
|
313
|
+
end
|
|
314
|
+
elsif interpolated_sql.include?("?")
|
|
315
|
+
# Handle ? style placeholders (MySQL, SQLite)
|
|
316
|
+
# We need to replace ? one by one in order
|
|
317
|
+
binds.each do |val|
|
|
318
|
+
# Get value from bind param
|
|
319
|
+
value = val
|
|
320
|
+
if val.respond_to?(:value_for_database)
|
|
321
|
+
value = val.value_for_database
|
|
322
|
+
elsif val.respond_to?(:value)
|
|
323
|
+
value = val.value
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
quoted_value = connection.quote(value)
|
|
327
|
+
interpolated_sql = interpolated_sql.sub("?", quoted_value)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
interpolated_sql
|
|
332
|
+
end
|
|
333
|
+
|
|
270
334
|
def self.format_explain_result(result, connection)
|
|
271
335
|
adapter_name = connection.adapter_name.downcase
|
|
272
336
|
|
|
@@ -311,8 +375,12 @@ module DeadBro
|
|
|
311
375
|
# Fallback to string representation
|
|
312
376
|
result.to_s
|
|
313
377
|
when "mysql", "mysql2", "trilogy"
|
|
314
|
-
# MySQL returns
|
|
315
|
-
if result.
|
|
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)
|
|
316
384
|
result.map { |row| row.is_a?(Hash) ? row.values.join(" | ") : row.to_s }.join("\n")
|
|
317
385
|
else
|
|
318
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
|
data/lib/dead_bro/version.rb
CHANGED
|
@@ -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.
|
|
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:
|
|
64
|
+
rubygems_version: 4.0.3
|
|
64
65
|
specification_version: 4
|
|
65
66
|
summary: Minimal APM for Rails apps.
|
|
66
67
|
test_files: []
|