rails-profiler 0.41.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.
@@ -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
@@ -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
@@ -0,0 +1,6 @@
1
+ <div id="profiler-compare"
2
+ data-token-a="<%= @token_a %>"
3
+ data-token-b="<%= @token_b %>"
4
+ data-node-a="<%= @node_a %>"
5
+ data-node-b="<%= @node_b %>">
6
+ </div>
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
@@ -62,7 +62,10 @@ module Profiler
62
62
  :cluster_heartbeat_interval, :cluster_offline_threshold,
63
63
  :cluster_master, :cluster_secret, :cluster_require_secret,
64
64
  :cluster_allow_insecure_http, :logger,
65
- :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
66
69
 
67
70
  attr_reader :authorize_block, :enabled, :track_tests, :max_profiles, :editor_url_scheme, :editor_path_map
68
71
 
@@ -172,6 +175,15 @@ module Profiler
172
175
  @collector_circuit_breaker_threshold = 3
173
176
  @editor_url_scheme = "vscode"
174
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
175
187
  end
176
188
 
177
189
  attr_reader :collector_circuit_breaker_threshold
@@ -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