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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94cb18c1904795fb713e2486d5f6d16e1bbce1a72d1820dd4122b2b7bc672a4d
4
- data.tar.gz: 8cf474737f8b84aa5f2a20e5d07f13a02c7345df6805d6b075f72bbb5175fc4f
3
+ metadata.gz: f71863664d8e95b34f553c692cfc6de2328151f3496dc5bb92ba309232dc3b52
4
+ data.tar.gz: ab521965229d38b7e8a2ae4fd56a466170219865863573bad59fbe8676f14ce6
5
5
  SHA512:
6
- metadata.gz: 48c72eaf044cb325c3d8cb9e5ddc5afabbf85f5830e675795e8afb34182db5eb381f90ce913af411b4cf4acb662f82bf23373f2c12785e3dcee0a2491624e2a6
7
- data.tar.gz: 20e31311478fd6e5a54e0f693ce5a3cddc27952e5816a56082050bf17e77e4b19aba10d74bde0e95689765ec9e851228c0921724bb566f7609848ab76a844d79
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
  [![Gem Version](https://img.shields.io/badge/gem-debug--agent-red)](https://github.com/topcheer/ruby-debug-agent)
4
- ![Tools](https://img.shields.io/badge/tools-40-blue)
5
- ![Inspectors](https://img.shields.io/badge/inspectors-13-green)
4
+ ![Tools](https://img.shields.io/badge/tools-54-blue)
5
+ ![Inspectors](https://img.shields.io/badge/inspectors-20-green)
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 — **40 diagnostic tools across 13 inspectors**.
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
- - **40 diagnostic tools** across **13 inspectors**
58
+ - **54 diagnostic tools** across **20 inspectors**
59
59
  - Zero external dependencies (no Datadog, no Grafana, no APM)
60
60
 
61
- ## Inspectors & Tools (40)
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