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,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.41.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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.41.0
4
+ version: 0.42.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -119,6 +119,7 @@ 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
122
123
  - app/controllers/profiler/api/config_controller.rb
123
124
  - app/controllers/profiler/api/console_controller.rb
124
125
  - app/controllers/profiler/api/env_vars_controller.rb
@@ -134,11 +135,13 @@ files:
134
135
  - app/controllers/profiler/api/toolbar_controller.rb
135
136
  - app/controllers/profiler/application_controller.rb
136
137
  - app/controllers/profiler/assets_controller.rb
138
+ - app/controllers/profiler/compare_controller.rb
137
139
  - app/controllers/profiler/health_controller.rb
138
140
  - app/controllers/profiler/profiles_controller.rb
139
141
  - app/controllers/profiler/test_runner_controller.rb
140
142
  - app/views/layouts/profiler/application.html.erb
141
143
  - app/views/layouts/profiler/embedded.html.erb
144
+ - app/views/profiler/compare/show.html.erb
142
145
  - app/views/profiler/health/show.html.erb
143
146
  - app/views/profiler/profiles/index.html.erb
144
147
  - app/views/profiler/profiles/show.html.erb
@@ -209,6 +212,7 @@ files:
209
212
  - lib/profiler/mcp/slave_support.rb
210
213
  - lib/profiler/mcp/tools/analyze_queries.rb
211
214
  - lib/profiler/mcp/tools/clear_profiles.rb
215
+ - lib/profiler/mcp/tools/compare_profiles.rb
212
216
  - lib/profiler/mcp/tools/delete_env_var.rb
213
217
  - lib/profiler/mcp/tools/explain_query.rb
214
218
  - lib/profiler/mcp/tools/get_profile_ajax.rb
@@ -240,7 +244,11 @@ files:
240
244
  - lib/profiler/otel/scoped_id_generator.rb
241
245
  - lib/profiler/otel/sdk.rb
242
246
  - lib/profiler/otel/trace_exporter.rb
247
+ - lib/profiler/peers.rb
248
+ - lib/profiler/peers/peer_proxy.rb
243
249
  - lib/profiler/process_snapshot.rb
250
+ - lib/profiler/profile_comparison.rb
251
+ - lib/profiler/profile_diff.rb
244
252
  - lib/profiler/railtie.rb
245
253
  - lib/profiler/redaction.rb
246
254
  - lib/profiler/sql_statement.rb