debug-agent 0.6.0 → 0.7.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: 7b9c945dc45966ff8c9d06b0679357d54ba9dc443d5ee68c532ae1058878c6e4
4
- data.tar.gz: a986e568c59ca3f8d04f71d647ba0649bbbff7659f021eca349bc3cce3b3b044
3
+ metadata.gz: fe4d325302d304db5821ef8bc5283ce3978e26d7721604efcbf00a646229346c
4
+ data.tar.gz: '058ae474cc6e35f83f59ee86da1ca807af84a51762c66a9452827805e28664c9'
5
5
  SHA512:
6
- metadata.gz: 90d75283c6f362c3be78a419923c26d622aa6d55ed3d29ebfca1c40341d7d264a9a6074fd30e93140fb13db44991a8a52261d0695a2df22bcf1e17e865f793bb
7
- data.tar.gz: 319d197cd23d9c9fd15845dc4a8a896b2560d3da83b85ac69e9687ba4ce3495ed1b720089ce82cc6adef8497e570177078f9430191bbca260a32a230f68171aa
6
+ metadata.gz: f72cf134d401b2f055e5103eea6b6d35e06bbc75f7962a6f33d37f906f91e6899a5f562a5a032209448f469c5830b24d582b43b34730fbf949a1c47d4a8b25cb
7
+ data.tar.gz: dbb70e23e5bce9c717c8ecbdf708b2724811beeac9fa3672f496ac25dfedda108f0c051f71c7af5a1421cf181d120534aff1a94f6c511df3af04a56745a19098
data/README.md CHANGED
@@ -1,12 +1,12 @@
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-84-blue)
5
- ![Inspectors](https://img.shields.io/badge/inspectors-31-green)
4
+ ![Tools](https://img.shields.io/badge/tools-98-blue)
5
+ ![Inspectors](https://img.shields.io/badge/inspectors-36-green)
6
6
  ![Ruby](https://img.shields.io/badge/Ruby-2.7%2B-CC342D)
7
7
  ![Gem](https://img.shields.io/badge/gem-debug--agent-red)
8
8
 
9
- 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 — **84 diagnostic tools across 31 inspectors**.
9
+ 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 — **98 diagnostic tools across 36 inspectors**.
10
10
 
11
11
  ## Version Support
12
12
 
@@ -71,10 +71,10 @@ http://localhost:4567/agent
71
71
  - **Context compression** — automatically summarizes old conversation when token limit is approached
72
72
  - **Dark-themed chat UI** with full markdown rendering (tables, code blocks, lists)
73
73
  - **Max tool rounds** (25) with forced final summary when limit is reached
74
- - **84 diagnostic tools** across **31 inspectors**
74
+ - **98 diagnostic tools** across **36 inspectors**
75
75
  - Zero external dependencies (no Datadog, no Grafana, no APM)
76
76
 
77
- ## Inspectors & Tools (84)
77
+ ## Inspectors & Tools (98)
78
78
 
79
79
  ### GC Inspector
80
80
  | Tool | Description |
@@ -250,6 +250,40 @@ http://localhost:4567/agent
250
250
  | `detect_pool_leaks` | Heuristic leak detection (growing pool, high wait ratio, saturation) |
251
251
  | `get_pool_wait_stats` | Connection acquire wait stats (avg, P95, max wait, timeout count) |
252
252
 
253
+ ### CPU Profiler Inspector (v0.7.0)
254
+ | Tool | Description |
255
+ |------|-------------|
256
+ | `start_cpu_profile` | Start a CPU profiling session (stackprof/stackprof-native) |
257
+ | `stop_cpu_profile` | Stop CPU profiling and return collected profile data |
258
+ | `get_top_functions` | Get top CPU-consuming functions from the current profile |
259
+
260
+ ### Memory Leak Detector Inspector (v0.7.0)
261
+ | Tool | Description |
262
+ |------|-------------|
263
+ | `take_heap_snapshot` | Capture an ObjectSpace heap snapshot for leak analysis |
264
+ | `compare_heap_snapshots` | Compare two heap snapshots to identify object growth |
265
+ | `get_leak_candidates` | Identify objects likely to be memory leaks |
266
+
267
+ ### Deployment/Build Info Inspector (v0.7.0)
268
+ | Tool | Description |
269
+ |------|-------------|
270
+ | `get_build_info` | Build version, commit hash, and gem metadata |
271
+ | `get_deployment_info` | Deployment environment, container, and orchestration metadata |
272
+ | `get_runtime_version` | Ruby interpreter version, engine, and platform details |
273
+
274
+ ### Snapshot & Diff Inspector (v0.7.0)
275
+ | Tool | Description |
276
+ |------|-------------|
277
+ | `take_snapshot` | Capture a runtime state snapshot |
278
+ | `compare_snapshots` | Compare two snapshots to identify state changes |
279
+ | `list_snapshots` | List all saved snapshots with timestamps |
280
+
281
+ ### Service Registry Inspector (v0.7.0)
282
+ | Tool | Description |
283
+ |------|-------------|
284
+ | `get_registered_services` | List all registered application services |
285
+ | `get_service_dependencies` | Map service-to-service dependency graph |
286
+
253
287
  ## Custom Tools
254
288
 
255
289
  ```ruby
@@ -302,6 +336,12 @@ cd demo && ruby -I../lib app.rb
302
336
 
303
337
  [![Gem](https://img.shields.io/badge/rubygems-debug--agent-red)](https://github.com/topcheer/ruby-debug-agent)
304
338
 
339
+ ## Built With
340
+
341
+ [![ggcode](https://img.shields.io/badge/built%20with-ggcode-blue)](https://github.com/topcheer/ggcode)
342
+
343
+ This project was built using [ggcode](https://github.com/topcheer/ggcode) — an AI coding assistant for terminal-based development.
344
+
305
345
  ## License
306
346
 
307
347
  MIT
@@ -0,0 +1,87 @@
1
+ require 'socket'
2
+ require 'etc'
3
+
4
+ module DebugAgent
5
+ register_tool('get_build_info',
6
+ 'Get Ruby build info: version, engine (MRI/JRuby/TruffleRuby), ' \
7
+ 'platform, build date, RUBY_DESCRIPTION') do
8
+ {
9
+ ruby_version: RUBY_VERSION,
10
+ ruby_engine: RUBY_ENGINE,
11
+ ruby_engine_version: defined?(RUBY_ENGINE_VERSION) ? RUBY_ENGINE_VERSION : RUBY_VERSION,
12
+ platform: RUBY_PLATFORM,
13
+ ruby_description: RUBY_DESCRIPTION,
14
+ ruby_patchlevel: defined?(RUBY_PATCHLEVEL) ? RUBY_PATCHLEVEL : nil,
15
+ ruby_revision: defined?(RUBY_REVISION) ? RUBY_REVISION.to_s : nil,
16
+ ruby_release_date: defined?(RUBY_RELEASE_DATE) ? RUBY_RELEASE_DATE : nil,
17
+ build_date: defined?(RUBY_RELEASE_DATE) ? RUBY_RELEASE_DATE : nil,
18
+ host_os: RbConfig::CONFIG['host_os'],
19
+ host_cpu: RbConfig::CONFIG['host_cpu'],
20
+ configure_args: RbConfig::CONFIG['configure_args']
21
+ }
22
+ rescue => e
23
+ { error: e.message }
24
+ end
25
+
26
+ register_tool('get_deployment_info',
27
+ 'Get deployment info: hostname, PID, uptime, container detection, ' \
28
+ 'APP_ENV, Rails env') do
29
+ rss = `ps -o rss= -p #{Process.pid}`.to_i
30
+ uptime_seconds = Time.now - DebugAgent::PROCESS_START_TIME
31
+
32
+ container_detected = File.exist?('/.dockerenv')
33
+ in_cgroup = false
34
+ begin
35
+ in_cgroup = File.read('/proc/1/cgroup').include?('docker') ||
36
+ File.read('/proc/1/cgroup').include?('containerd')
37
+ rescue
38
+ end
39
+
40
+ {
41
+ hostname: Socket.gethostname,
42
+ pid: Process.pid,
43
+ ppid: Process.ppid,
44
+ process_name: $0,
45
+ uptime_seconds: uptime_seconds.round(0),
46
+ rss_mb: (rss / 1024.0).round(2),
47
+ container_detected: container_detected || in_cgroup,
48
+ docker_detected: File.exist?('/.dockerenv'),
49
+ app_env: ENV['APP_ENV'] || ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'unknown',
50
+ rails_env: defined?(::Rails) ? ::Rails.env.to_s : nil,
51
+ user: Etc.getpwuid(Process.uid)&.name,
52
+ uid: Process.uid,
53
+ gid: Process.gid,
54
+ cpu_count: Etc.nprocessors
55
+ }
56
+ rescue => e
57
+ { error: e.message }
58
+ end
59
+
60
+ register_tool('get_runtime_versions',
61
+ 'Get versions of key gems: rails, sinatra, sidekiq, redis, puma ' \
62
+ 'if loaded') do
63
+ key_gems = %w[rails sinatra sidekiq redis puma rack rack-attack \
64
+ activerecord activesupport postgresql pg mysql2 bunny \
65
+ faraday httplog oj msgpack json]
66
+
67
+ versions = {}
68
+ key_gems.each do |gem_name|
69
+ spec = Gem.loaded_specs[gem_name] || Gem.loaded_specs.values.find { |s| s.name == gem_name }
70
+ versions[gem_name] = spec&.version&.to_s if spec
71
+ end
72
+
73
+ # Remove nils
74
+ versions.compact!
75
+
76
+ {
77
+ ruby_version: RUBY_VERSION,
78
+ ruby_engine: RUBY_ENGINE,
79
+ rubygems_version: Gem::VERSION,
80
+ bundler_version: defined?(Bundler) ? Bundler::VERSION : nil,
81
+ loaded_gem_count: Gem.loaded_specs.size,
82
+ key_gem_versions: versions
83
+ }
84
+ rescue => e
85
+ { error: e.message }
86
+ end
87
+ end
@@ -0,0 +1,192 @@
1
+ require 'timeout'
2
+
3
+ module DebugAgent
4
+ # CPU profiler inspector. Uses stackprof if available, otherwise falls back
5
+ # to a sampling profiler that periodically captures caller stacks.
6
+ @cpu_profile_data = nil
7
+ @cpu_profile_running = false
8
+ @cpu_profile_lock = Mutex.new
9
+
10
+ class << self
11
+ attr_reader :cpu_profile_data, :cpu_profile_running
12
+ end
13
+
14
+ register_tool('start_cpu_profile',
15
+ 'Start CPU profiling for a given duration. Uses stackprof if available, ' \
16
+ 'otherwise falls back to a sampling profiler. Auto-stops after duration.',
17
+ duration_seconds: { type: 'integer', description: 'Profile duration in seconds', required: false }) do |duration_seconds: 10|
18
+ duration = duration_seconds.to_i
19
+ duration = 10 if duration <= 0
20
+
21
+ @cpu_profile_lock.synchronize do
22
+ next { error: 'CPU profile already running' } if @cpu_profile_running
23
+ @cpu_profile_running = true
24
+ @cpu_profile_data = nil
25
+ end
26
+
27
+ if defined?(::StackProf)
28
+ # StackProf path: block-based profiling
29
+ StackProf.run(mode: :cpu, interval: 1000) do
30
+ sleep(duration)
31
+ end
32
+ @cpu_profile_lock.synchronize { @cpu_profile_running = false }
33
+ raw = StackProf.results
34
+ @cpu_profile_data = parse_stackprof(raw) if raw
35
+ {
36
+ status: 'completed',
37
+ backend: 'stackprof',
38
+ duration_seconds: duration,
39
+ samples: raw ? raw[:samples] : 0
40
+ }
41
+ else
42
+ # Fallback: sampling profiler using caller stacks
43
+ samples = Hash.new(0)
44
+ total_samples = 0
45
+ sample_lock = Mutex.new
46
+ end_time = Time.now + duration
47
+
48
+ sampler = Thread.new do
49
+ while Time.now < end_time && @cpu_profile_running
50
+ stack = caller(2)&.join("\n")
51
+ sample_lock.synchronize do
52
+ samples[stack] += 1
53
+ total_samples += 1
54
+ end if stack
55
+ sleep(0.001)
56
+ end
57
+ end
58
+
59
+ sampler.join
60
+ @cpu_profile_lock.synchronize { @cpu_profile_running = false }
61
+ @cpu_profile_data = parse_call_samples(samples, total_samples)
62
+ {
63
+ status: 'completed',
64
+ backend: 'sampling',
65
+ duration_seconds: duration,
66
+ total_samples: total_samples,
67
+ unique_stacks: samples.size
68
+ }
69
+ end
70
+ rescue => e
71
+ @cpu_profile_running = false
72
+ { error: e.message }
73
+ end
74
+
75
+ register_tool('stop_cpu_profile',
76
+ 'Stop CPU profiling and return top 20 methods by self time. ' \
77
+ 'Each entry includes method name, file, line, self_ms, total_ms, calls.') do
78
+ next { error: 'No profile data available. Run start_cpu_profile first.' } unless @cpu_profile_data
79
+
80
+ @cpu_profile_running = false
81
+ top = (@cpu_profile_data[:functions] || []).first(20)
82
+
83
+ {
84
+ status: 'stopped',
85
+ backend: @cpu_profile_data[:backend],
86
+ total_samples: @cpu_profile_data[:total_samples],
87
+ top_functions: top
88
+ }
89
+ rescue => e
90
+ { error: e.message }
91
+ end
92
+
93
+ register_tool('get_top_functions',
94
+ 'Return top methods from last CPU profile. Sort by self_time, total_time, or calls.',
95
+ limit: { type: 'integer', description: 'Number of functions to return (default 20)', required: false },
96
+ sort_by: { type: 'string', description: 'Sort key: self_time, total_time, or calls (default self_time)', required: false }) do |limit: 20, sort_by: 'self_time'|
97
+ next { error: 'No profile data available. Run start_cpu_profile first.' } unless @cpu_profile_data
98
+
99
+ funcs = (@cpu_profile_data[:functions] || []).dup
100
+ sort_key = %w[self_time total_time calls].include?(sort_by.to_s) ? sort_by.to_sym : :self_time
101
+ funcs.sort_by! { |f| -f[sort_key].to_f }
102
+ top = funcs.first(limit.to_i > 0 ? limit.to_i : 20)
103
+
104
+ {
105
+ sort_by: sort_key,
106
+ total_functions: @cpu_profile_data[:functions]&.size || 0,
107
+ top_functions: top
108
+ }
109
+ rescue => e
110
+ { error: e.message }
111
+ end
112
+
113
+ # --- Helpers ---
114
+
115
+ class << self
116
+ private
117
+
118
+ def parse_stackprof(raw)
119
+ return nil unless raw && raw[:frames]
120
+
121
+ funcs = raw[:frames].map do |_frame_key, frame|
122
+ {
123
+ method: frame[:name] || 'unknown',
124
+ file: frame[:file],
125
+ line: frame[:line],
126
+ self_ms: ((frame[:samples].to_f / raw[:samples].to_f) * (raw[:gc_profile_time] || raw[:walltime] || 0) * 1000).round(2),
127
+ total_ms: ((frame[:total_samples].to_f / raw[:samples].to_f) * (raw[:gc_profile_time] || raw[:walltime] || 0) * 1000).round(2),
128
+ calls: frame[:samples].to_i
129
+ }
130
+ end.sort_by { |f| -f[:self_ms] }
131
+
132
+ {
133
+ backend: 'stackprof',
134
+ total_samples: raw[:samples],
135
+ functions: funcs
136
+ }
137
+ end
138
+
139
+ def parse_call_samples(samples, total_samples)
140
+ method_stats = Hash.new { |h, k| h[k] = { self_time: 0, total_time: 0, calls: 0 } }
141
+
142
+ samples.each do |stack_str, count|
143
+ lines = stack_str.split("\n")
144
+ next if lines.empty?
145
+
146
+ # First line of caller(2) is the most recently called method
147
+ top_line = lines.first
148
+ parsed = parse_backtrace_line(top_line)
149
+
150
+ key = "#{parsed[:file]}:#{parsed[:method]}"
151
+ method_stats[key][:self_time] += count
152
+ method_stats[key][:total_time] += count
153
+ method_stats[key][:calls] += 1
154
+
155
+ # All lines contribute to total_time of their respective methods
156
+ lines.each do |line|
157
+ p = parse_backtrace_line(line)
158
+ k = "#{p[:file]}:#{p[:method]}"
159
+ method_stats[k][:total_time] += count
160
+ method_stats[k][:calls] += count unless k == key
161
+ end
162
+ end
163
+
164
+ funcs = method_stats.map do |_k, s|
165
+ parsed = parse_backtrace_line(samples.keys.find { |stk| stk.include?(_k) || true }&.split("\n")&.first || '')
166
+ {
167
+ method: _k.split(':').last,
168
+ file: _k.split(':')[0...-1].join(':'),
169
+ line: parsed[:line],
170
+ self_ms: (s[:self_time].to_f / total_samples.to_f * 10000).round(2),
171
+ total_ms: (s[:total_time].to_f / total_samples.to_f * 10000).round(2),
172
+ calls: s[:calls]
173
+ }
174
+ end.sort_by { |f| -f[:self_ms] }
175
+
176
+ {
177
+ backend: 'sampling',
178
+ total_samples: total_samples,
179
+ functions: funcs
180
+ }
181
+ end
182
+
183
+ def parse_backtrace_line(line)
184
+ # Format: "/path/to/file.rb:42:in `method_name'"
185
+ if line =~ /^(.+):(\d+):in `(.+)'$/
186
+ { file: $1, line: $2.to_i, method: $3 }
187
+ else
188
+ { file: 'unknown', line: 0, method: 'unknown' }
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,161 @@
1
+ require 'objspace'
2
+
3
+ module DebugAgent
4
+ # Memory leak detector inspector. Records heap snapshots and compares them
5
+ # to identify object types with consistent growth.
6
+ @heap_snapshots = {}
7
+ @heap_snapshot_counter = 0
8
+
9
+ class << self
10
+ attr_reader :heap_snapshots
11
+ end
12
+
13
+ register_tool('take_heap_snapshot',
14
+ 'Take a heap snapshot recording object counts by type, heap slots, ' \
15
+ 'and GC counts. Returns a snapshot ID for later comparison.') do
16
+ counts = Hash.new(0)
17
+ ObjectSpace.each_object do |obj|
18
+ begin
19
+ name = obj.class.name || obj.class.to_s
20
+ counts[name] += 1
21
+ rescue
22
+ counts['<unknown>'] += 1
23
+ end
24
+ end
25
+
26
+ gc_stats = GC.stat
27
+ total_objects = counts.values.sum
28
+
29
+ @heap_snapshot_counter += 1
30
+ snapshot_id = "heap-#{@heap_snapshot_counter}"
31
+
32
+ snapshot = {
33
+ id: snapshot_id,
34
+ taken_at: Time.now,
35
+ object_counts: counts,
36
+ total_objects: total_objects,
37
+ live_slots: gc_stats[:heap_live_slots],
38
+ free_slots: gc_stats[:heap_free_slots],
39
+ total_slots: gc_stats[:heap_live_slots].to_i + gc_stats[:heap_free_slots].to_i,
40
+ gc_count: gc_stats[:count],
41
+ major_gc_count: gc_stats[:major_gc_count],
42
+ minor_gc_count: gc_stats[:minor_gc_count],
43
+ total_allocated_objects: gc_stats[:total_allocated_objects],
44
+ total_freed_objects: gc_stats[:total_freed_objects],
45
+ heap_pages: gc_stats[:heap_length]
46
+ }
47
+
48
+ @heap_snapshots[snapshot_id] = snapshot
49
+
50
+ {
51
+ snapshot_id: snapshot_id,
52
+ taken_at: snapshot[:taken_at].iso8601,
53
+ summary: {
54
+ total_objects: total_objects,
55
+ total_classes: counts.size,
56
+ live_slots: snapshot[:live_slots],
57
+ free_slots: snapshot[:free_slots],
58
+ gc_count: snapshot[:gc_count],
59
+ top_types: counts.sort_by { |_, v| -v }.first(10).to_h
60
+ }
61
+ }
62
+ rescue => e
63
+ { error: e.message }
64
+ end
65
+
66
+ register_tool('compare_heap_snapshots',
67
+ 'Compare two heap snapshots. Returns per-type count delta and growth ' \
68
+ 'percentage, sorted by absolute growth.',
69
+ snapshot_a: { type: 'string', description: 'First snapshot ID', required: true },
70
+ snapshot_b: { type: 'string', description: 'Second snapshot ID', required: true }) do |snapshot_a:, snapshot_b:|
71
+ snap_a = @heap_snapshots[snapshot_a.to_s]
72
+ snap_b = @heap_snapshots[snapshot_b.to_s]
73
+
74
+ next { error: "Snapshot '#{snapshot_a}' not found. Available: #{@heap_snapshots.keys.join(', ')}" } unless snap_a
75
+ next { error: "Snapshot '#{snapshot_b}' not found. Available: #{@heap_snapshots.keys.join(', ')}" } unless snap_b
76
+
77
+ counts_a = snap_a[:object_counts]
78
+ counts_b = snap_b[:object_counts]
79
+
80
+ all_types = (counts_a.keys | counts_b.keys).uniq
81
+ deltas = all_types.map do |type|
82
+ ca = counts_a[type] || 0
83
+ cb = counts_b[type] || 0
84
+ delta = cb - ca
85
+ growth_pct = ca > 0 ? (delta.to_f / ca * 100).round(2) : (delta > 0 ? Float::INFINITY : 0.0)
86
+
87
+ {
88
+ type: type,
89
+ count_before: ca,
90
+ count_after: cb,
91
+ count_delta: delta,
92
+ growth_percentage: growth_pct
93
+ }
94
+ end
95
+
96
+ # Sort by absolute growth descending
97
+ deltas.sort_by! { |d| -d[:count_delta].abs }
98
+
99
+ {
100
+ snapshot_a: snapshot_a,
101
+ snapshot_b: snapshot_b,
102
+ time_between: (snap_b[:taken_at] - snap_a[:taken_at]).round(2),
103
+ summary: {
104
+ total_objects_before: snap_a[:total_objects],
105
+ total_objects_after: snap_b[:total_objects],
106
+ net_delta: snap_b[:total_objects] - snap_a[:total_objects],
107
+ gc_runs_between: snap_b[:gc_count] - snap_a[:gc_count],
108
+ live_slots_delta: snap_b[:live_slots] - snap_a[:live_slots]
109
+ },
110
+ type_changes: deltas.first(50)
111
+ }
112
+ rescue => e
113
+ { error: e.message }
114
+ end
115
+
116
+ register_tool('get_leak_candidates',
117
+ 'Identify object types with consistent growth across stored snapshots. ' \
118
+ 'Shows top growing object types.') do |snapshot_limit: 10|
119
+ next { error: 'Need at least 2 snapshots to detect trends' } if @heap_snapshots.size < 2
120
+
121
+ snapshots = @heap_snapshots.values.sort_by { |s| s[:taken_at] }
122
+ snapshots = snapshots.last([snapshot_limit.to_i, 2].max)
123
+
124
+ # For each type, compute growth across consecutive snapshot pairs
125
+ type_growth = Hash.new { |h, k| h[k] = { deltas: [], total_growth: 0 } }
126
+
127
+ snapshots.each_cons(2) do |s1, s2|
128
+ all_types = (s1[:object_counts].keys | s2[:object_counts].keys)
129
+ all_types.each do |type|
130
+ c1 = s1[:object_counts][type] || 0
131
+ c2 = s2[:object_counts][type] || 0
132
+ delta = c2 - c1
133
+ type_growth[type][:deltas] << delta
134
+ type_growth[type][:total_growth] += delta
135
+ end
136
+ end
137
+
138
+ candidates = type_growth
139
+ .select { |_, g| g[:total_growth] > 0 && g[:deltas].all? { |d| d >= 0 } }
140
+ .map do |type, g|
141
+ {
142
+ type: type,
143
+ total_growth: g[:total_growth],
144
+ snapshots_growing: g[:deltas].count(&:positive?),
145
+ total_snapshots_compared: g[:deltas].size,
146
+ consistency: g[:deltas].all?(&:positive?) ? 'consistent' : 'partial',
147
+ per_period_deltas: g[:deltas]
148
+ }
149
+ end
150
+ .sort_by { |c| -c[:total_growth] }
151
+
152
+ {
153
+ snapshots_analyzed: snapshots.size,
154
+ first_snapshot: snapshots.first[:id],
155
+ last_snapshot: snapshots.last[:id],
156
+ leak_candidates: candidates.first(20)
157
+ }
158
+ rescue => e
159
+ { error: e.message }
160
+ end
161
+ end
@@ -0,0 +1,88 @@
1
+ module DebugAgent
2
+ register_tool('get_registered_services',
3
+ 'List all registered debug agent tools grouped by inspector category. ' \
4
+ 'Shows tool count and names per group.') do
5
+ all_tools = registry.names
6
+
7
+ # Categorize tools by known inspector groups
8
+ categories = {
9
+ 'runtime' => %w[get_gc_stats get_memory_summary trigger_gc get_thread_summary get_runtime_info get_object_allocations],
10
+ 'gc' => %w[],
11
+ 'object_space' => %w[],
12
+ 'threads' => %w[],
13
+ 'process' => %w[],
14
+ 'system' => %w[],
15
+ 'http_tracker' => %w[],
16
+ 'routes' => %w[],
17
+ 'redis' => %w[],
18
+ 'rails' => %w[],
19
+ 'sidekiq' => %w[],
20
+ 'puma' => %w[],
21
+ 'logging' => %w[],
22
+ 'cache' => %w[],
23
+ 'http_client' => %w[],
24
+ 'metrics' => %w[],
25
+ 'active_record_stats' => %w[],
26
+ 'faraday' => %w[],
27
+ 'concurrent' => %w[],
28
+ 'security' => %w[],
29
+ 'health' => %w[],
30
+ 'scheduler' => %w[],
31
+ 'error_tracking' => %w[],
32
+ 'websocket' => %w[],
33
+ 'locks' => %w[],
34
+ 'migration' => %w[],
35
+ 'config' => %w[],
36
+ 'feature_flags' => %w[],
37
+ 'endpoint_test' => %w[],
38
+ 'pool_inspector' => %w[],
39
+ 'cpu_profile' => %w[],
40
+ 'leak_detector' => %w[],
41
+ 'build_info' => %w[],
42
+ 'snapshot' => %w[],
43
+ 'service_registry' => %w[]
44
+ }
45
+
46
+ # Since we cannot reliably categorize at runtime without a lookup table,
47
+ # we list all tools alphabetically with their descriptions
48
+ tools_with_desc = all_tools.sort.map do |name|
49
+ tool = registry.get(name)
50
+ {
51
+ name: name,
52
+ description: tool&.respond_to?(:description) ? tool.description : nil
53
+ }
54
+ end
55
+
56
+ {
57
+ total_tools: all_tools.size,
58
+ tools: tools_with_desc
59
+ }
60
+ rescue => e
61
+ { error: e.message }
62
+ end
63
+
64
+ register_tool('get_service_dependencies',
65
+ 'Show all loaded gems and their versions from Gem.loaded_specs') do
66
+ specs = Gem.loaded_specs.values.sort_by(&:name)
67
+
68
+ gems = specs.map do |spec|
69
+ {
70
+ name: spec.name,
71
+ version: spec.version.to_s,
72
+ loaded_from: spec.loaded_from,
73
+ dependencies: spec.dependencies.map { |d| "#{d.name} (#{d.requirement})" }
74
+ }
75
+ end
76
+
77
+ {
78
+ total_gems: gems.size,
79
+ ruby_version: RUBY_VERSION,
80
+ ruby_engine: RUBY_ENGINE,
81
+ rubygems_version: Gem::VERSION,
82
+ bundler_version: defined?(Bundler) ? Bundler::VERSION : nil,
83
+ gems: gems
84
+ }
85
+ rescue => e
86
+ { error: e.message }
87
+ end
88
+ end
@@ -0,0 +1,206 @@
1
+ require 'objspace'
2
+
3
+ module DebugAgent
4
+ # Snapshot & Diff inspector. Collects metrics across all inspectors at a
5
+ # point in time and allows comparing snapshots to detect changes.
6
+ @metric_snapshots = {}
7
+ @metric_snapshot_counter = 0
8
+
9
+ class << self
10
+ attr_reader :metric_snapshots
11
+ end
12
+
13
+ register_tool('take_snapshot',
14
+ 'Take a cross-inspector snapshot: thread count, fiber count, memory (RSS), ' \
15
+ 'GC stats, object count, DB pool stats, cache stats, error count. Returns snapshot ID.') do
16
+ gc = GC.stat
17
+ obj_counts = ObjectSpace.count_objects
18
+ rss = `ps -o rss= -p #{Process.pid}`.to_i
19
+
20
+ metrics = {
21
+ taken_at: Time.now,
22
+ threads: Thread.list.size,
23
+ alive_threads: Thread.list.count(&:alive?),
24
+ fiber_count: Fiber.respond_to?(:list) ? Fiber.list.size : count_fibers,
25
+ rss_mb: (rss / 1024.0).round(2),
26
+ gc: {
27
+ count: gc[:count],
28
+ major_gc_count: gc[:major_gc_count],
29
+ minor_gc_count: gc[:minor_gc_count],
30
+ heap_live_slots: gc[:heap_live_slots],
31
+ heap_free_slots: gc[:heap_free_slots],
32
+ total_allocated_objects: gc[:total_allocated_objects],
33
+ total_freed_objects: gc[:total_freed_objects],
34
+ old_objects: gc[:old_objects]
35
+ },
36
+ objects: {
37
+ total: obj_counts[:TOTAL] || obj_counts.values.sum,
38
+ free_slots: obj_counts[:FREE] || 0,
39
+ t_string: obj_counts[:T_STRING] || 0,
40
+ t_array: obj_counts[:T_ARRAY] || 0,
41
+ t_hash: obj_counts[:T_HASH] || 0,
42
+ t_object: obj_counts[:T_OBJECT] || 0,
43
+ t_data: obj_counts[:T_DATA] || 0
44
+ },
45
+ db_pool: gather_db_pool_stats,
46
+ cache: gather_cache_stats,
47
+ error_count: gather_error_count
48
+ }
49
+
50
+ @metric_snapshot_counter += 1
51
+ snapshot_id = "snap-#{@metric_snapshot_counter}"
52
+ @metric_snapshots[snapshot_id] = metrics
53
+
54
+ {
55
+ snapshot_id: snapshot_id,
56
+ taken_at: metrics[:taken_at].iso8601,
57
+ summary: {
58
+ threads: metrics[:threads],
59
+ fibers: metrics[:fiber_count],
60
+ rss_mb: metrics[:rss_mb],
61
+ total_objects: metrics[:objects][:total],
62
+ gc_count: metrics[:gc][:count],
63
+ live_slots: metrics[:gc][:heap_live_slots]
64
+ }
65
+ }
66
+ rescue => e
67
+ { error: e.message }
68
+ end
69
+
70
+ register_tool('compare_snapshots',
71
+ 'Compare two cross-inspector snapshots. Returns all changed values ' \
72
+ 'with deltas.',
73
+ snapshot_a: { type: 'string', description: 'First snapshot ID', required: true },
74
+ snapshot_b: { type: 'string', description: 'Second snapshot ID', required: true }) do |snapshot_a:, snapshot_b:|
75
+ snap_a = @metric_snapshots[snapshot_a.to_s]
76
+ snap_b = @metric_snapshots[snapshot_b.to_s]
77
+
78
+ next { error: "Snapshot '#{snapshot_a}' not found. Available: #{@metric_snapshots.keys.join(', ')}" } unless snap_a
79
+ next { error: "Snapshot '#{snapshot_b}' not found. Available: #{@metric_snapshots.keys.join(', ')}" } unless snap_b
80
+
81
+ changes = compute_snapshot_diff(snap_a, snap_b, '')
82
+
83
+ {
84
+ snapshot_a: snapshot_a,
85
+ snapshot_b: snapshot_b,
86
+ time_between_seconds: (snap_b[:taken_at] - snap_a[:taken_at]).round(2),
87
+ changes: changes
88
+ }
89
+ rescue => e
90
+ { error: e.message }
91
+ end
92
+
93
+ register_tool('list_snapshots',
94
+ 'List all stored cross-inspector snapshots') do
95
+ if @metric_snapshots.empty?
96
+ next { message: 'No snapshots taken yet. Call take_snapshot first.', count: 0, snapshots: [] }
97
+ end
98
+
99
+ {
100
+ count: @metric_snapshots.size,
101
+ snapshots: @metric_snapshots.map do |id, snap|
102
+ {
103
+ snapshot_id: id,
104
+ taken_at: snap[:taken_at].iso8601,
105
+ threads: snap[:threads],
106
+ rss_mb: snap[:rss_mb],
107
+ total_objects: snap[:objects][:total],
108
+ gc_count: snap[:gc][:count]
109
+ }
110
+ end
111
+ }
112
+ rescue => e
113
+ { error: e.message }
114
+ end
115
+
116
+ # --- Helpers ---
117
+
118
+ class << self
119
+ private
120
+
121
+ def count_fibers
122
+ # Best-effort fiber count via ObjectSpace
123
+ count = 0
124
+ ObjectSpace.each_object(Fiber) { count += 1 }
125
+ count
126
+ rescue
127
+ 0
128
+ end
129
+
130
+ def gather_db_pool_stats
131
+ return nil unless defined?(::ActiveRecord::Base)
132
+
133
+ begin
134
+ pool = ::ActiveRecord::Base.connection_pool
135
+ stats = pool.respond_to?(:stats) ? pool.stats : {}
136
+ {
137
+ size: pool.respond_to?(:size) ? pool.size : nil,
138
+ stats: stats
139
+ }
140
+ rescue
141
+ nil
142
+ end
143
+ end
144
+
145
+ def gather_cache_stats
146
+ return nil unless defined?(::Rails)
147
+
148
+ begin
149
+ cache = ::Rails.cache
150
+ return nil unless cache
151
+ stats = if cache.respond_to?(:stats)
152
+ cache.stats
153
+ elsif cache.respond_to?(:info)
154
+ cache.info
155
+ else
156
+ {}
157
+ end
158
+ { backend: cache.class.name, stats: stats }
159
+ rescue
160
+ nil
161
+ end
162
+ end
163
+
164
+ def gather_error_count
165
+ # ErrorTracking inspector stores error counts if available
166
+ if defined?(@error_log) && @error_log.is_a?(Array)
167
+ @error_log.size
168
+ else
169
+ 0
170
+ end
171
+ end
172
+
173
+ def compute_snapshot_diff(a, b, prefix)
174
+ changes = []
175
+
176
+ if a.is_a?(Hash) && b.is_a?(Hash)
177
+ all_keys = (a.keys | b.keys)
178
+ all_keys.each do |key|
179
+ va = a[key]
180
+ vb = b[key]
181
+ path = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
182
+
183
+ if va.is_a?(Hash) && vb.is_a?(Hash)
184
+ changes.concat(compute_snapshot_diff(va, vb, path))
185
+ elsif va != vb
186
+ changes << {
187
+ metric: path,
188
+ before: va,
189
+ after: vb,
190
+ delta: (va.is_a?(Numeric) && vb.is_a?(Numeric)) ? (vb - va) : nil
191
+ }
192
+ end
193
+ end
194
+ elsif a != b
195
+ changes << {
196
+ metric: prefix,
197
+ before: a,
198
+ after: b,
199
+ delta: (a.is_a?(Numeric) && b.is_a?(Numeric)) ? (b - a) : nil
200
+ }
201
+ end
202
+
203
+ changes
204
+ end
205
+ end
206
+ end
@@ -1,3 +1,3 @@
1
1
  module DebugAgent
2
- VERSION = '0.6.0'.freeze
2
+ VERSION = '0.7.0'.freeze
3
3
  end
data/lib/debug_agent.rb CHANGED
@@ -41,10 +41,24 @@ require_relative 'debug_agent/inspectors/config_inspector'
41
41
  require_relative 'debug_agent/inspectors/feature_flags'
42
42
  require_relative 'debug_agent/inspectors/endpoint_test'
43
43
  require_relative 'debug_agent/inspectors/pool_inspector'
44
+ require_relative 'debug_agent/inspectors/cpu_profile'
45
+ require_relative 'debug_agent/inspectors/leak_detector'
46
+ require_relative 'debug_agent/inspectors/build_info'
47
+ require_relative 'debug_agent/inspectors/snapshot'
48
+ require_relative 'debug_agent/inspectors/service_registry'
44
49
 
45
50
  module DebugAgent
46
51
  class Error < StandardError; end
47
52
 
53
+ # ggcode promotional message — shown once on first require
54
+ unless defined?(@_ggcode_banner_shown)
55
+ @_ggcode_banner_shown = true
56
+ puts ""
57
+ puts " This package was built with ggcode — an AI coding assistant."
58
+ puts " https://github.com/topcheer/ggcode"
59
+ puts ""
60
+ end
61
+
48
62
  # Process start time for uptime tracking
49
63
  PROCESS_START_TIME = Time.now
50
64
 
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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ggcode
@@ -165,10 +165,12 @@ files:
165
165
  - lib/debug_agent/context_compressor.rb
166
166
  - lib/debug_agent/engine.rb
167
167
  - lib/debug_agent/inspectors/active_record_stats.rb
168
+ - lib/debug_agent/inspectors/build_info.rb
168
169
  - lib/debug_agent/inspectors/cache.rb
169
170
  - lib/debug_agent/inspectors/concurrent.rb
170
171
  - lib/debug_agent/inspectors/config_inspector.rb
171
172
  - lib/debug_agent/inspectors/core_ext.rb
173
+ - lib/debug_agent/inspectors/cpu_profile.rb
172
174
  - lib/debug_agent/inspectors/endpoint_test.rb
173
175
  - lib/debug_agent/inspectors/error_tracking.rb
174
176
  - lib/debug_agent/inspectors/faraday.rb
@@ -177,6 +179,7 @@ files:
177
179
  - lib/debug_agent/inspectors/health.rb
178
180
  - lib/debug_agent/inspectors/http_client.rb
179
181
  - lib/debug_agent/inspectors/http_tracker.rb
182
+ - lib/debug_agent/inspectors/leak_detector.rb
180
183
  - lib/debug_agent/inspectors/locks.rb
181
184
  - lib/debug_agent/inspectors/logging.rb
182
185
  - lib/debug_agent/inspectors/metrics.rb
@@ -191,7 +194,9 @@ files:
191
194
  - lib/debug_agent/inspectors/runtime.rb
192
195
  - lib/debug_agent/inspectors/scheduler.rb
193
196
  - lib/debug_agent/inspectors/security.rb
197
+ - lib/debug_agent/inspectors/service_registry.rb
194
198
  - lib/debug_agent/inspectors/sidekiq.rb
199
+ - lib/debug_agent/inspectors/snapshot.rb
195
200
  - lib/debug_agent/inspectors/system.rb
196
201
  - lib/debug_agent/inspectors/threads.rb
197
202
  - lib/debug_agent/inspectors/websocket.rb