debug-agent 0.2.6 → 0.4.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 +110 -4
- data/lib/debug_agent/inspectors/active_record_stats.rb +131 -0
- data/lib/debug_agent/inspectors/cache.rb +194 -0
- data/lib/debug_agent/inspectors/concurrent.rb +78 -0
- data/lib/debug_agent/inspectors/core_ext.rb +105 -0
- data/lib/debug_agent/inspectors/faraday.rb +79 -0
- data/lib/debug_agent/inspectors/gc.rb +53 -0
- data/lib/debug_agent/inspectors/http_client.rb +145 -0
- data/lib/debug_agent/inspectors/logging.rb +163 -0
- data/lib/debug_agent/inspectors/metrics.rb +71 -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 +12 -0
- metadata +112 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 791b8f21395b061038d65dcae92007460556766622991c1c0a1692ae21b272f5
|
|
4
|
+
data.tar.gz: b57b2b97b9473da04c7b7fc3cf67729bddfc283e2ca71d34b4e6469d2c47e65a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7fd90445975ede4bbc7474c7a57e94e1914098d9c4743180fdcdb737611ef461f2a6034da40fd31eb101188c34a28a12ac056db119f510e3de37ac494e06b67b
|
|
7
|
+
data.tar.gz: acf3c742b54b5d8173e6908ec27e079cd66efd4063f515b9278ec8754ef84e1539bbe5e13d47c79d342c33046678b972b9c6873cb6c5c9c96618669209145fa2
|
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 — **54 diagnostic tools across 20 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
|
+
- **54 diagnostic tools** across **20 inspectors**
|
|
59
|
+
- Zero external dependencies (no Datadog, no Grafana, no APM)
|
|
55
60
|
|
|
56
|
-
## Inspectors & Tools (
|
|
61
|
+
## Inspectors & Tools (54)
|
|
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,84 @@ 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
|
+
|
|
155
|
+
### Logging Inspector
|
|
156
|
+
| Tool | Description |
|
|
157
|
+
|------|-------------|
|
|
158
|
+
| `get_log_buffer` | Recent log entries from the built-in ring buffer |
|
|
159
|
+
| `get_logger_info` | Current log level and configuration for registered loggers |
|
|
160
|
+
| `set_log_level` | Dynamically set the log level for a registered logger |
|
|
161
|
+
|
|
162
|
+
### Cache Inspector
|
|
163
|
+
| Tool | Description |
|
|
164
|
+
|------|-------------|
|
|
165
|
+
| `get_cache_stats` | Stats for registered caches (hit rate, miss count, key count) |
|
|
166
|
+
| `get_cache_keys` | List keys from a registered cache with optional prefix filter |
|
|
167
|
+
| `clear_cache` | Clear all entries from a registered cache |
|
|
168
|
+
|
|
169
|
+
### Outbound HTTP Inspector
|
|
170
|
+
| Tool | Description |
|
|
171
|
+
|------|-------------|
|
|
172
|
+
| `get_http_connections` | HTTP client connection stats (Net::HTTP, connection pool) |
|
|
173
|
+
| `get_outbound_summary` | Aggregated outbound HTTP call stats (total, avg latency, error rate) |
|
|
174
|
+
|
|
175
|
+
### Metrics Inspector
|
|
176
|
+
| Tool | Description |
|
|
177
|
+
|------|-------------|
|
|
178
|
+
| `get_registered_metrics` | List all registered Prometheus metrics |
|
|
179
|
+
| `get_metric_value` | Get current value of a specific metric by name |
|
|
180
|
+
|
|
181
|
+
### ActiveRecord Stats Inspector
|
|
182
|
+
| Tool | Description |
|
|
183
|
+
|------|-------------|
|
|
184
|
+
| `get_active_record_query_stats` | ActiveRecord query statistics (queries per model, N+1 detection) |
|
|
185
|
+
|
|
186
|
+
### Faraday Inspector
|
|
187
|
+
| Tool | Description |
|
|
188
|
+
|------|-------------|
|
|
189
|
+
| `get_faraday_connections` | List Faraday connection objects with host, port, and adapter info |
|
|
190
|
+
|
|
191
|
+
### Concurrent Inspector
|
|
192
|
+
| Tool | Description |
|
|
193
|
+
|------|-------------|
|
|
194
|
+
| `get_concurrent_state` | Ruby concurrency primitives state (Mutex, ConditionVariable, Queue) |
|
|
195
|
+
|
|
114
196
|
## Custom Tools
|
|
115
197
|
|
|
116
198
|
```ruby
|
|
@@ -133,12 +215,36 @@ end
|
|
|
133
215
|
|
|
134
216
|
## Run the Demo
|
|
135
217
|
|
|
218
|
+
The demo uses **Sinatra** + **redis-rb** + **SQLite** + **Sidekiq**. Start Redis with Docker Compose first:
|
|
219
|
+
|
|
220
|
+
### Docker Compose
|
|
221
|
+
|
|
222
|
+
```yaml
|
|
223
|
+
# docker-compose.yml
|
|
224
|
+
services:
|
|
225
|
+
redis:
|
|
226
|
+
image: redis:7-alpine
|
|
227
|
+
ports:
|
|
228
|
+
- "6379:6379"
|
|
229
|
+
command: redis-server --save 60 1 --loglevel warning
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
docker compose up -d
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Start the app
|
|
237
|
+
|
|
136
238
|
```bash
|
|
137
239
|
export LLM_API_KEY=your-key
|
|
138
240
|
cd demo && ruby -I../lib app.rb
|
|
139
241
|
# Open http://localhost:4567/agent
|
|
140
242
|
```
|
|
141
243
|
|
|
244
|
+
## RubyGems
|
|
245
|
+
|
|
246
|
+
[](https://github.com/topcheer/ruby-debug-agent)
|
|
247
|
+
|
|
142
248
|
## License
|
|
143
249
|
|
|
144
250
|
MIT
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
require 'thread'
|
|
2
|
+
|
|
3
|
+
module DebugAgent
|
|
4
|
+
# Track ActiveRecord SQL events (sql.active_record) to report query stats
|
|
5
|
+
# and N+1 detection hints. Auto-installs a subscriber when the tool is run.
|
|
6
|
+
@ar_stats = {
|
|
7
|
+
total_queries: 0,
|
|
8
|
+
total_time_ms: 0.0,
|
|
9
|
+
slow_queries: [],
|
|
10
|
+
query_counts: Hash.new(0),
|
|
11
|
+
query_times: Hash.new(0.0),
|
|
12
|
+
slow_threshold_ms: 100
|
|
13
|
+
}
|
|
14
|
+
@ar_lock = Mutex.new
|
|
15
|
+
@ar_tracker_installed = false
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
attr_reader :ar_stats
|
|
19
|
+
|
|
20
|
+
# Subscribe to sql.active_record notifications (idempotent).
|
|
21
|
+
def install_ar_tracker
|
|
22
|
+
return true if @ar_tracker_installed
|
|
23
|
+
return false unless defined?(::ActiveSupport::Notifications)
|
|
24
|
+
|
|
25
|
+
::ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, started, finished, _id, payload|
|
|
26
|
+
DebugAgent.record_ar_query(started, finished, payload)
|
|
27
|
+
end
|
|
28
|
+
@ar_tracker_installed = true
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def record_ar_query(started, finished, payload)
|
|
33
|
+
duration_ms = ((finished - started) * 1000.0)
|
|
34
|
+
sql = payload[:sql].to_s.strip
|
|
35
|
+
return if sql.empty?
|
|
36
|
+
|
|
37
|
+
fp = ar_fingerprint(sql)
|
|
38
|
+
name = payload[:name]
|
|
39
|
+
|
|
40
|
+
@ar_lock.synchronize do
|
|
41
|
+
s = @ar_stats
|
|
42
|
+
s[:total_queries] += 1
|
|
43
|
+
s[:total_time_ms] += duration_ms
|
|
44
|
+
next unless fp
|
|
45
|
+
s[:query_counts][fp] += 1
|
|
46
|
+
s[:query_times][fp] += duration_ms
|
|
47
|
+
if duration_ms >= s[:slow_threshold_ms] && s[:slow_queries].size < 200
|
|
48
|
+
s[:slow_queries] << {
|
|
49
|
+
sql: sql[0..500],
|
|
50
|
+
name: name,
|
|
51
|
+
duration_ms: duration_ms.round(2),
|
|
52
|
+
timestamp: finished.to_s
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
rescue
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Normalize SQL into a fingerprint for grouping / N+1 detection.
|
|
61
|
+
def ar_fingerprint(sql)
|
|
62
|
+
sql
|
|
63
|
+
.gsub(/'[^']*'/, '?')
|
|
64
|
+
.gsub(/\b\d+\b/, '?')
|
|
65
|
+
.gsub(/\s+/, ' ')
|
|
66
|
+
.strip[0..200]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
register_tool('get_active_record_query_stats',
|
|
71
|
+
'Query statistics from ActiveRecord: total queries, avg query time, ' \
|
|
72
|
+
'slow queries, and N+1 detection hints (subscribes to sql.active_record)') do
|
|
73
|
+
next { error: 'ActiveRecord is not loaded (activerecord gem not installed)' } unless defined?(::ActiveRecord)
|
|
74
|
+
|
|
75
|
+
install_ar_tracker
|
|
76
|
+
|
|
77
|
+
snapshot = @ar_lock.synchronize do
|
|
78
|
+
{
|
|
79
|
+
total_queries: @ar_stats[:total_queries],
|
|
80
|
+
total_time_ms: @ar_stats[:total_time_ms].round(2),
|
|
81
|
+
slow_queries: @ar_stats[:slow_queries].dup,
|
|
82
|
+
query_counts: @ar_stats[:query_counts].dup,
|
|
83
|
+
query_times: @ar_stats[:query_times].dup,
|
|
84
|
+
slow_threshold_ms: @ar_stats[:slow_threshold_ms]
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
total = snapshot[:total_queries]
|
|
89
|
+
avg = total.zero? ? 0.0 : (snapshot[:total_time_ms] / total)
|
|
90
|
+
|
|
91
|
+
# N+1 suspects: the same query fingerprint executed many times.
|
|
92
|
+
n_plus_suspects =
|
|
93
|
+
snapshot[:query_counts]
|
|
94
|
+
.select { |_, count| count >= 5 }
|
|
95
|
+
.sort_by { |_, count| -count }
|
|
96
|
+
.first(10)
|
|
97
|
+
.map do |fp, count|
|
|
98
|
+
time = snapshot[:query_times][fp]
|
|
99
|
+
{
|
|
100
|
+
fingerprint: fp,
|
|
101
|
+
count: count,
|
|
102
|
+
total_time_ms: time.round(2),
|
|
103
|
+
avg_time_ms: (time / count).round(2)
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
result = {
|
|
108
|
+
tracker_active: @ar_tracker_installed,
|
|
109
|
+
total_queries: total,
|
|
110
|
+
total_query_time_ms: snapshot[:total_time_ms],
|
|
111
|
+
avg_query_time_ms: avg.round(2),
|
|
112
|
+
slow_query_threshold_ms: snapshot[:slow_threshold_ms],
|
|
113
|
+
slow_query_count: snapshot[:slow_queries].size,
|
|
114
|
+
slow_queries: snapshot[:slow_queries].last(20).reverse,
|
|
115
|
+
n_plus_suspects: n_plus_suspects
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Best-effort: include query cache info if available.
|
|
119
|
+
if ::ActiveRecord::Base.connection.respond_to?(:query_cache)
|
|
120
|
+
begin
|
|
121
|
+
result[:query_cache_enabled] = ::ActiveRecord::Base.connection.query_cache_enabled
|
|
122
|
+
rescue
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
result
|
|
128
|
+
rescue => e
|
|
129
|
+
{ error: e.message }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
module DebugAgent
|
|
2
|
+
# Registry of named cache objects. Applications register caches so the
|
|
3
|
+
# inspector can introspect stats, keys, and clear them.
|
|
4
|
+
#
|
|
5
|
+
# DebugAgent.register_cache(:rails, Rails.cache)
|
|
6
|
+
@caches = {}
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
attr_reader :caches
|
|
10
|
+
|
|
11
|
+
def register_cache(name, cache)
|
|
12
|
+
@caches[name.to_s] = cache
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Introspect a cache object, supporting ActiveSupport::Cache::MemoryStore,
|
|
16
|
+
# generic ActiveSupport::Cache::Store, plain Hashes, and custom caches.
|
|
17
|
+
def cache_stats_for(cache)
|
|
18
|
+
if defined?(::ActiveSupport::Cache::MemoryStore) && cache.is_a?(::ActiveSupport::Cache::MemoryStore)
|
|
19
|
+
data = cache.instance_variable_get(:@data) || {}
|
|
20
|
+
key_access = cache.instance_variable_get(:@key_access) || {}
|
|
21
|
+
{
|
|
22
|
+
type: 'ActiveSupport::Cache::MemoryStore',
|
|
23
|
+
size: data.size,
|
|
24
|
+
max_size: cache.instance_variable_get(:@max_size),
|
|
25
|
+
tracked_keys: key_access.size,
|
|
26
|
+
sample_keys: data.keys.first(50)
|
|
27
|
+
}
|
|
28
|
+
elsif defined?(::ActiveSupport::Cache::Store) && defined?(::ActiveSupport::Cache) &&
|
|
29
|
+
cache.is_a?(::ActiveSupport::Cache::Store)
|
|
30
|
+
stats = best_effort_cache_stats(cache)
|
|
31
|
+
stats.merge(type: cache.class.name)
|
|
32
|
+
elsif cache.is_a?(Hash)
|
|
33
|
+
{
|
|
34
|
+
type: 'Hash',
|
|
35
|
+
size: cache.size,
|
|
36
|
+
sample_keys: cache.keys.first(50)
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
stats = best_effort_cache_stats(cache)
|
|
40
|
+
stats.merge(type: cache.class.name)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Extract hit/miss and size where the cache exposes them (e.g. Dalli).
|
|
45
|
+
def best_effort_cache_stats(cache)
|
|
46
|
+
result = {}
|
|
47
|
+
result[:size] = cache_size_for(cache)
|
|
48
|
+
|
|
49
|
+
if cache.respond_to?(:stats)
|
|
50
|
+
raw =
|
|
51
|
+
begin
|
|
52
|
+
cache.stats
|
|
53
|
+
rescue
|
|
54
|
+
{}
|
|
55
|
+
end
|
|
56
|
+
if raw.is_a?(Hash)
|
|
57
|
+
result[:raw_stats] = raw
|
|
58
|
+
hits = raw['get_hits'] || raw[:get_hits]
|
|
59
|
+
misses = raw['get_misses'] || raw[:get_misses]
|
|
60
|
+
if hits && misses
|
|
61
|
+
total = hits.to_i + misses.to_i
|
|
62
|
+
result[:hits] = hits.to_i
|
|
63
|
+
result[:misses] = misses.to_i
|
|
64
|
+
result[:hit_rate] = total.zero? ? nil : format('%.1f%%', hits.to_f / total * 100)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
result
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def cache_keys_for(cache)
|
|
72
|
+
if defined?(::ActiveSupport::Cache::MemoryStore) && cache.is_a?(::ActiveSupport::Cache::MemoryStore)
|
|
73
|
+
(cache.instance_variable_get(:@data) || {}).keys
|
|
74
|
+
elsif cache.is_a?(Hash)
|
|
75
|
+
cache.keys
|
|
76
|
+
elsif cache.respond_to?(:keys)
|
|
77
|
+
begin
|
|
78
|
+
cache.keys
|
|
79
|
+
rescue
|
|
80
|
+
[]
|
|
81
|
+
end
|
|
82
|
+
else
|
|
83
|
+
[]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def cache_size_for(cache)
|
|
88
|
+
if defined?(::ActiveSupport::Cache::MemoryStore) && cache.is_a?(::ActiveSupport::Cache::MemoryStore)
|
|
89
|
+
(cache.instance_variable_get(:@data) || {}).size
|
|
90
|
+
elsif cache.respond_to?(:size)
|
|
91
|
+
begin
|
|
92
|
+
cache.size
|
|
93
|
+
rescue
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
elsif cache.respond_to?(:length)
|
|
97
|
+
begin
|
|
98
|
+
cache.length
|
|
99
|
+
rescue
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
register_tool('get_cache_stats',
|
|
107
|
+
'Get stats for registered caches: hit/miss ratio, size, entries. ' \
|
|
108
|
+
'Supports Rails.cache, ActiveSupport::Cache::MemoryStore, and Hash caches') do |name: nil|
|
|
109
|
+
if caches.empty?
|
|
110
|
+
next { error: 'No caches registered. Call DebugAgent.register_cache(:name, cache).' }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
targets = name ? { name.to_s => caches[name.to_s] } : caches
|
|
114
|
+
targets = targets.reject { |_, c| c.nil? }
|
|
115
|
+
next { error: "No cache registered under '#{name}'" } if targets.empty?
|
|
116
|
+
|
|
117
|
+
results = targets.map do |cache_name, cache|
|
|
118
|
+
begin
|
|
119
|
+
cache_stats_for(cache).merge(name: cache_name)
|
|
120
|
+
rescue => e
|
|
121
|
+
{ name: cache_name, error: e.message }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
{ caches: results }
|
|
126
|
+
rescue => e
|
|
127
|
+
{ error: e.message }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
register_tool('get_cache_keys',
|
|
131
|
+
'List keys in a registered cache with optional prefix filter',
|
|
132
|
+
name: { type: 'string', description: 'Registered cache name (optional, defaults to first)', required: false },
|
|
133
|
+
prefix: { type: 'string', description: 'Only return keys starting with this prefix', required: false }) do |name: nil, prefix: nil|
|
|
134
|
+
if caches.empty?
|
|
135
|
+
next { error: 'No caches registered. Call DebugAgent.register_cache(:name, cache).' }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
cache_name, cache = name ? [name.to_s, caches[name.to_s]] : caches.first
|
|
139
|
+
next { error: "No cache registered under '#{name}'" } unless cache
|
|
140
|
+
|
|
141
|
+
keys = cache_keys_for(cache)
|
|
142
|
+
if prefix && !prefix.to_s.empty?
|
|
143
|
+
keys = keys.select { |k| k.to_s.start_with?(prefix.to_s) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
{
|
|
147
|
+
cache: cache_name,
|
|
148
|
+
total_keys: keys.size,
|
|
149
|
+
keys: keys.first(500)
|
|
150
|
+
}
|
|
151
|
+
rescue => e
|
|
152
|
+
{ error: e.message }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
register_tool('clear_cache',
|
|
156
|
+
'Clear a registered cache (destructive: removes all entries)',
|
|
157
|
+
name: { type: 'string', description: 'Registered cache name (optional, defaults to first)', required: false }) do |name: nil|
|
|
158
|
+
if caches.empty?
|
|
159
|
+
next { error: 'No caches registered. Call DebugAgent.register_cache(:name, cache).' }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
cache_name, cache = name ? [name.to_s, caches[name.to_s]] : caches.first
|
|
163
|
+
next { error: "No cache registered under '#{name}'" } unless cache
|
|
164
|
+
|
|
165
|
+
before = cache_size_for(cache)
|
|
166
|
+
|
|
167
|
+
cleared =
|
|
168
|
+
if cache.respond_to?(:clear)
|
|
169
|
+
cache.clear
|
|
170
|
+
true
|
|
171
|
+
elsif cache.is_a?(Hash)
|
|
172
|
+
cache.clear
|
|
173
|
+
true
|
|
174
|
+
elsif cache.respond_to?(:clear_all)
|
|
175
|
+
cache.clear_all
|
|
176
|
+
true
|
|
177
|
+
elsif cache.respond_to?(:delete_all)
|
|
178
|
+
cache.delete_all
|
|
179
|
+
true
|
|
180
|
+
elsif cache.respond_to?(:flushdb)
|
|
181
|
+
cache.flushdb
|
|
182
|
+
true
|
|
183
|
+
else
|
|
184
|
+
false
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
next { error: "Cache '#{cache_name}' (#{cache.class}) does not support clearing" } unless cleared
|
|
188
|
+
|
|
189
|
+
after = cache_size_for(cache)
|
|
190
|
+
{ cache: cache_name, cleared: true, size_before: before, size_after: after }
|
|
191
|
+
rescue => e
|
|
192
|
+
{ error: e.message }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module DebugAgent
|
|
2
|
+
# Inspector for the concurrent-ruby gem: global executor pools and any
|
|
3
|
+
# registered promises/futures.
|
|
4
|
+
#
|
|
5
|
+
# DebugAgent.register_concurrent(:my_task, Concurrent::Promises.future { ... })
|
|
6
|
+
@concurrent_promises = {}
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
attr_reader :concurrent_promises
|
|
10
|
+
|
|
11
|
+
def register_concurrent(name, promise)
|
|
12
|
+
@concurrent_promises[name.to_s] = promise
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def executor_info(executor)
|
|
16
|
+
return nil unless executor
|
|
17
|
+
info = { class: executor.class.name }
|
|
18
|
+
%i[running?].each do |m|
|
|
19
|
+
info[m] = executor.public_send(m) if executor.respond_to?(m)
|
|
20
|
+
end
|
|
21
|
+
%i[length largest_length queue_length scheduled_task_count completed_task_count
|
|
22
|
+
max_threads min_threads idletime max_queue].each do |m|
|
|
23
|
+
info[m] = (executor.public_send(m) rescue nil) if executor.respond_to?(m)
|
|
24
|
+
end
|
|
25
|
+
info
|
|
26
|
+
rescue => e
|
|
27
|
+
{ class: executor&.class&.name, error: e.message }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def promise_info(name, promise)
|
|
31
|
+
info = { name: name, class: promise.class.name }
|
|
32
|
+
info[:state] = promise.state if promise.respond_to?(:state)
|
|
33
|
+
if promise.respond_to?(:fulfilled?)
|
|
34
|
+
info[:fulfilled] = promise.fulfilled?
|
|
35
|
+
info[:rejected] = promise.rejected? if promise.respond_to?(:rejected?)
|
|
36
|
+
info[:pending] = promise.pending? if promise.respond_to?(:pending?)
|
|
37
|
+
end
|
|
38
|
+
if promise.respond_to?(:reason)
|
|
39
|
+
reason = promise.reason
|
|
40
|
+
info[:reason] = reason.is_a?(Exception) ? reason.message : reason.inspect unless reason.nil?
|
|
41
|
+
end
|
|
42
|
+
info
|
|
43
|
+
rescue => e
|
|
44
|
+
{ name: name, error: e.message }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
register_tool('get_concurrent_state',
|
|
49
|
+
'If concurrent-ruby is loaded, list global executor pools and registered ' \
|
|
50
|
+
'promises/futures with their state') do
|
|
51
|
+
next { error: 'concurrent-ruby is not loaded (concurrent-ruby gem not installed)' } unless defined?(::Concurrent)
|
|
52
|
+
|
|
53
|
+
executors = {}
|
|
54
|
+
if ::Concurrent.respond_to?(:global_io_executor)
|
|
55
|
+
executors[:global_io] = executor_info(::Concurrent.global_io_executor)
|
|
56
|
+
end
|
|
57
|
+
if ::Concurrent.respond_to?(:global_fast_executor)
|
|
58
|
+
executors[:global_fast] = executor_info(::Concurrent.global_fast_executor)
|
|
59
|
+
end
|
|
60
|
+
if ::Concurrent.respond_to?(:global_immediate_executor)
|
|
61
|
+
executors[:global_immediate] = executor_info(::Concurrent.global_immediate_executor)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
promises =
|
|
65
|
+
if concurrent_promises.empty?
|
|
66
|
+
{ message: 'No promises/futures registered. Call DebugAgent.register_concurrent(:name, future).' }
|
|
67
|
+
else
|
|
68
|
+
concurrent_promises.map { |n, p| promise_info(n, p) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
executors: executors,
|
|
73
|
+
registered_promises: promises
|
|
74
|
+
}
|
|
75
|
+
rescue => e
|
|
76
|
+
{ error: e.message }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -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
|