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.
- checksums.yaml +4 -4
- data/app/assets/builds/profiler-toolbar.js +60 -1
- data/app/assets/builds/profiler.js +120 -31
- data/app/controllers/profiler/api/cluster_controller.rb +35 -0
- data/app/controllers/profiler/api/events_controller.rb +35 -0
- data/app/controllers/profiler/api/profiles_controller.rb +8 -2
- data/app/controllers/profiler/api/slave_proxy_controller.rb +49 -0
- data/app/views/layouts/profiler/application.html.erb +3 -0
- data/config/routes.rb +11 -0
- data/lib/profiler/cluster/master_client.rb +79 -0
- data/lib/profiler/cluster/slave_proxy.rb +106 -0
- data/lib/profiler/cluster/slave_registry.rb +50 -0
- data/lib/profiler/configuration.rb +20 -1
- data/lib/profiler/mcp/server.rb +49 -20
- data/lib/profiler/mcp/slave_support.rb +23 -0
- data/lib/profiler/mcp/tools/analyze_queries.rb +5 -2
- data/lib/profiler/mcp/tools/clear_profiles.rb +11 -1
- data/lib/profiler/mcp/tools/delete_env_var.rb +7 -0
- data/lib/profiler/mcp/tools/explain_query.rb +8 -0
- data/lib/profiler/mcp/tools/get_profile_ajax.rb +5 -2
- data/lib/profiler/mcp/tools/get_profile_detail.rb +5 -2
- data/lib/profiler/mcp/tools/get_profile_dumps.rb +5 -2
- data/lib/profiler/mcp/tools/get_profile_http.rb +5 -2
- data/lib/profiler/mcp/tools/get_profile_mailers.rb +5 -2
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +5 -2
- data/lib/profiler/mcp/tools/list_env_vars.rb +38 -0
- data/lib/profiler/mcp/tools/list_slaves.rb +27 -0
- data/lib/profiler/mcp/tools/query_console_profiles.rb +4 -1
- data/lib/profiler/mcp/tools/query_jobs.rb +4 -1
- data/lib/profiler/mcp/tools/query_mailers.rb +4 -1
- data/lib/profiler/mcp/tools/query_profiles.rb +4 -1
- data/lib/profiler/mcp/tools/query_test_profiles.rb +4 -1
- data/lib/profiler/mcp/tools/reset_all_env_vars.rb +8 -1
- data/lib/profiler/mcp/tools/reset_env_var.rb +9 -0
- data/lib/profiler/mcp/tools/run_tests.rb +41 -0
- data/lib/profiler/mcp/tools/set_env_var.rb +7 -0
- data/lib/profiler/railtie.rb +11 -0
- data/lib/profiler/sse/bus.rb +13 -0
- data/lib/profiler/sse/event_bus.rb +47 -0
- data/lib/profiler/sse/redis_event_bus.rb +79 -0
- data/lib/profiler/storage/base_store.rb +18 -1
- data/lib/profiler/storage/file_store.rb +1 -1
- data/lib/profiler/storage/memory_store.rb +1 -1
- data/lib/profiler/storage/redis_store.rb +3 -1
- data/lib/profiler/storage/sqlite_store.rb +1 -1
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +10 -0
- 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
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
+
storage.list(limit: 1).first
|
|
20
23
|
else
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
+
storage.list(limit: 1).first
|
|
15
18
|
else
|
|
16
|
-
|
|
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
|
-
|
|
25
|
+
storage.list(limit: 1).first
|
|
23
26
|
else
|
|
24
|
-
|
|
27
|
+
storage.load(token)
|
|
25
28
|
end
|
|
26
29
|
unless profile
|
|
27
30
|
return [
|