rails-profiler 0.29.0 → 0.30.1
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 +99 -10
- 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 +107 -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 +39 -0
- data/lib/profiler/mcp/tools/list_slaves.rb +27 -0
- data/lib/profiler/mcp/tools/query_console_profiles.rb +6 -3
- data/lib/profiler/mcp/tools/query_jobs.rb +6 -3
- data/lib/profiler/mcp/tools/query_mailers.rb +4 -1
- data/lib/profiler/mcp/tools/query_profiles.rb +6 -3
- data/lib/profiler/mcp/tools/query_test_profiles.rb +6 -3
- 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 +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ef4a54cfcda2a485105a2adaaecdf2745f98dd12039ff47ff3e448a1ed6fb050
|
|
4
|
+
data.tar.gz: ac60b2c9103112ff2a7620df3fbe95b07aded1856662948666caca556224218b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 733cd3aed684f390a3e8dd7656a08f94f29c00e45c42f128c5b8369cbc9d2f5db763b7bcb59e1870114853c74c8e8dd4f3e8f022c326310e3c2ad1cd9c3ba3c5
|
|
7
|
+
data.tar.gz: bd0361ba931f6139527db2fd02c8f6518ff19ac82dde78758c7b86ed590c342cfc659ef9c60cc6240949a54d1ec7f42d573a1d0952002028d23a0b9e68477cbc
|
|
@@ -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,
|
|
@@ -5521,18 +5554,19 @@
|
|
|
5521
5554
|
};
|
|
5522
5555
|
};
|
|
5523
5556
|
const [section, setSection] = d2(initialSection);
|
|
5524
|
-
const infiniteOpts = (key) => ({
|
|
5557
|
+
const infiniteOpts = (key, fetchFn) => ({
|
|
5525
5558
|
query: {
|
|
5526
5559
|
enabled: section === key,
|
|
5560
|
+
queryFn: ({ pageParam = 0, signal }) => fetchFn(pageParam, signal),
|
|
5527
5561
|
getNextPageParam: (lastPage) => lastPage.has_more ? lastPage.offset + lastPage.limit : void 0,
|
|
5528
5562
|
initialPageParam: 0,
|
|
5529
5563
|
refetchOnWindowFocus: false
|
|
5530
5564
|
}
|
|
5531
5565
|
});
|
|
5532
|
-
const httpQuery = useListProfilesInfinite({ limit: 50 }, infiniteOpts("http"));
|
|
5533
|
-
const jobsQuery = useListJobsInfinite({ limit: 50 }, infiniteOpts("jobs"));
|
|
5534
|
-
const consolesQuery = useListConsolesInfinite({ limit: 50 }, infiniteOpts("console"));
|
|
5535
|
-
const testsQuery = useListTestsInfinite({ limit: 50 }, infiniteOpts("tests"));
|
|
5566
|
+
const httpQuery = useListProfilesInfinite({ limit: 50 }, infiniteOpts("http", (offset, signal) => listProfiles({ limit: 50, offset }, signal)));
|
|
5567
|
+
const jobsQuery = useListJobsInfinite({ limit: 50 }, infiniteOpts("jobs", (offset, signal) => listJobs({ limit: 50, offset }, signal)));
|
|
5568
|
+
const consolesQuery = useListConsolesInfinite({ limit: 50 }, infiniteOpts("console", (offset, signal) => listConsoles({ limit: 50, offset }, signal)));
|
|
5569
|
+
const testsQuery = useListTestsInfinite({ limit: 50 }, infiniteOpts("tests", (offset, signal) => listTests({ limit: 50, offset }, signal)));
|
|
5536
5570
|
const outboundQuery = useListOutboundRequests({ query: { enabled: section === "outbound", refetchOnWindowFocus: false } });
|
|
5537
5571
|
const envQuery = useGetEnvVars({ query: { enabled: section === "env", refetchOnWindowFocus: false } });
|
|
5538
5572
|
const { mutateAsync: deleteProfileMutation } = useDeleteProfile();
|
|
@@ -6321,6 +6355,53 @@
|
|
|
6321
6355
|
] });
|
|
6322
6356
|
}
|
|
6323
6357
|
|
|
6358
|
+
// app/assets/typescript/profiler/components/ProfilerSelector.tsx
|
|
6359
|
+
function ProfilerSelector() {
|
|
6360
|
+
const [slaves, setSlaves] = d2([]);
|
|
6361
|
+
const [loading, setLoading] = d2(true);
|
|
6362
|
+
const activeSlave = getActiveSlaveName();
|
|
6363
|
+
y2(() => {
|
|
6364
|
+
fetch("/_profiler/api/cluster/slaves").then((r3) => r3.json()).then((data) => {
|
|
6365
|
+
setSlaves(data.slaves ?? []);
|
|
6366
|
+
setLoading(false);
|
|
6367
|
+
}).catch(() => setLoading(false));
|
|
6368
|
+
}, []);
|
|
6369
|
+
if (loading || slaves.length === 0) return null;
|
|
6370
|
+
const handleChange = (e3) => {
|
|
6371
|
+
const value = e3.target.value;
|
|
6372
|
+
setActiveSlave(value === "__local__" ? null : value);
|
|
6373
|
+
};
|
|
6374
|
+
const currentValue = activeSlave ?? "__local__";
|
|
6375
|
+
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: [
|
|
6376
|
+
/* @__PURE__ */ u3("span", { style: { fontSize: "12px", fontWeight: 600, color: "var(--profiler-text-secondary, #666)", textTransform: "uppercase", letterSpacing: "0.05em" }, children: "Profiler" }),
|
|
6377
|
+
/* @__PURE__ */ u3(
|
|
6378
|
+
"select",
|
|
6379
|
+
{
|
|
6380
|
+
value: currentValue,
|
|
6381
|
+
onChange: handleChange,
|
|
6382
|
+
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" },
|
|
6383
|
+
children: [
|
|
6384
|
+
/* @__PURE__ */ u3("option", { value: "__local__", children: "Local (master)" }),
|
|
6385
|
+
slaves.map((slave) => /* @__PURE__ */ u3(
|
|
6386
|
+
"option",
|
|
6387
|
+
{
|
|
6388
|
+
value: slave.name,
|
|
6389
|
+
disabled: slave.status === "offline",
|
|
6390
|
+
children: [
|
|
6391
|
+
slave.name,
|
|
6392
|
+
" ",
|
|
6393
|
+
slave.status === "offline" ? "(offline)" : ""
|
|
6394
|
+
]
|
|
6395
|
+
},
|
|
6396
|
+
slave.name
|
|
6397
|
+
))
|
|
6398
|
+
]
|
|
6399
|
+
}
|
|
6400
|
+
),
|
|
6401
|
+
activeSlave && /* @__PURE__ */ u3("span", { style: { fontSize: "11px", color: "var(--profiler-text-secondary, #888)", fontStyle: "italic" }, children: "Viewing slave data via proxy" })
|
|
6402
|
+
] });
|
|
6403
|
+
}
|
|
6404
|
+
|
|
6324
6405
|
// app/assets/typescript/profiler/components/dashboard/tabs/RequestTab.tsx
|
|
6325
6406
|
function buildCurl2(profile) {
|
|
6326
6407
|
const headers = profile.headers ?? {};
|
|
@@ -9383,7 +9464,7 @@ Duration: ${event.duration.toFixed(2)}ms`;
|
|
|
9383
9464
|
}
|
|
9384
9465
|
|
|
9385
9466
|
// app/assets/typescript/profiler/theme.ts
|
|
9386
|
-
var
|
|
9467
|
+
var STORAGE_KEY2 = "profiler-theme";
|
|
9387
9468
|
var THEME_ATTRIBUTE = "data-theme";
|
|
9388
9469
|
var ThemeManager = class {
|
|
9389
9470
|
constructor() {
|
|
@@ -9399,18 +9480,18 @@ Duration: ${event.duration.toFixed(2)}ms`;
|
|
|
9399
9480
|
}
|
|
9400
9481
|
});
|
|
9401
9482
|
window.addEventListener("storage", (e3) => {
|
|
9402
|
-
if (e3.key ===
|
|
9483
|
+
if (e3.key === STORAGE_KEY2 && e3.newValue) {
|
|
9403
9484
|
this.currentTheme = e3.newValue;
|
|
9404
9485
|
this.applyTheme(this.currentTheme);
|
|
9405
9486
|
}
|
|
9406
9487
|
});
|
|
9407
9488
|
}
|
|
9408
9489
|
loadTheme() {
|
|
9409
|
-
const stored = localStorage.getItem(
|
|
9490
|
+
const stored = localStorage.getItem(STORAGE_KEY2);
|
|
9410
9491
|
return stored || "auto";
|
|
9411
9492
|
}
|
|
9412
9493
|
saveTheme(theme) {
|
|
9413
|
-
localStorage.setItem(
|
|
9494
|
+
localStorage.setItem(STORAGE_KEY2, theme);
|
|
9414
9495
|
}
|
|
9415
9496
|
applyTheme(theme) {
|
|
9416
9497
|
const resolvedTheme = this.resolveTheme(theme);
|
|
@@ -9508,6 +9589,14 @@ Duration: ${event.duration.toFixed(2)}ms`;
|
|
|
9508
9589
|
defaultOptions: { queries: { staleTime: 3e4, retry: 1 } }
|
|
9509
9590
|
});
|
|
9510
9591
|
document.addEventListener("DOMContentLoaded", () => {
|
|
9592
|
+
const selectorEl = document.getElementById("profiler-cluster-selector");
|
|
9593
|
+
const isMaster = document.querySelector('meta[name="profiler-is-master"]')?.getAttribute("content") === "true";
|
|
9594
|
+
if (selectorEl && isMaster) {
|
|
9595
|
+
J(
|
|
9596
|
+
/* @__PURE__ */ u3(QueryClientProvider, { client: queryClient, children: /* @__PURE__ */ u3(ProfilerSelector, {}) }),
|
|
9597
|
+
selectorEl
|
|
9598
|
+
);
|
|
9599
|
+
}
|
|
9511
9600
|
const indexEl = document.getElementById("profiler-index");
|
|
9512
9601
|
if (indexEl) {
|
|
9513
9602
|
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,107 @@
|
|
|
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,
|
|
84
|
+
use_ssl: uri.scheme == "https") do |http|
|
|
85
|
+
http.request(req)
|
|
86
|
+
end
|
|
87
|
+
return {} if resp.code == "204"
|
|
88
|
+
|
|
89
|
+
parse_response(resp)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_response(resp)
|
|
93
|
+
return {} if resp.body.nil? || resp.body.empty?
|
|
94
|
+
|
|
95
|
+
JSON.parse(resp.body)
|
|
96
|
+
rescue JSON::ParserError
|
|
97
|
+
{ "raw" => resp.body }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def profile_from_api(hash)
|
|
101
|
+
require_relative "../models/profile"
|
|
102
|
+
# from_hash expects symbol keys at the top level
|
|
103
|
+
Profiler::Models::Profile.from_hash(hash.transform_keys(&:to_sym))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
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
|