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.
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 +93 -5
  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 +106 -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 +38 -0
  26. data/lib/profiler/mcp/tools/list_slaves.rb +27 -0
  27. data/lib/profiler/mcp/tools/query_console_profiles.rb +4 -1
  28. data/lib/profiler/mcp/tools/query_jobs.rb +4 -1
  29. data/lib/profiler/mcp/tools/query_mailers.rb +4 -1
  30. data/lib/profiler/mcp/tools/query_profiles.rb +4 -1
  31. data/lib/profiler/mcp/tools/query_test_profiles.rb +4 -1
  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 +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5388819e53342bdba701e37c877cddf9b2e6b888fa163198ef7760b689bc8796
4
- data.tar.gz: ca44a3e2c623ab6d46fec1fef941e76fb7941a1c77e5261077f0585ab6e70b53
3
+ metadata.gz: fcc4a5ad4cdedde3dd6427df52aeb75a54c4c8e973e0816f5f3d71e958042a89
4
+ data.tar.gz: b4f2c42db2a3a4fd0d1afd3648dcde087284dd3cf35aef0bca86049a832f3f71
5
5
  SHA512:
6
- metadata.gz: 5f01b92b17838b1730e5e571299ba0de4abfcc51fe56921e343a35ffe3aeaa38ebff0f95565d334f0b9a78123b3fd489dc96accf4244a9bb346fdd1536e45ce4
7
- data.tar.gz: 1e8960c6060b7f9a070497def307c9e04f4e57ac61f28bf6e9d48aac136ff48655c0df2bd3481c0628537290efbc093db6df9cdc68eaa3cc8bf75785fb6d98bf
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 { 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,
@@ -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 STORAGE_KEY = "profiler-theme";
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 === STORAGE_KEY && e3.newValue) {
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(STORAGE_KEY);
9489
+ const stored = localStorage.getItem(STORAGE_KEY2);
9410
9490
  return stored || "auto";
9411
9491
  }
9412
9492
  saveTheme(theme) {
9413
- localStorage.setItem(STORAGE_KEY, theme);
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
- 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,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