debug-agent 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.3.0'.freeze
2
+ VERSION = '0.5.0'.freeze
3
3
  end
data/lib/debug_agent.rb CHANGED
@@ -23,6 +23,18 @@ 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'
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'
26
38
 
27
39
  module DebugAgent
28
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.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ggcode
@@ -164,9 +164,18 @@ 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/error_tracking.rb
172
+ - lib/debug_agent/inspectors/faraday.rb
168
173
  - lib/debug_agent/inspectors/gc.rb
174
+ - lib/debug_agent/inspectors/health.rb
175
+ - lib/debug_agent/inspectors/http_client.rb
169
176
  - lib/debug_agent/inspectors/http_tracker.rb
177
+ - lib/debug_agent/inspectors/logging.rb
178
+ - lib/debug_agent/inspectors/metrics.rb
170
179
  - lib/debug_agent/inspectors/object_space.rb
171
180
  - lib/debug_agent/inspectors/process_info.rb
172
181
  - lib/debug_agent/inspectors/puma.rb
@@ -174,9 +183,12 @@ files:
174
183
  - lib/debug_agent/inspectors/redis.rb
175
184
  - lib/debug_agent/inspectors/routes.rb
176
185
  - lib/debug_agent/inspectors/runtime.rb
186
+ - lib/debug_agent/inspectors/scheduler.rb
187
+ - lib/debug_agent/inspectors/security.rb
177
188
  - lib/debug_agent/inspectors/sidekiq.rb
178
189
  - lib/debug_agent/inspectors/system.rb
179
190
  - lib/debug_agent/inspectors/threads.rb
191
+ - lib/debug_agent/inspectors/websocket.rb
180
192
  - lib/debug_agent/llm_client.rb
181
193
  - lib/debug_agent/middleware.rb
182
194
  - lib/debug_agent/system_prompt_builder.rb