rails-profiler 0.28.0 → 0.30.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/profiler-toolbar.js +60 -1
  3. data/app/assets/builds/profiler.js +120 -31
  4. data/app/controllers/profiler/api/cluster_controller.rb +35 -0
  5. data/app/controllers/profiler/api/events_controller.rb +35 -0
  6. data/app/controllers/profiler/api/profiles_controller.rb +8 -2
  7. data/app/controllers/profiler/api/slave_proxy_controller.rb +49 -0
  8. data/app/views/layouts/profiler/application.html.erb +3 -0
  9. data/config/routes.rb +11 -0
  10. data/lib/profiler/cluster/master_client.rb +79 -0
  11. data/lib/profiler/cluster/slave_proxy.rb +106 -0
  12. data/lib/profiler/cluster/slave_registry.rb +50 -0
  13. data/lib/profiler/configuration.rb +20 -1
  14. data/lib/profiler/mcp/server.rb +49 -20
  15. data/lib/profiler/mcp/slave_support.rb +23 -0
  16. data/lib/profiler/mcp/tools/analyze_queries.rb +5 -2
  17. data/lib/profiler/mcp/tools/clear_profiles.rb +11 -1
  18. data/lib/profiler/mcp/tools/delete_env_var.rb +7 -0
  19. data/lib/profiler/mcp/tools/explain_query.rb +8 -0
  20. data/lib/profiler/mcp/tools/get_profile_ajax.rb +5 -2
  21. data/lib/profiler/mcp/tools/get_profile_detail.rb +5 -2
  22. data/lib/profiler/mcp/tools/get_profile_dumps.rb +5 -2
  23. data/lib/profiler/mcp/tools/get_profile_http.rb +5 -2
  24. data/lib/profiler/mcp/tools/get_profile_mailers.rb +5 -2
  25. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +5 -2
  26. data/lib/profiler/mcp/tools/list_env_vars.rb +38 -0
  27. data/lib/profiler/mcp/tools/list_slaves.rb +27 -0
  28. data/lib/profiler/mcp/tools/query_console_profiles.rb +4 -1
  29. data/lib/profiler/mcp/tools/query_jobs.rb +4 -1
  30. data/lib/profiler/mcp/tools/query_mailers.rb +4 -1
  31. data/lib/profiler/mcp/tools/query_profiles.rb +4 -1
  32. data/lib/profiler/mcp/tools/query_test_profiles.rb +4 -1
  33. data/lib/profiler/mcp/tools/reset_all_env_vars.rb +8 -1
  34. data/lib/profiler/mcp/tools/reset_env_var.rb +9 -0
  35. data/lib/profiler/mcp/tools/run_tests.rb +41 -0
  36. data/lib/profiler/mcp/tools/set_env_var.rb +7 -0
  37. data/lib/profiler/railtie.rb +11 -0
  38. data/lib/profiler/sse/bus.rb +13 -0
  39. data/lib/profiler/sse/event_bus.rb +47 -0
  40. data/lib/profiler/sse/redis_event_bus.rb +79 -0
  41. data/lib/profiler/storage/base_store.rb +18 -1
  42. data/lib/profiler/storage/file_store.rb +1 -1
  43. data/lib/profiler/storage/memory_store.rb +1 -1
  44. data/lib/profiler/storage/redis_store.rb +3 -1
  45. data/lib/profiler/storage/sqlite_store.rb +1 -1
  46. data/lib/profiler/version.rb +1 -1
  47. data/lib/profiler.rb +10 -0
  48. metadata +13 -2
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Profiler
8
+ module Cluster
9
+ class MasterClient
10
+ def start
11
+ @registered = false
12
+ begin
13
+ register!
14
+ @registered = true
15
+ rescue => e
16
+ log_warn("Could not register with master at startup: #{e.message} — will retry in heartbeat loop")
17
+ end
18
+ start_heartbeat_thread
19
+ end
20
+
21
+ private
22
+
23
+ def register!
24
+ config = Profiler.configuration
25
+ uri = URI("#{config.master_url}/_profiler/api/cluster/register")
26
+ body = { name: config.resolved_name, url: config.self_url }.to_json
27
+ resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json")
28
+ unless resp.code.to_i.between?(200, 299)
29
+ raise "Master returned #{resp.code}: #{resp.body.to_s.slice(0, 200)}"
30
+ end
31
+
32
+ log_info("Registered with master at #{config.master_url} as '#{config.resolved_name}'")
33
+ end
34
+
35
+ def heartbeat!
36
+ config = Profiler.configuration
37
+ uri = URI("#{config.master_url}/_profiler/api/cluster/heartbeat")
38
+ body = { name: config.resolved_name }.to_json
39
+ resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json")
40
+ raise "Heartbeat rejected #{resp.code}" unless resp.code.to_i.between?(200, 299)
41
+ end
42
+
43
+ def start_heartbeat_thread
44
+ Thread.new do
45
+ interval = Profiler.configuration.cluster_heartbeat_interval
46
+ loop do
47
+ sleep interval
48
+ unless @registered
49
+ register!
50
+ @registered = true
51
+ log_info("Re-registered with master after previous failure")
52
+ else
53
+ heartbeat!
54
+ end
55
+ rescue => e
56
+ @registered = false
57
+ log_warn("Cluster communication failed: #{e.message} — will retry")
58
+ end
59
+ end
60
+ end
61
+
62
+ def log_info(msg)
63
+ if defined?(Rails)
64
+ Rails.logger.info("[Profiler Cluster] #{msg}")
65
+ else
66
+ $stderr.puts("[Profiler Cluster] #{msg}")
67
+ end
68
+ end
69
+
70
+ def log_warn(msg)
71
+ if defined?(Rails)
72
+ Rails.logger.warn("[Profiler Cluster] #{msg}")
73
+ else
74
+ $stderr.puts("[Profiler Cluster] WARN #{msg}")
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Profiler
8
+ module Cluster
9
+ class SlaveProxy
10
+ # A slow or hung slave must not block the master's request thread for long.
11
+ OPEN_TIMEOUT = 5 # seconds
12
+ READ_TIMEOUT = 10 # seconds
13
+
14
+ def initialize(slave_name)
15
+ entry = Profiler.slave_registry.find!(slave_name)
16
+ raise Profiler::Error, "Slave profiler '#{slave_name}' is offline" if entry.status == "offline"
17
+
18
+ @base_url = entry.url.to_s.chomp("/")
19
+ end
20
+
21
+ # Storage-compatible interface for MCP query tools
22
+
23
+ def list(limit: 50, offset: 0, **)
24
+ # all_types mirrors Profiler.storage.list, which returns every profile type;
25
+ # without it the proxy would only ever see http profiles (see ProfilesController#index).
26
+ data = get_json("/_profiler/api/profiles", limit: limit, offset: offset, all_types: true)
27
+ Array(data["profiles"]).map { |h| profile_from_api(h) }
28
+ end
29
+
30
+ def load(token)
31
+ data = get_json("/_profiler/api/profiles/#{token}")
32
+ return nil unless data["token"] || data["profile"]
33
+
34
+ raw = data["profile"] || data
35
+ profile_from_api(raw)
36
+ end
37
+
38
+ def clear(type: nil)
39
+ params = type ? "?type=#{URI.encode_www_form_component(type)}" : ""
40
+ delete_json("/_profiler/api/profiles/clear#{params}")
41
+ end
42
+
43
+ # Generic HTTP access for action tools
44
+
45
+ def get_json(path, params = {})
46
+ uri = build_uri(path, params)
47
+ request(uri, Net::HTTP::Get.new(uri))
48
+ end
49
+
50
+ def post_json(path, body = {})
51
+ request(*json_request(Net::HTTP::Post, path, body))
52
+ end
53
+
54
+ def patch_json(path, body = {})
55
+ request(*json_request(Net::HTTP::Patch, path, body))
56
+ end
57
+
58
+ def delete_json(path)
59
+ uri = build_uri(path)
60
+ request(uri, Net::HTTP::Delete.new(uri))
61
+ end
62
+
63
+ private
64
+
65
+ def build_uri(path, params = {})
66
+ uri = URI("#{@base_url}#{path}")
67
+ unless params.empty?
68
+ uri.query = URI.encode_www_form(params.compact.transform_values(&:to_s))
69
+ end
70
+ uri
71
+ end
72
+
73
+ def json_request(klass, path, body)
74
+ uri = build_uri(path)
75
+ req = klass.new(uri)
76
+ req["Content-Type"] = "application/json"
77
+ req.body = body.to_json
78
+ [uri, req]
79
+ end
80
+
81
+ def request(uri, req)
82
+ resp = Net::HTTP.start(uri.hostname, uri.port,
83
+ open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
84
+ http.request(req)
85
+ end
86
+ return {} if resp.code == "204"
87
+
88
+ parse_response(resp)
89
+ end
90
+
91
+ def parse_response(resp)
92
+ return {} if resp.body.nil? || resp.body.empty?
93
+
94
+ JSON.parse(resp.body)
95
+ rescue JSON::ParserError
96
+ { "raw" => resp.body }
97
+ end
98
+
99
+ def profile_from_api(hash)
100
+ require_relative "../models/profile"
101
+ # from_hash expects symbol keys at the top level
102
+ Profiler::Models::Profile.from_hash(hash.transform_keys(&:to_sym))
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent-ruby"
4
+
5
+ module Profiler
6
+ module Cluster
7
+ class SlaveRegistry
8
+ Entry = Struct.new(:name, :url, :registered_at, :last_heartbeat_at, keyword_init: true) do
9
+ def status
10
+ threshold = Profiler.configuration.cluster_offline_threshold
11
+ (Time.now - last_heartbeat_at) < threshold ? "online" : "offline"
12
+ end
13
+
14
+ def to_h
15
+ super.merge(status: status).tap do |h|
16
+ h[:registered_at] = h[:registered_at]&.iso8601
17
+ h[:last_heartbeat_at] = h[:last_heartbeat_at]&.iso8601
18
+ end
19
+ end
20
+ end
21
+
22
+ def initialize
23
+ @slaves = Concurrent::Hash.new
24
+ end
25
+
26
+ def register(name:, url:)
27
+ now = Time.now
28
+ if (existing = @slaves[name])
29
+ existing.url = url
30
+ existing.last_heartbeat_at = now
31
+ else
32
+ @slaves[name] = Entry.new(name: name, url: url, registered_at: now, last_heartbeat_at: now)
33
+ end
34
+ @slaves[name]
35
+ end
36
+
37
+ def heartbeat(name)
38
+ @slaves[name]&.tap { |e| e.last_heartbeat_at = Time.now }
39
+ end
40
+
41
+ def find!(name)
42
+ @slaves[name] || raise(Profiler::Error, "Unknown slave profiler: #{name}")
43
+ end
44
+
45
+ def all
46
+ @slaves.values.map(&:to_h)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -14,7 +14,9 @@ module Profiler
14
14
  :track_console,
15
15
  :track_tests,
16
16
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
17
- :compress_bodies, :compress_body_threshold
17
+ :compress_bodies, :compress_body_threshold,
18
+ :name, :master_url, :self_url,
19
+ :cluster_heartbeat_interval, :cluster_offline_threshold
18
20
 
19
21
  attr_writer :tmp_path
20
22
 
@@ -54,12 +56,29 @@ module Profiler
54
56
  @compress_bodies = true
55
57
  @compress_body_threshold = 10 * 1024 # 10 KB
56
58
  @tmp_path = nil
59
+ @name = nil
60
+ @master_url = nil
61
+ @self_url = nil
62
+ @cluster_heartbeat_interval = 15
63
+ @cluster_offline_threshold = 60
57
64
  end
58
65
 
59
66
  def tmp_path
60
67
  @tmp_path || default_tmp_path
61
68
  end
62
69
 
70
+ def slave?
71
+ !master_url.nil? && !master_url.empty?
72
+ end
73
+
74
+ def master?
75
+ !slave?
76
+ end
77
+
78
+ def resolved_name
79
+ @name || (defined?(Rails) ? Rails.application.class.module_parent_name.underscore : "profiler")
80
+ end
81
+
63
82
  def authorize_with(&block)
64
83
  @authorize_block = block
65
84
  end
@@ -59,6 +59,7 @@ module Profiler
59
59
  private
60
60
 
61
61
  def build_tools
62
+ require_relative "slave_support"
62
63
  require_relative "file_cache"
63
64
  require_relative "path_extractor"
64
65
  require_relative "body_formatter"
@@ -82,6 +83,9 @@ module Profiler
82
83
  require_relative "tools/delete_env_var"
83
84
  require_relative "tools/reset_env_var"
84
85
  require_relative "tools/reset_all_env_vars"
86
+ require_relative "tools/list_slaves"
87
+
88
+ slave_param = { type: "string", description: "Name of a connected slave profiler to target. Omit to use this profiler's own data." }
85
89
 
86
90
  [
87
91
  define_tool(
@@ -95,7 +99,8 @@ module Profiler
95
99
  profile_type: { type: "string", description: "Filter by type: 'http' or 'job'" },
96
100
  limit: { type: "number", description: "Maximum number of results (default 20)" },
97
101
  fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, type, method, path, duration, queries, status, token. Omit for all." },
98
- cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen. Returns profiles older than this." }
102
+ cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen. Returns profiles older than this." },
103
+ slave: slave_param
99
104
  }
100
105
  },
101
106
  handler: Tools::QueryProfiles
@@ -112,7 +117,8 @@ module Profiler
112
117
  json_path: { type: "string", description: "JSONPath expression to extract from response body (e.g. '$.data.items[0]'). Only applied when save_bodies is true." },
113
118
  xml_path: { type: "string", description: "XPath expression to extract from response body (e.g. '//items/item[1]/name'). Only applied when save_bodies is true." },
114
119
  log_min_level: { type: "string", description: "Minimum log level to include in the logs section: DEBUG, INFO, WARN, ERROR, FATAL. Only applied when 'logs' section is requested." },
115
- env_filter: { type: "string", description: "Required when requesting the env section. Case-insensitive substring filter on ENV key name (e.g. 'RAILS', 'DATABASE')." }
120
+ env_filter: { type: "string", description: "Required when requesting the env section. Case-insensitive substring filter on ENV key name (e.g. 'RAILS', 'DATABASE')." },
121
+ slave: slave_param
116
122
  },
117
123
  required: ["token"]
118
124
  },
@@ -124,7 +130,8 @@ module Profiler
124
130
  input_schema: {
125
131
  properties: {
126
132
  token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
127
- summary_only: { type: "boolean", description: "Return only the summary statistics section, skipping slow query and N+1 details." }
133
+ summary_only: { type: "boolean", description: "Return only the summary statistics section, skipping slow query and N+1 details." },
134
+ slave: slave_param
128
135
  },
129
136
  required: ["token"]
130
137
  },
@@ -136,7 +143,8 @@ module Profiler
136
143
  input_schema: {
137
144
  properties: {
138
145
  token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
139
- query_index: { type: "integer", description: "Zero-based index of the query within the profile's database queries list (required)" }
146
+ query_index: { type: "integer", description: "Zero-based index of the query within the profile's database queries list (required)" },
147
+ slave: slave_param
140
148
  },
141
149
  required: ["token", "query_index"]
142
150
  },
@@ -147,7 +155,8 @@ module Profiler
147
155
  description: "Get detailed AJAX sub-request breakdown for a profile. Use 'latest' as token to get the most recent profile.",
148
156
  input_schema: {
149
157
  properties: {
150
- token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
158
+ token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
159
+ slave: slave_param
151
160
  },
152
161
  required: ["token"]
153
162
  },
@@ -158,7 +167,8 @@ module Profiler
158
167
  description: "Get variable dumps captured during a profile. Use 'latest' as token to get the most recent profile.",
159
168
  input_schema: {
160
169
  properties: {
161
- token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
170
+ token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
171
+ slave: slave_param
162
172
  },
163
173
  required: ["token"]
164
174
  },
@@ -174,7 +184,8 @@ module Profiler
174
184
  save_bodies: { type: "boolean", description: "Save request/response bodies to temp files and return paths instead of inlining content." },
175
185
  max_body_size: { type: "number", description: "Truncate inlined body content at N characters. Ignored when save_bodies is true." },
176
186
  json_path: { type: "string", description: "JSONPath expression to extract from response bodies (e.g. '$.data.items[0]'). Only applied when save_bodies is true." },
177
- xml_path: { type: "string", description: "XPath expression to extract from response bodies (e.g. '//items/item[1]/name'). Only applied when save_bodies is true." }
187
+ xml_path: { type: "string", description: "XPath expression to extract from response bodies (e.g. '//items/item[1]/name'). Only applied when save_bodies is true." },
188
+ slave: slave_param
178
189
  },
179
190
  required: ["token"]
180
191
  },
@@ -190,7 +201,8 @@ module Profiler
190
201
  action: { type: "string", description: "Filter by mailer action (partial match, e.g. 'welcome_email')" },
191
202
  delivery_mode: { type: "string", description: "Filter by delivery mode: 'deliver_now', 'deliver_later', or 'queued'" },
192
203
  save_bodies: { type: "boolean", description: "Save email bodies to temp files and return paths instead of inlining content." },
193
- max_body_size: { type: "number", description: "Truncate inlined body content at N characters." }
204
+ max_body_size: { type: "number", description: "Truncate inlined body content at N characters." },
205
+ slave: slave_param
194
206
  },
195
207
  required: ["token"]
196
208
  },
@@ -205,7 +217,8 @@ module Profiler
205
217
  status: { type: "string", description: "Filter by status (completed, failed)" },
206
218
  limit: { type: "number", description: "Maximum number of results (default 20)" },
207
219
  fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, job_class, queue, status, duration, token. Omit for all." },
208
- cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen. Returns jobs older than this." }
220
+ cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen. Returns jobs older than this." },
221
+ slave: slave_param
209
222
  }
210
223
  },
211
224
  handler: Tools::QueryJobs
@@ -221,7 +234,8 @@ module Profiler
221
234
  has_error: { type: "boolean", description: "Filter to only emails with delivery errors" },
222
235
  limit: { type: "number", description: "Maximum number of results (default 20)" },
223
236
  fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, profile, mailer, action, subject, to, mode, duration, status, token. Omit for all." },
224
- cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen." }
237
+ cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen." },
238
+ slave: slave_param
225
239
  }
226
240
  },
227
241
  handler: Tools::QueryMailers
@@ -236,7 +250,8 @@ module Profiler
236
250
  min_duration: { type: "number", description: "Minimum duration in milliseconds" },
237
251
  limit: { type: "number", description: "Maximum number of results (default 20)" },
238
252
  fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, test_name, status, duration, queries, n1, token. Omit for all." },
239
- cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen." }
253
+ cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen." },
254
+ slave: slave_param
240
255
  }
241
256
  },
242
257
  handler: Tools::QueryTestProfiles
@@ -246,7 +261,8 @@ module Profiler
246
261
  description: "Get detailed data for a test profile: metadata, SQL queries, N+1 patterns, cache, exception. Use 'latest' as token for the most recent test.",
247
262
  input_schema: {
248
263
  properties: {
249
- token: { type: "string", description: "Test profile token, or 'latest' for the most recent test profile (required)" }
264
+ token: { type: "string", description: "Test profile token, or 'latest' for the most recent test profile (required)" },
265
+ slave: slave_param
250
266
  },
251
267
  required: ["token"]
252
268
  },
@@ -273,7 +289,8 @@ module Profiler
273
289
  max_output: {
274
290
  type: "number",
275
291
  description: "Maximum characters of output to return (tail). Default: 4000."
276
- }
292
+ },
293
+ slave: slave_param
277
294
  }
278
295
  },
279
296
  handler: Tools::RunTests
@@ -288,7 +305,8 @@ module Profiler
288
305
  min_duration: { type: "number", description: "Minimum duration in milliseconds" },
289
306
  limit: { type: "number", description: "Maximum number of results (default 20)" },
290
307
  fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, expression, return_value, status, duration, queries, token. Omit for all." },
291
- cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen. Returns profiles older than this." }
308
+ cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen. Returns profiles older than this." },
309
+ slave: slave_param
292
310
  }
293
311
  },
294
312
  handler: Tools::QueryConsoleProfiles
@@ -298,7 +316,8 @@ module Profiler
298
316
  description: "Clear profiler history. Omit type to clear everything, or pass 'http', 'job', 'test', or 'console' to clear only that type.",
299
317
  input_schema: {
300
318
  properties: {
301
- type: { type: "string", description: "Optional: 'http' to clear only requests, 'job' to clear only jobs, 'test' to clear only test profiles, 'console' to clear only console sessions" }
319
+ type: { type: "string", description: "Optional: 'http' to clear only requests, 'job' to clear only jobs, 'test' to clear only test profiles, 'console' to clear only console sessions" },
320
+ slave: slave_param
302
321
  }
303
322
  },
304
323
  handler: Tools::ClearProfiles
@@ -309,7 +328,8 @@ module Profiler
309
328
  input_schema: {
310
329
  properties: {
311
330
  include_all: { type: "boolean", description: "If true, return all ENV variables (not just overrides). Default: false." },
312
- filter: { type: "string", description: "Case-insensitive substring filter on key name." }
331
+ filter: { type: "string", description: "Case-insensitive substring filter on key name." },
332
+ slave: slave_param
313
333
  }
314
334
  },
315
335
  handler: Tools::ListEnvVars
@@ -320,7 +340,8 @@ module Profiler
320
340
  input_schema: {
321
341
  properties: {
322
342
  key: { type: "string", description: "Environment variable name (required)" },
323
- value: { type: "string", description: "New value (required)" }
343
+ value: { type: "string", description: "New value (required)" },
344
+ slave: slave_param
324
345
  },
325
346
  required: ["key", "value"]
326
347
  },
@@ -331,7 +352,8 @@ module Profiler
331
352
  description: "Delete an environment variable for this session (persisted across restarts until reset).",
332
353
  input_schema: {
333
354
  properties: {
334
- key: { type: "string", description: "Environment variable name (required)" }
355
+ key: { type: "string", description: "Environment variable name (required)" },
356
+ slave: slave_param
335
357
  },
336
358
  required: ["key"]
337
359
  },
@@ -342,7 +364,8 @@ module Profiler
342
364
  description: "Restore an overridden environment variable to its original value.",
343
365
  input_schema: {
344
366
  properties: {
345
- key: { type: "string", description: "Environment variable name to restore (required)" }
367
+ key: { type: "string", description: "Environment variable name to restore (required)" },
368
+ slave: slave_param
346
369
  },
347
370
  required: ["key"]
348
371
  },
@@ -351,8 +374,14 @@ module Profiler
351
374
  define_tool(
352
375
  name: "reset_all_env_vars",
353
376
  description: "Restore all overridden environment variables to their original values.",
354
- input_schema: { properties: {} },
377
+ input_schema: { properties: { slave: slave_param } },
355
378
  handler: Tools::ResetAllEnvVars
379
+ ),
380
+ define_tool(
381
+ name: "list_slaves",
382
+ description: "List all slave profilers connected to this master profiler, with their connection status.",
383
+ input_schema: { properties: {} },
384
+ handler: Tools::ListSlaves
356
385
  )
357
386
  ]
358
387
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module SlaveSupport
6
+ def self.resolve_storage(params)
7
+ if (slave_name = params["slave"])
8
+ require_relative "../cluster/slave_proxy"
9
+ Cluster::SlaveProxy.new(slave_name)
10
+ else
11
+ Profiler.storage
12
+ end
13
+ end
14
+
15
+ def self.with_slave_proxy(params)
16
+ return nil unless params["slave"]
17
+
18
+ require_relative "../cluster/slave_proxy"
19
+ Cluster::SlaveProxy.new(params["slave"])
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
@@ -15,10 +17,11 @@ module Profiler
15
17
  ]
16
18
  end
17
19
 
20
+ storage = MCP::SlaveSupport.resolve_storage(params)
18
21
  profile = if token == "latest"
19
- Profiler.storage.list(limit: 1).first
22
+ storage.list(limit: 1).first
20
23
  else
21
- Profiler.storage.load(token)
24
+ storage.load(token)
22
25
  end
23
26
  unless profile
24
27
  return [
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
5
+ require "uri"
6
+
3
7
  module Profiler
4
8
  module MCP
5
9
  module Tools
@@ -11,7 +15,13 @@ module Profiler
11
15
  return [{ type: "text", text: "Error: type must be 'http', 'job', 'test', or 'console'" }]
12
16
  end
13
17
 
14
- Profiler.storage.clear(type: type)
18
+ if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
19
+ path = "/_profiler/api/profiles/clear"
20
+ path += "?type=#{URI.encode_www_form_component(type)}" if type
21
+ proxy.delete_json(path)
22
+ else
23
+ Profiler.storage.clear(type: type)
24
+ end
15
25
 
16
26
  label = type ? "#{type} profiles" : "all profiles"
17
27
  [{ type: "text", text: "Cleared #{label}." }]
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
@@ -9,6 +11,11 @@ module Profiler
9
11
 
10
12
  return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
11
13
 
14
+ if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
15
+ proxy.patch_json("/_profiler/api/env_vars", { key: key, value: "" })
16
+ return [{ type: "text", text: "Deleted #{key} on slave '#{params["slave"]}'. Override persisted across restarts until reset." }]
17
+ end
18
+
12
19
  Profiler.env_override_store.delete(key)
13
20
  ENV.delete(key)
14
21
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  require "profiler/explain_runner"
4
6
 
5
7
  module Profiler
@@ -18,6 +20,12 @@ module Profiler
18
20
  return [{ type: "text", text: "Error: query_index parameter is required" }]
19
21
  end
20
22
 
23
+ if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
24
+ result = proxy.post_json("/_profiler/api/explain", { token: token, query_index: query_index.to_i })
25
+ text = result["error"] ? "Error: #{result["error"]}" : result.inspect
26
+ return [{ type: "text", text: text }]
27
+ end
28
+
21
29
  unless Profiler.configuration.enabled
22
30
  return [{ type: "text", text: "Error: EXPLAIN is only available when the profiler is enabled" }]
23
31
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  module Profiler
4
6
  module MCP
5
7
  module Tools
@@ -10,10 +12,11 @@ module Profiler
10
12
  return [{ type: "text", text: "Error: token parameter is required" }]
11
13
  end
12
14
 
15
+ storage = MCP::SlaveSupport.resolve_storage(params)
13
16
  profile = if token == "latest"
14
- Profiler.storage.list(limit: 1).first
17
+ storage.list(limit: 1).first
15
18
  else
16
- Profiler.storage.load(token)
19
+ storage.load(token)
17
20
  end
18
21
  unless profile
19
22
  return [{ type: "text", text: "Profile not found: #{token}" }]
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../slave_support"
4
+
3
5
  require "shellwords"
4
6
  require "cgi"
5
7
 
@@ -18,10 +20,11 @@ module Profiler
18
20
  ]
19
21
  end
20
22
 
23
+ storage = MCP::SlaveSupport.resolve_storage(params)
21
24
  profile = if token == "latest"
22
- Profiler.storage.list(limit: 1).first
25
+ storage.list(limit: 1).first
23
26
  else
24
- Profiler.storage.load(token)
27
+ storage.load(token)
25
28
  end
26
29
  unless profile
27
30
  return [