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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f71863664d8e95b34f553c692cfc6de2328151f3496dc5bb92ba309232dc3b52
4
- data.tar.gz: ab521965229d38b7e8a2ae4fd56a466170219865863573bad59fbe8676f14ce6
3
+ metadata.gz: 7b9c945dc45966ff8c9d06b0679357d54ba9dc443d5ee68c532ae1058878c6e4
4
+ data.tar.gz: a986e568c59ca3f8d04f71d647ba0649bbbff7659f021eca349bc3cce3b3b044
5
5
  SHA512:
6
- metadata.gz: 01edd9b04e2c7054f6f247536eb85af48db98eb6c6367b4d863ea477b49388b347ad1a78d1758548caf965f483f10b432d979f397af8947e482ab3283104e0b6
7
- data.tar.gz: cf20b7101a695433ddc35d6e4654d99555e50c7220fa8e4132e1495e6e9cadc88c1b217db487a5f9973c5a4a358ce2d6368bff3d65fbb9968ed420d2ee46d7a5
6
+ metadata.gz: 90d75283c6f362c3be78a419923c26d622aa6d55ed3d29ebfca1c40341d7d264a9a6074fd30e93140fb13db44991a8a52261d0695a2df22bcf1e17e865f793bb
7
+ data.tar.gz: 319d197cd23d9c9fd15845dc4a8a896b2560d3da83b85ac69e9687ba4ce3495ed1b720089ce82cc6adef8497e570177078f9430191bbca260a32a230f68171aa
data/README.md CHANGED
@@ -1,10 +1,26 @@
1
1
  # Ruby Debug Agent
2
2
 
3
3
  [![Gem Version](https://img.shields.io/badge/gem-debug--agent-red)](https://github.com/topcheer/ruby-debug-agent)
4
- ![Tools](https://img.shields.io/badge/tools-54-blue)
5
- ![Inspectors](https://img.shields.io/badge/inspectors-20-green)
4
+ ![Tools](https://img.shields.io/badge/tools-84-blue)
5
+ ![Inspectors](https://img.shields.io/badge/inspectors-31-green)
6
+ ![Ruby](https://img.shields.io/badge/Ruby-2.7%2B-CC342D)
7
+ ![Gem](https://img.shields.io/badge/gem-debug--agent-red)
6
8
 
7
- An AI-powered runtime debugging agent that embeds directly into your Ruby application. Add one gem, configure an LLM key, and chat with your live app at `/agent` to inspect GC, ObjectSpace, threads, routes, Redis, Rails models/routes, Sidekiq queues, Puma stats, fibers/signals, process info, HTTP requests, and more — **54 diagnostic tools across 20 inspectors**.
9
+ An AI-powered runtime debugging agent that embeds directly into your Ruby application. Add one gem, configure an LLM key, and chat with your live app at `/agent` to inspect GC, ObjectSpace, threads, routes, Redis, Rails models/routes, Sidekiq queues, Puma stats, fibers/signals, process info, HTTP requests, and more — **84 diagnostic tools across 31 inspectors**.
10
+
11
+ ## Version Support
12
+
13
+ | Ruby Version | Status |
14
+ |--------------|--------|
15
+ | 2.6 | Not supported |
16
+ | 2.7 | Minimum supported |
17
+ | 3.0 | Supported (Fiber.list available) |
18
+ | 3.1 | Supported |
19
+ | 3.2 | Supported |
20
+ | 3.3 | Supported |
21
+ | 3.4 | Tested |
22
+
23
+ > Requires Ruby 2.7+ for pattern matching guards. Framework inspectors (Rails, Sidekiq, Puma) are optional and auto-detected via `defined?`.
8
24
 
9
25
  ## Quick Start
10
26
 
@@ -55,10 +71,10 @@ http://localhost:4567/agent
55
71
  - **Context compression** — automatically summarizes old conversation when token limit is approached
56
72
  - **Dark-themed chat UI** with full markdown rendering (tables, code blocks, lists)
57
73
  - **Max tool rounds** (25) with forced final summary when limit is reached
58
- - **54 diagnostic tools** across **20 inspectors**
74
+ - **84 diagnostic tools** across **31 inspectors**
59
75
  - Zero external dependencies (no Datadog, no Grafana, no APM)
60
76
 
61
- ## Inspectors & Tools (54)
77
+ ## Inspectors & Tools (84)
62
78
 
63
79
  ### GC Inspector
64
80
  | Tool | Description |
@@ -193,6 +209,47 @@ http://localhost:4567/agent
193
209
  |------|-------------|
194
210
  | `get_concurrent_state` | Ruby concurrency primitives state (Mutex, ConditionVariable, Queue) |
195
211
 
212
+ ### Deadlock & Lock Contention Inspector (v0.6.0)
213
+ | Tool | Description |
214
+ |------|-------------|
215
+ | `get_lock_contention` | Mutex contention stats (wait time, hold time, acquisition count) |
216
+ | `detect_deadlock` | Analyze all threads for deadlock patterns (circular wait detection) |
217
+ | `get_mutex_stats` | Per-lock statistics: total acquisitions, contentions, average wait time |
218
+
219
+ ### Database Migration Inspector (v0.6.0)
220
+ | Tool | Description |
221
+ |------|-------------|
222
+ | `get_migration_status` | Current schema version, applied count, last migration applied |
223
+ | `get_pending_migrations` | Migrations not yet applied (version, description, dependencies) |
224
+ | `get_migration_history` | Applied migration history (version, applied_at, duration_ms) |
225
+
226
+ ### Configuration Inspector (v0.6.0)
227
+ | Tool | Description |
228
+ |------|-------------|
229
+ | `get_config_snapshot` | All registered config values (sensitive keys masked) |
230
+ | `get_env_vars_masked` | Process environment variables with secret values redacted |
231
+ | `get_config_sources` | Config source hierarchy (env, file, defaults) with effective values |
232
+
233
+ ### Feature Flags Inspector (v0.6.0)
234
+ | Tool | Description |
235
+ |------|-------------|
236
+ | `get_feature_flags` | List all registered feature flags with current state |
237
+ | `evaluate_feature_flag` | Evaluate a specific flag for a given context/user |
238
+
239
+ ### Endpoint Testing Inspector (v0.6.0)
240
+ | Tool | Description |
241
+ |------|-------------|
242
+ | `test_endpoint` | Make an HTTP request to own app, return full response (status, headers, body) |
243
+ | `batch_test_endpoints` | Test multiple endpoints in one call with aggregated results |
244
+ | `get_endpoint_coverage` | Compare registered routes vs tested endpoints (coverage report) |
245
+
246
+ ### Connection Pool Inspector (v0.6.0)
247
+ | Tool | Description |
248
+ |------|-------------|
249
+ | `get_pool_details` | Detailed DB pool stats (pool size, active, idle, waiting, max) |
250
+ | `detect_pool_leaks` | Heuristic leak detection (growing pool, high wait ratio, saturation) |
251
+ | `get_pool_wait_stats` | Connection acquire wait stats (avg, P95, max wait, timeout count) |
252
+
196
253
  ## Custom Tools
197
254
 
198
255
  ```ruby
@@ -0,0 +1,137 @@
1
+ require 'time'
2
+
3
+ module DebugAgent
4
+ # Register configuration hashes for inspection.
5
+ #
6
+ # DebugAgent.register_config(:app, {
7
+ # app_name: 'MyApp',
8
+ # port: 4567,
9
+ # api_key: 'secret123'
10
+ # })
11
+ @registered_configs = {}
12
+
13
+ SENSITIVE_KEY_PATTERN = /password|secret|token|api.?key|private.?key|credential/i
14
+
15
+ class << self
16
+ attr_reader :registered_configs
17
+
18
+ def register_config(name, config_hash, source: 'registered')
19
+ @registered_configs[name.to_s] = {
20
+ values: config_hash,
21
+ source: source,
22
+ registered_at: Time.now.iso8601
23
+ }
24
+ end
25
+ end
26
+
27
+ class << self
28
+ private
29
+
30
+ def mask_sensitive(key, value)
31
+ return value unless value.is_a?(String) || value.is_a?(Symbol)
32
+ return '***' if key.to_s =~ SENSITIVE_KEY_PATTERN
33
+ value
34
+ end
35
+
36
+ def mask_config_hash(hash)
37
+ hash.map do |k, v|
38
+ if v.is_a?(Hash)
39
+ [k, mask_config_hash(v)]
40
+ else
41
+ [k, mask_sensitive(k, v)]
42
+ end
43
+ end.to_h
44
+ end
45
+ end
46
+
47
+ register_tool('get_config_snapshot',
48
+ 'Get all registered configuration values. Sensitive keys (password, secret, ' \
49
+ 'token, api_key, etc.) are automatically masked') do
50
+ if registered_configs.empty?
51
+ next { error: 'No configs registered. Call DebugAgent.register_config(:name, hash).' }
52
+ end
53
+
54
+ configs = registered_configs.map do |name, entry|
55
+ {
56
+ name: name,
57
+ source: entry[:source],
58
+ registered_at: entry[:registered_at],
59
+ values: mask_config_hash(entry[:values] || {}),
60
+ key_count: (entry[:values] || {}).size,
61
+ masked_keys: (entry[:values] || {}).keys.select { |k| k.to_s =~ SENSITIVE_KEY_PATTERN }
62
+ }
63
+ end
64
+
65
+ {
66
+ total_configs: configs.size,
67
+ configs: configs
68
+ }
69
+ rescue => e
70
+ { error: e.message }
71
+ end
72
+
73
+ register_tool('get_env_vars',
74
+ 'Dump environment variables (ENV) with optional prefix filter. ' \
75
+ 'Sensitive values are automatically masked',
76
+ prefix: { type: 'string', description: 'Only return vars starting with this prefix (e.g. APP_, RAILS_)', required: false }) do |prefix: nil|
77
+ vars = ENV.to_h
78
+
79
+ if prefix && !prefix.to_s.empty?
80
+ vars = vars.select { |k, _| k.start_with?(prefix.to_s) }
81
+ end
82
+
83
+ masked = {}
84
+ sensitive_count = 0
85
+ vars.each do |k, v|
86
+ if k =~ SENSITIVE_KEY_PATTERN
87
+ masked[k] = '***'
88
+ sensitive_count += 1
89
+ else
90
+ masked[k] = v
91
+ end
92
+ end
93
+
94
+ {
95
+ total_vars: masked.size,
96
+ sensitive_masked: sensitive_count,
97
+ prefix_filter: prefix,
98
+ env_vars: masked
99
+ }
100
+ rescue => e
101
+ { error: e.message }
102
+ end
103
+
104
+ register_tool('get_config_sources',
105
+ 'Configuration provenance: shows where each registered config comes from ' \
106
+ '(environment, file, default, or registered)') do
107
+ if registered_configs.empty?
108
+ next { error: 'No configs registered. Call DebugAgent.register_config(:name, hash).' }
109
+ end
110
+
111
+ sources = registered_configs.map do |name, entry|
112
+ {
113
+ name: name,
114
+ source: entry[:source],
115
+ registered_at: entry[:registered_at],
116
+ keys: (entry[:values] || {}).keys
117
+ }
118
+ end
119
+
120
+ # Also show ENV as a config source
121
+ env_config_count = ENV.size
122
+
123
+ {
124
+ registered_config_sources: sources,
125
+ total_sources: sources.size,
126
+ env_var_count: env_config_count,
127
+ summary: {
128
+ registered: sources.count { |s| s[:source] == 'registered' },
129
+ file: sources.count { |s| s[:source] == 'file' },
130
+ env: sources.count { |s| s[:source] == 'env' },
131
+ default: sources.count { |s| s[:source] == 'default' }
132
+ }
133
+ }
134
+ rescue => e
135
+ { error: e.message }
136
+ end
137
+ end
@@ -0,0 +1,284 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module DebugAgent
7
+ @tested_routes = []
8
+ @tested_routes_lock = Mutex.new
9
+
10
+ class << self
11
+ attr_reader :tested_routes
12
+
13
+ def record_tested_route(method, path, status, duration_ms)
14
+ @tested_routes_lock.synchronize do
15
+ @tested_routes << {
16
+ method: method,
17
+ path: path,
18
+ status: status,
19
+ duration_ms: duration_ms,
20
+ tested_at: Time.now.iso8601
21
+ }
22
+ @tested_routes.shift if @tested_routes.size > 500
23
+ end
24
+ end
25
+
26
+ def reset_tested_routes
27
+ @tested_routes_lock.synchronize { @tested_routes.clear }
28
+ end
29
+ end
30
+
31
+ class << self
32
+ private
33
+
34
+ def app_port
35
+ app = DebugAgent.app
36
+ if app
37
+ app_class = app.is_a?(Class) ? app : app.class
38
+ if app_class.respond_to?(:port)
39
+ return app_class.port
40
+ end
41
+ if app_class.respond_to?(:settings) && app_class.settings.respond_to?(:port)
42
+ return app_class.settings.port
43
+ end
44
+ end
45
+ # Common defaults
46
+ ENV['PORT']&.to_i || 4567
47
+ end
48
+
49
+ def app_host
50
+ 'localhost'
51
+ end
52
+
53
+ def perform_http_request(method, path, headers, body)
54
+ port = app_port
55
+ host = app_host
56
+
57
+ uri = URI("http://#{host}:#{port}#{path}")
58
+ http = Net::HTTP.new(uri.host, uri.port)
59
+ http.read_timeout = 30
60
+ http.open_timeout = 10
61
+
62
+ request_method = Net::HTTP.const_get(method.capitalize)
63
+ req = request_method.new(uri.request_uri)
64
+
65
+ # Set headers
66
+ headers&.each do |k, v|
67
+ req[k.to_s] = v.to_s
68
+ end
69
+
70
+ # Set body if provided
71
+ if body && !body.to_s.empty?
72
+ req['Content-Type'] ||= 'application/json'
73
+ req.body = body.to_s
74
+ end
75
+
76
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
77
+ response = http.request(req)
78
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
79
+
80
+ resp_body = response.body
81
+ parsed_body = nil
82
+ begin
83
+ parsed_body = JSON.parse(resp_body) if resp_body && !resp_body.empty?
84
+ rescue JSON::ParserError
85
+ parsed_body = resp_body
86
+ end
87
+
88
+ {
89
+ status: response.code.to_i,
90
+ status_message: response.message,
91
+ headers: response.each_header.to_h,
92
+ body: parsed_body,
93
+ duration_ms: duration,
94
+ method: method,
95
+ path: path,
96
+ url: uri.to_s
97
+ }
98
+ rescue Errno::ECONNREFUSED
99
+ { error: "Connection refused — app not running on #{host}:#{app_port}" }
100
+ rescue => e
101
+ { error: "Request failed: #{e.message}", method: method, path: path }
102
+ end
103
+ end
104
+
105
+ register_tool('test_endpoint',
106
+ 'Send an HTTP request to your own running app. Returns status, headers, body, ' \
107
+ 'and duration. Useful for testing API endpoints from the debug agent',
108
+ method: { type: 'string', description: 'HTTP method: GET, POST, PUT, DELETE, PATCH', required: true },
109
+ path: { type: 'string', description: 'Request path (e.g. /api/orders)', required: true },
110
+ headers: { type: 'object', description: 'Optional HTTP headers as key-value pairs (e.g. {"X-API-Key": "demo-key-12345"})', required: false },
111
+ body: { type: 'string', description: 'Optional request body (JSON string for POST/PUT)', required: false }) do |method:, path:, headers: nil, body: nil|
112
+ method_up = method.to_s.upcase
113
+ unless %w[GET POST PUT DELETE PATCH HEAD OPTIONS].include?(method_up)
114
+ next { error: "Unsupported HTTP method: #{method}. Use GET, POST, PUT, DELETE, PATCH, HEAD, or OPTIONS." }
115
+ end
116
+
117
+ result = perform_http_request(method_up, path, headers, body)
118
+
119
+ if result[:error]
120
+ next result
121
+ end
122
+
123
+ record_tested_route(method_up, path, result[:status], result[:duration_ms])
124
+
125
+ result
126
+ rescue => e
127
+ { error: e.message }
128
+ end
129
+
130
+ register_tool('batch_test_endpoints',
131
+ 'Run multiple endpoint tests with assertions. Each test specifies method, path, ' \
132
+ 'optional headers/body, and optional assertions (expected_status, expected_body_contains)',
133
+ tests: { type: 'array', description: 'Array of test objects: {method, path, headers?, body?, expected_status?, expected_body_contains?}', required: true }) do |tests:|
134
+ unless tests.is_a?(Array)
135
+ next { error: 'tests must be an array of test objects' }
136
+ end
137
+
138
+ results = tests.map do |test|
139
+ test = test.is_a?(Hash) ? test : {}
140
+ method = (test['method'] || test[:method] || 'GET').to_s.upcase
141
+ path = test['path'] || test[:path]
142
+ headers = test['headers'] || test[:headers]
143
+ body = test['body'] || test[:body]
144
+
145
+ unless path
146
+ next { method: method, path: '(missing)', error: 'Missing required field: path' }
147
+ end
148
+
149
+ http_result = perform_http_request(method, path, headers, body)
150
+
151
+ if http_result[:error]
152
+ next { method: method, path: path, error: http_result[:error], passed: false }
153
+ end
154
+
155
+ record_tested_route(method, path, http_result[:status], http_result[:duration_ms])
156
+
157
+ # Run assertions
158
+ passed = true
159
+ failures = []
160
+
161
+ expected_status = test['expected_status'] || test[:expected_status]
162
+ if expected_status && http_result[:status] != expected_status.to_i
163
+ passed = false
164
+ failures << "Expected status #{expected_status}, got #{http_result[:status]}"
165
+ end
166
+
167
+ expected_contains = test['expected_body_contains'] || test[:expected_body_contains]
168
+ if expected_contains
169
+ body_str = http_result[:body].is_a?(String) ? http_result[:body] : JSON.generate(http_result[:body])
170
+ unless body_str.include?(expected_contains.to_s)
171
+ passed = false
172
+ failures << "Body does not contain: '#{expected_contains}'"
173
+ end
174
+ end
175
+
176
+ {
177
+ method: method,
178
+ path: path,
179
+ status: http_result[:status],
180
+ duration_ms: http_result[:duration_ms],
181
+ passed: passed,
182
+ failures: failures,
183
+ body_preview: (http_result[:body].is_a?(String) ? http_result[:body][0..200] : http_result[:body])
184
+ }
185
+ end
186
+
187
+ total = results.size
188
+ passed_count = results.count { |r| r[:passed] }
189
+ failed_count = total - passed_count
190
+
191
+ {
192
+ total: total,
193
+ passed: passed_count,
194
+ failed: failed_count,
195
+ pass_rate: total.zero? ? '0%' : format('%.0f%%', passed_count.to_f / total * 100),
196
+ results: results
197
+ }
198
+ rescue => e
199
+ { error: e.message }
200
+ end
201
+
202
+ register_tool('get_endpoint_coverage',
203
+ 'Compare registered Sinatra/Rails routes against tested routes. Shows ' \
204
+ 'which endpoints have been tested via the agent and which are untested') do
205
+ # Get all routes from the app
206
+ all_routes = []
207
+ app = DebugAgent.app
208
+
209
+ if app
210
+ app_class = app.is_a?(Class) ? app : app.class
211
+
212
+ if app_class.respond_to?(:routes)
213
+ app_class.routes.each do |method, route_list|
214
+ route_list.each do |route|
215
+ pattern = route[0]
216
+ pattern_str = case pattern
217
+ when Regexp then pattern.source
218
+ else pattern.to_s
219
+ end
220
+ all_routes << { method: method.to_s.upcase, pattern: pattern_str }
221
+ end
222
+ end
223
+ end
224
+ end
225
+
226
+ if all_routes.empty? && defined?(Sinatra) && defined?(Sinatra::Base)
227
+ Sinatra::Base.routes.each do |method, route_list|
228
+ route_list.each do |route|
229
+ pattern = route[0]
230
+ pattern_str = case pattern
231
+ when Regexp then pattern.source
232
+ else pattern.to_s
233
+ end
234
+ all_routes << { method: method.to_s.upcase, pattern: pattern_str }
235
+ end
236
+ end
237
+ end
238
+
239
+ tested = tested_routes_lock.synchronize { @tested_routes.dup }
240
+ tested_routes_set = tested.map { |t| "#{t[:method]} #{t[:path]}" }.to_set rescue tested.map { |t| "#{t[:method]} #{t[:path]}" }
241
+
242
+ # Match tested routes against app routes
243
+ covered = []
244
+ uncovered = []
245
+
246
+ all_routes.each do |route|
247
+ pattern = route[:pattern]
248
+ # Simplify regex patterns for matching (e.g. \A\/api\/orders\/(?<id>[^\/?]+) -> /api/orders)
249
+ base_pattern = pattern
250
+ .gsub(/\A\^?\\A?/, '')
251
+ .gsub(/\$?\\z?\z/, '')
252
+ .gsub(/\(\?<\w+>[^\)]+\)/, ':param')
253
+ .gsub(/\(\?:[^\)]+\)/, ':param')
254
+ .gsub(/\+|\*/, '')
255
+ .gsub(/\\\//, '/')
256
+
257
+ was_tested = tested.any? do |t|
258
+ t[:method] == route[:method] && (
259
+ t[:path] == pattern ||
260
+ t[:path].start_with?(base_pattern.gsub(/:param.*/, ''))
261
+ )
262
+ end
263
+
264
+ if was_tested
265
+ covered << route
266
+ else
267
+ uncovered << route
268
+ end
269
+ end
270
+
271
+ {
272
+ total_routes: all_routes.size,
273
+ tested_routes: tested_routes_set.size,
274
+ covered: covered.size,
275
+ uncovered: uncovered.size,
276
+ coverage_rate: all_routes.empty? ? '0%' : format('%.0f%%', covered.size.to_f / all_routes.size * 100),
277
+ covered_routes: covered,
278
+ uncovered_routes: uncovered,
279
+ recent_tests: tested.last(50)
280
+ }
281
+ rescue => e
282
+ { error: e.message }
283
+ end
284
+ end