rails-profiler 0.29.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 +18 -1
- data/app/assets/builds/profiler.js +93 -5
- data/app/controllers/profiler/api/cluster_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 +10 -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/version.rb +1 -1
- data/lib/profiler.rb +7 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fcc4a5ad4cdedde3dd6427df52aeb75a54c4c8e973e0816f5f3d71e958042a89
|
|
4
|
+
data.tar.gz: b4f2c42db2a3a4fd0d1afd3648dcde087284dd3cf35aef0bca86049a832f3f71
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e0083dfee931dcc4e1d13eff5e873cd18eb24a8853ce503b57a38e4ec7bd3d32862e4298cf8a4cc100ee4b47863eb715a0317488e41b0b9e228127d7fcd62fd
|
|
7
|
+
data.tar.gz: 8eabcaefdfeaf5fd5fbc0149aacb179dd139b05c92157fa48708484e140c868c36869c3d03023ffcfc7603f318fde2e156ef292ca1265406d176de591c832d64
|
|
@@ -4146,9 +4146,26 @@
|
|
|
4146
4146
|
] }) });
|
|
4147
4147
|
}
|
|
4148
4148
|
|
|
4149
|
+
// app/assets/typescript/profiler/cluster-context.ts
|
|
4150
|
+
var STORAGE_KEY = "profiler-active-slave";
|
|
4151
|
+
var activeSlavePrefix = (() => {
|
|
4152
|
+
try {
|
|
4153
|
+
const stored = sessionStorage.getItem(STORAGE_KEY);
|
|
4154
|
+
return stored ? `/slaves/${stored}` : "";
|
|
4155
|
+
} catch {
|
|
4156
|
+
return "";
|
|
4157
|
+
}
|
|
4158
|
+
})();
|
|
4159
|
+
function getActiveSlavePrefix() {
|
|
4160
|
+
return activeSlavePrefix;
|
|
4161
|
+
}
|
|
4162
|
+
|
|
4149
4163
|
// app/assets/typescript/profiler/api-fetcher.ts
|
|
4150
4164
|
async function apiFetch(config) {
|
|
4151
|
-
const
|
|
4165
|
+
const prefix = getActiveSlavePrefix();
|
|
4166
|
+
const resolvedUrl = prefix ? config.url.replace("/_profiler/api", `/_profiler/api${prefix}`) : config.url;
|
|
4167
|
+
const { method, params, data, headers = {}, signal } = config;
|
|
4168
|
+
const url = resolvedUrl;
|
|
4152
4169
|
const qs = params ? "?" + new URLSearchParams(Object.entries(params).map(([k3, v3]) => [k3, String(v3)])).toString() : "";
|
|
4153
4170
|
const res = await fetch(url + qs, {
|
|
4154
4171
|
method,
|
|
@@ -3419,9 +3419,42 @@
|
|
|
3419
3419
|
);
|
|
3420
3420
|
}
|
|
3421
3421
|
|
|
3422
|
+
// app/assets/typescript/profiler/cluster-context.ts
|
|
3423
|
+
var STORAGE_KEY = "profiler-active-slave";
|
|
3424
|
+
var activeSlavePrefix = (() => {
|
|
3425
|
+
try {
|
|
3426
|
+
const stored = sessionStorage.getItem(STORAGE_KEY);
|
|
3427
|
+
return stored ? `/slaves/${stored}` : "";
|
|
3428
|
+
} catch {
|
|
3429
|
+
return "";
|
|
3430
|
+
}
|
|
3431
|
+
})();
|
|
3432
|
+
function getActiveSlavePrefix() {
|
|
3433
|
+
return activeSlavePrefix;
|
|
3434
|
+
}
|
|
3435
|
+
function getActiveSlaveName() {
|
|
3436
|
+
if (!activeSlavePrefix) return null;
|
|
3437
|
+
return activeSlavePrefix.replace("/slaves/", "");
|
|
3438
|
+
}
|
|
3439
|
+
function setActiveSlave(name) {
|
|
3440
|
+
try {
|
|
3441
|
+
if (name) {
|
|
3442
|
+
sessionStorage.setItem(STORAGE_KEY, name);
|
|
3443
|
+
} else {
|
|
3444
|
+
sessionStorage.removeItem(STORAGE_KEY);
|
|
3445
|
+
}
|
|
3446
|
+
} catch {
|
|
3447
|
+
}
|
|
3448
|
+
activeSlavePrefix = name ? `/slaves/${name}` : "";
|
|
3449
|
+
window.location.reload();
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3422
3452
|
// app/assets/typescript/profiler/api-fetcher.ts
|
|
3423
3453
|
async function apiFetch(config) {
|
|
3424
|
-
const
|
|
3454
|
+
const prefix = getActiveSlavePrefix();
|
|
3455
|
+
const resolvedUrl = prefix ? config.url.replace("/_profiler/api", `/_profiler/api${prefix}`) : config.url;
|
|
3456
|
+
const { method, params, data, headers = {}, signal } = config;
|
|
3457
|
+
const url = resolvedUrl;
|
|
3425
3458
|
const qs = params ? "?" + new URLSearchParams(Object.entries(params).map(([k3, v3]) => [k3, String(v3)])).toString() : "";
|
|
3426
3459
|
const res = await fetch(url + qs, {
|
|
3427
3460
|
method,
|
|
@@ -6321,6 +6354,53 @@
|
|
|
6321
6354
|
] });
|
|
6322
6355
|
}
|
|
6323
6356
|
|
|
6357
|
+
// app/assets/typescript/profiler/components/ProfilerSelector.tsx
|
|
6358
|
+
function ProfilerSelector() {
|
|
6359
|
+
const [slaves, setSlaves] = d2([]);
|
|
6360
|
+
const [loading, setLoading] = d2(true);
|
|
6361
|
+
const activeSlave = getActiveSlaveName();
|
|
6362
|
+
y2(() => {
|
|
6363
|
+
fetch("/_profiler/api/cluster/slaves").then((r3) => r3.json()).then((data) => {
|
|
6364
|
+
setSlaves(data.slaves ?? []);
|
|
6365
|
+
setLoading(false);
|
|
6366
|
+
}).catch(() => setLoading(false));
|
|
6367
|
+
}, []);
|
|
6368
|
+
if (loading || slaves.length === 0) return null;
|
|
6369
|
+
const handleChange = (e3) => {
|
|
6370
|
+
const value = e3.target.value;
|
|
6371
|
+
setActiveSlave(value === "__local__" ? null : value);
|
|
6372
|
+
};
|
|
6373
|
+
const currentValue = activeSlave ?? "__local__";
|
|
6374
|
+
return /* @__PURE__ */ u3("div", { class: "profiler-selector", style: { display: "flex", alignItems: "center", gap: "8px", padding: "4px 12px", background: "var(--profiler-bg-secondary, #f5f5f5)", borderBottom: "1px solid var(--profiler-border, #e0e0e0)" }, children: [
|
|
6375
|
+
/* @__PURE__ */ u3("span", { style: { fontSize: "12px", fontWeight: 600, color: "var(--profiler-text-secondary, #666)", textTransform: "uppercase", letterSpacing: "0.05em" }, children: "Profiler" }),
|
|
6376
|
+
/* @__PURE__ */ u3(
|
|
6377
|
+
"select",
|
|
6378
|
+
{
|
|
6379
|
+
value: currentValue,
|
|
6380
|
+
onChange: handleChange,
|
|
6381
|
+
style: { fontSize: "13px", padding: "2px 6px", borderRadius: "4px", border: "1px solid var(--profiler-border, #ccc)", background: "var(--profiler-bg, #fff)", color: "var(--profiler-text, #333)", cursor: "pointer" },
|
|
6382
|
+
children: [
|
|
6383
|
+
/* @__PURE__ */ u3("option", { value: "__local__", children: "Local (master)" }),
|
|
6384
|
+
slaves.map((slave) => /* @__PURE__ */ u3(
|
|
6385
|
+
"option",
|
|
6386
|
+
{
|
|
6387
|
+
value: slave.name,
|
|
6388
|
+
disabled: slave.status === "offline",
|
|
6389
|
+
children: [
|
|
6390
|
+
slave.name,
|
|
6391
|
+
" ",
|
|
6392
|
+
slave.status === "offline" ? "(offline)" : ""
|
|
6393
|
+
]
|
|
6394
|
+
},
|
|
6395
|
+
slave.name
|
|
6396
|
+
))
|
|
6397
|
+
]
|
|
6398
|
+
}
|
|
6399
|
+
),
|
|
6400
|
+
activeSlave && /* @__PURE__ */ u3("span", { style: { fontSize: "11px", color: "var(--profiler-text-secondary, #888)", fontStyle: "italic" }, children: "Viewing slave data via proxy" })
|
|
6401
|
+
] });
|
|
6402
|
+
}
|
|
6403
|
+
|
|
6324
6404
|
// app/assets/typescript/profiler/components/dashboard/tabs/RequestTab.tsx
|
|
6325
6405
|
function buildCurl2(profile) {
|
|
6326
6406
|
const headers = profile.headers ?? {};
|
|
@@ -9383,7 +9463,7 @@ Duration: ${event.duration.toFixed(2)}ms`;
|
|
|
9383
9463
|
}
|
|
9384
9464
|
|
|
9385
9465
|
// app/assets/typescript/profiler/theme.ts
|
|
9386
|
-
var
|
|
9466
|
+
var STORAGE_KEY2 = "profiler-theme";
|
|
9387
9467
|
var THEME_ATTRIBUTE = "data-theme";
|
|
9388
9468
|
var ThemeManager = class {
|
|
9389
9469
|
constructor() {
|
|
@@ -9399,18 +9479,18 @@ Duration: ${event.duration.toFixed(2)}ms`;
|
|
|
9399
9479
|
}
|
|
9400
9480
|
});
|
|
9401
9481
|
window.addEventListener("storage", (e3) => {
|
|
9402
|
-
if (e3.key ===
|
|
9482
|
+
if (e3.key === STORAGE_KEY2 && e3.newValue) {
|
|
9403
9483
|
this.currentTheme = e3.newValue;
|
|
9404
9484
|
this.applyTheme(this.currentTheme);
|
|
9405
9485
|
}
|
|
9406
9486
|
});
|
|
9407
9487
|
}
|
|
9408
9488
|
loadTheme() {
|
|
9409
|
-
const stored = localStorage.getItem(
|
|
9489
|
+
const stored = localStorage.getItem(STORAGE_KEY2);
|
|
9410
9490
|
return stored || "auto";
|
|
9411
9491
|
}
|
|
9412
9492
|
saveTheme(theme) {
|
|
9413
|
-
localStorage.setItem(
|
|
9493
|
+
localStorage.setItem(STORAGE_KEY2, theme);
|
|
9414
9494
|
}
|
|
9415
9495
|
applyTheme(theme) {
|
|
9416
9496
|
const resolvedTheme = this.resolveTheme(theme);
|
|
@@ -9508,6 +9588,14 @@ Duration: ${event.duration.toFixed(2)}ms`;
|
|
|
9508
9588
|
defaultOptions: { queries: { staleTime: 3e4, retry: 1 } }
|
|
9509
9589
|
});
|
|
9510
9590
|
document.addEventListener("DOMContentLoaded", () => {
|
|
9591
|
+
const selectorEl = document.getElementById("profiler-cluster-selector");
|
|
9592
|
+
const isMaster = document.querySelector('meta[name="profiler-is-master"]')?.getAttribute("content") === "true";
|
|
9593
|
+
if (selectorEl && isMaster) {
|
|
9594
|
+
J(
|
|
9595
|
+
/* @__PURE__ */ u3(QueryClientProvider, { client: queryClient, children: /* @__PURE__ */ u3(ProfilerSelector, {}) }),
|
|
9596
|
+
selectorEl
|
|
9597
|
+
);
|
|
9598
|
+
}
|
|
9511
9599
|
const indexEl = document.getElementById("profiler-index");
|
|
9512
9600
|
if (indexEl) {
|
|
9513
9601
|
J(
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module Api
|
|
5
|
+
class ClusterController < Profiler::ApplicationController
|
|
6
|
+
skip_before_action :verify_authenticity_token
|
|
7
|
+
|
|
8
|
+
def register
|
|
9
|
+
name = params[:name].to_s
|
|
10
|
+
url = params[:url].to_s
|
|
11
|
+
|
|
12
|
+
if name.empty? || url.empty?
|
|
13
|
+
return render json: { error: "name and url are required" }, status: :unprocessable_entity
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Profiler.slave_registry.register(name: name, url: url)
|
|
17
|
+
render json: { ok: true, name: name }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def heartbeat
|
|
21
|
+
name = params[:name].to_s
|
|
22
|
+
entry = Profiler.slave_registry.heartbeat(name)
|
|
23
|
+
if entry.nil?
|
|
24
|
+
render json: { error: "Unknown slave — please re-register" }, status: :not_found
|
|
25
|
+
else
|
|
26
|
+
render json: { ok: true }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def slaves
|
|
31
|
+
render json: { slaves: Profiler.slave_registry.all }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -9,8 +9,10 @@ module Profiler
|
|
|
9
9
|
limit = (params[:limit] || 50).to_i
|
|
10
10
|
offset = (params[:offset] || 0).to_i
|
|
11
11
|
all = Profiler.storage.list(limit: 1000, offset: 0)
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# all_types is an opt-in used by the cluster proxy so it can mirror the full
|
|
13
|
+
# storage.list contract; the dashboard relies on the default http-only filter.
|
|
14
|
+
scope = all_types? ? all : all.select { |p| p.profile_type == "http" }
|
|
15
|
+
page = scope.drop(offset).first(limit + 1)
|
|
14
16
|
render json: {
|
|
15
17
|
profiles: page.first(limit).map(&:to_h),
|
|
16
18
|
limit: limit,
|
|
@@ -50,6 +52,10 @@ module Profiler
|
|
|
50
52
|
|
|
51
53
|
private
|
|
52
54
|
|
|
55
|
+
def all_types?
|
|
56
|
+
%w[1 true].include?(params[:all_types].to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
53
59
|
def recalculate_ajax_data(profile)
|
|
54
60
|
# Find AJAX collector in the configured collectors
|
|
55
61
|
ajax_collector_class = Profiler::Collectors::AjaxCollector
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../../lib/profiler/cluster/slave_proxy"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module Api
|
|
7
|
+
class SlaveProxyController < Profiler::ApplicationController
|
|
8
|
+
skip_before_action :verify_authenticity_token
|
|
9
|
+
|
|
10
|
+
def forward
|
|
11
|
+
proxy = Cluster::SlaveProxy.new(params[:slave_name])
|
|
12
|
+
sub_path = params[:path].to_s
|
|
13
|
+
full_path = "/_profiler/api/#{sub_path}"
|
|
14
|
+
|
|
15
|
+
result = case request.method
|
|
16
|
+
when "GET"
|
|
17
|
+
proxy.get_json(full_path, request.query_parameters.to_h)
|
|
18
|
+
when "POST"
|
|
19
|
+
proxy.post_json(full_path, parsed_body)
|
|
20
|
+
when "PATCH"
|
|
21
|
+
proxy.patch_json(full_path, parsed_body)
|
|
22
|
+
when "DELETE"
|
|
23
|
+
proxy.delete_json(full_path)
|
|
24
|
+
else
|
|
25
|
+
return head :method_not_allowed
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
render json: result
|
|
29
|
+
rescue Profiler::Error => e
|
|
30
|
+
render json: { error: e.message }, status: :bad_gateway
|
|
31
|
+
rescue => e
|
|
32
|
+
render json: { error: "Proxy error: #{e.message}" }, status: :bad_gateway
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def parsed_body
|
|
38
|
+
return {} if request.body.nil?
|
|
39
|
+
|
|
40
|
+
raw = request.body.read
|
|
41
|
+
return {} if raw.empty?
|
|
42
|
+
|
|
43
|
+
JSON.parse(raw)
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
{}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
<%= csrf_meta_tags %>
|
|
7
7
|
<%= csp_meta_tag %>
|
|
8
8
|
<meta name="profiler-version" content="<%= Profiler::VERSION %>">
|
|
9
|
+
<meta name="profiler-name" content="<%= Profiler.configuration.resolved_name %>">
|
|
10
|
+
<meta name="profiler-is-master" content="<%= Profiler.configuration.master? %>">
|
|
9
11
|
|
|
10
12
|
<link rel="stylesheet" href="/_profiler/assets/profiler.css">
|
|
11
13
|
<script src="/_profiler/assets/profiler.js" defer></script>
|
|
12
14
|
</head>
|
|
13
15
|
|
|
14
16
|
<body>
|
|
17
|
+
<div id="profiler-cluster-selector"></div>
|
|
15
18
|
<%= yield %>
|
|
16
19
|
</body>
|
|
17
20
|
</html>
|
data/config/routes.rb
CHANGED
|
@@ -51,5 +51,15 @@ Profiler::Engine.routes.draw do
|
|
|
51
51
|
get "test_runner/runs/:id/stream", to: "test_runner#stream", as: :test_runner_run_stream
|
|
52
52
|
delete "test_runner/runs/:id", to: "test_runner#destroy"
|
|
53
53
|
get "events/:token", to: "events#subscribe", as: :profile_events
|
|
54
|
+
|
|
55
|
+
# Cluster endpoints (master-side)
|
|
56
|
+
post "cluster/register", to: "cluster#register"
|
|
57
|
+
post "cluster/heartbeat", to: "cluster#heartbeat"
|
|
58
|
+
get "cluster/slaves", to: "cluster#slaves"
|
|
59
|
+
|
|
60
|
+
# Slave proxy — must be last to avoid shadowing other api routes
|
|
61
|
+
scope "/slaves/:slave_name" do
|
|
62
|
+
match "*path", to: "slave_proxy#forward", via: :all
|
|
63
|
+
end
|
|
54
64
|
end
|
|
55
65
|
end
|
|
@@ -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
|