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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/profiler-toolbar.js +18 -1
  3. data/app/assets/builds/profiler.js +99 -10
  4. data/app/controllers/profiler/api/cluster_controller.rb +35 -0
  5. data/app/controllers/profiler/api/profiles_controller.rb +8 -2
  6. data/app/controllers/profiler/api/slave_proxy_controller.rb +49 -0
  7. data/app/views/layouts/profiler/application.html.erb +3 -0
  8. data/config/routes.rb +10 -0
  9. data/lib/profiler/cluster/master_client.rb +79 -0
  10. data/lib/profiler/cluster/slave_proxy.rb +107 -0
  11. data/lib/profiler/cluster/slave_registry.rb +50 -0
  12. data/lib/profiler/configuration.rb +20 -1
  13. data/lib/profiler/mcp/server.rb +49 -20
  14. data/lib/profiler/mcp/slave_support.rb +23 -0
  15. data/lib/profiler/mcp/tools/analyze_queries.rb +5 -2
  16. data/lib/profiler/mcp/tools/clear_profiles.rb +11 -1
  17. data/lib/profiler/mcp/tools/delete_env_var.rb +7 -0
  18. data/lib/profiler/mcp/tools/explain_query.rb +8 -0
  19. data/lib/profiler/mcp/tools/get_profile_ajax.rb +5 -2
  20. data/lib/profiler/mcp/tools/get_profile_detail.rb +5 -2
  21. data/lib/profiler/mcp/tools/get_profile_dumps.rb +5 -2
  22. data/lib/profiler/mcp/tools/get_profile_http.rb +5 -2
  23. data/lib/profiler/mcp/tools/get_profile_mailers.rb +5 -2
  24. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +5 -2
  25. data/lib/profiler/mcp/tools/list_env_vars.rb +39 -0
  26. data/lib/profiler/mcp/tools/list_slaves.rb +27 -0
  27. data/lib/profiler/mcp/tools/query_console_profiles.rb +6 -3
  28. data/lib/profiler/mcp/tools/query_jobs.rb +6 -3
  29. data/lib/profiler/mcp/tools/query_mailers.rb +4 -1
  30. data/lib/profiler/mcp/tools/query_profiles.rb +6 -3
  31. data/lib/profiler/mcp/tools/query_test_profiles.rb +6 -3
  32. data/lib/profiler/mcp/tools/reset_all_env_vars.rb +8 -1
  33. data/lib/profiler/mcp/tools/reset_env_var.rb +9 -0
  34. data/lib/profiler/mcp/tools/run_tests.rb +41 -0
  35. data/lib/profiler/mcp/tools/set_env_var.rb +7 -0
  36. data/lib/profiler/railtie.rb +11 -0
  37. data/lib/profiler/version.rb +1 -1
  38. data/lib/profiler.rb +7 -0
  39. metadata +9 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5388819e53342bdba701e37c877cddf9b2e6b888fa163198ef7760b689bc8796
4
- data.tar.gz: ca44a3e2c623ab6d46fec1fef941e76fb7941a1c77e5261077f0585ab6e70b53
3
+ metadata.gz: ef4a54cfcda2a485105a2adaaecdf2745f98dd12039ff47ff3e448a1ed6fb050
4
+ data.tar.gz: ac60b2c9103112ff2a7620df3fbe95b07aded1856662948666caca556224218b
5
5
  SHA512:
6
- metadata.gz: 5f01b92b17838b1730e5e571299ba0de4abfcc51fe56921e343a35ffe3aeaa38ebff0f95565d334f0b9a78123b3fd489dc96accf4244a9bb346fdd1536e45ce4
7
- data.tar.gz: 1e8960c6060b7f9a070497def307c9e04f4e57ac61f28bf6e9d48aac136ff48655c0df2bd3481c0628537290efbc093db6df9cdc68eaa3cc8bf75785fb6d98bf
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 { url, method, params, data, headers = {}, signal } = config;
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 { url, method, params, data, headers = {}, signal } = config;
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 STORAGE_KEY = "profiler-theme";
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 === STORAGE_KEY && e3.newValue) {
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(STORAGE_KEY);
9490
+ const stored = localStorage.getItem(STORAGE_KEY2);
9410
9491
  return stored || "auto";
9411
9492
  }
9412
9493
  saveTheme(theme) {
9413
- localStorage.setItem(STORAGE_KEY, theme);
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
- http = all.select { |p| p.profile_type == "http" }
13
- page = http.drop(offset).first(limit + 1)
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