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.
@@ -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"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.40.0"
4
+ VERSION = "0.42.0"
5
5
 
6
6
  GEM_ROOT = File.expand_path("../..", __dir__)
7
7
 
data/lib/profiler.rb CHANGED
@@ -275,6 +275,10 @@ require_relative "profiler/storage/private_files"
275
275
  require_relative "profiler/storage/unavailable"
276
276
  require_relative "profiler/assets"
277
277
  require_relative "profiler/ajax_data"
278
+ require_relative "profiler/peers"
279
+ require_relative "profiler/peers/peer_proxy"
280
+ require_relative "profiler/profile_diff"
281
+ require_relative "profiler/profile_comparison"
278
282
  require_relative "profiler/env_override_store"
279
283
  require_relative "profiler/instrumentation/thread_context_propagation"
280
284
  require_relative "profiler/instrumentation/executor_context_propagation"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.40.0
4
+ version: 0.42.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-19 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -119,6 +119,8 @@ files:
119
119
  - app/assets/javascript/profiler-ajax-interceptor.js
120
120
  - app/controllers/profiler/api/ajax_controller.rb
121
121
  - app/controllers/profiler/api/cluster_controller.rb
122
+ - app/controllers/profiler/api/compare_controller.rb
123
+ - app/controllers/profiler/api/config_controller.rb
122
124
  - app/controllers/profiler/api/console_controller.rb
123
125
  - app/controllers/profiler/api/env_vars_controller.rb
124
126
  - app/controllers/profiler/api/events_controller.rb
@@ -133,11 +135,13 @@ files:
133
135
  - app/controllers/profiler/api/toolbar_controller.rb
134
136
  - app/controllers/profiler/application_controller.rb
135
137
  - app/controllers/profiler/assets_controller.rb
138
+ - app/controllers/profiler/compare_controller.rb
136
139
  - app/controllers/profiler/health_controller.rb
137
140
  - app/controllers/profiler/profiles_controller.rb
138
141
  - app/controllers/profiler/test_runner_controller.rb
139
142
  - app/views/layouts/profiler/application.html.erb
140
143
  - app/views/layouts/profiler/embedded.html.erb
144
+ - app/views/profiler/compare/show.html.erb
141
145
  - app/views/profiler/health/show.html.erb
142
146
  - app/views/profiler/profiles/index.html.erb
143
147
  - app/views/profiler/profiles/show.html.erb
@@ -208,6 +212,7 @@ files:
208
212
  - lib/profiler/mcp/slave_support.rb
209
213
  - lib/profiler/mcp/tools/analyze_queries.rb
210
214
  - lib/profiler/mcp/tools/clear_profiles.rb
215
+ - lib/profiler/mcp/tools/compare_profiles.rb
211
216
  - lib/profiler/mcp/tools/delete_env_var.rb
212
217
  - lib/profiler/mcp/tools/explain_query.rb
213
218
  - lib/profiler/mcp/tools/get_profile_ajax.rb
@@ -239,7 +244,11 @@ files:
239
244
  - lib/profiler/otel/scoped_id_generator.rb
240
245
  - lib/profiler/otel/sdk.rb
241
246
  - lib/profiler/otel/trace_exporter.rb
247
+ - lib/profiler/peers.rb
248
+ - lib/profiler/peers/peer_proxy.rb
242
249
  - lib/profiler/process_snapshot.rb
250
+ - lib/profiler/profile_comparison.rb
251
+ - lib/profiler/profile_diff.rb
243
252
  - lib/profiler/railtie.rb
244
253
  - lib/profiler/redaction.rb
245
254
  - lib/profiler/sql_statement.rb