debug-agent 0.2.6 → 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.
@@ -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
@@ -0,0 +1,73 @@
1
+ module DebugAgent
2
+ register_tool('get_puma_stats',
3
+ 'Get Puma worker stats: running workers, threads, backlog, requests ' \
4
+ '(uses Puma.stats if Puma is loaded)') do
5
+ unless defined?(::Puma)
6
+ return { error: 'Puma is not loaded (puma gem not installed)' }
7
+ end
8
+
9
+ raw =
10
+ if ::Puma.respond_to?(:stats_hash)
11
+ ::Puma.stats_hash
12
+ elsif ::Puma.respond_to?(:stats)
13
+ # Puma.stats returns a JSON string in older versions; parse it.
14
+ s = ::Puma.stats
15
+ s.is_a?(String) ? JSON.parse(s, symbolize_names: true) : s
16
+ else
17
+ return { error: 'Puma is loaded but does not expose Puma.stats' }
18
+ end
19
+
20
+ raw = raw.respond_to?(:transform_keys) ? raw : raw
21
+
22
+ # Normalize into a structured summary.
23
+ workers = []
24
+
25
+ # Clustered mode: raw is { workers: N, booted_workers: N, old_workers: N,
26
+ # phase: N, worker_status: [...] }
27
+ # Single mode: raw is { backed_up: N, running: N, pool_capacity: N,
28
+ # max_threads: N, requests_count: N }
29
+ if raw.is_a?(Hash) && raw.key?(:worker_status)
30
+ raw[:worker_status].each_with_index do |w, i|
31
+ last_stats = w[:last_status] || {}
32
+ workers << {
33
+ index: i,
34
+ pid: w[:pid],
35
+ index_field: w[:index],
36
+ booted: w[:booted],
37
+ last_checkin: w[:last_checkin],
38
+ running_threads: last_stats[:running],
39
+ pool_capacity: last_stats[:pool_capacity],
40
+ max_threads: last_stats[:max_threads],
41
+ backlog: last_stats[:backed_up],
42
+ requests: last_stats[:requests_count]
43
+ }
44
+ end
45
+
46
+ {
47
+ mode: 'cluster',
48
+ configured_workers: raw[:workers],
49
+ booted_workers: raw[:booted_workers],
50
+ old_workers: raw[:old_workers],
51
+ phase: raw[:phase],
52
+ workers: workers,
53
+ total_running_threads: workers.sum { |w| w[:running_threads].to_i },
54
+ total_backlog: workers.sum { |w| w[:backlog].to_i },
55
+ total_requests: workers.sum { |w| w[:requests].to_i }
56
+ }
57
+ elsif raw.is_a?(Hash)
58
+ {
59
+ mode: 'single',
60
+ running_threads: raw[:running],
61
+ pool_capacity: raw[:pool_capacity],
62
+ max_threads: raw[:max_threads],
63
+ backlog: raw[:backed_up],
64
+ requests: raw[:requests_count],
65
+ raw: raw
66
+ }
67
+ else
68
+ { raw: raw }
69
+ end
70
+ rescue => e
71
+ { error: e.message }
72
+ end
73
+ end