debug-agent 0.3.0 → 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 +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/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/version.rb +1 -1
- data/lib/debug_agent.rb +7 -0
- metadata +8 -1
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,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,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
|
|
@@ -62,6 +62,59 @@ module DebugAgent
|
|
|
62
62
|
{ error: e.message }
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
register_tool('get_gc_profiler_detail',
|
|
66
|
+
'Get GC::Profiler raw data with computed stats: total time, count, ' \
|
|
67
|
+
'min/avg/max GC time, total mark and sweep time, per-GC entries') do
|
|
68
|
+
unless defined?(GC::Profiler)
|
|
69
|
+
next { enabled: false, message: 'GC::Profiler is not available on this Ruby implementation' }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
raw_data = GC::Profiler.raw_data
|
|
73
|
+
total_time = GC::Profiler.total_time
|
|
74
|
+
|
|
75
|
+
if raw_data.nil? || raw_data.empty?
|
|
76
|
+
next {
|
|
77
|
+
enabled: true,
|
|
78
|
+
total_gc_time_seconds: 0,
|
|
79
|
+
gc_count: 0,
|
|
80
|
+
message: 'GC::Profiler has no data. Call GC::Profiler.enable to start collecting.'
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
gc_times = raw_data.map { |e| e[:GC_TIME].to_f }
|
|
85
|
+
mark_times = raw_data.map { |e| e[:GC_MARK_TIME].to_f }
|
|
86
|
+
sweep_times = raw_data.map { |e| e[:GC_SWEEP_TIME].to_f }
|
|
87
|
+
avg = gc_times.sum / gc_times.size
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
enabled: true,
|
|
91
|
+
total_gc_time_seconds: total_time.round(6),
|
|
92
|
+
gc_count: raw_data.size,
|
|
93
|
+
gc_time_stats_ms: {
|
|
94
|
+
min: (gc_times.min * 1000).round(3),
|
|
95
|
+
avg: (avg * 1000).round(3),
|
|
96
|
+
max: (gc_times.max * 1000).round(3)
|
|
97
|
+
},
|
|
98
|
+
total_mark_time_seconds: mark_times.sum.round(6),
|
|
99
|
+
total_sweep_time_seconds: sweep_times.sum.round(6),
|
|
100
|
+
entries: raw_data.map.with_index do |entry, i|
|
|
101
|
+
{
|
|
102
|
+
index: i,
|
|
103
|
+
gc_time_ms: (entry[:GC_TIME].to_f * 1000).round(3),
|
|
104
|
+
gc_invoke_time: entry[:GC_INVOKE_TIME]&.round(6),
|
|
105
|
+
heap_use_pages: entry[:HEAP_USE_PAGES],
|
|
106
|
+
heap_live_objects: entry[:HEAP_LIVE_OBJECTS],
|
|
107
|
+
heap_free_objects: entry[:HEAP_FREE_OBJECTS],
|
|
108
|
+
heap_total_objects: entry[:HEAP_TOTAL_OBJECTS],
|
|
109
|
+
gc_mark_time_ms: (entry[:GC_MARK_TIME].to_f * 1000).round(3),
|
|
110
|
+
gc_sweep_time_ms: (entry[:GC_SWEEP_TIME].to_f * 1000).round(3)
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
}
|
|
114
|
+
rescue => e
|
|
115
|
+
{ error: e.message }
|
|
116
|
+
end
|
|
117
|
+
|
|
65
118
|
register_tool('force_gc',
|
|
66
119
|
'Trigger a full garbage collection (GC.start with full_mark) and show before/after comparison') do
|
|
67
120
|
before_stats = GC.stat
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
require 'thread'
|
|
3
|
+
|
|
4
|
+
module DebugAgent
|
|
5
|
+
# Track outbound Net::HTTP calls (latency, errors, hosts) and live
|
|
6
|
+
# connections by wrapping Net::HTTP#request, #start and #finish.
|
|
7
|
+
@outbound_stats = { total: 0, latencies: [], errors: 0, hosts: {} }
|
|
8
|
+
@outbound_lock = Mutex.new
|
|
9
|
+
@http_connections = {}
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
attr_reader :outbound_stats
|
|
13
|
+
|
|
14
|
+
def record_outbound(http, req, latency_ms, error)
|
|
15
|
+
@outbound_lock.synchronize do
|
|
16
|
+
s = @outbound_stats
|
|
17
|
+
s[:total] += 1
|
|
18
|
+
s[:latencies] << latency_ms
|
|
19
|
+
s[:latencies].shift if s[:latencies].size > 1000
|
|
20
|
+
|
|
21
|
+
host_key = "#{http.address}:#{http.port}"
|
|
22
|
+
h = (s[:hosts][host_key] ||= { count: 0, latencies: [], errors: 0 })
|
|
23
|
+
h[:count] += 1
|
|
24
|
+
h[:latencies] << latency_ms
|
|
25
|
+
h[:latencies].shift if h[:latencies].size > 200
|
|
26
|
+
if error
|
|
27
|
+
s[:errors] += 1
|
|
28
|
+
h[:errors] += 1
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def track_http_connect(http)
|
|
34
|
+
@outbound_lock.synchronize do
|
|
35
|
+
@http_connections[http.object_id] = {
|
|
36
|
+
host: http.address,
|
|
37
|
+
port: http.port,
|
|
38
|
+
use_ssl: http.use_ssl?,
|
|
39
|
+
started_at: Time.now.iso8601,
|
|
40
|
+
active: true
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def track_http_disconnect(http)
|
|
46
|
+
@outbound_lock.synchronize do
|
|
47
|
+
conn = @http_connections[http.object_id]
|
|
48
|
+
conn[:active] = false if conn
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Wrap Net::HTTP once to capture outbound request metrics.
|
|
53
|
+
def install_outbound_tracker
|
|
54
|
+
return false unless defined?(::Net::HTTP)
|
|
55
|
+
return true if ::Net::HTTP.include?(OutboundHttpTracker)
|
|
56
|
+
|
|
57
|
+
::Net::HTTP.prepend(OutboundHttpTracker)
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Prepended module that instruments Net::HTTP request lifecycle.
|
|
63
|
+
module OutboundHttpTracker
|
|
64
|
+
def start
|
|
65
|
+
DebugAgent.track_http_connect(self) rescue nil
|
|
66
|
+
super
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def finish
|
|
70
|
+
DebugAgent.track_http_disconnect(self) rescue nil
|
|
71
|
+
super
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def request(req, *args, &block)
|
|
75
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
76
|
+
begin
|
|
77
|
+
result = super
|
|
78
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0)
|
|
79
|
+
DebugAgent.record_outbound(self, req, elapsed, nil) rescue nil
|
|
80
|
+
result
|
|
81
|
+
rescue => e
|
|
82
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0)
|
|
83
|
+
DebugAgent.record_outbound(self, req, elapsed, e) rescue nil
|
|
84
|
+
raise
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Auto-install the tracker at load time when Net::HTTP is available.
|
|
90
|
+
install_outbound_tracker
|
|
91
|
+
|
|
92
|
+
register_tool('get_http_connections',
|
|
93
|
+
'List Net::HTTP connections and their state: host, port, use_ssl, ' \
|
|
94
|
+
'start_time, active connections') do
|
|
95
|
+
conns = @outbound_lock.synchronize { @http_connections.values }
|
|
96
|
+
active = conns.select { |c| c[:active] }
|
|
97
|
+
{
|
|
98
|
+
active_count: active.size,
|
|
99
|
+
total_tracked: conns.size,
|
|
100
|
+
tracker_active: defined?(::Net::HTTP) && ::Net::HTTP.include?(OutboundHttpTracker),
|
|
101
|
+
connections: conns.last(200)
|
|
102
|
+
}
|
|
103
|
+
rescue => e
|
|
104
|
+
{ error: e.message }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
register_tool('get_outbound_summary',
|
|
108
|
+
'Summary of outbound HTTP calls tracked by the agent: total, avg latency, ' \
|
|
109
|
+
'error rate, top hosts') do
|
|
110
|
+
snapshot = @outbound_lock.synchronize do
|
|
111
|
+
{
|
|
112
|
+
total: @outbound_stats[:total],
|
|
113
|
+
latencies: @outbound_stats[:latencies].dup,
|
|
114
|
+
errors: @outbound_stats[:errors],
|
|
115
|
+
hosts: @outbound_stats[:hosts].transform_values(&:dup)
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
lats = snapshot[:latencies]
|
|
120
|
+
avg = lats.empty? ? 0.0 : (lats.sum / lats.size)
|
|
121
|
+
total = snapshot[:total]
|
|
122
|
+
|
|
123
|
+
top_hosts = snapshot[:hosts].map do |host, info|
|
|
124
|
+
hl = info[:latencies]
|
|
125
|
+
{
|
|
126
|
+
host: host,
|
|
127
|
+
count: info[:count],
|
|
128
|
+
avg_latency_ms: hl.empty? ? 0 : (hl.sum / hl.size).round(2),
|
|
129
|
+
errors: info[:errors]
|
|
130
|
+
}
|
|
131
|
+
end.sort_by { |h| -h[:count] }.first(10)
|
|
132
|
+
|
|
133
|
+
{
|
|
134
|
+
total_requests: total,
|
|
135
|
+
avg_latency_ms: avg.round(2),
|
|
136
|
+
error_count: snapshot[:errors],
|
|
137
|
+
error_rate: total.zero? ? '0.0%' : format('%.1f%%', snapshot[:errors].to_f / total * 100),
|
|
138
|
+
tracked_hosts: snapshot[:hosts].size,
|
|
139
|
+
tracker_active: defined?(::Net::HTTP) && ::Net::HTTP.include?(OutboundHttpTracker),
|
|
140
|
+
top_hosts: top_hosts
|
|
141
|
+
}
|
|
142
|
+
rescue => e
|
|
143
|
+
{ error: e.message }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
require 'thread'
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module DebugAgent
|
|
6
|
+
# Ring buffer of recent log entries and a registry of named loggers.
|
|
7
|
+
#
|
|
8
|
+
# DebugAgent.register_logger(:app, Rails.logger)
|
|
9
|
+
MAX_LOGS = 100
|
|
10
|
+
|
|
11
|
+
@log_buffer = []
|
|
12
|
+
@log_buffer_lock = Mutex.new
|
|
13
|
+
@loggers = {}
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
attr_reader :loggers
|
|
17
|
+
|
|
18
|
+
def register_logger(name, logger)
|
|
19
|
+
@loggers[name.to_s] = logger
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Invoked by the wrapped Logger#add to push an entry into the ring buffer.
|
|
23
|
+
def capture_log(severity, args)
|
|
24
|
+
args = args.is_a?(Array) ? args : [args]
|
|
25
|
+
# Logger passes (message, progname); pick the meaningful value.
|
|
26
|
+
msg = args.compact.first
|
|
27
|
+
entry = {
|
|
28
|
+
timestamp: Time.now.iso8601,
|
|
29
|
+
severity: severity_label(severity),
|
|
30
|
+
message: msg.respond_to?(:to_str) ? msg.to_s : msg.inspect
|
|
31
|
+
}
|
|
32
|
+
@log_buffer_lock.synchronize do
|
|
33
|
+
@log_buffer << entry
|
|
34
|
+
@log_buffer.shift if @log_buffer.size > MAX_LOGS
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Wrap the standard Logger#add / << so all log output flows into the ring
|
|
39
|
+
# buffer. Only wraps once — guarded by checking for the aliased method.
|
|
40
|
+
def install_log_capture
|
|
41
|
+
return false unless defined?(::Logger)
|
|
42
|
+
return true if ::Logger.method_defined?(:_original_add)
|
|
43
|
+
|
|
44
|
+
::Logger.class_eval do
|
|
45
|
+
alias_method :_original_add, :add
|
|
46
|
+
alias_method :_original_lshift, :<<
|
|
47
|
+
|
|
48
|
+
def add(severity, *args, &block)
|
|
49
|
+
if block
|
|
50
|
+
msg = args[0]
|
|
51
|
+
msg = block.call if msg.nil?
|
|
52
|
+
DebugAgent.capture_log(severity, [msg]) rescue nil
|
|
53
|
+
_original_add(severity, msg, *args[1..-1])
|
|
54
|
+
else
|
|
55
|
+
DebugAgent.capture_log(severity, args) rescue nil
|
|
56
|
+
_original_add(severity, *args)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def <<(msg)
|
|
61
|
+
DebugAgent.capture_log(nil, [msg]) rescue nil
|
|
62
|
+
_original_lshift(msg)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Map a Logger severity integer to a human-readable label.
|
|
69
|
+
def severity_label(severity)
|
|
70
|
+
labels = %w[DEBUG INFO WARN ERROR FATAL ANY]
|
|
71
|
+
idx = severity.is_a?(Integer) ? severity : (defined?(::Logger) ? ::Logger::UNKNOWN : 5)
|
|
72
|
+
labels[idx] || 'UNKNOWN'
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Attempt to wrap Logger at load time (no-op if Logger isn't loaded yet).
|
|
77
|
+
install_log_capture
|
|
78
|
+
|
|
79
|
+
LEVEL_MAP = {
|
|
80
|
+
'debug' => defined?(::Logger) ? ::Logger::DEBUG : 0,
|
|
81
|
+
'info' => defined?(::Logger) ? ::Logger::INFO : 1,
|
|
82
|
+
'warn' => defined?(::Logger) ? ::Logger::WARN : 2,
|
|
83
|
+
'error' => defined?(::Logger) ? ::Logger::ERROR : 3,
|
|
84
|
+
'fatal' => defined?(::Logger) ? ::Logger::FATAL : 4
|
|
85
|
+
}.freeze
|
|
86
|
+
|
|
87
|
+
register_tool('get_log_buffer',
|
|
88
|
+
'Return recent log entries captured from the built-in ring buffer ' \
|
|
89
|
+
'(Logger#add and << are auto-wrapped)') do |limit: 50|
|
|
90
|
+
limit = limit.to_i
|
|
91
|
+
limit = 50 if limit <= 0
|
|
92
|
+
entries = @log_buffer_lock.synchronize { @log_buffer.dup }
|
|
93
|
+
{
|
|
94
|
+
total_captured: entries.size,
|
|
95
|
+
capacity: MAX_LOGS,
|
|
96
|
+
capture_active: defined?(::Logger) && ::Logger.method_defined?(:_original_add),
|
|
97
|
+
entries: entries.last(limit).reverse
|
|
98
|
+
}
|
|
99
|
+
rescue => e
|
|
100
|
+
{ error: e.message }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
register_tool('get_logger_info',
|
|
104
|
+
'List registered loggers with configuration: level, device, formatter, progname') do
|
|
105
|
+
if loggers.empty?
|
|
106
|
+
next {
|
|
107
|
+
message: 'No loggers registered. Call DebugAgent.register_logger(:name, logger).',
|
|
108
|
+
capture_active: defined?(::Logger) && ::Logger.method_defined?(:_original_add)
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
list = loggers.map do |name, logger|
|
|
113
|
+
info = { name: name, class: logger.class.name }
|
|
114
|
+
info[:level] = severity_label(logger.level) if logger.respond_to?(:level)
|
|
115
|
+
info[:progname] = logger.progname if logger.respond_to?(:progname)
|
|
116
|
+
|
|
117
|
+
if defined?(::Logger) && logger.is_a?(::Logger)
|
|
118
|
+
logdev = logger.instance_variable_get(:@logdev)
|
|
119
|
+
dev = logdev&.instance_variable_get(:@dev)
|
|
120
|
+
info[:device] =
|
|
121
|
+
case dev
|
|
122
|
+
when IO then dev.inspect
|
|
123
|
+
when String then dev
|
|
124
|
+
when nil then nil
|
|
125
|
+
else dev.inspect
|
|
126
|
+
end
|
|
127
|
+
formatter = logger.instance_variable_get(:@formatter)
|
|
128
|
+
info[:formatter] = formatter ? formatter.class.name : 'default'
|
|
129
|
+
end
|
|
130
|
+
info
|
|
131
|
+
rescue => e
|
|
132
|
+
{ name: name, error: e.message }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
{ loggers: list }
|
|
136
|
+
rescue => e
|
|
137
|
+
{ error: e.message }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
register_tool('set_log_level',
|
|
141
|
+
"Dynamically change a registered logger's level",
|
|
142
|
+
logger_name: { type: 'string', description: 'Registered logger name' },
|
|
143
|
+
level: { type: 'string', description: 'One of: debug, info, warn, error, fatal' }) do |logger_name:, level:|
|
|
144
|
+
logger = loggers[logger_name.to_s]
|
|
145
|
+
next({ error: "No logger registered under '#{logger_name}'" }) unless logger
|
|
146
|
+
next({ error: 'Logger does not respond to level=' }) unless logger.respond_to?(:level=)
|
|
147
|
+
|
|
148
|
+
target = LEVEL_MAP[level.to_s.downcase]
|
|
149
|
+
next({ error: "Invalid level '#{level}'. Use debug/info/warn/error/fatal." }) unless target
|
|
150
|
+
|
|
151
|
+
previous = severity_label(logger.level)
|
|
152
|
+
logger.level = target
|
|
153
|
+
|
|
154
|
+
{
|
|
155
|
+
logger: logger_name,
|
|
156
|
+
previous_level: previous,
|
|
157
|
+
new_level: level.to_s.downcase,
|
|
158
|
+
success: true
|
|
159
|
+
}
|
|
160
|
+
rescue => e
|
|
161
|
+
{ error: e.message }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module DebugAgent
|
|
2
|
+
# Inspector for Prometheus metrics (prometheus-client gem).
|
|
3
|
+
# Uses the default registry: Prometheus::Client.registry.
|
|
4
|
+
|
|
5
|
+
class << self
|
|
6
|
+
# Resolve the Prometheus registry to inspect.
|
|
7
|
+
def prometheus_registry
|
|
8
|
+
return nil unless defined?(::Prometheus) && defined?(::Prometheus::Client)
|
|
9
|
+
::Prometheus::Client.respond_to?(:registry) ? ::Prometheus::Client.registry : nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Safely read a metric's value(s). Different metric types return
|
|
13
|
+
# different shapes from #get.
|
|
14
|
+
def prometheus_metric_value(metric)
|
|
15
|
+
begin
|
|
16
|
+
value = metric.get({})
|
|
17
|
+
# Counter/Gauge return a Hash of {labels => value}; unwrap the unlabeled value.
|
|
18
|
+
if value.is_a?(Hash) && value.size == 1 && value.key?({})
|
|
19
|
+
value[{}]
|
|
20
|
+
else
|
|
21
|
+
value
|
|
22
|
+
end
|
|
23
|
+
rescue => e
|
|
24
|
+
{ error: e.message }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
register_tool('get_registered_metrics',
|
|
30
|
+
'List registered Prometheus metrics from the prometheus-client gem: ' \
|
|
31
|
+
'name, type, docstring, value') do
|
|
32
|
+
registry = prometheus_registry
|
|
33
|
+
next { error: 'Prometheus client is not loaded (prometheus-client gem not installed)' } unless registry
|
|
34
|
+
next { error: 'No Prometheus registry available' } unless registry.respond_to?(:metrics)
|
|
35
|
+
|
|
36
|
+
metrics = registry.metrics.map do |metric|
|
|
37
|
+
{
|
|
38
|
+
name: metric.name,
|
|
39
|
+
type: metric.respond_to?(:type) ? metric.type.to_s : 'unknown',
|
|
40
|
+
docstring: metric.respond_to?(:docstring) ? metric.docstring : nil,
|
|
41
|
+
value: prometheus_metric_value(metric)
|
|
42
|
+
}
|
|
43
|
+
rescue => e
|
|
44
|
+
{ name: metric&.respond_to?(:name) ? metric.name : 'unknown', error: e.message }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
{ total: metrics.size, metrics: metrics }
|
|
48
|
+
rescue => e
|
|
49
|
+
{ error: e.message }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
register_tool('get_metric_value',
|
|
53
|
+
'Get a specific Prometheus metric value by name',
|
|
54
|
+
name: { type: 'string', description: 'Registered metric name' }) do |name:|
|
|
55
|
+
registry = prometheus_registry
|
|
56
|
+
next { error: 'Prometheus client is not loaded (prometheus-client gem not installed)' } unless registry
|
|
57
|
+
next { error: 'No Prometheus registry available' } unless registry.respond_to?(:metrics)
|
|
58
|
+
|
|
59
|
+
metric = registry.metrics.find { |m| m.respond_to?(:name) && m.name.to_s == name.to_s }
|
|
60
|
+
next { error: "Metric '#{name}' not found in registry" } unless metric
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
name: metric.name,
|
|
64
|
+
type: metric.respond_to?(:type) ? metric.type.to_s : 'unknown',
|
|
65
|
+
docstring: metric.respond_to?(:docstring) ? metric.docstring : nil,
|
|
66
|
+
value: prometheus_metric_value(metric)
|
|
67
|
+
}
|
|
68
|
+
rescue => e
|
|
69
|
+
{ error: e.message }
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/debug_agent/version.rb
CHANGED
data/lib/debug_agent.rb
CHANGED
|
@@ -23,6 +23,13 @@ require_relative 'debug_agent/inspectors/redis'
|
|
|
23
23
|
require_relative 'debug_agent/inspectors/rails'
|
|
24
24
|
require_relative 'debug_agent/inspectors/sidekiq'
|
|
25
25
|
require_relative 'debug_agent/inspectors/puma'
|
|
26
|
+
require_relative 'debug_agent/inspectors/logging'
|
|
27
|
+
require_relative 'debug_agent/inspectors/cache'
|
|
28
|
+
require_relative 'debug_agent/inspectors/http_client'
|
|
29
|
+
require_relative 'debug_agent/inspectors/metrics'
|
|
30
|
+
require_relative 'debug_agent/inspectors/active_record_stats'
|
|
31
|
+
require_relative 'debug_agent/inspectors/faraday'
|
|
32
|
+
require_relative 'debug_agent/inspectors/concurrent'
|
|
26
33
|
|
|
27
34
|
module DebugAgent
|
|
28
35
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ggcode
|
|
@@ -164,9 +164,16 @@ files:
|
|
|
164
164
|
- lib/debug_agent/config.rb
|
|
165
165
|
- lib/debug_agent/context_compressor.rb
|
|
166
166
|
- lib/debug_agent/engine.rb
|
|
167
|
+
- lib/debug_agent/inspectors/active_record_stats.rb
|
|
168
|
+
- lib/debug_agent/inspectors/cache.rb
|
|
169
|
+
- lib/debug_agent/inspectors/concurrent.rb
|
|
167
170
|
- lib/debug_agent/inspectors/core_ext.rb
|
|
171
|
+
- lib/debug_agent/inspectors/faraday.rb
|
|
168
172
|
- lib/debug_agent/inspectors/gc.rb
|
|
173
|
+
- lib/debug_agent/inspectors/http_client.rb
|
|
169
174
|
- lib/debug_agent/inspectors/http_tracker.rb
|
|
175
|
+
- lib/debug_agent/inspectors/logging.rb
|
|
176
|
+
- lib/debug_agent/inspectors/metrics.rb
|
|
170
177
|
- lib/debug_agent/inspectors/object_space.rb
|
|
171
178
|
- lib/debug_agent/inspectors/process_info.rb
|
|
172
179
|
- lib/debug_agent/inspectors/puma.rb
|