debug-agent 0.4.0 → 0.5.1

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: 791b8f21395b061038d65dcae92007460556766622991c1c0a1692ae21b272f5
4
- data.tar.gz: b57b2b97b9473da04c7b7fc3cf67729bddfc283e2ca71d34b4e6469d2c47e65a
3
+ metadata.gz: 6811d0a31e6c030eb3883ec27085c87cdaa7f9e0217e4e0e7b25cba7d3f2fc6b
4
+ data.tar.gz: a327319653c66f29a8099d468739c45e683b47891ffb7952b705a4ea016dcfc6
5
5
  SHA512:
6
- metadata.gz: 7fd90445975ede4bbc7474c7a57e94e1914098d9c4743180fdcdb737611ef461f2a6034da40fd31eb101188c34a28a12ac056db119f510e3de37ac494e06b67b
7
- data.tar.gz: acf3c742b54b5d8173e6908ec27e079cd66efd4063f515b9278ec8754ef84e1539bbe5e13d47c79d342c33046678b972b9c6873cb6c5c9c96618669209145fa2
6
+ metadata.gz: 3a8d3017f048ea920c9051e148b129e74da019693d12dff637ed023eecca16a97d82ef887d2943f6d5f2983968d4d8f5cd259da6513498fdc3653db5b36d42dd
7
+ data.tar.gz: 79429a53f7be512a9f58d69e4d0948a3e29bffe2f27ce06733154189ca54441e683337cf1ca57acbc58fefe86bd5236462e24371bc12646659a824832cbe4a63
data/README.md CHANGED
@@ -1,11 +1,27 @@
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-54-blue)
5
- ![Inspectors](https://img.shields.io/badge/inspectors-20-green)
4
+ ![Tools](https://img.shields.io/badge/tools-67-blue)
5
+ ![Inspectors](https://img.shields.io/badge/inspectors-25-green)
6
+ ![Ruby](https://img.shields.io/badge/Ruby-2.7%2B-CC342D)
7
+ ![Gem](https://img.shields.io/badge/gem-debug--agent-red)
6
8
 
7
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 — **54 diagnostic tools across 20 inspectors**.
8
10
 
11
+ ## Version Support
12
+
13
+ | Ruby Version | Status |
14
+ |--------------|--------|
15
+ | 2.6 | Not supported |
16
+ | 2.7 | Minimum supported |
17
+ | 3.0 | Supported (Fiber.list available) |
18
+ | 3.1 | Supported |
19
+ | 3.2 | Supported |
20
+ | 3.3 | Supported |
21
+ | 3.4 | Tested |
22
+
23
+ > Requires Ruby 2.7+ for pattern matching guards. Framework inspectors (Rails, Sidekiq, Puma) are optional and auto-detected via `defined?`.
24
+
9
25
  ## Quick Start
10
26
 
11
27
  ### 1. Install
@@ -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,110 @@
1
+ module DebugAgent
2
+ # Registry of health check blocks. Each block returns a hash with at least
3
+ # a :status key ('UP', 'DOWN', or 'DEGRADED').
4
+ #
5
+ # DebugAgent.register_health_check(:database) { { status: 'UP' } }
6
+ @health_checks = {}
7
+
8
+ class << self
9
+ attr_reader :health_checks
10
+
11
+ def register_health_check(name, &block)
12
+ @health_checks[name.to_s] = block
13
+ end
14
+ end
15
+
16
+ register_tool('get_health_status',
17
+ 'Run all registered health checks and report status per component: UP, DOWN, or DEGRADED') do
18
+ if health_checks.empty?
19
+ next {
20
+ message: 'No health checks registered. Call DebugAgent.register_health_check(:name) { ... }.',
21
+ overall_status: 'UNKNOWN'
22
+ }
23
+ end
24
+
25
+ results = {}
26
+ up = 0
27
+ down = 0
28
+ degraded = 0
29
+
30
+ health_checks.each do |check_name, block|
31
+ begin
32
+ result = block.call
33
+ status = result.is_a?(Hash) ? (result[:status] || result['status'] || 'UP').to_s.upcase : 'UP'
34
+ results[check_name] = result.merge(status: status, latency_ms: nil)
35
+
36
+ case status
37
+ when 'UP' then up += 1
38
+ when 'DOWN' then down += 1
39
+ when 'DEGRADED' then degraded += 1
40
+ end
41
+ rescue => e
42
+ results[check_name] = { status: 'DOWN', error: e.message }
43
+ down += 1
44
+ end
45
+ end
46
+
47
+ overall = if down > 0
48
+ 'DOWN'
49
+ elsif degraded > 0
50
+ 'DEGRADED'
51
+ else
52
+ 'UP'
53
+ end
54
+
55
+ {
56
+ overall_status: overall,
57
+ up: up,
58
+ down: down,
59
+ degraded: degraded,
60
+ total: health_checks.size,
61
+ components: results
62
+ }
63
+ rescue => e
64
+ { error: e.message }
65
+ end
66
+
67
+ register_tool('get_health_detail',
68
+ 'Deep dive into a specific health check component for detailed diagnostics',
69
+ component_name: { type: 'string', description: 'Name of the health check component to inspect', required: true }) do |component_name:|
70
+ if health_checks.empty?
71
+ next { error: 'No health checks registered.' }
72
+ end
73
+
74
+ key = component_name.to_s
75
+ block = health_checks[key]
76
+ next { error: "No health check registered for '#{component_name}'. Available: #{health_checks.keys.join(', ')}" } unless block
77
+
78
+ # Run the check multiple times to measure latency
79
+ samples = []
80
+ 3.times do
81
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ begin
83
+ result = block.call
84
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0).round(2)
85
+ status = result.is_a?(Hash) ? (result[:status] || result['status'] || 'UP').to_s.upcase : 'UP'
86
+ samples << { status: status, latency_ms: elapsed, detail: result }
87
+ rescue => e
88
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0).round(2)
89
+ samples << { status: 'DOWN', latency_ms: elapsed, error: e.message }
90
+ break
91
+ end
92
+ end
93
+
94
+ latencies = samples.map { |s| s[:latency_ms] }
95
+
96
+ {
97
+ component: key,
98
+ registered_checks: health_checks.keys,
99
+ latest: samples.last,
100
+ samples: samples.size,
101
+ latency_ms: {
102
+ min: latencies.min,
103
+ avg: (latencies.sum / latencies.size.to_f).round(2),
104
+ max: latencies.max
105
+ }
106
+ }
107
+ rescue => e
108
+ { error: e.message }
109
+ end
110
+ end
@@ -0,0 +1,154 @@
1
+ module DebugAgent
2
+ # Registry of scheduled jobs (Sidekiq::Cron, rufus-scheduler, whenever, or
3
+ # custom Thread-based timers). Applications register jobs so the inspector
4
+ # can list them and report execution history.
5
+ #
6
+ # DebugAgent.register_scheduled_job(:cleanup, 'every 30s', last_run: Time.now)
7
+ @scheduled_jobs = {}
8
+ @job_history = {}
9
+ @scheduler_lock = Mutex.new
10
+
11
+ class << self
12
+ attr_reader :scheduled_jobs, :job_history
13
+
14
+ def register_scheduled_job(name, schedule, **opts)
15
+ @scheduled_jobs[name.to_s] = {
16
+ schedule: schedule,
17
+ name: name.to_s,
18
+ class: opts[:class] || name.to_s,
19
+ queue: opts[:queue],
20
+ enabled: opts.key?(:enabled) ? opts[:enabled] : true,
21
+ last_run: opts[:last_run],
22
+ next_run: opts[:next_run],
23
+ registered_at: Time.now
24
+ }
25
+ end
26
+
27
+ # Record a job execution for history tracking.
28
+ def record_job_execution(name, duration_ms, success: true, error: nil)
29
+ @scheduler_lock.synchronize do
30
+ history = (@job_history[name.to_s] ||= [])
31
+ history << {
32
+ timestamp: Time.now.iso8601,
33
+ duration_ms: duration_ms.round(2),
34
+ success: success,
35
+ error: error
36
+ }
37
+ history.shift if history.size > 100
38
+
39
+ # Update last_run on the job itself
40
+ if @scheduled_jobs[name.to_s]
41
+ @scheduled_jobs[name.to_s][:last_run] = Time.now
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ register_tool('get_scheduled_jobs',
48
+ 'List scheduled jobs from Sidekiq::Cron, rufus-scheduler, whenever, ' \
49
+ 'or custom Thread-based timers. Shows schedule, last run, and status') do
50
+ jobs = []
51
+
52
+ # Registered jobs (Thread-based, custom, etc.)
53
+ scheduled_jobs.each do |name, job|
54
+ jobs << {
55
+ name: name,
56
+ schedule: job[:schedule],
57
+ class: job[:class],
58
+ queue: job[:queue],
59
+ enabled: job[:enabled],
60
+ source: job[:source] || 'registered',
61
+ last_run: job[:last_run],
62
+ next_run: job[:next_run]
63
+ }
64
+ end
65
+
66
+ # Sidekiq::Cron jobs
67
+ if defined?(::Sidekiq::Cron::Job)
68
+ begin
69
+ ::Sidekiq::Cron::Job.all.each do |cron_job|
70
+ jobs << {
71
+ name: cron_job.name,
72
+ schedule: cron_job.cron,
73
+ class: cron_job.klass,
74
+ queue: cron_job.queue_name,
75
+ enabled: cron_job.status == 'enabled',
76
+ source: 'sidekiq-cron',
77
+ last_run: cron_job.last_enqueue_time
78
+ }
79
+ end
80
+ rescue => e
81
+ jobs << { source: 'sidekiq-cron', error: e.message }
82
+ end
83
+ end
84
+
85
+ # rufus-scheduler
86
+ if defined?(::Rufus::Scheduler)
87
+ begin
88
+ ObjectSpace.each_object(::Rufus::Scheduler) do |scheduler|
89
+ scheduler.jobs.each do |job|
90
+ jobs << {
91
+ name: job.respond_to?(:tags) ? job.tags.first : nil,
92
+ schedule: job.respond_to?(:original) ? job.original : job.class.name,
93
+ class: job.class.name,
94
+ enabled: !job.respond_to?(:paused?) || !job.paused?,
95
+ source: 'rufus-scheduler',
96
+ last_run: job.respond_to?(:last_time) ? job.last_time : nil,
97
+ next_run: job.respond_to?(:next_time) ? job.next_time : nil
98
+ }
99
+ end
100
+ end
101
+ rescue => e
102
+ jobs << { source: 'rufus-scheduler', error: e.message }
103
+ end
104
+ end
105
+
106
+ if jobs.empty?
107
+ next {
108
+ message: 'No scheduled jobs registered. Call DebugAgent.register_scheduled_job(:name, schedule). ' \
109
+ 'Also auto-detects Sidekiq::Cron and rufus-scheduler if loaded.',
110
+ total: 0
111
+ }
112
+ end
113
+
114
+ { total: jobs.size, jobs: jobs }
115
+ rescue => e
116
+ { error: e.message }
117
+ end
118
+
119
+ register_tool('get_job_history',
120
+ 'Get recent execution history for scheduled jobs: run times, duration, success/failure',
121
+ job_name: { type: 'string', description: 'Job name to filter history (optional, returns all if omitted)', required: false }) do |job_name: nil|
122
+ if job_history.empty?
123
+ next { message: 'No job execution history recorded. Jobs must call DebugAgent.record_job_execution to track history.', total: 0 }
124
+ end
125
+
126
+ if job_name
127
+ key = job_name.to_s
128
+ history = job_history[key] || []
129
+ next {
130
+ job: key,
131
+ total: history.size,
132
+ history: history.reverse.first(50)
133
+ }
134
+ end
135
+
136
+ all = job_history.map do |name, entries|
137
+ successful = entries.count { |e| e[:success] }
138
+ failed = entries.count { |e| !e[:success] }
139
+ durations = entries.map { |e| e[:duration_ms] }
140
+ {
141
+ job: name,
142
+ total_runs: entries.size,
143
+ successful: successful,
144
+ failed: failed,
145
+ avg_duration_ms: durations.empty? ? 0 : (durations.sum / durations.size.to_f).round(2),
146
+ last_run: entries.last&.dig(:timestamp)
147
+ }
148
+ end
149
+
150
+ { total_jobs: all.size, total_runs: job_history.values.map(&:size).sum, jobs: all }
151
+ rescue => e
152
+ { error: e.message }
153
+ end
154
+ end
@@ -0,0 +1,201 @@
1
+ module DebugAgent
2
+ # Registry of auth configurations (Devise, Warden, OmniAuth, Rack auth).
3
+ #
4
+ # DebugAgent.register_auth_config(:api_key, { strategy: 'X-API-Key', secret_present: true })
5
+ @auth_configs = {}
6
+
7
+ # Registry of session stores (Rack::Session, ActiveRecord::Session, custom).
8
+ #
9
+ # DebugAgent.register_session_store(:rack_session, env['rack.session'])
10
+ @session_stores = {}
11
+
12
+ class << self
13
+ attr_reader :auth_configs, :session_stores
14
+
15
+ def register_auth_config(name, config)
16
+ @auth_configs[name.to_s] = config
17
+ end
18
+
19
+ def register_session_store(name, store)
20
+ @session_stores[name.to_s] = store
21
+ end
22
+ end
23
+
24
+ register_tool('get_auth_config',
25
+ 'List registered auth configurations (Devise, Warden, OmniAuth, Rack auth). ' \
26
+ 'Shows strategy, whether a secret is present, and token expiry') do |name: nil|
27
+ # Auto-detect from loaded gems when nothing is registered
28
+ if auth_configs.empty?
29
+ auto = {}
30
+ if defined?(::Devise)
31
+ auto['Devise'] = {
32
+ strategy: 'Devise',
33
+ secret_present: defined?(Devise.secret_key) && !Devise.secret_key.to_s.empty?,
34
+ models: (Devise.mappings.keys.map(&:to_s) rescue []),
35
+ token_expiry: nil
36
+ }
37
+ end
38
+ if defined?(::Warden)
39
+ auto['Warden'] = {
40
+ strategy: 'Warden',
41
+ secret_present: !!(Warden::Manager.respond_to?(:secret) rescue false),
42
+ default_strategies: (Warden::Config.new.default_strategies rescue []),
43
+ token_expiry: nil
44
+ }
45
+ end
46
+ if defined?(::OmniAuth)
47
+ auto['OmniAuth'] = {
48
+ strategy: 'OmniAuth',
49
+ providers: (OmniAuth.strategies.map(&:to_s) rescue []),
50
+ secret_present: !!(OmniAuth.configuration rescue nil),
51
+ token_expiry: nil
52
+ }
53
+ end
54
+ if auto.any?
55
+ next { auth_configs: auto, source: 'auto-detected' }
56
+ end
57
+ next { error: 'No auth configs registered. Call DebugAgent.register_auth_config(:name, config).' }
58
+ end
59
+
60
+ targets = name ? { name.to_s => auth_configs[name.to_s] } : auth_configs
61
+ targets = targets.reject { |_, c| c.nil? }
62
+ next { error: "No auth config registered under '#{name}'" } if targets.empty?
63
+
64
+ results = targets.map do |cfg_name, cfg|
65
+ begin
66
+ if cfg.is_a?(Hash)
67
+ normalized = {
68
+ name: cfg_name,
69
+ strategy: cfg[:strategy] || cfg['strategy'],
70
+ secret_present: cfg.key?(:secret_present) ? cfg[:secret_present] : cfg['secret_present'],
71
+ token_expiry: cfg[:token_expiry] || cfg['token_expiry']
72
+ }.merge(cfg.reject { |k, _| %i[strategy secret_present token_expiry].include?(k.to_sym) })
73
+ normalized
74
+ else
75
+ { name: cfg_name, type: cfg.class.name, raw: cfg.inspect }
76
+ end
77
+ rescue => e
78
+ { name: cfg_name, error: e.message }
79
+ end
80
+ end
81
+
82
+ { auth_configs: results }
83
+ rescue => e
84
+ { error: e.message }
85
+ end
86
+
87
+ register_tool('get_active_sessions',
88
+ 'List active sessions from registered session stores ' \
89
+ '(Rack::Session, ActiveRecord::Session). ' \
90
+ 'Shows session ID, user, creation, and expiry') do |name: nil|
91
+ if session_stores.empty?
92
+ next { error: 'No session stores registered. Call DebugAgent.register_session_store(:name, store).' }
93
+ end
94
+
95
+ targets = name ? { name.to_s => session_stores[name.to_s] } : session_stores
96
+ targets = targets.reject { |_, s| s.nil? }
97
+ next { error: "No session store registered under '#{name}'" } if targets.empty?
98
+
99
+ results = targets.map do |store_name, store|
100
+ begin
101
+ introspect_session_store(store_name, store)
102
+ rescue => e
103
+ { name: store_name, error: e.message }
104
+ end
105
+ end
106
+
107
+ { session_stores: results }
108
+ rescue => e
109
+ { error: e.message }
110
+ end
111
+
112
+ register_tool('get_cors_config',
113
+ 'Show CORS settings (Rack::Cors config: allowed origins, methods, headers)') do
114
+ # Try to find Rack::Cors middleware in the app middleware stack
115
+ cors_rules = []
116
+
117
+ if app && app.respond_to?(:middleware)
118
+ app.middleware.each do |middleware_entry|
119
+ middleware_class = middleware_entry.shift if middleware_entry.is_a?(Array)
120
+ next unless middleware_class.to_s.include?('Cors')
121
+
122
+ args = middleware_entry || []
123
+ cors_rules << { middleware: middleware_class.to_s, args: args.inspect }
124
+ end
125
+ end
126
+
127
+ # Try Rack::Cors introspection
128
+ if defined?(::Rack::Cors)
129
+ begin
130
+ rack_cors = ::Rack::Cors
131
+ cors_rules << { middleware: 'Rack::Cors', version: rack_cors.respond_to?(:VERSION) ? rack_cors::VERSION : 'unknown' }
132
+ rescue => e
133
+ cors_rules << { middleware: 'Rack::Cors', error: e.message }
134
+ end
135
+ end
136
+
137
+ if cors_rules.empty?
138
+ next {
139
+ message: 'No CORS configuration detected. Install rack-cors and configure Rack::Cors middleware, ' \
140
+ 'or the inspector could not find CORS rules in the middleware stack.',
141
+ cors_enabled: false
142
+ }
143
+ end
144
+
145
+ { cors_enabled: true, rules: cors_rules }
146
+ rescue => e
147
+ { error: e.message }
148
+ end
149
+
150
+ # --- Helpers ---
151
+
152
+ def self.introspect_session_store(store_name, store)
153
+ info = { name: store_name, type: store.class.name }
154
+
155
+ # Rack::Session::Abstract::SessionHash or similar
156
+ if store.respond_to?(:to_hash)
157
+ begin
158
+ data = store.to_hash
159
+ info[:session_count] = data.size
160
+ info[:keys] = data.keys.first(50)
161
+ info[:has_user] = data.key?('user_id') || data.key?(:user_id) || data.key?('user')
162
+ rescue
163
+ info[:session_count] = 'unable to read'
164
+ end
165
+ end
166
+
167
+ # ActiveRecord::SessionStore
168
+ if store.respond_to?(:all)
169
+ begin
170
+ sessions = store.all.to_a
171
+ info[:session_count] = sessions.size
172
+ info[:sessions] = sessions.first(20).map do |sess|
173
+ sess_data = begin
174
+ sess.respond_to?(:data) ? sess.data.keys : []
175
+ rescue
176
+ []
177
+ end
178
+ {
179
+ session_id: sess.respond_to?(:session_id) ? sess.session_id : sess.id,
180
+ data: sess_data,
181
+ created_at: sess.respond_to?(:created_at) ? sess.created_at : nil,
182
+ updated_at: sess.respond_to?(:updated_at) ? sess.updated_at : nil
183
+ }
184
+ end
185
+ rescue
186
+ info[:session_count] = 'unable to read'
187
+ end
188
+ end
189
+
190
+ # Generic session store with session_id method
191
+ if store.respond_to?(:session_id)
192
+ begin
193
+ info[:session_id] = store.session_id
194
+ rescue
195
+ # ignore
196
+ end
197
+ end
198
+
199
+ info
200
+ end
201
+ end
@@ -0,0 +1,188 @@
1
+ require 'time'
2
+ require 'thread'
3
+
4
+ module DebugAgent
5
+ # Registry of WebSocket servers (faye-websocket, websocket-driver, ActionCable).
6
+ # Applications register WS server objects so the inspector can introspect them.
7
+ #
8
+ # DebugAgent.register_ws_server(:faye, ws_server)
9
+ @ws_servers = {}
10
+ @ws_connections = []
11
+ @ws_lock = Mutex.new
12
+ @ws_message_log = []
13
+
14
+ class << self
15
+ attr_reader :ws_servers, :ws_connections, :ws_message_log
16
+
17
+ def register_ws_server(name, server)
18
+ @ws_servers[name.to_s] = server
19
+ end
20
+
21
+ # Track a WebSocket connection
22
+ def track_ws_connection(conn_id, remote_addr, channel = nil)
23
+ @ws_lock.synchronize do
24
+ @ws_connections << {
25
+ id: conn_id,
26
+ remote_addr: remote_addr,
27
+ channel: channel,
28
+ connected_since: Time.now.iso8601,
29
+ messages_sent: 0,
30
+ messages_received: 0
31
+ }
32
+ end
33
+ conn_id
34
+ end
35
+
36
+ # Remove a tracked WebSocket connection
37
+ def untrack_ws_connection(conn_id)
38
+ @ws_lock.synchronize do
39
+ @ws_connections.reject! { |c| c[:id] == conn_id }
40
+ end
41
+ end
42
+
43
+ # Log a WebSocket message
44
+ def log_ws_message(conn_id, direction, data, size = nil)
45
+ @ws_lock.synchronize do
46
+ @ws_message_log << {
47
+ timestamp: Time.now.iso8601,
48
+ connection_id: conn_id,
49
+ direction: direction, # 'sent' or 'received'
50
+ size: size || data.to_s.bytesize,
51
+ preview: data.to_s[0...200]
52
+ }
53
+ @ws_message_log.shift if @ws_message_log.size > 200
54
+
55
+ conn = @ws_connections.find { |c| c[:id] == conn_id }
56
+ if conn
57
+ if direction == 'sent'
58
+ conn[:messages_sent] += 1
59
+ else
60
+ conn[:messages_received] += 1
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ register_tool('get_ws_connections',
68
+ 'Get active WebSocket connections (faye-websocket, websocket-driver, ActionCable). ' \
69
+ 'Shows connection ID, remote address, connected since, and message counts') do
70
+ conns = @ws_lock.synchronize { @ws_connections.dup }
71
+
72
+ # Also try to auto-detect ActionCable connections
73
+ if defined?(::ActionCable)
74
+ begin
75
+ server = ::ActionCable.server
76
+ if server && server.respond_to?(:connections)
77
+ server.connections.each do |conn|
78
+ next if conns.any? { |c| c[:id] == conn.object_id }
79
+ conns << {
80
+ id: conn.object_id,
81
+ remote_addr: conn.respond_to?(:env) ? (conn.env['REMOTE_ADDR'] rescue 'unknown') : 'unknown',
82
+ channel: 'ActionCable',
83
+ connected_since: nil,
84
+ messages_sent: conn.respond_to?(:transmissions) ? conn.transmissions : nil,
85
+ messages_received: nil,
86
+ source: 'actioncable'
87
+ }
88
+ end
89
+ end
90
+ rescue => e
91
+ conns << { source: 'actioncable', error: e.message }
92
+ end
93
+ end
94
+
95
+ if conns.empty?
96
+ next {
97
+ message: 'No WebSocket connections tracked. Register with DebugAgent.register_ws_server ' \
98
+ 'or DebugAgent.track_ws_connection. Auto-detects ActionCable if loaded.',
99
+ total: 0
100
+ }
101
+ end
102
+
103
+ { total: conns.size, connections: conns }
104
+ rescue => e
105
+ { error: e.message }
106
+ end
107
+
108
+ register_tool('get_ws_stats',
109
+ 'Get WebSocket statistics: total connections, total messages, ' \
110
+ 'messages per connection, uptime') do
111
+ conns = @ws_lock.synchronize { @ws_connections.dup }
112
+ msgs = @ws_lock.synchronize { @ws_message_log.dup }
113
+
114
+ if conns.empty? && msgs.empty?
115
+ next { total_connections: 0, total_messages: 0, message: 'No WebSocket activity recorded.' }
116
+ end
117
+
118
+ sent = conns.sum { |c| c[:messages_sent] || 0 }
119
+ received = conns.sum { |c| c[:messages_received] || 0 }
120
+ total_bytes = msgs.sum { |m| m[:size] || 0 }
121
+
122
+ {
123
+ total_connections: conns.size,
124
+ total_messages_sent: sent,
125
+ total_messages_received: received,
126
+ total_messages: sent + received,
127
+ total_bytes: total_bytes,
128
+ avg_messages_per_connection: conns.empty? ? 0 : ((sent + received).to_f / conns.size).round(2),
129
+ registered_servers: ws_servers.keys,
130
+ recent_messages: msgs.reverse.first(20)
131
+ }
132
+ rescue => e
133
+ { error: e.message }
134
+ end
135
+
136
+ register_tool('get_ws_channels',
137
+ 'Get WebSocket channels with subscriber counts (ActionCable channels or custom pub/sub)') do
138
+ channels = []
139
+
140
+ # Auto-detect ActionCable channels
141
+ if defined?(::ActionCable::Channel::Base)
142
+ begin
143
+ # Look for channel subclasses
144
+ channel_classes = []
145
+ ObjectSpace.each_object(Class) do |klass|
146
+ if klass < ::ActionCable::Channel::Base && klass != ::ActionCable::Channel::Base
147
+ channel_classes << klass
148
+ end
149
+ end
150
+
151
+ channel_classes.uniq.each do |klass|
152
+ channels << {
153
+ name: klass.name,
154
+ source: 'actioncable',
155
+ subscribers: 0 # ActionCable doesn't expose per-channel counts easily
156
+ }
157
+ end
158
+ rescue => e
159
+ channels << { source: 'actioncable', error: e.message }
160
+ end
161
+ end
162
+
163
+ # Track connections grouped by channel from our own registry
164
+ @ws_lock.synchronize do
165
+ by_channel = @ws_connections.group_by { |c| c[:channel] || 'default' }
166
+ by_channel.each do |channel, conns|
167
+ existing = channels.find { |ch| ch[:name] == channel }
168
+ if existing
169
+ existing[:subscribers] = conns.size
170
+ else
171
+ channels << { name: channel, source: 'tracked', subscribers: conns.size }
172
+ end
173
+ end
174
+ end
175
+
176
+ if channels.empty?
177
+ next {
178
+ message: 'No WebSocket channels found. Define ActionCable channels or use ' \
179
+ 'DebugAgent.track_ws_connection with a channel name.',
180
+ total: 0
181
+ }
182
+ end
183
+
184
+ { total: channels.size, channels: channels }
185
+ rescue => e
186
+ { error: e.message }
187
+ end
188
+ end
@@ -83,72 +83,72 @@ module DebugAgent
83
83
 
84
84
  def stream_request(path, body, handler)
85
85
  uri = URI(@cfg.base_url + path)
86
- http = Net::HTTP.new(uri.host, uri.port)
87
- http.use_ssl = uri.scheme == 'https'
88
- http.read_timeout = @cfg.timeout_seconds
89
86
 
90
- request = Net::HTTP::Post.new(uri.path)
91
- request['Authorization'] = "Bearer #{@cfg.api_key}"
92
- request['Content-Type'] = 'application/json'
93
- request.body = JSON.generate(body)
94
-
95
- response = http.request(request)
96
-
97
- if response.code.to_i >= 400
98
- raise RetriableError.new(response.code.to_i, "HTTP #{response.code}: #{response.body}")
99
- end
87
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', read_timeout: @cfg.timeout_seconds) do |http|
88
+ request = Net::HTTP::Post.new(uri.path)
89
+ request['Authorization'] = "Bearer #{@cfg.api_key}"
90
+ request['Content-Type'] = 'application/json'
91
+ request.body = JSON.generate(body)
100
92
 
101
- tool_call_map = {}
102
- finish_reason = nil
103
- usage = nil
93
+ tool_call_map = {}
94
+ finish_reason = nil
95
+ usage = nil
104
96
 
105
- response.read_body do |chunk|
106
- chunk.split("\n").each do |line|
107
- next unless line.start_with?('data: ')
108
-
109
- data_str = line[6..]
110
- next if data_str.strip == '[DONE]'
111
-
112
- begin
113
- parsed = JSON.parse(data_str)
114
- rescue JSON::ParserError
115
- next
116
- end
117
-
118
- if parsed['usage'] && parsed['usage']['prompt_tokens']
119
- usage = parsed['usage']
120
- end
121
-
122
- choices = parsed['choices'] || []
123
- next if choices.empty?
124
-
125
- choice = choices[0]
126
- delta = choice['delta'] || {}
127
-
128
- if delta['content'] && !delta['content'].empty?
129
- handler.on_content(delta['content'])
97
+ http.request(request) do |response|
98
+ if response.code.to_i >= 400
99
+ err_body = response.read_body
100
+ raise RetriableError.new(response.code.to_i, "HTTP #{response.code}: #{err_body}")
130
101
  end
131
102
 
132
- if delta['tool_calls']
133
- delta['tool_calls'].each do |tc|
134
- idx = tc['index'] || 0
135
- tool_call_map[idx] ||= { 'id' => '', 'type' => 'function', 'function' => { 'name' => '', 'arguments' => '' } }
136
- entry = tool_call_map[idx]
137
- entry['id'] = tc['id'] if tc['id']
138
- entry['type'] = tc['type'] if tc['type']
139
- fn = tc['function'] || {}
140
- entry['function']['name'] += fn['name'] if fn['name']
141
- entry['function']['arguments'] += fn['arguments'] if fn['arguments']
103
+ response.read_body do |chunk|
104
+ chunk.split("\n").each do |line|
105
+ next unless line.start_with?('data: ')
106
+
107
+ data_str = line[6..]
108
+ next if data_str.strip == '[DONE]'
109
+
110
+ begin
111
+ parsed = JSON.parse(data_str)
112
+ rescue JSON::ParserError
113
+ next
114
+ end
115
+
116
+ if parsed['usage'] && parsed['usage']['prompt_tokens']
117
+ usage = parsed['usage']
118
+ end
119
+
120
+ choices = parsed['choices'] || []
121
+ next if choices.empty?
122
+
123
+ choice = choices[0]
124
+ delta = choice['delta'] || {}
125
+
126
+ if delta['content'] && !delta['content'].empty?
127
+ handler.on_content(delta['content'])
128
+ end
129
+
130
+ if delta['tool_calls']
131
+ delta['tool_calls'].each do |tc|
132
+ idx = tc['index'] || 0
133
+ tool_call_map[idx] ||= { 'id' => '', 'type' => 'function', 'function' => { 'name' => '', 'arguments' => '' } }
134
+ entry = tool_call_map[idx]
135
+ entry['id'] = tc['id'] if tc['id']
136
+ entry['type'] = tc['type'] if tc['type']
137
+ fn = tc['function'] || {}
138
+ entry['function']['name'] += fn['name'] if fn['name']
139
+ entry['function']['arguments'] += fn['arguments'] if fn['arguments']
140
+ end
141
+ end
142
+
143
+ finish_reason = choice['finish_reason'] if choice['finish_reason']
142
144
  end
143
145
  end
144
-
145
- finish_reason = choice['finish_reason'] if choice['finish_reason']
146
146
  end
147
- end
148
147
 
149
- tool_calls = tool_call_map.keys.sort.map { |k| tool_call_map[k] }.select { |tc| tc['function']['name'] && !tc['function']['name'].empty? }
148
+ tool_calls = tool_call_map.keys.sort.map { |k| tool_call_map[k] }.select { |tc| tc['function']['name'] && !tc['function']['name'].empty? }
150
149
 
151
- handler.on_complete(tool_calls, finish_reason, usage)
150
+ handler.on_complete(tool_calls, finish_reason, usage)
151
+ end
152
152
  end
153
153
 
154
154
  # ==================== Non-Streaming POST with retry ====================
@@ -1,3 +1,3 @@
1
1
  module DebugAgent
2
- VERSION = '0.4.0'.freeze
2
+ VERSION = '0.5.1'.freeze
3
3
  end
data/lib/debug_agent.rb CHANGED
@@ -30,6 +30,11 @@ require_relative 'debug_agent/inspectors/metrics'
30
30
  require_relative 'debug_agent/inspectors/active_record_stats'
31
31
  require_relative 'debug_agent/inspectors/faraday'
32
32
  require_relative 'debug_agent/inspectors/concurrent'
33
+ require_relative 'debug_agent/inspectors/security'
34
+ require_relative 'debug_agent/inspectors/health'
35
+ require_relative 'debug_agent/inspectors/scheduler'
36
+ require_relative 'debug_agent/inspectors/error_tracking'
37
+ require_relative 'debug_agent/inspectors/websocket'
33
38
 
34
39
  module DebugAgent
35
40
  class Error < StandardError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: debug-agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ggcode
@@ -168,8 +168,10 @@ files:
168
168
  - lib/debug_agent/inspectors/cache.rb
169
169
  - lib/debug_agent/inspectors/concurrent.rb
170
170
  - lib/debug_agent/inspectors/core_ext.rb
171
+ - lib/debug_agent/inspectors/error_tracking.rb
171
172
  - lib/debug_agent/inspectors/faraday.rb
172
173
  - lib/debug_agent/inspectors/gc.rb
174
+ - lib/debug_agent/inspectors/health.rb
173
175
  - lib/debug_agent/inspectors/http_client.rb
174
176
  - lib/debug_agent/inspectors/http_tracker.rb
175
177
  - lib/debug_agent/inspectors/logging.rb
@@ -181,9 +183,12 @@ files:
181
183
  - lib/debug_agent/inspectors/redis.rb
182
184
  - lib/debug_agent/inspectors/routes.rb
183
185
  - lib/debug_agent/inspectors/runtime.rb
186
+ - lib/debug_agent/inspectors/scheduler.rb
187
+ - lib/debug_agent/inspectors/security.rb
184
188
  - lib/debug_agent/inspectors/sidekiq.rb
185
189
  - lib/debug_agent/inspectors/system.rb
186
190
  - lib/debug_agent/inspectors/threads.rb
191
+ - lib/debug_agent/inspectors/websocket.rb
187
192
  - lib/debug_agent/llm_client.rb
188
193
  - lib/debug_agent/middleware.rb
189
194
  - lib/debug_agent/system_prompt_builder.rb