rails-profiler 0.41.0 → 0.43.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,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
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mcp/resources/n1_patterns"
4
+
5
+ module Profiler
6
+ class ProfileDiff
7
+ # A query is "slower" when its total time per fingerprint grew by more than this share.
8
+ SLOWER_THRESHOLD_PCT = 20
9
+ MAX_QUERIES_PER_BLOCK = 25
10
+
11
+ attr_reader :profile_a, :profile_b
12
+
13
+ def initialize(profile_a, profile_b, node_a: nil, node_b: nil)
14
+ @profile_a = profile_a
15
+ @profile_b = profile_b
16
+ @node_a = node_a
17
+ @node_b = node_b
18
+ end
19
+
20
+ def same_endpoint?
21
+ profile_a.path == profile_b.path && profile_a.method == profile_b.method
22
+ end
23
+
24
+ def compute
25
+ {
26
+ profile_a: profile_summary(profile_a),
27
+ profile_b: profile_summary(profile_b),
28
+ same_endpoint: same_endpoint?,
29
+ metrics: compute_metrics,
30
+ sql: compare_sql,
31
+ collectors: {
32
+ database: compare_database,
33
+ cache: compare_cache,
34
+ views: compare_views,
35
+ http: compare_http
36
+ }
37
+ }
38
+ end
39
+
40
+ private
41
+
42
+ def compute_metrics
43
+ db_a = profile_a.collector_data("database") || {}
44
+ db_b = profile_b.collector_data("database") || {}
45
+ cache_a = profile_a.collector_data("cache") || {}
46
+ cache_b = profile_b.collector_data("cache") || {}
47
+ view_a = profile_a.collector_data("view") || {}
48
+ view_b = profile_b.collector_data("view") || {}
49
+ http_a = profile_a.collector_data("http") || {}
50
+ http_b = profile_b.collector_data("http") || {}
51
+
52
+ {
53
+ duration: metric_diff(
54
+ profile_a.duration, profile_b.duration, :lower,
55
+ threshold_pct: Profiler.configuration.regression_duration_threshold
56
+ ),
57
+ memory: metric_diff(
58
+ profile_a.memory, profile_b.memory, :lower,
59
+ threshold_pct: Profiler.configuration.regression_memory_threshold
60
+ ),
61
+ allocated_objects: metric_diff(
62
+ profile_a.allocated_objects, profile_b.allocated_objects, :lower,
63
+ threshold_pct: Profiler.configuration.regression_memory_threshold
64
+ ),
65
+ db_queries: metric_diff(
66
+ db_a["total_queries"], db_b["total_queries"], :lower,
67
+ threshold_pct: Profiler.configuration.regression_query_threshold
68
+ ),
69
+ db_duration: metric_diff(
70
+ db_a["total_duration"], db_b["total_duration"], :lower,
71
+ threshold_pct: Profiler.configuration.regression_duration_threshold
72
+ ),
73
+ cache_hit_rate: metric_diff(
74
+ cache_a["hit_rate"], cache_b["hit_rate"], :higher,
75
+ threshold_pct: Profiler.configuration.regression_cache_threshold
76
+ ),
77
+ view_duration: metric_diff(
78
+ view_a["total_duration"], view_b["total_duration"], :lower,
79
+ threshold_pct: Profiler.configuration.regression_duration_threshold
80
+ ),
81
+ http_requests: metric_diff(
82
+ http_a["total_requests"], http_b["total_requests"], :lower,
83
+ threshold_pct: 0
84
+ ),
85
+ http_duration: metric_diff(
86
+ http_a["total_duration"], http_b["total_duration"], :lower,
87
+ threshold_pct: Profiler.configuration.regression_duration_threshold
88
+ )
89
+ }
90
+ end
91
+
92
+ def metric_diff(val_a, val_b, direction, threshold_pct: 10)
93
+ return nil if val_a.nil? && val_b.nil?
94
+
95
+ a = val_a&.to_f
96
+ b = val_b&.to_f
97
+ delta = (b || 0) - (a || 0)
98
+ delta_pct = (a && a != 0) ? (delta / a * 100).round(1) : nil
99
+
100
+ status = compute_status(delta, delta_pct, direction, threshold_pct)
101
+
102
+ { value_a: val_a, value_b: val_b, delta: delta.round(4), delta_pct: delta_pct, status: status }
103
+ end
104
+
105
+ def compute_status(delta, delta_pct, direction, threshold_pct)
106
+ change_pct = delta_pct&.abs || 0
107
+
108
+ if delta == 0
109
+ :neutral
110
+ elsif change_pct <= threshold_pct
111
+ :neutral
112
+ elsif (direction == :lower && delta < 0) || (direction == :higher && delta > 0)
113
+ :improved
114
+ else
115
+ :regression
116
+ end
117
+ end
118
+
119
+ def profile_summary(profile)
120
+ {
121
+ token: profile.token,
122
+ node: node_for(profile),
123
+ path: profile.path,
124
+ method: profile.method,
125
+ status: profile.status,
126
+ duration: profile.duration,
127
+ memory: profile.memory,
128
+ allocated_objects: profile.allocated_objects,
129
+ started_at: profile.started_at,
130
+ profile_type: profile.profile_type
131
+ }
132
+ end
133
+
134
+ def node_for(profile)
135
+ profile.equal?(profile_a) ? @node_a : @node_b
136
+ end
137
+
138
+ # The queries of both profiles grouped by normalized statement: those only B runs (new),
139
+ # those only A ran (gone), and those whose total time grew past SLOWER_THRESHOLD_PCT.
140
+ def compare_sql
141
+ groups_a = sql_groups(profile_a)
142
+ groups_b = sql_groups(profile_b)
143
+
144
+ {
145
+ new: (groups_b.keys - groups_a.keys).map { |k| sql_entry(k, nil, groups_b[k]) }
146
+ .sort_by { |e| -e[:total_duration_b] }.first(MAX_QUERIES_PER_BLOCK),
147
+ gone: (groups_a.keys - groups_b.keys).map { |k| sql_entry(k, groups_a[k], nil) }
148
+ .sort_by { |e| -e[:total_duration_a] }.first(MAX_QUERIES_PER_BLOCK),
149
+ slower: slower_sql(groups_a, groups_b)
150
+ }
151
+ end
152
+
153
+ def slower_sql(groups_a, groups_b)
154
+ (groups_a.keys & groups_b.keys).filter_map do |key|
155
+ entry = sql_entry(key, groups_a[key], groups_b[key])
156
+ next unless entry[:total_duration_a].positive?
157
+
158
+ pct = ((entry[:total_duration_b] - entry[:total_duration_a]) / entry[:total_duration_a] * 100).round(1)
159
+ entry.merge(delta_pct: pct) if pct > SLOWER_THRESHOLD_PCT
160
+ end.sort_by { |e| -(e[:total_duration_b] - e[:total_duration_a]) }.first(MAX_QUERIES_PER_BLOCK)
161
+ end
162
+
163
+ def sql_groups(profile)
164
+ queries = (profile.collector_data("database") || {})["queries"] || []
165
+ queries.group_by { |q| MCP::Resources::N1Patterns.normalize_sql(q["sql"].to_s) }
166
+ end
167
+
168
+ def sql_entry(fingerprint, queries_a, queries_b)
169
+ {
170
+ fingerprint: fingerprint,
171
+ count_a: queries_a&.size || 0,
172
+ count_b: queries_b&.size || 0,
173
+ total_duration_a: sql_total(queries_a),
174
+ total_duration_b: sql_total(queries_b)
175
+ }
176
+ end
177
+
178
+ def sql_total(queries)
179
+ Array(queries).sum { |q| q["duration"].to_f }.round(2)
180
+ end
181
+
182
+ def compare_database
183
+ db_a = profile_a.collector_data("database") || {}
184
+ db_b = profile_b.collector_data("database") || {}
185
+
186
+ queries_a = db_a["queries"] || []
187
+ queries_b = db_b["queries"] || []
188
+
189
+ {
190
+ total_queries_a: db_a["total_queries"],
191
+ total_queries_b: db_b["total_queries"],
192
+ total_duration_a: db_a["total_duration"],
193
+ total_duration_b: db_b["total_duration"],
194
+ slow_queries_a: db_a["slow_queries"],
195
+ slow_queries_b: db_b["slow_queries"],
196
+ cached_queries_a: db_a["cached_queries"],
197
+ cached_queries_b: db_b["cached_queries"],
198
+ queries_a: queries_a.first(10),
199
+ queries_b: queries_b.first(10)
200
+ }
201
+ end
202
+
203
+ def compare_cache
204
+ cache_a = profile_a.collector_data("cache") || {}
205
+ cache_b = profile_b.collector_data("cache") || {}
206
+
207
+ {
208
+ total_reads_a: cache_a["total_reads"],
209
+ total_reads_b: cache_b["total_reads"],
210
+ total_writes_a: cache_a["total_writes"],
211
+ total_writes_b: cache_b["total_writes"],
212
+ hit_rate_a: cache_a["hit_rate"],
213
+ hit_rate_b: cache_b["hit_rate"]
214
+ }
215
+ end
216
+
217
+ def compare_views
218
+ view_a = profile_a.collector_data("view") || {}
219
+ view_b = profile_b.collector_data("view") || {}
220
+
221
+ {
222
+ total_views_a: view_a["total_views"],
223
+ total_views_b: view_b["total_views"],
224
+ views_a: rendered_templates(view_a),
225
+ views_b: rendered_templates(view_b),
226
+ total_partials_a: view_a["total_partials"],
227
+ total_partials_b: view_b["total_partials"],
228
+ total_duration_a: view_a["total_duration"],
229
+ total_duration_b: view_b["total_duration"]
230
+ }
231
+ end
232
+
233
+ def rendered_templates(view_data)
234
+ Array(view_data["views"]).map { |v| v["identifier"].to_s }.tally
235
+ end
236
+
237
+ def compare_http
238
+ http_a = profile_a.collector_data("http") || {}
239
+ http_b = profile_b.collector_data("http") || {}
240
+
241
+ {
242
+ total_requests_a: http_a["total_requests"],
243
+ total_requests_b: http_b["total_requests"],
244
+ total_duration_a: http_a["total_duration"],
245
+ total_duration_b: http_b["total_duration"],
246
+ slow_requests_a: http_a["slow_requests"],
247
+ slow_requests_b: http_b["slow_requests"],
248
+ error_requests_a: http_a["error_requests"],
249
+ error_requests_b: http_b["error_requests"]
250
+ }
251
+ end
252
+ end
253
+ end
@@ -131,6 +131,8 @@ module Profiler
131
131
 
132
132
  require_relative "cluster/security"
133
133
  Profiler::Cluster::Security.warn_about_configuration(Rails.logger)
134
+ require_relative "peers"
135
+ Profiler::Peers.warn_about_configuration(Rails.logger)
134
136
  next unless Profiler.configuration.slave?
135
137
 
136
138
  require_relative "cluster/master_client"