debug-agent 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ require 'time'
2
+
3
+ module DebugAgent
4
+ # Register connection pools for deep-dive inspection.
5
+ #
6
+ # DebugAgent.register_pool(:primary, ActiveRecord::Base.connection_pool)
7
+ @registered_pools = {}
8
+ @pool_stats = {}
9
+ @pool_meta_lock = Mutex.new
10
+
11
+ class << self
12
+ attr_reader :registered_pools, :pool_stats
13
+
14
+ def register_pool(name, pool)
15
+ pool_name = name.to_s
16
+ @registered_pools[pool_name] = pool
17
+ @pool_meta_lock.synchronize do
18
+ @pool_stats[pool_name] ||= {
19
+ acquire_times: [],
20
+ last_checked_at: nil
21
+ }
22
+ end
23
+ pool
24
+ end
25
+ end
26
+
27
+ class << self
28
+ private
29
+
30
+ def ar_pool_stats
31
+ return nil unless defined?(::ActiveRecord::Base)
32
+
33
+ begin
34
+ pool = ::ActiveRecord::Base.connection_pool
35
+ stats = {}
36
+
37
+ # ActiveRecord::ConnectionPool#stats (Rails 5+)
38
+ if pool.respond_to?(:stats)
39
+ stats = pool.stats
40
+ end
41
+
42
+ result = {
43
+ size: pool.respond_to?(:size) ? pool.size : nil,
44
+ class: pool.class.name,
45
+ stats: stats
46
+ }
47
+
48
+ # Try to get more detail via instance variables if available
49
+ if pool.instance_variable_defined?(:@available)
50
+ available = pool.instance_variable_get(:@available)
51
+ if available.respond_to?(:size)
52
+ result[:available_connections] = available.size
53
+ end
54
+ end
55
+
56
+ if pool.instance_variable_defined?(:@connections)
57
+ connections = pool.instance_variable_get(:@connections)
58
+ if connections.is_a?(Array)
59
+ result[:total_connections] = connections.size
60
+ result[:connections] = connections.map do |conn|
61
+ conn_info(conn)
62
+ end
63
+ end
64
+ end
65
+
66
+ result
67
+ rescue => e
68
+ { error: "ActiveRecord pool inspection failed: #{e.message}" }
69
+ end
70
+ end
71
+
72
+ def conn_info(conn)
73
+ info = { object_id: conn.object_id, class: conn.class.name }
74
+ if conn.respond_to?(:active?)
75
+ begin
76
+ info[:active] = conn.active?
77
+ rescue
78
+ nil
79
+ end
80
+ end
81
+ if conn.respond_to?(:in_use?)
82
+ begin
83
+ info[:in_use] = conn.in_use?
84
+ rescue
85
+ nil
86
+ end
87
+ end
88
+ if conn.respond_to?(:seconds_idle)
89
+ begin
90
+ info[:seconds_idle] = conn.seconds_idle
91
+ rescue
92
+ nil
93
+ end end
94
+ info
95
+ rescue
96
+ { object_id: conn&.object_id, class: conn&.class&.name }
97
+ end
98
+
99
+ def pool_details_for(name, pool)
100
+ if defined?(::ActiveRecord::ConnectionAdapters::AbstractAdapter) &&
101
+ pool.is_a?(::ActiveRecord::ConnectionAdapters::AbstractAdapter)
102
+ # Direct adapter, not a pool
103
+ return {
104
+ name: name,
105
+ class: pool.class.name,
106
+ type: 'adapter',
107
+ active: (pool.active? if pool.respond_to?(:active?)),
108
+ in_use: (pool.in_use? if pool.respond_to?(:in_use?)),
109
+ seconds_idle: (pool.seconds_idle if pool.respond_to?(:seconds_idle))
110
+ }
111
+ end
112
+
113
+ # ActiveRecord ConnectionPool
114
+ if pool.respond_to?(:stats) || pool.respond_to?(:size)
115
+ stats = {}
116
+ stats[:size] = pool.size if pool.respond_to?(:size)
117
+ stats[:stats] = pool.stats if pool.respond_to?(:stats)
118
+
119
+ if pool.instance_variable_defined?(:@connections)
120
+ conns = pool.instance_variable_get(:@connections)
121
+ if conns.is_a?(Array)
122
+ stats[:total_connections] = conns.size
123
+ stats[:connections] = conns.map { |c| conn_info(c) }
124
+ stats[:busy_connections] = conns.count { |c| c.respond_to?(:in_use?) ? c.in_use? : false }
125
+ stats[:idle_connections] = conns.count { |c| c.respond_to?(:in_use?) ? !c.in_use? : true }
126
+ stats[:dead_connections] = conns.count do |c|
127
+ c.respond_to?(:active?) ? !c.active? : false
128
+ rescue
129
+ true
130
+ end
131
+ end
132
+ end
133
+
134
+ return { name: name, class: pool.class.name, type: 'active_record_pool' }.merge(stats)
135
+ end
136
+
137
+ # Custom pool — best-effort introspection
138
+ info = { name: name, class: pool.class.name, type: 'custom' }
139
+ %i[size available busy idle dead count total active checkout checkin].each do |m|
140
+ if pool.respond_to?(m)
141
+ begin
142
+ val = pool.public_send(m)
143
+ info[m] = val unless val.nil?
144
+ rescue
145
+ nil
146
+ end
147
+ end
148
+ end
149
+ info
150
+ end
151
+ end
152
+
153
+ register_tool('get_pool_details',
154
+ 'Connection pool deep dive: size, busy, dead, idle connections. ' \
155
+ 'Auto-detects ActiveRecord::Base.connection_pool or uses registered pools') do
156
+ pools = []
157
+
158
+ # Registered pools
159
+ registered_pools.each do |name, pool|
160
+ pools << pool_details_for(name, pool)
161
+ end
162
+
163
+ # Auto-detect ActiveRecord pool if not already registered
164
+ if defined?(::ActiveRecord::Base) && registered_pools.none? { |_, p| p == ::ActiveRecord::Base.connection_pool rescue false }
165
+ begin
166
+ ar = ar_pool_stats
167
+ if ar && !ar[:error]
168
+ pools << ar.merge(name: 'active_record_default', source: 'auto_detected')
169
+ end
170
+ rescue
171
+ nil
172
+ end
173
+ end
174
+
175
+ if pools.empty?
176
+ next {
177
+ error: 'No connection pools available. Load ActiveRecord or register a pool ' \
178
+ 'with DebugAgent.register_pool(:name, pool).'
179
+ }
180
+ end
181
+
182
+ {
183
+ total_pools: pools.size,
184
+ pools: pools
185
+ }
186
+ rescue => e
187
+ { error: e.message }
188
+ end
189
+
190
+ register_tool('detect_pool_leaks',
191
+ 'Heuristic connection pool leak detection: flags connections checked out ' \
192
+ 'for more than 30 seconds (configurable threshold)') do
193
+ threshold_seconds = 30
194
+ leaks = []
195
+ checked_out = []
196
+
197
+ all_pools = registered_pools.dup
198
+
199
+ # Auto-detect ActiveRecord pool
200
+ if defined?(::ActiveRecord::Base)
201
+ begin
202
+ all_pools['active_record_default'] = ::ActiveRecord::Base.connection_pool
203
+ rescue
204
+ nil
205
+ end
206
+ end
207
+
208
+ all_pools.each do |name, pool|
209
+ pool_info = { name: name, connections: [], leak_count: 0 }
210
+
211
+ # ActiveRecord ConnectionPool — access connections via instance variable
212
+ if pool.instance_variable_defined?(:@connections)
213
+ conns = pool.instance_variable_get(:@connections)
214
+ if conns.is_a?(Array)
215
+ conns.each do |conn|
216
+ conn_data = conn_info(conn)
217
+
218
+ # Check if connection has been checked out for too long
219
+ if conn.respond_to?(:in_use?) && conn.in_use?
220
+ idle_secs = (conn.respond_to?(:seconds_idle) ? conn.seconds_idle : nil)
221
+ conn_data[:status] = 'checked_out'
222
+ conn_data[:idle_seconds] = idle_secs
223
+ checked_out << { pool: name }.merge(conn_data)
224
+
225
+ if idle_secs && idle_secs > threshold_seconds
226
+ conn_data[:leak_suspect] = true
227
+ conn_data[:leak_reason] = "Checked out for #{idle_secs}s (threshold: #{threshold_seconds}s)"
228
+ pool_info[:leak_count] += 1
229
+ end
230
+ end
231
+
232
+ pool_info[:connections] << conn_data
233
+ end
234
+ end
235
+ end
236
+
237
+ leaks << pool_info if pool_info[:connections].any?
238
+ end
239
+
240
+ total_leaks = leaks.sum { |p| p[:leak_count] }
241
+
242
+ {
243
+ threshold_seconds: threshold_seconds,
244
+ total_leak_suspects: total_leaks,
245
+ pools_inspected: leaks.size,
246
+ checked_out_connections: checked_out.size,
247
+ pools: leaks,
248
+ recommendation: if total_leaks > 0
249
+ "#{total_leaks} potential connection leak(s) detected. " \
250
+ "Check for missing connection.release or unclosed transactions."
251
+ elsif checked_out.any?
252
+ "#{checked_out.size} connection(s) checked out but within normal threshold."
253
+ else
254
+ 'No connection leaks detected.'
255
+ end
256
+ }
257
+ rescue => e
258
+ { error: e.message }
259
+ end
260
+
261
+ register_tool('get_pool_wait_stats',
262
+ 'Connection pool acquisition wait time statistics: how long threads wait ' \
263
+ 'to acquire a database connection from the pool') do
264
+ all_pools = registered_pools.dup
265
+
266
+ # Auto-detect ActiveRecord pool
267
+ if defined?(::ActiveRecord::Base)
268
+ begin
269
+ all_pools['active_record_default'] = ::ActiveRecord::Base.connection_pool
270
+ rescue
271
+ nil
272
+ end
273
+ end
274
+
275
+ pool_stats = all_pools.map do |name, pool|
276
+ stats = { name: name, class: pool.class.name }
277
+
278
+ # ActiveRecord connection pool metrics
279
+ if pool.respond_to?(:stats)
280
+ begin
281
+ raw = pool.stats
282
+ stats[:raw_stats] = raw
283
+ stats[:size] = raw[:size] if raw.is_a?(Hash) && raw[:size]
284
+ stats[:busy] = raw[:busy] if raw.is_a?(Hash) && raw[:busy]
285
+ stats[:dead] = raw[:dead] if raw.is_a?(Hash) && raw[:dead]
286
+ stats[:idle] = raw[:idle] if raw.is_a?(Hash) && raw[:idle]
287
+ stats[:waiting] = raw[:waiting] if raw.is_a?(Hash) && raw[:waiting]
288
+ rescue
289
+ nil
290
+ end
291
+ end
292
+
293
+ # Size info
294
+ stats[:pool_size] = pool.size if pool.respond_to?(:size)
295
+
296
+ # Count threads waiting (approximate: scan for threads blocked on connection checkout)
297
+ waiting_threads = Thread.list.select do |t|
298
+ next false unless t.alive? && t.status == 'sleep'
299
+ bt = begin
300
+ t.backtrace || []
301
+ rescue
302
+ []
303
+ end
304
+ bt.any? { |line| line =~ /connection_pool|with_connection|checkout|acquire/i }
305
+ end
306
+ stats[:threads_waiting_for_connection] = waiting_threads.size
307
+ stats[:waiting_thread_ids] = waiting_threads.map(&:object_id)
308
+
309
+ stats
310
+ end
311
+
312
+ {
313
+ total_pools: pool_stats.size,
314
+ total_waiting_threads: pool_stats.sum { |p| p[:threads_waiting_for_connection] || 0 },
315
+ pools: pool_stats
316
+ }
317
+ rescue => e
318
+ { error: e.message }
319
+ end
320
+ end
@@ -1,3 +1,3 @@
1
1
  module DebugAgent
2
- VERSION = '0.5.0'.freeze
2
+ VERSION = '0.6.0'.freeze
3
3
  end
data/lib/debug_agent.rb CHANGED
@@ -35,6 +35,12 @@ require_relative 'debug_agent/inspectors/health'
35
35
  require_relative 'debug_agent/inspectors/scheduler'
36
36
  require_relative 'debug_agent/inspectors/error_tracking'
37
37
  require_relative 'debug_agent/inspectors/websocket'
38
+ require_relative 'debug_agent/inspectors/locks'
39
+ require_relative 'debug_agent/inspectors/migration'
40
+ require_relative 'debug_agent/inspectors/config_inspector'
41
+ require_relative 'debug_agent/inspectors/feature_flags'
42
+ require_relative 'debug_agent/inspectors/endpoint_test'
43
+ require_relative 'debug_agent/inspectors/pool_inspector'
38
44
 
39
45
  module DebugAgent
40
46
  class Error < StandardError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: debug-agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ggcode
@@ -167,16 +167,22 @@ files:
167
167
  - lib/debug_agent/inspectors/active_record_stats.rb
168
168
  - lib/debug_agent/inspectors/cache.rb
169
169
  - lib/debug_agent/inspectors/concurrent.rb
170
+ - lib/debug_agent/inspectors/config_inspector.rb
170
171
  - lib/debug_agent/inspectors/core_ext.rb
172
+ - lib/debug_agent/inspectors/endpoint_test.rb
171
173
  - lib/debug_agent/inspectors/error_tracking.rb
172
174
  - lib/debug_agent/inspectors/faraday.rb
175
+ - lib/debug_agent/inspectors/feature_flags.rb
173
176
  - lib/debug_agent/inspectors/gc.rb
174
177
  - lib/debug_agent/inspectors/health.rb
175
178
  - lib/debug_agent/inspectors/http_client.rb
176
179
  - lib/debug_agent/inspectors/http_tracker.rb
180
+ - lib/debug_agent/inspectors/locks.rb
177
181
  - lib/debug_agent/inspectors/logging.rb
178
182
  - lib/debug_agent/inspectors/metrics.rb
183
+ - lib/debug_agent/inspectors/migration.rb
179
184
  - lib/debug_agent/inspectors/object_space.rb
185
+ - lib/debug_agent/inspectors/pool_inspector.rb
180
186
  - lib/debug_agent/inspectors/process_info.rb
181
187
  - lib/debug_agent/inspectors/puma.rb
182
188
  - lib/debug_agent/inspectors/rails.rb