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.
- checksums.yaml +4 -4
- data/README.md +110 -4
- data/lib/debug_agent/inspectors/active_record_stats.rb +131 -0
- data/lib/debug_agent/inspectors/cache.rb +194 -0
- data/lib/debug_agent/inspectors/concurrent.rb +78 -0
- data/lib/debug_agent/inspectors/core_ext.rb +105 -0
- data/lib/debug_agent/inspectors/faraday.rb +79 -0
- data/lib/debug_agent/inspectors/gc.rb +53 -0
- data/lib/debug_agent/inspectors/http_client.rb +145 -0
- data/lib/debug_agent/inspectors/logging.rb +163 -0
- data/lib/debug_agent/inspectors/metrics.rb +71 -0
- data/lib/debug_agent/inspectors/puma.rb +73 -0
- data/lib/debug_agent/inspectors/rails.rb +127 -0
- data/lib/debug_agent/inspectors/redis.rb +205 -0
- data/lib/debug_agent/inspectors/sidekiq.rb +121 -0
- data/lib/debug_agent/version.rb +1 -1
- data/lib/debug_agent.rb +12 -0
- metadata +112 -2
|
@@ -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
|