debug-agent 0.2.6 → 0.3.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 +4 -4
- data/README.md +69 -4
- data/lib/debug_agent/inspectors/core_ext.rb +105 -0
- data/lib/debug_agent/inspectors/puma.rb +73 -0
- data/lib/debug_agent/inspectors/rails.rb +127 -0
- data/lib/debug_agent/inspectors/redis.rb +205 -0
- data/lib/debug_agent/inspectors/sidekiq.rb +121 -0
- data/lib/debug_agent/version.rb +1 -1
- data/lib/debug_agent.rb +5 -0
- metadata +105 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 94cb18c1904795fb713e2486d5f6d16e1bbce1a72d1820dd4122b2b7bc672a4d
|
|
4
|
+
data.tar.gz: 8cf474737f8b84aa5f2a20e5d07f13a02c7345df6805d6b075f72bbb5175fc4f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 48c72eaf044cb325c3d8cb9e5ddc5afabbf85f5830e675795e8afb34182db5eb381f90ce913af411b4cf4acb662f82bf23373f2c12785e3dcee0a2491624e2a6
|
|
7
|
+
data.tar.gz: 20e31311478fd6e5a54e0f693ce5a3cddc27952e5816a56082050bf17e77e4b19aba10d74bde0e95689765ec9e851228c0921724bb566f7609848ab76a844d79
|
data/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Ruby Debug Agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/topcheer/ruby-debug-agent)
|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
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 — **40 diagnostic tools across 13 inspectors**.
|
|
4
8
|
|
|
5
9
|
## Quick Start
|
|
6
10
|
|
|
@@ -51,9 +55,10 @@ http://localhost:4567/agent
|
|
|
51
55
|
- **Context compression** — automatically summarizes old conversation when token limit is approached
|
|
52
56
|
- **Dark-themed chat UI** with full markdown rendering (tables, code blocks, lists)
|
|
53
57
|
- **Max tool rounds** (25) with forced final summary when limit is reached
|
|
54
|
-
- **
|
|
58
|
+
- **40 diagnostic tools** across **13 inspectors**
|
|
59
|
+
- Zero external dependencies (no Datadog, no Grafana, no APM)
|
|
55
60
|
|
|
56
|
-
## Inspectors & Tools (
|
|
61
|
+
## Inspectors & Tools (40)
|
|
57
62
|
|
|
58
63
|
### GC Inspector
|
|
59
64
|
| Tool | Description |
|
|
@@ -75,6 +80,7 @@ http://localhost:4567/agent
|
|
|
75
80
|
| `get_thread_list` | List all threads with status and backtrace summary |
|
|
76
81
|
| `get_thread_count` | Thread count |
|
|
77
82
|
| `get_main_thread_info` | Main thread priority, status |
|
|
83
|
+
| `get_thread_backtrace` | Full backtrace for a specific thread |
|
|
78
84
|
|
|
79
85
|
### Route Inspector
|
|
80
86
|
| Tool | Description |
|
|
@@ -88,6 +94,7 @@ http://localhost:4567/agent
|
|
|
88
94
|
| `get_process_info` | PID, ppid, platform, Ruby version, uptime |
|
|
89
95
|
| `get_cpu_time` | Process.times() user/sys CPU time |
|
|
90
96
|
| `get_environment_variables` | Environment variables (masked secrets) |
|
|
97
|
+
| `get_process_memory` | Process RSS, VMS, and memory growth trend |
|
|
91
98
|
|
|
92
99
|
### Runtime Inspector
|
|
93
100
|
| Tool | Description |
|
|
@@ -108,9 +115,43 @@ http://localhost:4567/agent
|
|
|
108
115
|
| Tool | Description |
|
|
109
116
|
|------|-------------|
|
|
110
117
|
| `get_system_info` | Hostname, CPU cores, disk |
|
|
111
|
-
| `get_disk_usage` | Disk usage for working directory |
|
|
118
|
+
| `get_disk_usage` | Disk usage for the working directory |
|
|
112
119
|
| `get_file_descriptors` | Open file descriptor count |
|
|
113
120
|
|
|
121
|
+
### Redis Inspector
|
|
122
|
+
| Tool | Description |
|
|
123
|
+
|------|-------------|
|
|
124
|
+
| `get_redis_info` | Redis server info: memory, clients, persistence |
|
|
125
|
+
| `get_redis_keys` | Scan Redis keyspace with pattern matching |
|
|
126
|
+
| `get_redis_slowlog` | Redis slow query log entries |
|
|
127
|
+
| `get_redis_stats` | Per-command call count, hit/miss ratio, keyspace stats |
|
|
128
|
+
|
|
129
|
+
### Rails Inspector
|
|
130
|
+
| Tool | Description |
|
|
131
|
+
|------|-------------|
|
|
132
|
+
| `get_rails_models` | List ActiveRecord models with table names and associations |
|
|
133
|
+
| `get_rails_routes` | List Rails routes with helper names and HTTP verbs |
|
|
134
|
+
| `get_rails_db_schema` | Database schema version and pending migrations |
|
|
135
|
+
|
|
136
|
+
### Sidekiq Inspector
|
|
137
|
+
| Tool | Description |
|
|
138
|
+
|------|-------------|
|
|
139
|
+
| `get_sidekiq_queues` | Queue list with depth, latency, and size |
|
|
140
|
+
| `get_sidekiq_workers` | Active Sidekiq workers with job and host info |
|
|
141
|
+
| `get_sidekiq_jobs` | Inspect jobs in a queue/retry set with payload |
|
|
142
|
+
|
|
143
|
+
### Puma Inspector
|
|
144
|
+
| Tool | Description |
|
|
145
|
+
|------|-------------|
|
|
146
|
+
| `get_puma_stats` | Puma cluster stats: workers, threads, running/backlog, boot time |
|
|
147
|
+
|
|
148
|
+
### Fibers/Signals Inspector
|
|
149
|
+
| Tool | Description |
|
|
150
|
+
|------|-------------|
|
|
151
|
+
| `get_fiber_list` | List active Ruby Fibers with state and backtrace |
|
|
152
|
+
| `get_signal_handlers` | List registered signal handlers (Signal.trap) |
|
|
153
|
+
| `get_trap_handlers` | Inspect trap handlers for SIGINT, SIGTERM, etc. |
|
|
154
|
+
|
|
114
155
|
## Custom Tools
|
|
115
156
|
|
|
116
157
|
```ruby
|
|
@@ -133,12 +174,36 @@ end
|
|
|
133
174
|
|
|
134
175
|
## Run the Demo
|
|
135
176
|
|
|
177
|
+
The demo uses **Sinatra** + **redis-rb** + **SQLite** + **Sidekiq**. Start Redis with Docker Compose first:
|
|
178
|
+
|
|
179
|
+
### Docker Compose
|
|
180
|
+
|
|
181
|
+
```yaml
|
|
182
|
+
# docker-compose.yml
|
|
183
|
+
services:
|
|
184
|
+
redis:
|
|
185
|
+
image: redis:7-alpine
|
|
186
|
+
ports:
|
|
187
|
+
- "6379:6379"
|
|
188
|
+
command: redis-server --save 60 1 --loglevel warning
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
docker compose up -d
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Start the app
|
|
196
|
+
|
|
136
197
|
```bash
|
|
137
198
|
export LLM_API_KEY=your-key
|
|
138
199
|
cd demo && ruby -I../lib app.rb
|
|
139
200
|
# Open http://localhost:4567/agent
|
|
140
201
|
```
|
|
141
202
|
|
|
203
|
+
## RubyGems
|
|
204
|
+
|
|
205
|
+
[](https://github.com/topcheer/ruby-debug-agent)
|
|
206
|
+
|
|
142
207
|
## License
|
|
143
208
|
|
|
144
209
|
MIT
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
module DebugAgent
|
|
2
|
+
register_tool('get_fiber_info',
|
|
3
|
+
'List alive fibers (Ruby 3.0+ via Fiber.list) with status and backtrace') do
|
|
4
|
+
unless Fiber.respond_to?(:list)
|
|
5
|
+
return {
|
|
6
|
+
supported: false,
|
|
7
|
+
message: 'Fiber.list requires Ruby 3.0+. ' \
|
|
8
|
+
"Current Ruby: #{RUBY_VERSION} (#{RUBY_ENGINE})"
|
|
9
|
+
}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
fibers = Fiber.list.map do |fiber|
|
|
13
|
+
backtrace =
|
|
14
|
+
begin
|
|
15
|
+
fiber.backtrace || []
|
|
16
|
+
rescue => e
|
|
17
|
+
["<unable to get backtrace: #{e.message}>"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
object_id: fiber.object_id,
|
|
22
|
+
to_s: fiber.to_s,
|
|
23
|
+
alive: fiber.alive?,
|
|
24
|
+
resizable: fiber.respond_to?(:resizable?) ? fiber.resizable? : nil,
|
|
25
|
+
storage: fiber.respond_to?(:storage) ? (fiber.storage&.keys&.map(&:to_s) rescue nil) : nil,
|
|
26
|
+
backtrace_summary: backtrace.first(5),
|
|
27
|
+
backtrace_length: backtrace.size
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
supported: true,
|
|
33
|
+
total_fibers: fibers.size,
|
|
34
|
+
alive_fibers: fibers.count { |f| f[:alive] },
|
|
35
|
+
fibers: fibers
|
|
36
|
+
}
|
|
37
|
+
rescue => e
|
|
38
|
+
{ error: e.message }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
register_tool('get_signal_handlers',
|
|
42
|
+
'List registered signal handlers (Signal.trap) and default handlers') do
|
|
43
|
+
handlers = []
|
|
44
|
+
|
|
45
|
+
Signal.list.each do |name, number|
|
|
46
|
+
begin
|
|
47
|
+
current = Signal.trap(name)
|
|
48
|
+
rescue => e
|
|
49
|
+
current = "<error: #{e.message}>"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
handlers << {
|
|
53
|
+
signal: name,
|
|
54
|
+
number: number,
|
|
55
|
+
handler:
|
|
56
|
+
case current
|
|
57
|
+
when 'DEFAULT' then 'DEFAULT (system default)'
|
|
58
|
+
when 'IGNORE' then 'IGNORE (ignored)'
|
|
59
|
+
when 'EXIT' then 'EXIT (terminate process)'
|
|
60
|
+
when 'SYSTEM_DEFAULT' then 'SYSTEM_DEFAULT'
|
|
61
|
+
when String then current
|
|
62
|
+
when Proc
|
|
63
|
+
begin
|
|
64
|
+
src = current.source_location
|
|
65
|
+
src ? "Proc at #{src.join(':')}" : 'Proc (unknown source)'
|
|
66
|
+
rescue
|
|
67
|
+
'Proc'
|
|
68
|
+
end
|
|
69
|
+
else
|
|
70
|
+
current.inspect
|
|
71
|
+
end
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
{
|
|
76
|
+
total_signals: handlers.size,
|
|
77
|
+
signals: handlers.sort_by { |h| h[:number] }
|
|
78
|
+
}
|
|
79
|
+
rescue => e
|
|
80
|
+
{ error: e.message }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
register_tool('get_encoding_info',
|
|
84
|
+
'Get Ruby encoding info: Encoding.list, default external/internal/locale encodings') do
|
|
85
|
+
list = Encoding.list.map do |enc|
|
|
86
|
+
{
|
|
87
|
+
name: enc.name,
|
|
88
|
+
aliases: Encoding.aliases.select { |_, n| n == enc.name }.keys,
|
|
89
|
+
dummy: enc.dummy?,
|
|
90
|
+
ascii_compatible: enc.ascii_compatible?
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
total_encodings: list.size,
|
|
96
|
+
default_external: Encoding.default_external.to_s,
|
|
97
|
+
default_internal: Encoding.default_internal&.to_s,
|
|
98
|
+
locale: Encoding.find('locale').to_s,
|
|
99
|
+
filesystem: Encoding.find('filesystem').to_s,
|
|
100
|
+
encodings: list
|
|
101
|
+
}
|
|
102
|
+
rescue => e
|
|
103
|
+
{ error: e.message }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module DebugAgent
|
|
2
|
+
register_tool('get_puma_stats',
|
|
3
|
+
'Get Puma worker stats: running workers, threads, backlog, requests ' \
|
|
4
|
+
'(uses Puma.stats if Puma is loaded)') do
|
|
5
|
+
unless defined?(::Puma)
|
|
6
|
+
return { error: 'Puma is not loaded (puma gem not installed)' }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
raw =
|
|
10
|
+
if ::Puma.respond_to?(:stats_hash)
|
|
11
|
+
::Puma.stats_hash
|
|
12
|
+
elsif ::Puma.respond_to?(:stats)
|
|
13
|
+
# Puma.stats returns a JSON string in older versions; parse it.
|
|
14
|
+
s = ::Puma.stats
|
|
15
|
+
s.is_a?(String) ? JSON.parse(s, symbolize_names: true) : s
|
|
16
|
+
else
|
|
17
|
+
return { error: 'Puma is loaded but does not expose Puma.stats' }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
raw = raw.respond_to?(:transform_keys) ? raw : raw
|
|
21
|
+
|
|
22
|
+
# Normalize into a structured summary.
|
|
23
|
+
workers = []
|
|
24
|
+
|
|
25
|
+
# Clustered mode: raw is { workers: N, booted_workers: N, old_workers: N,
|
|
26
|
+
# phase: N, worker_status: [...] }
|
|
27
|
+
# Single mode: raw is { backed_up: N, running: N, pool_capacity: N,
|
|
28
|
+
# max_threads: N, requests_count: N }
|
|
29
|
+
if raw.is_a?(Hash) && raw.key?(:worker_status)
|
|
30
|
+
raw[:worker_status].each_with_index do |w, i|
|
|
31
|
+
last_stats = w[:last_status] || {}
|
|
32
|
+
workers << {
|
|
33
|
+
index: i,
|
|
34
|
+
pid: w[:pid],
|
|
35
|
+
index_field: w[:index],
|
|
36
|
+
booted: w[:booted],
|
|
37
|
+
last_checkin: w[:last_checkin],
|
|
38
|
+
running_threads: last_stats[:running],
|
|
39
|
+
pool_capacity: last_stats[:pool_capacity],
|
|
40
|
+
max_threads: last_stats[:max_threads],
|
|
41
|
+
backlog: last_stats[:backed_up],
|
|
42
|
+
requests: last_stats[:requests_count]
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
mode: 'cluster',
|
|
48
|
+
configured_workers: raw[:workers],
|
|
49
|
+
booted_workers: raw[:booted_workers],
|
|
50
|
+
old_workers: raw[:old_workers],
|
|
51
|
+
phase: raw[:phase],
|
|
52
|
+
workers: workers,
|
|
53
|
+
total_running_threads: workers.sum { |w| w[:running_threads].to_i },
|
|
54
|
+
total_backlog: workers.sum { |w| w[:backlog].to_i },
|
|
55
|
+
total_requests: workers.sum { |w| w[:requests].to_i }
|
|
56
|
+
}
|
|
57
|
+
elsif raw.is_a?(Hash)
|
|
58
|
+
{
|
|
59
|
+
mode: 'single',
|
|
60
|
+
running_threads: raw[:running],
|
|
61
|
+
pool_capacity: raw[:pool_capacity],
|
|
62
|
+
max_threads: raw[:max_threads],
|
|
63
|
+
backlog: raw[:backed_up],
|
|
64
|
+
requests: raw[:requests_count],
|
|
65
|
+
raw: raw
|
|
66
|
+
}
|
|
67
|
+
else
|
|
68
|
+
{ raw: raw }
|
|
69
|
+
end
|
|
70
|
+
rescue => e
|
|
71
|
+
{ error: e.message }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
module DebugAgent
|
|
2
|
+
register_tool('get_rails_routes',
|
|
3
|
+
'List all Rails routes: verb, path, controller#action ' \
|
|
4
|
+
'(requires Rails.application.routes)') do
|
|
5
|
+
unless defined?(::Rails) && defined?(::ActionDispatch)
|
|
6
|
+
return { error: 'Rails is not loaded (Rails::Application not found)' }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
routes_set = ::Rails.application.routes.routes
|
|
10
|
+
|
|
11
|
+
routes = routes_set.map do |route|
|
|
12
|
+
{
|
|
13
|
+
name: route.name,
|
|
14
|
+
verb: route.verb.source.gsub(/[$^]/, ''),
|
|
15
|
+
path: route.path.spec.to_s,
|
|
16
|
+
controller: route.defaults[:controller]&.to_s,
|
|
17
|
+
action: route.defaults[:action]&.to_s,
|
|
18
|
+
internal: route.internal?
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
total: routes.size,
|
|
24
|
+
routes: routes
|
|
25
|
+
}
|
|
26
|
+
rescue => e
|
|
27
|
+
{ error: e.message }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
register_tool('get_rails_models',
|
|
31
|
+
'List ActiveRecord models: class name, table name, columns ' \
|
|
32
|
+
'(iterates ActiveRecord::Base.descendants)') do
|
|
33
|
+
unless defined?(::ActiveRecord)
|
|
34
|
+
return { error: 'ActiveRecord is not loaded (ActiveRecord::Base not found)' }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
models = ::ActiveRecord::Base.descendants.map do |model|
|
|
38
|
+
columns =
|
|
39
|
+
begin
|
|
40
|
+
if model.table_exists?
|
|
41
|
+
model.columns.map do |col|
|
|
42
|
+
{
|
|
43
|
+
name: col.name,
|
|
44
|
+
type: col.sql_type_metadata&.type.to_s,
|
|
45
|
+
sql_type: col.sql_type_metadata&.sql_type.to_s,
|
|
46
|
+
null: col.null,
|
|
47
|
+
default: col.default,
|
|
48
|
+
primary: col.name == model.primary_key
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
else
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
rescue => e
|
|
55
|
+
[{ error: e.message }]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
table_name = begin
|
|
59
|
+
model.table_name
|
|
60
|
+
rescue
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
table_exists = begin
|
|
65
|
+
model.table_exists?
|
|
66
|
+
rescue
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
class_name: model.name,
|
|
72
|
+
table_name: table_name,
|
|
73
|
+
table_exists: table_exists,
|
|
74
|
+
column_count: columns.size,
|
|
75
|
+
columns: columns
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
total: models.size,
|
|
81
|
+
models: models.sort_by { |m| m[:class_name].to_s }
|
|
82
|
+
}
|
|
83
|
+
rescue => e
|
|
84
|
+
{ error: e.message }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
register_tool('get_rails_schema',
|
|
88
|
+
'Get ActiveRecord schema cache: table names and column definitions ' \
|
|
89
|
+
'(uses ActiveRecord::Base.connection.tables)') do
|
|
90
|
+
unless defined?(::ActiveRecord)
|
|
91
|
+
return { error: 'ActiveRecord is not loaded (ActiveRecord::Base not found)' }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
connection = ::ActiveRecord::Base.connection
|
|
95
|
+
tables = connection.tables
|
|
96
|
+
|
|
97
|
+
schema = tables.map do |table|
|
|
98
|
+
columns =
|
|
99
|
+
begin
|
|
100
|
+
connection.columns(table).map do |col|
|
|
101
|
+
{
|
|
102
|
+
name: col.name,
|
|
103
|
+
type: col.sql_type_metadata&.type.to_s,
|
|
104
|
+
sql_type: col.sql_type_metadata&.sql_type.to_s,
|
|
105
|
+
null: col.null,
|
|
106
|
+
default: col.default
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
rescue => e
|
|
110
|
+
[{ error: e.message }]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
table: table,
|
|
115
|
+
column_count: columns.size,
|
|
116
|
+
columns: columns
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
total_tables: schema.size,
|
|
122
|
+
tables: schema
|
|
123
|
+
}
|
|
124
|
+
rescue => e
|
|
125
|
+
{ error: e.message }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
module DebugAgent
|
|
2
|
+
# Registry for Redis clients (redis-rb). Applications register their
|
|
3
|
+
# Redis / connection-pool objects so the inspector can introspect them.
|
|
4
|
+
#
|
|
5
|
+
# DebugAgent.register_redis_client(:cache, Redis.new(url: ENV['REDIS_URL']))
|
|
6
|
+
@redis_clients = {}
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
attr_reader :redis_clients
|
|
10
|
+
|
|
11
|
+
def register_redis_client(name, client)
|
|
12
|
+
@redis_clients[name.to_s] = client
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Resolve a registered Redis client. Accepts a bare Redis object or a
|
|
17
|
+
# ConnectionPool (redis-rb ships ConnectionPool support). We yield a
|
|
18
|
+
# usable connection object to the block.
|
|
19
|
+
def self.with_redis(name = nil)
|
|
20
|
+
name, client = if name
|
|
21
|
+
[name.to_s, redis_clients[name.to_s]]
|
|
22
|
+
else
|
|
23
|
+
redis_clients.first
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
return [nil, nil] unless client
|
|
27
|
+
|
|
28
|
+
[name, client]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
register_tool('get_redis_pool_stats',
|
|
32
|
+
'Get Redis connection pool stats: registered clients, pool size, ' \
|
|
33
|
+
'available/in-use connections, host, port, db') do |name: nil|
|
|
34
|
+
return { error: 'Redis is not loaded (redis gem not installed)' } unless defined?(::Redis)
|
|
35
|
+
return { error: 'No Redis clients registered. Call DebugAgent.register_redis_client(:name, client).' } if redis_clients.empty?
|
|
36
|
+
|
|
37
|
+
targets = name ? { name.to_s => redis_clients[name.to_s] } : redis_clients
|
|
38
|
+
targets = targets.reject { |_, c| c.nil? }
|
|
39
|
+
return { error: "No Redis client registered under '#{name}'" } if targets.empty?
|
|
40
|
+
|
|
41
|
+
stats = targets.map do |client_name, client|
|
|
42
|
+
begin
|
|
43
|
+
info = {}
|
|
44
|
+
|
|
45
|
+
# ConnectionPool vs bare Redis
|
|
46
|
+
pool = nil
|
|
47
|
+
redis_conn = nil
|
|
48
|
+
|
|
49
|
+
if defined?(::ConnectionPool) && client.is_a?(::ConnectionPool)
|
|
50
|
+
pool = client
|
|
51
|
+
client.with { |c| redis_conn = c }
|
|
52
|
+
else
|
|
53
|
+
redis_conn = client
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Basic server connection details
|
|
57
|
+
info[:client_name] = client_name
|
|
58
|
+
info[:type] = pool ? 'connection_pool' : 'redis'
|
|
59
|
+
|
|
60
|
+
if redis_conn.respond_to?(:connection)
|
|
61
|
+
conn = redis_conn.connection rescue {}
|
|
62
|
+
info[:host] = conn[:host]
|
|
63
|
+
info[:port] = conn[:port]
|
|
64
|
+
info[:db] = conn[:db]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if pool
|
|
68
|
+
# ConnectionPool does not expose live counters publicly; report
|
|
69
|
+
# configured size. Available/in-use are best-effort via instance vars.
|
|
70
|
+
info[:pool_configured_size] = pool.instance_variable_get(:@size)
|
|
71
|
+
info[:pool_available] = pool.instance_variable_get(:@available)&.length
|
|
72
|
+
info[:pool_in_use] = info[:pool_configured_size].to_i - info[:pool_available].to_i
|
|
73
|
+
else
|
|
74
|
+
info[:pool_configured_size] = 1
|
|
75
|
+
info[:pool_available] = 1
|
|
76
|
+
info[:pool_in_use] = 0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Ping to confirm reachability
|
|
80
|
+
info[:connected] = begin
|
|
81
|
+
redis_conn.ping == 'PONG'
|
|
82
|
+
rescue => e
|
|
83
|
+
info[:ping_error] = e.message
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
info
|
|
88
|
+
rescue => e
|
|
89
|
+
{ client_name: client_name, error: e.message }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
{ clients: stats }
|
|
94
|
+
rescue => e
|
|
95
|
+
{ error: e.message }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
register_tool('get_redis_info',
|
|
99
|
+
'Execute Redis INFO command and parse key sections ' \
|
|
100
|
+
'(Server, Clients, Memory, Stats, Keyspace)') do |name: nil|
|
|
101
|
+
return { error: 'Redis is not loaded (redis gem not installed)' } unless defined?(::Redis)
|
|
102
|
+
|
|
103
|
+
_name, client = DebugAgent.with_redis(name)
|
|
104
|
+
return { error: 'No Redis clients registered. Call DebugAgent.register_redis_client(:name, client).' } unless client
|
|
105
|
+
|
|
106
|
+
redis_conn =
|
|
107
|
+
if defined?(::ConnectionPool) && client.is_a?(::ConnectionPool)
|
|
108
|
+
client.with { |c| c }
|
|
109
|
+
else
|
|
110
|
+
client
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
raw = redis_conn.info
|
|
114
|
+
sections = {}
|
|
115
|
+
|
|
116
|
+
# Group known INFO keys into sections
|
|
117
|
+
section_keys = {
|
|
118
|
+
'Server' => %w[redis_version redis_mode os arch_bits tcp_port uptime_in_seconds uptime_in_days],
|
|
119
|
+
'Clients' => %w[connected_clients blocked_clients tracking_clients],
|
|
120
|
+
'Memory' => %w[used_memory used_memory_human used_memory_peak used_memory_peak_human used_memory_rss mem_fragmentation_ratio maxmemory maxmemory_human],
|
|
121
|
+
'Stats' => %w[total_connections_received total_commands_processed instantaneous_ops_per_sec keyspace_hits keyspace_misses expired_keys evicted_keys pubsub_channels pubsub_patterns],
|
|
122
|
+
'Persistence' => %w[rdb_last_bgsave_status rdb_changes_since_last_save aof_enabled]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
section_keys.each do |section, keys|
|
|
126
|
+
sections[section] = keys.each_with_object({}) do |k, h|
|
|
127
|
+
h[k] = raw[k] if raw.key?(k)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Keyspace section looks like "db0:keys=10,expires=0,avg_ttl=0"
|
|
132
|
+
keyspace = {}
|
|
133
|
+
raw.each do |k, v|
|
|
134
|
+
next unless k =~ /^db\d+$/
|
|
135
|
+
parsed = v.split(',').each_with_object({}) do |pair, h|
|
|
136
|
+
key, val = pair.split('=')
|
|
137
|
+
h[key] = val
|
|
138
|
+
end
|
|
139
|
+
keyspace[k] = parsed
|
|
140
|
+
end
|
|
141
|
+
sections['Keyspace'] = keyspace unless keyspace.empty?
|
|
142
|
+
|
|
143
|
+
{ sections: sections, raw_keys: raw.size }
|
|
144
|
+
rescue => e
|
|
145
|
+
{ error: e.message }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
register_tool('get_redis_latency',
|
|
149
|
+
'Measure Redis PING latency over 10 samples (min/avg/max in ms)') do |name: nil, samples: 10|
|
|
150
|
+
return { error: 'Redis is not loaded (redis gem not installed)' } unless defined?(::Redis)
|
|
151
|
+
|
|
152
|
+
_name, client = DebugAgent.with_redis(name)
|
|
153
|
+
return { error: 'No Redis clients registered. Call DebugAgent.register_redis_client(:name, client).' } unless client
|
|
154
|
+
|
|
155
|
+
samples = samples.to_i
|
|
156
|
+
samples = 10 if samples <= 0
|
|
157
|
+
|
|
158
|
+
redis_conn =
|
|
159
|
+
if defined?(::ConnectionPool) && client.is_a?(::ConnectionPool)
|
|
160
|
+
client.with { |c| c }
|
|
161
|
+
else
|
|
162
|
+
client
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
latencies = []
|
|
166
|
+
samples.times do
|
|
167
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
168
|
+
redis_conn.ping
|
|
169
|
+
finish = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
170
|
+
latencies << ((finish - start) * 1000.0)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
{
|
|
174
|
+
samples: latencies.size,
|
|
175
|
+
min_ms: latencies.min.round(3),
|
|
176
|
+
avg_ms: (latencies.sum / latencies.size).round(3),
|
|
177
|
+
max_ms: latencies.max.round(3),
|
|
178
|
+
all_ms: latencies.map { |l| l.round(3) }
|
|
179
|
+
}
|
|
180
|
+
rescue => e
|
|
181
|
+
{ error: e.message }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
register_tool('get_redis_db_size',
|
|
185
|
+
'Execute Redis DBSIZE command (number of keys in current db)') do |name: nil|
|
|
186
|
+
return { error: 'Redis is not loaded (redis gem not installed)' } unless defined?(::Redis)
|
|
187
|
+
|
|
188
|
+
_name, client = DebugAgent.with_redis(name)
|
|
189
|
+
return { error: 'No Redis clients registered. Call DebugAgent.register_redis_client(:name, client).' } unless client
|
|
190
|
+
|
|
191
|
+
redis_conn =
|
|
192
|
+
if defined?(::ConnectionPool) && client.is_a?(::ConnectionPool)
|
|
193
|
+
client.with { |c| c }
|
|
194
|
+
else
|
|
195
|
+
client
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
{
|
|
199
|
+
db_size: redis_conn.dbsize,
|
|
200
|
+
client: _name
|
|
201
|
+
}
|
|
202
|
+
rescue => e
|
|
203
|
+
{ error: e.message }
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module DebugAgent
|
|
2
|
+
# Registry for Sidekiq queue objects (Sidekiq::Queue instances). Applications
|
|
3
|
+
# register named queues so the inspector can read live stats.
|
|
4
|
+
#
|
|
5
|
+
# DebugAgent.register_sidekiq_queue(:default, Sidekiq::Queue.new('default'))
|
|
6
|
+
@sidekiq_queues = {}
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
attr_reader :sidekiq_queues
|
|
10
|
+
|
|
11
|
+
def register_sidekiq_queue(name, queue)
|
|
12
|
+
@sidekiq_queues[name.to_s] = queue
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
register_tool('get_sidekiq_queues',
|
|
17
|
+
'Get Sidekiq queue stats: processed, failed, enqueued totals and ' \
|
|
18
|
+
'per-queue sizes (requires sidekiq)') do
|
|
19
|
+
unless defined?(::Sidekiq)
|
|
20
|
+
return { error: 'Sidekiq is not loaded (sidekiq gem not installed)' }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
stats = ::Sidekiq::Stats.new
|
|
24
|
+
|
|
25
|
+
queues =
|
|
26
|
+
if sidekiq_queues.any?
|
|
27
|
+
sidekiq_queues.map do |name, queue|
|
|
28
|
+
{
|
|
29
|
+
name: name,
|
|
30
|
+
size: queue.size,
|
|
31
|
+
latency_seconds: queue.latency.to_f.round(3)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
else
|
|
35
|
+
::Sidekiq::Queue.all.map do |queue|
|
|
36
|
+
{
|
|
37
|
+
name: queue.name,
|
|
38
|
+
size: queue.size,
|
|
39
|
+
latency_seconds: queue.latency.to_f.round(3)
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
processed: stats.processed,
|
|
46
|
+
failed: stats.failed,
|
|
47
|
+
enqueued: stats.enqueued,
|
|
48
|
+
queues: queues
|
|
49
|
+
}
|
|
50
|
+
rescue => e
|
|
51
|
+
{ error: e.message }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
register_tool('get_sidekiq_workers',
|
|
55
|
+
'Get Sidekiq worker stats: busy workers, processes, total concurrency ' \
|
|
56
|
+
'(requires sidekiq)') do
|
|
57
|
+
unless defined?(::Sidekiq)
|
|
58
|
+
return { error: 'Sidekiq is not loaded (sidekiq gem not installed)' }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
processes = ::Sidekiq::ProcessSet.new.to_a
|
|
62
|
+
|
|
63
|
+
total_busy = processes.sum(&:busy)
|
|
64
|
+
total_concurrency = processes.sum(&:concurrency)
|
|
65
|
+
|
|
66
|
+
process_list = processes.map do |p|
|
|
67
|
+
{
|
|
68
|
+
identity: p.identity,
|
|
69
|
+
hostname: p['hostname'],
|
|
70
|
+
pid: p['pid'],
|
|
71
|
+
started_at: p['started_at'],
|
|
72
|
+
concurrency: p.concurrency,
|
|
73
|
+
busy: p.busy,
|
|
74
|
+
queues: p['queues'] || []
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
busy: total_busy,
|
|
80
|
+
processes: process_list.size,
|
|
81
|
+
total_concurrency: total_concurrency,
|
|
82
|
+
process_list: process_list
|
|
83
|
+
}
|
|
84
|
+
rescue => e
|
|
85
|
+
{ error: e.message }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
register_tool('get_sidekiq_retries',
|
|
89
|
+
'Get Sidekiq retry set: count and sample jobs (requires sidekiq)') do |sample_size: 10|
|
|
90
|
+
unless defined?(::Sidekiq)
|
|
91
|
+
return { error: 'Sidekiq is not loaded (sidekiq gem not installed)' }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
retry_set = ::Sidekiq::RetrySet.new
|
|
95
|
+
sample_size = sample_size.to_i
|
|
96
|
+
sample_size = 10 if sample_size <= 0
|
|
97
|
+
|
|
98
|
+
samples = []
|
|
99
|
+
retry_set.first(sample_size).each do |job|
|
|
100
|
+
samples << {
|
|
101
|
+
class: job.klass,
|
|
102
|
+
queue: job.queue,
|
|
103
|
+
args: job.args,
|
|
104
|
+
retry_count: job['retry_count'],
|
|
105
|
+
failed_at: job['failed_at'],
|
|
106
|
+
next_retry: job['next_at'] || job.at,
|
|
107
|
+
jid: job.jid,
|
|
108
|
+
error_message: job['error_message'],
|
|
109
|
+
error_class: job['error_class']
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
retry_count: retry_set.size,
|
|
115
|
+
sample_size: samples.size,
|
|
116
|
+
sample_jobs: samples
|
|
117
|
+
}
|
|
118
|
+
rescue => e
|
|
119
|
+
{ error: e.message }
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/debug_agent/version.rb
CHANGED
data/lib/debug_agent.rb
CHANGED
|
@@ -18,6 +18,11 @@ require_relative 'debug_agent/inspectors/object_space'
|
|
|
18
18
|
require_relative 'debug_agent/inspectors/threads'
|
|
19
19
|
require_relative 'debug_agent/inspectors/routes'
|
|
20
20
|
require_relative 'debug_agent/inspectors/process_info'
|
|
21
|
+
require_relative 'debug_agent/inspectors/core_ext'
|
|
22
|
+
require_relative 'debug_agent/inspectors/redis'
|
|
23
|
+
require_relative 'debug_agent/inspectors/rails'
|
|
24
|
+
require_relative 'debug_agent/inspectors/sidekiq'
|
|
25
|
+
require_relative 'debug_agent/inspectors/puma'
|
|
21
26
|
|
|
22
27
|
module DebugAgent
|
|
23
28
|
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ggcode
|
|
@@ -51,8 +51,106 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '3.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: sinatra
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '4.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '4.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: redis
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '5.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '5.0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: connection_pool
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '2.4'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '2.4'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: sidekiq
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '7.0'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '7.0'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: sqlite3
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '2.0'
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '2.0'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: puma
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - "~>"
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '6.0'
|
|
131
|
+
type: :development
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - "~>"
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '6.0'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: rackup
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - "~>"
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '2.0'
|
|
145
|
+
type: :development
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - "~>"
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '2.0'
|
|
54
152
|
description: Embed an AI debugging assistant into your Ruby web app. Inspect GC, threads,
|
|
55
|
-
memory, ObjectSpace, routes, HTTP requests, and more.
|
|
153
|
+
memory, ObjectSpace, routes, HTTP requests, Redis, Sidekiq, Puma, and more.
|
|
56
154
|
email:
|
|
57
155
|
- noreply@ggcode.dev
|
|
58
156
|
executables: []
|
|
@@ -66,12 +164,17 @@ files:
|
|
|
66
164
|
- lib/debug_agent/config.rb
|
|
67
165
|
- lib/debug_agent/context_compressor.rb
|
|
68
166
|
- lib/debug_agent/engine.rb
|
|
167
|
+
- lib/debug_agent/inspectors/core_ext.rb
|
|
69
168
|
- lib/debug_agent/inspectors/gc.rb
|
|
70
169
|
- lib/debug_agent/inspectors/http_tracker.rb
|
|
71
170
|
- lib/debug_agent/inspectors/object_space.rb
|
|
72
171
|
- lib/debug_agent/inspectors/process_info.rb
|
|
172
|
+
- lib/debug_agent/inspectors/puma.rb
|
|
173
|
+
- lib/debug_agent/inspectors/rails.rb
|
|
174
|
+
- lib/debug_agent/inspectors/redis.rb
|
|
73
175
|
- lib/debug_agent/inspectors/routes.rb
|
|
74
176
|
- lib/debug_agent/inspectors/runtime.rb
|
|
177
|
+
- lib/debug_agent/inspectors/sidekiq.rb
|
|
75
178
|
- lib/debug_agent/inspectors/system.rb
|
|
76
179
|
- lib/debug_agent/inspectors/threads.rb
|
|
77
180
|
- lib/debug_agent/llm_client.rb
|