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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94cb18c1904795fb713e2486d5f6d16e1bbce1a72d1820dd4122b2b7bc672a4d
4
- data.tar.gz: 8cf474737f8b84aa5f2a20e5d07f13a02c7345df6805d6b075f72bbb5175fc4f
3
+ metadata.gz: 791b8f21395b061038d65dcae92007460556766622991c1c0a1692ae21b272f5
4
+ data.tar.gz: b57b2b97b9473da04c7b7fc3cf67729bddfc283e2ca71d34b4e6469d2c47e65a
5
5
  SHA512:
6
- metadata.gz: 48c72eaf044cb325c3d8cb9e5ddc5afabbf85f5830e675795e8afb34182db5eb381f90ce913af411b4cf4acb662f82bf23373f2c12785e3dcee0a2491624e2a6
7
- data.tar.gz: 20e31311478fd6e5a54e0f693ce5a3cddc27952e5816a56082050bf17e77e4b19aba10d74bde0e95689765ec9e851228c0921724bb566f7609848ab76a844d79
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
  [![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,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
@@ -1,3 +1,3 @@
1
1
  module DebugAgent
2
- VERSION = '0.3.0'.freeze
2
+ VERSION = '0.4.0'.freeze
3
3
  end
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.3.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