rails-profiler 0.40.0 → 0.42.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/CHANGELOG.md +26 -0
- data/app/assets/builds/profiler-toolbar.js +2 -2
- data/app/assets/builds/profiler.js +13 -12
- data/app/controllers/profiler/api/compare_controller.rb +42 -0
- data/app/controllers/profiler/api/config_controller.rb +17 -0
- data/app/controllers/profiler/application_controller.rb +9 -0
- data/app/controllers/profiler/compare_controller.rb +14 -0
- data/app/views/profiler/compare/show.html.erb +6 -0
- data/config/routes.rb +6 -0
- data/lib/profiler/configuration.rb +41 -2
- data/lib/profiler/mcp/server.rb +15 -0
- data/lib/profiler/mcp/tools/compare_profiles.rb +158 -0
- data/lib/profiler/peers/peer_proxy.rb +83 -0
- data/lib/profiler/peers.rb +142 -0
- data/lib/profiler/profile_comparison.rb +44 -0
- data/lib/profiler/profile_diff.rb +253 -0
- data/lib/profiler/railtie.rb +2 -0
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +4 -0
- metadata +11 -2
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module Api
|
|
5
|
+
class CompareController < ApplicationController
|
|
6
|
+
skip_before_action :verify_authenticity_token
|
|
7
|
+
|
|
8
|
+
# Only the node names of config.peers are read from the request; a URL is never accepted.
|
|
9
|
+
def show
|
|
10
|
+
token_a = params[:a]
|
|
11
|
+
token_b = params[:b]
|
|
12
|
+
|
|
13
|
+
errors = []
|
|
14
|
+
errors << "Missing parameter: a" unless token_a.present?
|
|
15
|
+
errors << "Missing parameter: b" unless token_b.present?
|
|
16
|
+
|
|
17
|
+
if errors.any?
|
|
18
|
+
render json: { errors: errors }, status: :unprocessable_entity and return
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
node_a = params[:node_a].to_s
|
|
22
|
+
node_b = params[:node_b].to_s
|
|
23
|
+
if token_a == token_b && node_a == node_b
|
|
24
|
+
render json: { errors: ["Cannot compare a profile with itself"] }, status: :unprocessable_entity and return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
render json: ProfileComparison.call(token_a.to_s, token_b.to_s, node_a: node_a, node_b: node_b)
|
|
28
|
+
rescue ProfileComparison::NotFound => e
|
|
29
|
+
render json: { errors: e.message.split("\n") }, status: :not_found
|
|
30
|
+
rescue Profiler::Error => e
|
|
31
|
+
render json: { errors: [e.message] }, status: :bad_gateway
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The nodes a profile can be read from: this instance, then the names of config.peers.
|
|
35
|
+
def nodes
|
|
36
|
+
render json: { nodes: [Peers::LOCAL_NODE] + Peers.names }
|
|
37
|
+
rescue Profiler::Error => e
|
|
38
|
+
render json: { errors: [e.message] }, status: :unprocessable_entity
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module Api
|
|
5
|
+
# Read-only: the settings the file:line links need, for the dashboard and for the browser
|
|
6
|
+
# extension, which falls back on them when it has none of its own.
|
|
7
|
+
class ConfigController < ApplicationController
|
|
8
|
+
def show
|
|
9
|
+
config = Profiler.configuration
|
|
10
|
+
render json: {
|
|
11
|
+
editor_url_scheme: config.editor_link_scheme,
|
|
12
|
+
editor_path_map: config.editor_path_map
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../../../lib/profiler/cluster/security"
|
|
4
|
+
require_relative "../../../lib/profiler/peers"
|
|
4
5
|
|
|
5
6
|
module Profiler
|
|
6
7
|
class ApplicationController < ActionController::Base
|
|
@@ -23,6 +24,7 @@ module Profiler
|
|
|
23
24
|
def authorize_request
|
|
24
25
|
# Checked first, so that :allow_local does not log a refusal for the master's calls.
|
|
25
26
|
return if cluster_master_request?
|
|
27
|
+
return if peer_read_request?
|
|
26
28
|
return if Profiler.configuration.authorized?(request)
|
|
27
29
|
|
|
28
30
|
deny("Not authorized to access the profiler")
|
|
@@ -47,6 +49,13 @@ module Profiler
|
|
|
47
49
|
Profiler.configuration.slave? && Cluster::Security.request_secret_valid?(request)
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
# Another instance reading one profile to compare it with its own (Profiler::Peers). The
|
|
53
|
+
# shared peer_token opens exactly one door: GET of a single profile, nothing else.
|
|
54
|
+
def peer_read_request?
|
|
55
|
+
request.get? && controller_path == "profiler/api/profiles" && action_name == "show" &&
|
|
56
|
+
Peers.request_token_valid?(request)
|
|
57
|
+
end
|
|
58
|
+
|
|
50
59
|
def handle_unverified_request
|
|
51
60
|
deny("Missing #{Profiler::FORGERY_PROTECTION_HEADER} header or CSRF token")
|
|
52
61
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
class CompareController < ApplicationController
|
|
5
|
+
def show
|
|
6
|
+
@token_a = params[:a]
|
|
7
|
+
@token_b = params[:b]
|
|
8
|
+
@node_a = params[:node_a].to_s
|
|
9
|
+
@node_b = params[:node_b].to_s
|
|
10
|
+
|
|
11
|
+
redirect_to root_path unless @token_a.present? && @token_b.present?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -12,6 +12,8 @@ Profiler::Engine.routes.draw do
|
|
|
12
12
|
|
|
13
13
|
root to: "profiles#index"
|
|
14
14
|
|
|
15
|
+
get "compare", to: "compare#show"
|
|
16
|
+
|
|
15
17
|
resources :profiles, only: [:index, :show] do
|
|
16
18
|
member do
|
|
17
19
|
get :timeline
|
|
@@ -32,6 +34,9 @@ Profiler::Engine.routes.draw do
|
|
|
32
34
|
get "health", to: "health#show"
|
|
33
35
|
|
|
34
36
|
namespace :api do
|
|
37
|
+
get "compare", to: "compare#show"
|
|
38
|
+
get "compare/nodes", to: "compare#nodes"
|
|
39
|
+
|
|
35
40
|
resources :profiles, only: [:index, :show, :destroy] do
|
|
36
41
|
collection { delete :clear }
|
|
37
42
|
end
|
|
@@ -45,6 +50,7 @@ Profiler::Engine.routes.draw do
|
|
|
45
50
|
collection { delete :clear }
|
|
46
51
|
end
|
|
47
52
|
resources :outbound_http, only: [:index]
|
|
53
|
+
get "config", to: "config#show"
|
|
48
54
|
get "toolbar/:token", to: "toolbar#show"
|
|
49
55
|
post "ajax/link", to: "ajax#link"
|
|
50
56
|
post "explain", to: "explain#create"
|
|
@@ -37,6 +37,9 @@ module Profiler
|
|
|
37
37
|
# config.http_skip_hosts += Profiler::Configuration::LOCAL_HTTP_HOSTS.
|
|
38
38
|
LOCAL_HTTP_HOSTS = [/\A127\.0\.0\.1\z/, /\Alocalhost\z/i, /\A::1\z/].freeze
|
|
39
39
|
|
|
40
|
+
# URL schemes the source links can use. Anything else is never turned into a link.
|
|
41
|
+
EDITOR_URL_SCHEMES = %w[vscode idea cursor].freeze
|
|
42
|
+
|
|
40
43
|
attr_accessor :storage_options, :collectors,
|
|
41
44
|
:slow_query_threshold, :max_queries_warning, :sql_backtrace,
|
|
42
45
|
:track_memory, :allocated_objects_warning_threshold,
|
|
@@ -59,9 +62,12 @@ module Profiler
|
|
|
59
62
|
:cluster_heartbeat_interval, :cluster_offline_threshold,
|
|
60
63
|
:cluster_master, :cluster_secret, :cluster_require_secret,
|
|
61
64
|
:cluster_allow_insecure_http, :logger,
|
|
62
|
-
:collector_circuit_breaker_enabled
|
|
65
|
+
:collector_circuit_breaker_enabled,
|
|
66
|
+
:peers, :peer_token, :peer_allow_insecure_http,
|
|
67
|
+
:regression_duration_threshold, :regression_memory_threshold,
|
|
68
|
+
:regression_query_threshold, :regression_cache_threshold
|
|
63
69
|
|
|
64
|
-
attr_reader :authorize_block, :enabled, :track_tests, :max_profiles
|
|
70
|
+
attr_reader :authorize_block, :enabled, :track_tests, :max_profiles, :editor_url_scheme, :editor_path_map
|
|
65
71
|
|
|
66
72
|
def initialize
|
|
67
73
|
@assigned = []
|
|
@@ -167,6 +173,17 @@ module Profiler
|
|
|
167
173
|
# Profiler::Collectors::CircuitBreaker); false never stops one.
|
|
168
174
|
@collector_circuit_breaker_enabled = true
|
|
169
175
|
@collector_circuit_breaker_threshold = 3
|
|
176
|
+
@editor_url_scheme = "vscode"
|
|
177
|
+
@editor_path_map = {}
|
|
178
|
+
# Other instances whose profiles can be compared with this one's: [{ name:, url: }]. The only
|
|
179
|
+
# URLs the profiler ever calls to read a profile (Profiler::Peers). Empty: no outgoing call.
|
|
180
|
+
@peers = []
|
|
181
|
+
@peer_token = nil
|
|
182
|
+
@peer_allow_insecure_http = false
|
|
183
|
+
@regression_duration_threshold = 10 # % change threshold, changes below this are neutral
|
|
184
|
+
@regression_memory_threshold = 10 # % change threshold
|
|
185
|
+
@regression_query_threshold = 0 # % change threshold, 0 means any query count increase is a regression
|
|
186
|
+
@regression_cache_threshold = 5 # % change threshold for cache hit rate
|
|
170
187
|
end
|
|
171
188
|
|
|
172
189
|
attr_reader :collector_circuit_breaker_threshold
|
|
@@ -182,6 +199,28 @@ module Profiler
|
|
|
182
199
|
end
|
|
183
200
|
end
|
|
184
201
|
|
|
202
|
+
# The editor the file:line links open: "vscode", "idea" or "cursor" (String or Symbol). A
|
|
203
|
+
# value outside that list is kept as given, but #editor_link_scheme answers nil for it, so
|
|
204
|
+
# that no link is built.
|
|
205
|
+
def editor_url_scheme=(value)
|
|
206
|
+
@editor_url_scheme = value.nil? ? nil : value.to_s
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Container path prefix => host path prefix, for a server running in a container while the
|
|
210
|
+
# editor runs on the host: { "/app" => "/home/me/project" }. The longest matching prefix wins.
|
|
211
|
+
def editor_path_map=(value)
|
|
212
|
+
unless value.nil? || value.respond_to?(:to_h)
|
|
213
|
+
raise ArgumentError, "editor_path_map must be a Hash of container path => host path"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
@editor_path_map = (value || {}).to_h.to_h { |from, to| [from.to_s, to.to_s] }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The scheme the dashboard may use, or nil when the setting is not on the allow list.
|
|
220
|
+
def editor_link_scheme
|
|
221
|
+
EDITOR_URL_SCHEMES.include?(@editor_url_scheme) ? @editor_url_scheme : nil
|
|
222
|
+
end
|
|
223
|
+
|
|
185
224
|
# Always a Pathname, whether set from a String or left to its default.
|
|
186
225
|
def tmp_path
|
|
187
226
|
@tmp_path || default_tmp_path
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -123,6 +123,7 @@ module Profiler
|
|
|
123
123
|
require_relative "tools/list_slaves"
|
|
124
124
|
|
|
125
125
|
slave_param = { type: "string", description: "Name of a connected slave profiler to target. Omit to use this profiler's own data." }
|
|
126
|
+
require_relative "tools/compare_profiles"
|
|
126
127
|
|
|
127
128
|
[
|
|
128
129
|
define_tool(
|
|
@@ -419,6 +420,20 @@ module Profiler
|
|
|
419
420
|
description: "List all slave profilers connected to this master profiler, with their connection status.",
|
|
420
421
|
input_schema: { properties: {} },
|
|
421
422
|
handler: Tools::ListSlaves
|
|
423
|
+
),
|
|
424
|
+
define_tool(
|
|
425
|
+
name: "compare_profiles",
|
|
426
|
+
description: "Compare two profiles, possibly from different instances listed in config.peers, and show metric deltas (duration, allocations, cache hit rate, views, HTTP) and SQL grouped by normalized fingerprint (new, gone, slower). Detects regressions based on configured thresholds.",
|
|
427
|
+
input_schema: {
|
|
428
|
+
properties: {
|
|
429
|
+
token_a: { type: "string", description: "Token of the baseline profile (required)" },
|
|
430
|
+
token_b: { type: "string", description: "Token of the profile to compare against baseline (required)" },
|
|
431
|
+
node_a: { type: "string", description: "Name of the config.peers entry holding profile A. Omit or \"local\" for this instance." },
|
|
432
|
+
node_b: { type: "string", description: "Name of the config.peers entry holding profile B. Omit or \"local\" for this instance." }
|
|
433
|
+
},
|
|
434
|
+
required: ["token_a", "token_b"]
|
|
435
|
+
},
|
|
436
|
+
handler: Tools::CompareProfiles
|
|
422
437
|
)
|
|
423
438
|
].compact
|
|
424
439
|
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class CompareProfiles
|
|
7
|
+
def self.call(params)
|
|
8
|
+
token_a = params["token_a"]
|
|
9
|
+
token_b = params["token_b"]
|
|
10
|
+
|
|
11
|
+
errors = []
|
|
12
|
+
errors << "Missing parameter: token_a" if token_a.nil? || token_a.empty?
|
|
13
|
+
errors << "Missing parameter: token_b" if token_b.nil? || token_b.empty?
|
|
14
|
+
|
|
15
|
+
if errors.any?
|
|
16
|
+
return [{ type: "text", text: "Error: #{errors.join(', ')}" }]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
node_a = params["node_a"].to_s
|
|
20
|
+
node_b = params["node_b"].to_s
|
|
21
|
+
|
|
22
|
+
if token_a == token_b && node_a == node_b
|
|
23
|
+
return [{ type: "text", text: "Error: Cannot compare a profile with itself" }]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
diff = ProfileComparison.call(token_a, token_b, node_a: node_a, node_b: node_b)
|
|
28
|
+
rescue Profiler::Error => e
|
|
29
|
+
return [{ type: "text", text: e.message.start_with?("Profile not found") ? e.message : "Error: #{e.message}" }]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
[{ type: "text", text: format_diff(diff) }]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.format_diff(diff)
|
|
36
|
+
pa = diff[:profile_a]
|
|
37
|
+
pb = diff[:profile_b]
|
|
38
|
+
m = diff[:metrics]
|
|
39
|
+
|
|
40
|
+
lines = []
|
|
41
|
+
lines << "# Profile Comparison\n"
|
|
42
|
+
lines << "**A:** `#{pa[:token]}` on #{pa[:node]}: #{pa[:method]} #{pa[:path]} (#{pa[:started_at]})"
|
|
43
|
+
lines << "**B:** `#{pb[:token]}` on #{pb[:node]}: #{pb[:method]} #{pb[:path]} (#{pb[:started_at]})\n"
|
|
44
|
+
|
|
45
|
+
unless diff[:same_endpoint]
|
|
46
|
+
lines << "⚠️ **Warning:** These profiles are from different endpoints - comparison may not be meaningful.\n"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
lines << "## Scorecard\n"
|
|
50
|
+
lines << "| Metric | A | B | Delta | Status |"
|
|
51
|
+
lines << "|--------|---|---|-------|--------|"
|
|
52
|
+
|
|
53
|
+
append_metric(lines, "Duration (ms)", m[:duration])
|
|
54
|
+
append_metric(lines, "Allocated objects", m[:allocated_objects])
|
|
55
|
+
append_metric(lines, "DB Queries", m[:db_queries])
|
|
56
|
+
append_metric(lines, "DB Duration (ms)", m[:db_duration])
|
|
57
|
+
append_metric(lines, "Cache Hit Rate (%)", m[:cache_hit_rate])
|
|
58
|
+
append_metric(lines, "View Duration (ms)", m[:view_duration])
|
|
59
|
+
append_metric(lines, "HTTP Requests", m[:http_requests])
|
|
60
|
+
append_metric(lines, "HTTP Duration (ms)", m[:http_duration])
|
|
61
|
+
|
|
62
|
+
lines << ""
|
|
63
|
+
lines += format_sql(diff[:sql])
|
|
64
|
+
lines += format_database(diff[:collectors][:database])
|
|
65
|
+
lines += format_cache(diff[:collectors][:cache])
|
|
66
|
+
lines += format_views(diff[:collectors][:views])
|
|
67
|
+
lines += format_http(diff[:collectors][:http])
|
|
68
|
+
|
|
69
|
+
lines.join("\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.append_metric(lines, label, metric, formatter = nil)
|
|
73
|
+
return unless metric
|
|
74
|
+
|
|
75
|
+
fmt = formatter || ->(v) { v&.round(2)&.to_s || "-" }
|
|
76
|
+
val_a = fmt.call(metric[:value_a])
|
|
77
|
+
val_b = fmt.call(metric[:value_b])
|
|
78
|
+
delta = metric[:delta]
|
|
79
|
+
delta_pct = metric[:delta_pct]
|
|
80
|
+
sign = delta >= 0 ? "+" : ""
|
|
81
|
+
delta_str = "#{sign}#{delta.round(2)}"
|
|
82
|
+
delta_str += " (#{sign}#{delta_pct}%)" if delta_pct
|
|
83
|
+
|
|
84
|
+
icon = case metric[:status]
|
|
85
|
+
when :improved then "✅"
|
|
86
|
+
when :regression then "❌"
|
|
87
|
+
else "➖"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
lines << "| #{label} | #{val_a} | #{val_b} | #{delta_str} | #{icon} |"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.markdown_table(title, guard_key, data, rows)
|
|
94
|
+
return [] unless data[guard_key]
|
|
95
|
+
|
|
96
|
+
lines = ["## #{title}\n", "| Metric | A | B |", "|--------|---|---|"]
|
|
97
|
+
rows.each { |label, a, b| lines << "| #{label} | #{a || '-'} | #{b || '-'} |" }
|
|
98
|
+
lines << ""
|
|
99
|
+
lines
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.format_database(db)
|
|
103
|
+
markdown_table("Database", :total_queries_a, db, [
|
|
104
|
+
["Total Queries", db[:total_queries_a], db[:total_queries_b]],
|
|
105
|
+
["Total Duration (ms)", db[:total_duration_a]&.round(2), db[:total_duration_b]&.round(2)],
|
|
106
|
+
["Slow Queries", db[:slow_queries_a], db[:slow_queries_b]],
|
|
107
|
+
["Cached Queries", db[:cached_queries_a], db[:cached_queries_b]]
|
|
108
|
+
])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.format_sql(sql)
|
|
112
|
+
lines = []
|
|
113
|
+
{ new: "New queries (only in B)", gone: "Gone queries (only in A)", slower: "Slower queries" }.each do |key, title|
|
|
114
|
+
lines << "## #{title}\n"
|
|
115
|
+
if sql[key].empty?
|
|
116
|
+
lines << "None.\n"
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
lines << "| Query | Count A | Count B | Time A (ms) | Time B (ms) |"
|
|
121
|
+
lines << "|-------|---------|---------|-------------|-------------|"
|
|
122
|
+
sql[key].each do |q|
|
|
123
|
+
lines << "| `#{q[:fingerprint].to_s.gsub("|", "\\|").slice(0, 120)}` | #{q[:count_a]} | #{q[:count_b]} | " \
|
|
124
|
+
"#{q[:total_duration_a]} | #{q[:total_duration_b]} |"
|
|
125
|
+
end
|
|
126
|
+
lines << ""
|
|
127
|
+
end
|
|
128
|
+
lines
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.format_cache(cache)
|
|
132
|
+
markdown_table("Cache", :hit_rate_a, cache, [
|
|
133
|
+
["Total Reads", cache[:total_reads_a], cache[:total_reads_b]],
|
|
134
|
+
["Total Writes", cache[:total_writes_a], cache[:total_writes_b]],
|
|
135
|
+
["Hit Rate (%)", cache[:hit_rate_a], cache[:hit_rate_b]]
|
|
136
|
+
])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.format_views(views)
|
|
140
|
+
markdown_table("Views", :total_duration_a, views, [
|
|
141
|
+
["Templates", views[:total_views_a], views[:total_views_b]],
|
|
142
|
+
["Partials", views[:total_partials_a], views[:total_partials_b]],
|
|
143
|
+
["Total Duration (ms)", views[:total_duration_a]&.round(2), views[:total_duration_b]&.round(2)]
|
|
144
|
+
])
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.format_http(http)
|
|
148
|
+
markdown_table("Outbound HTTP", :total_requests_a, http, [
|
|
149
|
+
["Total Requests", http[:total_requests_a], http[:total_requests_b]],
|
|
150
|
+
["Total Duration (ms)", http[:total_duration_a]&.round(2), http[:total_duration_b]&.round(2)],
|
|
151
|
+
["Slow Requests", http[:slow_requests_a], http[:slow_requests_b]],
|
|
152
|
+
["Error Requests", http[:error_requests_a], http[:error_requests_b]]
|
|
153
|
+
])
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "../peers"
|
|
7
|
+
require_relative "../storage/token"
|
|
8
|
+
|
|
9
|
+
module Profiler
|
|
10
|
+
module Peers
|
|
11
|
+
# Reads one profile from a peer listed in config.peers, through its /_profiler/api. The only
|
|
12
|
+
# target is the URL of the configuration entry found by name: no URL is ever taken as an argument.
|
|
13
|
+
class PeerProxy
|
|
14
|
+
# Same budget as Cluster::SlaveProxy: a slow peer must not hold the request thread for long.
|
|
15
|
+
OPEN_TIMEOUT = 5 # seconds
|
|
16
|
+
READ_TIMEOUT = 10 # seconds
|
|
17
|
+
|
|
18
|
+
attr_reader :name
|
|
19
|
+
|
|
20
|
+
def initialize(node, open_timeout: nil, read_timeout: nil)
|
|
21
|
+
peer = Peers.find!(node)
|
|
22
|
+
if (problem = Peers.token_problem)
|
|
23
|
+
raise Profiler::Error, "#{problem}: requests to peer #{peer.name.inspect} are refused"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@name = peer.name
|
|
27
|
+
@base_url = peer.url.to_s.chomp("/")
|
|
28
|
+
@open_timeout = open_timeout || OPEN_TIMEOUT
|
|
29
|
+
@read_timeout = read_timeout || READ_TIMEOUT
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# nil when the peer has no such profile. A token the gem could not have issued is not
|
|
33
|
+
# asked for.
|
|
34
|
+
def load(token)
|
|
35
|
+
return nil unless Storage::Token.valid?(token)
|
|
36
|
+
|
|
37
|
+
data = get_json("/_profiler/api/profiles/#{Cluster::Security.escape_segment(token)}")
|
|
38
|
+
return nil if data.nil?
|
|
39
|
+
raise Profiler::Error, "Peer #{@name.inspect} sent an unexpected answer" unless data.is_a?(Hash) && data["token"]
|
|
40
|
+
|
|
41
|
+
require_relative "../models/profile"
|
|
42
|
+
Profiler::Models::Profile.from_hash(data.transform_keys(&:to_sym))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def get_json(path)
|
|
48
|
+
if (reason = Cluster::Security.api_path_denial(path))
|
|
49
|
+
raise Profiler::Error, "Refusing to send a request to peer #{@name.inspect}: #{reason}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
uri = URI("#{@base_url}#{path}")
|
|
53
|
+
req = Net::HTTP::Get.new(uri)
|
|
54
|
+
req[Profiler::FORGERY_PROTECTION_HEADER] = "1"
|
|
55
|
+
Peers.outgoing_headers.each { |header, value| req[header] = value }
|
|
56
|
+
# The profiler's own call, never recorded in the profile of the request that makes it.
|
|
57
|
+
resp = Profiler.untracked_http do
|
|
58
|
+
Net::HTTP.start(uri.hostname, uri.port, open_timeout: @open_timeout, read_timeout: @read_timeout,
|
|
59
|
+
use_ssl: uri.scheme == "https") { |http| http.request(req) }
|
|
60
|
+
end
|
|
61
|
+
interpret(resp)
|
|
62
|
+
rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError, IOError => e
|
|
63
|
+
raise Profiler::Error, "Peer #{@name.inspect} is unreachable (#{e.class}: #{e.message})"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Net::HTTP never follows a redirect: a 3xx is reported rather than read as an answer.
|
|
67
|
+
def interpret(resp)
|
|
68
|
+
code = resp.code.to_s
|
|
69
|
+
return nil if code == "404"
|
|
70
|
+
raise Profiler::Error, "Peer #{@name.inspect} answered #{code} (redirect not followed)" if code.start_with?("3")
|
|
71
|
+
if %w[401 403].include?(code)
|
|
72
|
+
raise Profiler::Error, "Peer #{@name.inspect} refused the request (#{code}): check that both instances " \
|
|
73
|
+
"share the same config.peer_token"
|
|
74
|
+
end
|
|
75
|
+
raise Profiler::Error, "Peer #{@name.inspect} answered #{code}" unless code == "200"
|
|
76
|
+
|
|
77
|
+
JSON.parse(resp.body.to_s)
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
raise Profiler::Error, "Peer #{@name.inspect} answered something that is not JSON"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "active_support/security_utils"
|
|
6
|
+
require_relative "cluster/security"
|
|
7
|
+
|
|
8
|
+
module Profiler
|
|
9
|
+
# Static, explicit list of other instances (config.peers) whose profiles can be read to be
|
|
10
|
+
# compared with the local ones. Deliberately separate from the master/slave cluster: nothing
|
|
11
|
+
# here is registered at runtime, and the URLs come from the operator's configuration only,
|
|
12
|
+
# never from a request, an API parameter or an MCP tool argument.
|
|
13
|
+
module Peers
|
|
14
|
+
TOKEN_HEADER = "X-Profiler-Peer-Token"
|
|
15
|
+
TOKEN_ENV_KEY = "HTTP_X_PROFILER_PEER_TOKEN"
|
|
16
|
+
MIN_TOKEN_LENGTH = 32
|
|
17
|
+
# The names that mean "this instance" in a node selector.
|
|
18
|
+
LOCAL_NODE = "local"
|
|
19
|
+
|
|
20
|
+
Peer = Struct.new(:name, :url)
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# The validated peers, checked once per configured list (the list is static and trusted).
|
|
24
|
+
# Raises Profiler::Error naming the first invalid entry.
|
|
25
|
+
def all
|
|
26
|
+
list = Profiler.configuration.peers
|
|
27
|
+
return [] if list.nil? || list.empty?
|
|
28
|
+
|
|
29
|
+
cache = @validated
|
|
30
|
+
return cache[1] if cache && cache[0].equal?(list) && cache[2] == list.hash
|
|
31
|
+
|
|
32
|
+
peers = validate!(list)
|
|
33
|
+
@validated = [list, peers, list.hash]
|
|
34
|
+
peers
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def names
|
|
38
|
+
all.map(&:name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def local?(node)
|
|
42
|
+
node.nil? || node.to_s.strip.empty? || node.to_s == LOCAL_NODE
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The peer called node, or a Profiler::Error. Never builds a peer from anything but config.peers.
|
|
46
|
+
def find!(node)
|
|
47
|
+
peers = all
|
|
48
|
+
peer = peers.find { |p| p.name == node.to_s }
|
|
49
|
+
return peer if peer
|
|
50
|
+
|
|
51
|
+
known = peers.empty? ? "none configured (config.peers is empty)" : peers.map(&:name).join(", ")
|
|
52
|
+
raise Profiler::Error, "Unknown node #{node.to_s.inspect}: it is not in config.peers (known nodes: #{known})"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Why the configured token cannot be used, or nil when it can.
|
|
56
|
+
def token_problem
|
|
57
|
+
token = Profiler.configuration.peer_token.to_s
|
|
58
|
+
return "No config.peer_token is configured" if token.strip.empty?
|
|
59
|
+
return nil if token.strip.length >= MIN_TOKEN_LENGTH
|
|
60
|
+
|
|
61
|
+
"config.peer_token is shorter than #{MIN_TOKEN_LENGTH} characters, so it is ignored as if none were configured"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def configured_token?
|
|
65
|
+
token_problem.nil?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def outgoing_headers
|
|
69
|
+
{ TOKEN_HEADER => Profiler.configuration.peer_token.to_s }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Constant-time comparison of the token a request carries; false when none is configured.
|
|
73
|
+
def valid_token?(presented)
|
|
74
|
+
return false unless configured_token?
|
|
75
|
+
return false if presented.to_s.empty?
|
|
76
|
+
|
|
77
|
+
ActiveSupport::SecurityUtils.secure_compare(presented.to_s, Profiler.configuration.peer_token.to_s)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def request_token_valid?(request)
|
|
81
|
+
valid_token?(request.get_header(TOKEN_ENV_KEY))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Logged once at boot when peers are configured without a usable token: every call to a
|
|
85
|
+
# peer is then refused.
|
|
86
|
+
def warn_about_configuration(logger)
|
|
87
|
+
return if Profiler.configuration.peers.to_a.empty?
|
|
88
|
+
return unless (problem = token_problem)
|
|
89
|
+
|
|
90
|
+
Profiler.log_warn("Peers: #{problem}: comparing profiles across nodes is refused. Generate one with " \
|
|
91
|
+
"`ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'` and give the same value to " \
|
|
92
|
+
"every instance.", logger: logger)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def reset!
|
|
96
|
+
@validated = nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def validate!(list)
|
|
102
|
+
raise Profiler::Error, "config.peers must be an Array of { name:, url: }" unless list.is_a?(Array)
|
|
103
|
+
|
|
104
|
+
peers = list.each_with_index.map { |entry, index| build_peer(entry, index) }
|
|
105
|
+
duplicate = peers.map(&:name).tally.find { |_, count| count > 1 }
|
|
106
|
+
raise Profiler::Error, "config.peers: the name #{duplicate[0].inspect} is used twice" if duplicate
|
|
107
|
+
|
|
108
|
+
peers
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_peer(entry, index)
|
|
112
|
+
unless entry.respond_to?(:to_h) && !entry.is_a?(Array)
|
|
113
|
+
raise Profiler::Error, "config.peers[#{index}] must be a Hash with :name and :url"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
hash = entry.to_h.transform_keys(&:to_s)
|
|
117
|
+
name = hash["name"].to_s.strip
|
|
118
|
+
unless name.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/) && name != LOCAL_NODE
|
|
119
|
+
raise Profiler::Error, "config.peers[#{index}]: name #{hash["name"].inspect} must be 1 to 64 letters, " \
|
|
120
|
+
"digits, dots, dashes or underscores, and cannot be #{LOCAL_NODE.inspect}"
|
|
121
|
+
end
|
|
122
|
+
if (reason = url_denial(hash["url"]))
|
|
123
|
+
raise Profiler::Error, "config.peers[#{index}] (#{name}): #{reason}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
Peer.new(name, Cluster::Security.normalized_url(hash["url"]).freeze)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# nil when the peer URL is acceptable: a plain http(s) URL, with HTTPS required unless the
|
|
130
|
+
# host is a loopback address or config.peer_allow_insecure_http is set.
|
|
131
|
+
def url_denial(url)
|
|
132
|
+
target = Cluster::Security.normalize(url)
|
|
133
|
+
return "#{url.inspect} is not a valid http(s) URL" unless target
|
|
134
|
+
return nil if target[:scheme] == "https" || Cluster::Security.loopback_host?(target[:host])
|
|
135
|
+
return nil if Profiler.configuration.peer_allow_insecure_http
|
|
136
|
+
|
|
137
|
+
"#{url} uses plain HTTP to a host that is not a loopback address: HTTPS is required " \
|
|
138
|
+
"(or set config.peer_allow_insecure_http = true)"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "peers"
|
|
4
|
+
require_relative "peers/peer_proxy"
|
|
5
|
+
require_relative "profile_diff"
|
|
6
|
+
|
|
7
|
+
module Profiler
|
|
8
|
+
# Loads two profiles, each from this instance or from a peer of config.peers, and diffs them.
|
|
9
|
+
# The node is a name in config.peers (or blank / "local"); it is never a URL.
|
|
10
|
+
module ProfileComparison
|
|
11
|
+
NotFound = Class.new(Profiler::Error)
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Both nodes are resolved before any profile is read, so an unknown node fails
|
|
15
|
+
# without a single outgoing call.
|
|
16
|
+
def call(token_a, token_b, node_a: nil, node_b: nil)
|
|
17
|
+
sources = [node_a, node_b].map { |node| source_for(node) }
|
|
18
|
+
profile_a = sources[0].load(token_a)
|
|
19
|
+
profile_b = sources[1].load(token_b)
|
|
20
|
+
|
|
21
|
+
missing = []
|
|
22
|
+
missing << "Profile not found: #{token_a}#{on_node(node_a)}" unless profile_a
|
|
23
|
+
missing << "Profile not found: #{token_b}#{on_node(node_b)}" unless profile_b
|
|
24
|
+
raise NotFound, missing.join("\n") if missing.any?
|
|
25
|
+
|
|
26
|
+
ProfileDiff.new(profile_a, profile_b, node_a: label(node_a), node_b: label(node_b)).compute
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def source_for(node)
|
|
32
|
+
Peers.local?(node) ? Profiler.storage : Peers::PeerProxy.new(node)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def label(node)
|
|
36
|
+
Peers.local?(node) ? Peers::LOCAL_NODE : node.to_s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_node(node)
|
|
40
|
+
Peers.local?(node) ? "" : " (node #{node})"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|