debug-agent 0.3.0 → 0.5.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 +46 -5
- 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/error_tracking.rb +120 -0
- data/lib/debug_agent/inspectors/faraday.rb +79 -0
- data/lib/debug_agent/inspectors/gc.rb +53 -0
- data/lib/debug_agent/inspectors/health.rb +110 -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/scheduler.rb +154 -0
- data/lib/debug_agent/inspectors/security.rb +201 -0
- data/lib/debug_agent/inspectors/websocket.rb +188 -0
- data/lib/debug_agent/llm_client.rb +56 -56
- data/lib/debug_agent/version.rb +1 -1
- data/lib/debug_agent.rb +12 -0
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f71863664d8e95b34f553c692cfc6de2328151f3496dc5bb92ba309232dc3b52
|
|
4
|
+
data.tar.gz: ab521965229d38b7e8a2ae4fd56a466170219865863573bad59fbe8676f14ce6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 01edd9b04e2c7054f6f247536eb85af48db98eb6c6367b4d863ea477b49388b347ad1a78d1758548caf965f483f10b432d979f397af8947e482ab3283104e0b6
|
|
7
|
+
data.tar.gz: cf20b7101a695433ddc35d6e4654d99555e50c7220fa8e4132e1495e6e9cadc88c1b217db487a5f9973c5a4a358ce2d6368bff3d65fbb9968ed420d2ee46d7a5
|
data/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Ruby Debug Agent
|
|
2
2
|
|
|
3
3
|
[](https://github.com/topcheer/ruby-debug-agent)
|
|
4
|
-

|
|
5
|
+

|
|
6
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 — **
|
|
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**.
|
|
8
8
|
|
|
9
9
|
## Quick Start
|
|
10
10
|
|
|
@@ -55,10 +55,10 @@ http://localhost:4567/agent
|
|
|
55
55
|
- **Context compression** — automatically summarizes old conversation when token limit is approached
|
|
56
56
|
- **Dark-themed chat UI** with full markdown rendering (tables, code blocks, lists)
|
|
57
57
|
- **Max tool rounds** (25) with forced final summary when limit is reached
|
|
58
|
-
- **
|
|
58
|
+
- **54 diagnostic tools** across **20 inspectors**
|
|
59
59
|
- Zero external dependencies (no Datadog, no Grafana, no APM)
|
|
60
60
|
|
|
61
|
-
## Inspectors & Tools (
|
|
61
|
+
## Inspectors & Tools (54)
|
|
62
62
|
|
|
63
63
|
### GC Inspector
|
|
64
64
|
| Tool | Description |
|
|
@@ -152,6 +152,47 @@ http://localhost:4567/agent
|
|
|
152
152
|
| `get_signal_handlers` | List registered signal handlers (Signal.trap) |
|
|
153
153
|
| `get_trap_handlers` | Inspect trap handlers for SIGINT, SIGTERM, etc. |
|
|
154
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
|
+
|
|
155
196
|
## Custom Tools
|
|
156
197
|
|
|
157
198
|
```ruby
|
|
@@ -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,120 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
require 'thread'
|
|
3
|
+
|
|
4
|
+
module DebugAgent
|
|
5
|
+
MAX_ERRORS = 50
|
|
6
|
+
@error_buffer = []
|
|
7
|
+
@error_buffer_lock = Mutex.new
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
attr_reader :error_buffer
|
|
11
|
+
|
|
12
|
+
def record_error(error, context = {})
|
|
13
|
+
@error_buffer_lock.synchronize do
|
|
14
|
+
@error_buffer << {
|
|
15
|
+
timestamp: Time.now.iso8601,
|
|
16
|
+
class: error.class.name,
|
|
17
|
+
message: error.message,
|
|
18
|
+
backtrace: (error.backtrace || []).first(10),
|
|
19
|
+
context: context
|
|
20
|
+
}
|
|
21
|
+
@error_buffer.shift if @error_buffer.size > MAX_ERRORS
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Capture unhandled exceptions at process exit
|
|
27
|
+
at_exit do
|
|
28
|
+
if $!
|
|
29
|
+
DebugAgent.record_error($!)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
register_tool('get_recent_errors',
|
|
34
|
+
'Get recent unhandled exceptions captured by the agent (ring buffer, max 50). ' \
|
|
35
|
+
'Each entry: timestamp, class, message, backtrace',
|
|
36
|
+
limit: { type: 'integer', description: 'Maximum number of errors to return (default 20)', required: false }) do |limit: 20|
|
|
37
|
+
errors = @error_buffer_lock.synchronize { @error_buffer.dup }
|
|
38
|
+
|
|
39
|
+
if errors.empty?
|
|
40
|
+
next {
|
|
41
|
+
message: 'No errors captured yet. Errors are recorded via at_exit and when ' \
|
|
42
|
+
'DebugAgent.record_error is called (e.g. from a Sinatra error handler).',
|
|
43
|
+
total: 0
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
limit = limit.to_i
|
|
48
|
+
limit = 20 if limit <= 0
|
|
49
|
+
|
|
50
|
+
{ total: errors.size, errors: errors.reverse.first(limit) }
|
|
51
|
+
rescue => e
|
|
52
|
+
{ error: e.message }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
register_tool('get_error_stats',
|
|
56
|
+
'Get error statistics: total errors, rate per minute, and top error types') do
|
|
57
|
+
errors = @error_buffer_lock.synchronize { @error_buffer.dup }
|
|
58
|
+
|
|
59
|
+
if errors.empty?
|
|
60
|
+
next { total: 0, message: 'No errors captured yet.' }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Group by error class
|
|
64
|
+
by_class = errors.group_by { |e| e[:class] }
|
|
65
|
+
top_types = by_class.map do |klass, entries|
|
|
66
|
+
{ class: klass, count: entries.size, last_seen: entries.last[:timestamp] }
|
|
67
|
+
end.sort_by { |t| -t[:count] }
|
|
68
|
+
|
|
69
|
+
# Calculate rate per minute (errors in the last 60 seconds)
|
|
70
|
+
now = Time.now
|
|
71
|
+
recent = errors.select do |e|
|
|
72
|
+
begin
|
|
73
|
+
(now - Time.iso8601(e[:timestamp])) <= 60
|
|
74
|
+
rescue
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
total: errors.size,
|
|
81
|
+
buffer_capacity: MAX_ERRORS,
|
|
82
|
+
rate_per_minute: recent.size,
|
|
83
|
+
unique_error_types: by_class.size,
|
|
84
|
+
top_error_types: top_types.first(10),
|
|
85
|
+
first_error: errors.first[:timestamp],
|
|
86
|
+
last_error: errors.last[:timestamp]
|
|
87
|
+
}
|
|
88
|
+
rescue => e
|
|
89
|
+
{ error: e.message }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
register_tool('get_error_patterns',
|
|
93
|
+
'Group captured errors by exception class to identify recurring patterns') do
|
|
94
|
+
errors = @error_buffer_lock.synchronize { @error_buffer.dup }
|
|
95
|
+
|
|
96
|
+
if errors.empty?
|
|
97
|
+
next { total: 0, message: 'No errors captured yet.' }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
patterns = errors.group_by { |e| e[:class] }.map do |klass, entries|
|
|
101
|
+
sample_messages = entries.map { |e| e[:message] }.uniq.first(5)
|
|
102
|
+
{
|
|
103
|
+
class: klass,
|
|
104
|
+
count: entries.size,
|
|
105
|
+
sample_messages: sample_messages,
|
|
106
|
+
first_seen: entries.first[:timestamp],
|
|
107
|
+
last_seen: entries.last[:timestamp],
|
|
108
|
+
sample_backtrace: entries.last[:backtrace]
|
|
109
|
+
}
|
|
110
|
+
end.sort_by { |p| -p[:count] }
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
total_errors: errors.size,
|
|
114
|
+
unique_patterns: patterns.size,
|
|
115
|
+
patterns: patterns
|
|
116
|
+
}
|
|
117
|
+
rescue => e
|
|
118
|
+
{ error: e.message }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module DebugAgent
|
|
2
|
+
# Registry of named Faraday connections so the inspector can introspect them.
|
|
3
|
+
#
|
|
4
|
+
# DebugAgent.register_faraday(:api, Faraday.new('https://api.example.com'))
|
|
5
|
+
@faraday_connections = {}
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
attr_reader :faraday_connections
|
|
9
|
+
|
|
10
|
+
def register_faraday(name, conn)
|
|
11
|
+
@faraday_connections[name.to_s] = conn
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def faraday_conn_info(name, conn)
|
|
15
|
+
info = { name: name, class: conn.class.name }
|
|
16
|
+
|
|
17
|
+
if conn.respond_to?(:url_prefix)
|
|
18
|
+
info[:url] = conn.url_prefix.to_s
|
|
19
|
+
info[:host] = conn.url_prefix.host
|
|
20
|
+
info[:port] = conn.url_prefix.port
|
|
21
|
+
info[:scheme] = conn.url_prefix.scheme
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
builder = conn.respond_to?(:builder) ? conn.builder : nil
|
|
25
|
+
if builder
|
|
26
|
+
handlers =
|
|
27
|
+
if builder.respond_to?(:handlers)
|
|
28
|
+
builder.handlers.map { |h| faraday_handler_name(h) }
|
|
29
|
+
else
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
info[:middleware] = handlers
|
|
33
|
+
|
|
34
|
+
adapter =
|
|
35
|
+
if builder.respond_to?(:adapter)
|
|
36
|
+
faraday_handler_name(builder.adapter)
|
|
37
|
+
end
|
|
38
|
+
info[:adapter] = adapter if adapter
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
info[:headers] = conn.headers.to_h if conn.respond_to?(:headers) && conn.headers.respond_to?(:to_h)
|
|
42
|
+
|
|
43
|
+
info
|
|
44
|
+
rescue => e
|
|
45
|
+
{ name: name, error: e.message }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def faraday_handler_name(handler)
|
|
49
|
+
return handler.name if handler.respond_to?(:name)
|
|
50
|
+
return handler.class.name if handler.respond_to?(:class)
|
|
51
|
+
handler.to_s
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
register_tool('get_faraday_connections',
|
|
56
|
+
'List registered Faraday connections with URL, adapter, and middleware stack ' \
|
|
57
|
+
'(requires faraday gem)') do |name: nil|
|
|
58
|
+
next { error: 'Faraday is not loaded (faraday gem not installed)' } unless defined?(::Faraday)
|
|
59
|
+
|
|
60
|
+
conns = faraday_connections
|
|
61
|
+
if conns.empty?
|
|
62
|
+
next {
|
|
63
|
+
message: 'No Faraday connections registered. Call DebugAgent.register_faraday(:name, conn).'
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
targets = name ? { name.to_s => conns[name.to_s] } : conns
|
|
68
|
+
targets = targets.reject { |_, c| c.nil? }
|
|
69
|
+
next { error: "No Faraday connection registered under '#{name}'" } if targets.empty?
|
|
70
|
+
|
|
71
|
+
list = targets.map do |conn_name, conn|
|
|
72
|
+
faraday_conn_info(conn_name, conn)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
{ connections: list }
|
|
76
|
+
rescue => e
|
|
77
|
+
{ error: e.message }
|
|
78
|
+
end
|
|
79
|
+
end
|