rails-profiler 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/builds/profiler-toolbar.js +60 -1
- data/app/assets/builds/profiler.js +120 -31
- data/app/controllers/profiler/api/cluster_controller.rb +35 -0
- data/app/controllers/profiler/api/events_controller.rb +35 -0
- data/app/controllers/profiler/api/profiles_controller.rb +8 -2
- data/app/controllers/profiler/api/slave_proxy_controller.rb +49 -0
- data/app/views/layouts/profiler/application.html.erb +3 -0
- data/config/routes.rb +11 -0
- data/lib/profiler/cluster/master_client.rb +79 -0
- data/lib/profiler/cluster/slave_proxy.rb +106 -0
- data/lib/profiler/cluster/slave_registry.rb +50 -0
- data/lib/profiler/configuration.rb +20 -1
- data/lib/profiler/mcp/server.rb +49 -20
- data/lib/profiler/mcp/slave_support.rb +23 -0
- data/lib/profiler/mcp/tools/analyze_queries.rb +5 -2
- data/lib/profiler/mcp/tools/clear_profiles.rb +11 -1
- data/lib/profiler/mcp/tools/delete_env_var.rb +7 -0
- data/lib/profiler/mcp/tools/explain_query.rb +8 -0
- data/lib/profiler/mcp/tools/get_profile_ajax.rb +5 -2
- data/lib/profiler/mcp/tools/get_profile_detail.rb +5 -2
- data/lib/profiler/mcp/tools/get_profile_dumps.rb +5 -2
- data/lib/profiler/mcp/tools/get_profile_http.rb +5 -2
- data/lib/profiler/mcp/tools/get_profile_mailers.rb +5 -2
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +5 -2
- data/lib/profiler/mcp/tools/list_env_vars.rb +38 -0
- data/lib/profiler/mcp/tools/list_slaves.rb +27 -0
- data/lib/profiler/mcp/tools/query_console_profiles.rb +4 -1
- data/lib/profiler/mcp/tools/query_jobs.rb +4 -1
- data/lib/profiler/mcp/tools/query_mailers.rb +4 -1
- data/lib/profiler/mcp/tools/query_profiles.rb +4 -1
- data/lib/profiler/mcp/tools/query_test_profiles.rb +4 -1
- data/lib/profiler/mcp/tools/reset_all_env_vars.rb +8 -1
- data/lib/profiler/mcp/tools/reset_env_var.rb +9 -0
- data/lib/profiler/mcp/tools/run_tests.rb +41 -0
- data/lib/profiler/mcp/tools/set_env_var.rb +7 -0
- data/lib/profiler/railtie.rb +11 -0
- data/lib/profiler/sse/bus.rb +13 -0
- data/lib/profiler/sse/event_bus.rb +47 -0
- data/lib/profiler/sse/redis_event_bus.rb +79 -0
- data/lib/profiler/storage/base_store.rb +18 -1
- data/lib/profiler/storage/file_store.rb +1 -1
- data/lib/profiler/storage/memory_store.rb +1 -1
- data/lib/profiler/storage/redis_store.rb +3 -1
- data/lib/profiler/storage/sqlite_store.rb +1 -1
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +10 -0
- metadata +13 -2
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module MCP
|
|
5
7
|
module Tools
|
|
@@ -10,10 +12,11 @@ module Profiler
|
|
|
10
12
|
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
11
13
|
end
|
|
12
14
|
|
|
15
|
+
storage = MCP::SlaveSupport.resolve_storage(params)
|
|
13
16
|
profile = if token == "latest"
|
|
14
|
-
|
|
17
|
+
storage.list(limit: 1).first
|
|
15
18
|
else
|
|
16
|
-
|
|
19
|
+
storage.load(token)
|
|
17
20
|
end
|
|
18
21
|
unless profile
|
|
19
22
|
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
3
5
|
require "shellwords"
|
|
4
6
|
require "uri"
|
|
5
7
|
|
|
@@ -13,10 +15,11 @@ module Profiler
|
|
|
13
15
|
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
14
16
|
end
|
|
15
17
|
|
|
18
|
+
storage = MCP::SlaveSupport.resolve_storage(params)
|
|
16
19
|
profile = if token == "latest"
|
|
17
|
-
|
|
20
|
+
storage.list(limit: 1).first
|
|
18
21
|
else
|
|
19
|
-
|
|
22
|
+
storage.load(token)
|
|
20
23
|
end
|
|
21
24
|
unless profile
|
|
22
25
|
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module MCP
|
|
5
7
|
module Tools
|
|
@@ -12,10 +14,11 @@ module Profiler
|
|
|
12
14
|
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
13
15
|
end
|
|
14
16
|
|
|
17
|
+
storage = MCP::SlaveSupport.resolve_storage(params)
|
|
15
18
|
profile = if token == "latest"
|
|
16
|
-
|
|
19
|
+
storage.list(limit: 1).first
|
|
17
20
|
else
|
|
18
|
-
|
|
21
|
+
storage.load(token)
|
|
19
22
|
end
|
|
20
23
|
unless profile
|
|
21
24
|
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module MCP
|
|
5
7
|
module Tools
|
|
@@ -10,11 +12,12 @@ module Profiler
|
|
|
10
12
|
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
11
13
|
end
|
|
12
14
|
|
|
15
|
+
storage = MCP::SlaveSupport.resolve_storage(params)
|
|
13
16
|
profile = if token == "latest"
|
|
14
|
-
profiles =
|
|
17
|
+
profiles = storage.list(limit: 200)
|
|
15
18
|
profiles.find { |p| p.profile_type == "test" }
|
|
16
19
|
else
|
|
17
|
-
|
|
20
|
+
storage.load(token)
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
unless profile && profile.profile_type == "test"
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module MCP
|
|
5
7
|
module Tools
|
|
6
8
|
class ListEnvVars
|
|
7
9
|
def self.call(params)
|
|
10
|
+
if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
|
|
11
|
+
data = proxy.get_json("/_profiler/api/env_vars",
|
|
12
|
+
include_all: params["include_all"], filter: params["filter"])
|
|
13
|
+
text = format_slave_env_vars(data, params)
|
|
14
|
+
return [{ type: "text", text: text }]
|
|
15
|
+
end
|
|
16
|
+
|
|
8
17
|
include_all = params["include_all"]
|
|
9
18
|
filter = params["filter"]&.downcase
|
|
10
19
|
|
|
@@ -51,6 +60,35 @@ module Profiler
|
|
|
51
60
|
|
|
52
61
|
[{ type: "text", text: text }]
|
|
53
62
|
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def self.format_slave_env_vars(data, params)
|
|
67
|
+
overrides = data["overrides"] || {}
|
|
68
|
+
filter = params["filter"]&.downcase
|
|
69
|
+
|
|
70
|
+
if params["include_all"]
|
|
71
|
+
vars = (data["variables"] || {}).sort.to_h
|
|
72
|
+
vars = vars.select { |k, _| k.downcase.include?(filter) } if filter
|
|
73
|
+
return "No environment variables match filter '#{params["filter"]}'." if vars.empty?
|
|
74
|
+
|
|
75
|
+
rows = vars.map do |key, value|
|
|
76
|
+
override = overrides[key]
|
|
77
|
+
overridden = override ? "✓ (was: #{override["original"] || "(unset)"})" : ""
|
|
78
|
+
"| #{key} | #{value} | #{overridden} |"
|
|
79
|
+
end
|
|
80
|
+
"**ENV variables (#{vars.size})**\n\n| Key | Current Value | Overridden |\n|-----|--------------|------------|\n" + rows.join("\n")
|
|
81
|
+
else
|
|
82
|
+
overrides = overrides.select { |k, _| k.downcase.include?(filter) } if filter
|
|
83
|
+
return "No overrides active." if overrides.empty?
|
|
84
|
+
|
|
85
|
+
rows = overrides.map do |key, entry|
|
|
86
|
+
current = entry["value"] == "__PROFILER_DELETED__" ? "(deleted)" : entry["value"]
|
|
87
|
+
"| #{key} | #{current} | #{entry["original"] || "(unset)"} |"
|
|
88
|
+
end
|
|
89
|
+
"**Active ENV overrides (#{overrides.size})**\n\n| Key | Current Value | Original Value |\n|-----|--------------|----------------|\n" + rows.join("\n")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
54
92
|
end
|
|
55
93
|
end
|
|
56
94
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class ListSlaves
|
|
7
|
+
def self.call(_params)
|
|
8
|
+
slaves = Profiler.slave_registry.all
|
|
9
|
+
|
|
10
|
+
if slaves.empty?
|
|
11
|
+
return [{ type: "text", text: "No slave profilers connected." }]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
lines = ["# Connected Slave Profilers\n"]
|
|
15
|
+
lines << "| Name | URL | Status | Registered At | Last Heartbeat |"
|
|
16
|
+
lines << "|------|-----|--------|--------------|----------------|"
|
|
17
|
+
|
|
18
|
+
slaves.each do |s|
|
|
19
|
+
lines << "| #{s[:name]} | #{s[:url]} | #{s[:status]} | #{s[:registered_at]} | #{s[:last_heartbeat_at]} |"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
[{ type: "text", text: lines.join("\n") }]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module MCP
|
|
5
7
|
module Tools
|
|
@@ -9,7 +11,8 @@ module Profiler
|
|
|
9
11
|
def self.call(params)
|
|
10
12
|
limit = params["limit"]&.to_i || 20
|
|
11
13
|
fetch_size = [limit * 5, 500].min
|
|
12
|
-
|
|
14
|
+
storage = MCP::SlaveSupport.resolve_storage(params)
|
|
15
|
+
profiles = storage.list(limit: fetch_size)
|
|
13
16
|
|
|
14
17
|
consoles = profiles.select { |p| p.profile_type == "console" }
|
|
15
18
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module MCP
|
|
5
7
|
module Tools
|
|
@@ -9,7 +11,8 @@ module Profiler
|
|
|
9
11
|
def self.call(params)
|
|
10
12
|
limit = params["limit"]&.to_i || 20
|
|
11
13
|
fetch_size = [limit * 5, 500].min
|
|
12
|
-
|
|
14
|
+
storage = MCP::SlaveSupport.resolve_storage(params)
|
|
15
|
+
profiles = storage.list(limit: fetch_size)
|
|
13
16
|
|
|
14
17
|
jobs = profiles.select { |p| p.profile_type == "job" }
|
|
15
18
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module MCP
|
|
5
7
|
module Tools
|
|
@@ -10,7 +12,8 @@ module Profiler
|
|
|
10
12
|
def call(params)
|
|
11
13
|
limit = params["limit"]&.to_i || 20
|
|
12
14
|
fetch_size = [limit * 10, 1000].min
|
|
13
|
-
|
|
15
|
+
storage = MCP::SlaveSupport.resolve_storage(params)
|
|
16
|
+
profiles = storage.list(limit: fetch_size)
|
|
14
17
|
|
|
15
18
|
emails = []
|
|
16
19
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module MCP
|
|
5
7
|
module Tools
|
|
@@ -7,9 +9,10 @@ module Profiler
|
|
|
7
9
|
ALL_FIELDS = %w[time type method path duration queries status token].freeze
|
|
8
10
|
|
|
9
11
|
def self.call(params)
|
|
12
|
+
storage = MCP::SlaveSupport.resolve_storage(params)
|
|
10
13
|
limit = params["limit"]&.to_i || 20
|
|
11
14
|
fetch_size = [limit * 5, 500].min
|
|
12
|
-
profiles =
|
|
15
|
+
profiles = storage.list(limit: fetch_size)
|
|
13
16
|
|
|
14
17
|
profiles = profiles.select { |p| p.path&.include?(params["path"]) } if params["path"]
|
|
15
18
|
profiles = profiles.select { |p| p.method == params["method"]&.upcase } if params["method"]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module MCP
|
|
5
7
|
module Tools
|
|
@@ -9,7 +11,8 @@ module Profiler
|
|
|
9
11
|
def self.call(params)
|
|
10
12
|
limit = params["limit"]&.to_i || 20
|
|
11
13
|
fetch_size = [limit * 5, 500].min
|
|
12
|
-
|
|
14
|
+
storage = MCP::SlaveSupport.resolve_storage(params)
|
|
15
|
+
profiles = storage.list(limit: fetch_size)
|
|
13
16
|
|
|
14
17
|
tests = profiles.select { |p| p.profile_type == "test" }
|
|
15
18
|
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module MCP
|
|
5
7
|
module Tools
|
|
6
8
|
class ResetAllEnvVars
|
|
7
|
-
def self.call(
|
|
9
|
+
def self.call(params)
|
|
10
|
+
if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
|
|
11
|
+
proxy.delete_json("/_profiler/api/env_vars/reset_all")
|
|
12
|
+
return [{ type: "text", text: "Reset all ENV overrides on slave '#{params["slave"]}'." }]
|
|
13
|
+
end
|
|
14
|
+
|
|
8
15
|
overrides = Profiler.env_override_store.all_overrides
|
|
9
16
|
count = overrides.size
|
|
10
17
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
3
7
|
module Profiler
|
|
4
8
|
module MCP
|
|
5
9
|
module Tools
|
|
@@ -9,6 +13,11 @@ module Profiler
|
|
|
9
13
|
|
|
10
14
|
return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
|
|
11
15
|
|
|
16
|
+
if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
|
|
17
|
+
proxy.delete_json("/_profiler/api/env_vars/reset?key=#{URI.encode_www_form_component(key)}")
|
|
18
|
+
return [{ type: "text", text: "Reset #{key} on slave '#{params["slave"]}'." }]
|
|
19
|
+
end
|
|
20
|
+
|
|
12
21
|
overrides = Profiler.env_override_store.all_overrides
|
|
13
22
|
unless overrides.key?(key)
|
|
14
23
|
return [{ type: "text", text: "No active override for #{key}." }]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
3
5
|
require_relative "../../test_runner/discovery"
|
|
4
6
|
require_relative "../../test_runner/run_store"
|
|
5
7
|
require_relative "../../test_runner/runner"
|
|
@@ -13,6 +15,10 @@ module Profiler
|
|
|
13
15
|
POLL_TIMEOUT = 10 # seconds per wait_for_output call
|
|
14
16
|
|
|
15
17
|
def self.call(params)
|
|
18
|
+
if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
|
|
19
|
+
return run_on_slave(proxy, params)
|
|
20
|
+
end
|
|
21
|
+
|
|
16
22
|
files = Array(params["files"])
|
|
17
23
|
framework = params["framework"]&.to_s
|
|
18
24
|
timeout_secs = (params["timeout_seconds"] || DEFAULT_TIMEOUT).to_i
|
|
@@ -65,6 +71,41 @@ module Profiler
|
|
|
65
71
|
|
|
66
72
|
private
|
|
67
73
|
|
|
74
|
+
def self.run_on_slave(proxy, params)
|
|
75
|
+
files = Array(params["files"])
|
|
76
|
+
framework = params["framework"].to_s
|
|
77
|
+
timeout_secs = (params["timeout_seconds"] || DEFAULT_TIMEOUT).to_i
|
|
78
|
+
max_output = (params["max_output"] || DEFAULT_MAX_OUTPUT).to_i
|
|
79
|
+
|
|
80
|
+
run_data = proxy.post_json("/_profiler/api/test_runner/runs", { files: files, framework: framework.presence || "rspec" })
|
|
81
|
+
|
|
82
|
+
if run_data["error"]
|
|
83
|
+
return [{ type: "text", text: "Error starting tests on slave: #{run_data["error"]}" }]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
run_id = run_data["id"]
|
|
87
|
+
deadline = Time.now + timeout_secs
|
|
88
|
+
timed_out = false
|
|
89
|
+
|
|
90
|
+
loop do
|
|
91
|
+
break if Time.now > deadline && (timed_out = true)
|
|
92
|
+
run_data = proxy.get_json("/_profiler/api/test_runner/runs/#{run_id}")
|
|
93
|
+
break if %w[completed failed cancelled].include?(run_data["status"])
|
|
94
|
+
sleep 2
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
output = run_data["output_lines"]&.join.to_s
|
|
98
|
+
output = "…(truncated)\n" + output[-(max_output)..] if output.length > max_output
|
|
99
|
+
|
|
100
|
+
text = "# Test Run on slave '#{params["slave"]}' #{timed_out ? "(timed out)" : ""}\n\n"
|
|
101
|
+
text += "| Field | Value |\n|-------|-------|\n"
|
|
102
|
+
text += "| Run ID | `#{run_id}` |\n"
|
|
103
|
+
text += "| Status | **#{run_data["status"]}** |\n"
|
|
104
|
+
text += "| Exit code | #{run_data["exit_code"].inspect} |\n\n"
|
|
105
|
+
text += "## Output\n```\n#{output.strip}\n```"
|
|
106
|
+
[{ type: "text", text: text }]
|
|
107
|
+
end
|
|
108
|
+
|
|
68
109
|
def self.collect_run_profiles(since)
|
|
69
110
|
Profiler.storage.list(limit: 500).select do |p|
|
|
70
111
|
p.profile_type == "test" && p.started_at && p.started_at >= since
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../slave_support"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module MCP
|
|
5
7
|
module Tools
|
|
@@ -10,6 +12,11 @@ module Profiler
|
|
|
10
12
|
|
|
11
13
|
return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
|
|
12
14
|
|
|
15
|
+
if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
|
|
16
|
+
proxy.patch_json("/_profiler/api/env_vars", { key: key, value: value })
|
|
17
|
+
return [{ type: "text", text: "Set #{key}=#{value} on slave '#{params["slave"]}'" }]
|
|
18
|
+
end
|
|
19
|
+
|
|
13
20
|
Profiler.env_override_store.set(key, value)
|
|
14
21
|
ENV[key] = value
|
|
15
22
|
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -93,6 +93,17 @@ module Profiler
|
|
|
93
93
|
end
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
initializer "profiler.start_cluster_client" do
|
|
97
|
+
# Check slave? inside on_load — the app's own initializers (config/initializers/profiler.rb)
|
|
98
|
+
# set master_url AFTER railtie initializers run, so the check must happen after_initialize.
|
|
99
|
+
ActiveSupport.on_load(:after_initialize) do
|
|
100
|
+
next unless Profiler.configuration.enabled && Profiler.configuration.slave?
|
|
101
|
+
|
|
102
|
+
require_relative "cluster/master_client"
|
|
103
|
+
Profiler::Cluster::MasterClient.new.start
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
96
107
|
console do
|
|
97
108
|
next unless Profiler.configuration.enabled && Profiler.configuration.track_console
|
|
98
109
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module SSE
|
|
5
|
+
def self.current
|
|
6
|
+
if defined?(Profiler::Storage::RedisStore) && Profiler.storage.is_a?(Profiler::Storage::RedisStore)
|
|
7
|
+
RedisEventBus.instance
|
|
8
|
+
else
|
|
9
|
+
EventBus.instance
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "singleton"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "set"
|
|
7
|
+
require "concurrent"
|
|
8
|
+
|
|
9
|
+
module Profiler
|
|
10
|
+
module SSE
|
|
11
|
+
class EventBus
|
|
12
|
+
include Singleton
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@subscriptions = Concurrent::Hash.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def subscribe(token, collectors)
|
|
19
|
+
id = SecureRandom.uuid
|
|
20
|
+
@subscriptions[id] = { token: token, collectors: Set.new(collectors.map(&:to_s)), queue: Queue.new }
|
|
21
|
+
id
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def unsubscribe(id)
|
|
25
|
+
@subscriptions.delete(id)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def broadcast(token, collectors)
|
|
29
|
+
changed = Set.new(collectors.map(&:to_s))
|
|
30
|
+
@subscriptions.each_value do |sub|
|
|
31
|
+
next unless sub[:token] == token
|
|
32
|
+
# Empty collector set means "match all"; non-empty set filters by intersection.
|
|
33
|
+
next if sub[:collectors].any? && (sub[:collectors] & changed).empty?
|
|
34
|
+
sub[:queue] << { token: token, collectors: changed.to_a, timestamp: Time.now.to_f }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def wait_for_event(id, timeout: 30)
|
|
39
|
+
sub = @subscriptions[id]
|
|
40
|
+
return nil unless sub
|
|
41
|
+
Timeout.timeout(timeout) { sub[:queue].pop }
|
|
42
|
+
rescue Timeout::Error
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "singleton"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "set"
|
|
7
|
+
require "json"
|
|
8
|
+
require "concurrent"
|
|
9
|
+
|
|
10
|
+
module Profiler
|
|
11
|
+
module SSE
|
|
12
|
+
class RedisEventBus
|
|
13
|
+
include Singleton
|
|
14
|
+
|
|
15
|
+
CHANNEL_PREFIX = "profiler:events"
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@subscriptions = Concurrent::Hash.new
|
|
19
|
+
@listener_thread = nil
|
|
20
|
+
@listener_mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def subscribe(token, collectors)
|
|
24
|
+
id = SecureRandom.uuid
|
|
25
|
+
@subscriptions[id] = { token: token, collectors: Set.new(collectors.map(&:to_s)), queue: Queue.new }
|
|
26
|
+
ensure_listener_running
|
|
27
|
+
id
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def unsubscribe(id)
|
|
31
|
+
@subscriptions.delete(id)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def broadcast(token, collectors)
|
|
35
|
+
payload = { token: token, collectors: collectors.map(&:to_s), timestamp: Time.now.to_f }.to_json
|
|
36
|
+
publish_redis_client.publish("#{CHANNEL_PREFIX}:#{token}", payload)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def wait_for_event(id, timeout: 30)
|
|
40
|
+
sub = @subscriptions[id]
|
|
41
|
+
return nil unless sub
|
|
42
|
+
Timeout.timeout(timeout) { sub[:queue].pop }
|
|
43
|
+
rescue Timeout::Error
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def publish_redis_client
|
|
50
|
+
Profiler.storage.redis
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ensure_listener_running
|
|
54
|
+
@listener_mutex.synchronize do
|
|
55
|
+
return if @listener_thread&.alive?
|
|
56
|
+
|
|
57
|
+
@listener_thread = Thread.new do
|
|
58
|
+
# Use a dedicated connection for blocking psubscribe
|
|
59
|
+
Profiler.storage.redis.dup.psubscribe("#{CHANNEL_PREFIX}:*") do |on|
|
|
60
|
+
on.pmessage do |_pattern, _channel, message|
|
|
61
|
+
payload = JSON.parse(message, symbolize_names: true)
|
|
62
|
+
token = payload[:token]
|
|
63
|
+
changed = Set.new(payload[:collectors].map(&:to_s))
|
|
64
|
+
|
|
65
|
+
@subscriptions.each_value do |sub|
|
|
66
|
+
next unless sub[:token] == token
|
|
67
|
+
next if sub[:collectors].any? && (sub[:collectors] & changed).empty?
|
|
68
|
+
sub[:queue] << { token: token, collectors: changed.to_a, timestamp: payload[:timestamp] }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
rescue StandardError
|
|
73
|
+
# Thread restarts on the next subscribe call
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
module Profiler
|
|
4
4
|
module Storage
|
|
5
5
|
class BaseStore
|
|
6
|
+
# Public interface: persists the profile then fires an SSE broadcast.
|
|
7
|
+
# Subclasses implement #do_save, not #save.
|
|
6
8
|
def save(token, profile)
|
|
7
|
-
|
|
9
|
+
result = do_save(token, profile)
|
|
10
|
+
broadcast_event(token, profile)
|
|
11
|
+
result
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def do_save(token, profile)
|
|
15
|
+
raise NotImplementedError, "#{self.class} must implement #do_save"
|
|
8
16
|
end
|
|
9
17
|
|
|
10
18
|
def load(token)
|
|
@@ -36,6 +44,15 @@ module Profiler
|
|
|
36
44
|
def clear(type: nil)
|
|
37
45
|
raise NotImplementedError, "#{self.class} must implement #clear"
|
|
38
46
|
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def broadcast_event(token, profile)
|
|
51
|
+
collectors = profile.collectors_data.keys
|
|
52
|
+
Profiler::SSE.current.broadcast(token, collectors)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
# Never let a broadcast failure prevent profile persistence
|
|
55
|
+
end
|
|
39
56
|
end
|
|
40
57
|
end
|
|
41
58
|
end
|
|
@@ -10,13 +10,15 @@ module Profiler
|
|
|
10
10
|
class RedisStore < BaseStore
|
|
11
11
|
DEFAULT_TTL = 24 * 60 * 60 # 24 hours
|
|
12
12
|
|
|
13
|
+
attr_reader :redis
|
|
14
|
+
|
|
13
15
|
def initialize(options = {})
|
|
14
16
|
@redis = options[:redis] || build_redis_client(options)
|
|
15
17
|
@ttl = options[:ttl] || DEFAULT_TTL
|
|
16
18
|
@key_prefix = options[:key_prefix] || "profiler"
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
def
|
|
21
|
+
def do_save(token, profile)
|
|
20
22
|
key = profile_key(token)
|
|
21
23
|
@redis.setex(key, @ttl, profile.to_json)
|
|
22
24
|
|
data/lib/profiler/version.rb
CHANGED
data/lib/profiler.rb
CHANGED
|
@@ -29,6 +29,13 @@ module Profiler
|
|
|
29
29
|
@env_override_store ||= EnvOverrideStore.new
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
def slave_registry
|
|
33
|
+
@slave_registry ||= begin
|
|
34
|
+
require_relative "profiler/cluster/slave_registry"
|
|
35
|
+
Cluster::SlaveRegistry.new
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
32
39
|
def enabled?
|
|
33
40
|
configuration.enabled
|
|
34
41
|
end
|
|
@@ -107,5 +114,8 @@ require_relative "profiler/collectors/mailer_collector"
|
|
|
107
114
|
|
|
108
115
|
require_relative "profiler/env_override_store"
|
|
109
116
|
require_relative "profiler/instrumentation/thread_context_propagation"
|
|
117
|
+
require_relative "profiler/sse/event_bus"
|
|
118
|
+
require_relative "profiler/sse/redis_event_bus"
|
|
119
|
+
require_relative "profiler/sse/bus"
|
|
110
120
|
require_relative "profiler/railtie" if defined?(Rails::Railtie)
|
|
111
121
|
require_relative "profiler/engine" if defined?(Rails::Engine)
|