rails-minify 0.2.0 → 0.4.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,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMinify
4
+ # Finds queries a page could run with load_async, from production traffic
5
+ # (config.rails_minify.async_queries = 0.01 samples 1% of requests).
6
+ #
7
+ # When a controller renders, every relation it left in an instance variable
8
+ # unloaded already has its SQL fixed: nothing the view does can change it.
9
+ # So all of them can start at once, in the background, while the view
10
+ # renders, instead of one after another where the view first uses each.
11
+ # For each sampled request that has such relations, one log line records
12
+ # which the view loaded, the time its main query took and when the view
13
+ # first needed it; bin/rails minify:async:report LOG=... adds the lines up
14
+ # and estimates what load_async would save per action.
15
+ #
16
+ # Only main queries move to the background: a relation's preloads
17
+ # (includes/preload) still run in the foreground when it's used, so they
18
+ # aren't counted. Relations the view counts (size, any?) without loading
19
+ # aren't candidates. Association proxies (@user.posts) aren't either.
20
+ module AsyncQueries
21
+ TAG = "[rails-minify] load_async"
22
+
23
+ class << self
24
+ attr_reader :rate, :logger
25
+
26
+ # rate: true or 1.0 for every request, a Float for a share of them,
27
+ # nil/false to stop.
28
+ def install!(rate:, logger: nil)
29
+ @rate = rate == true ? 1.0 : rate
30
+ @logger = logger
31
+ return if @installed
32
+
33
+ @installed = true
34
+ ActiveSupport.on_load(:action_controller_base) { prepend ControllerHook }
35
+ ActiveSupport.on_load(:active_record) { ActiveRecord::Relation.prepend RelationHook }
36
+ ActiveSupport::Notifications.monotonic_subscribe("sql.active_record") do |_, start, finish, _, payload|
37
+ current&.sql((finish - start) * 1000.0, payload)
38
+ end
39
+ end
40
+
41
+ def current = ActiveSupport::IsolatedExecutionState[:rails_minify_async_queries]
42
+
43
+ def sample? = rate && (rate >= 1.0 || rand < rate)
44
+
45
+ def track(action)
46
+ request = Request.new(action)
47
+ ActiveSupport::IsolatedExecutionState[:rails_minify_async_queries] = request
48
+ yield
49
+ ensure
50
+ ActiveSupport::IsolatedExecutionState[:rails_minify_async_queries] = nil
51
+ line = request&.log_line
52
+ (logger || ActiveRecord::Base.logger)&.info(line) if line
53
+ end
54
+ end
55
+
56
+ Candidate = Struct.new(:name, :relation, :model, :query_ms, :offset_ms)
57
+
58
+ # One sampled request.
59
+ class Request
60
+ attr_reader :action, :candidates, :sql_ms
61
+
62
+ def initialize(action)
63
+ @action = action
64
+ @candidates = []
65
+ @sql_ms = 0.0
66
+ end
67
+
68
+ # The controller's assigns, as the view gets them.
69
+ def rendering(assigns)
70
+ return if @render_start
71
+
72
+ @render_start = now
73
+ assigns.each do |name, value|
74
+ next unless value.is_a?(ActiveRecord::Relation) && !value.loaded?
75
+ next if value.is_a?(ActiveRecord::Associations::CollectionProxy)
76
+
77
+ @candidates << Candidate.new(name, value, value.model.name)
78
+ end
79
+ end
80
+
81
+ def loading(relation)
82
+ candidate = @candidates.find { it.relation.equal?(relation) } if @render_start && !@measuring
83
+ return yield unless candidate && !candidate.query_ms
84
+
85
+ offset = now - @render_start
86
+ @measuring = []
87
+ begin
88
+ yield
89
+ ensure
90
+ candidate.query_ms = @measuring.first || 0.0
91
+ candidate.offset_ms = offset
92
+ @measuring = nil
93
+ end
94
+ end
95
+
96
+ def sql(ms, payload)
97
+ return if payload[:cached] || payload[:name] == "SCHEMA" || payload[:async]
98
+
99
+ @sql_ms += ms
100
+ @measuring&.<<(ms)
101
+ end
102
+
103
+ def log_line
104
+ return if @candidates.empty?
105
+
106
+ list = @candidates.map do |c|
107
+ c.query_ms ? format("@%s=%s:%.1f@%.1f", c.name, c.model, c.query_ms, c.offset_ms) : "@#{c.name}=#{c.model}:-"
108
+ end
109
+ format("%s %s sql=%.1f %s", TAG, action, @sql_ms, list.join(" "))
110
+ end
111
+
112
+ private
113
+
114
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
115
+ end
116
+
117
+ module ControllerHook
118
+ def process_action(...)
119
+ return super unless AsyncQueries.sample?
120
+
121
+ AsyncQueries.track("#{self.class.name}##{action_name}") { super }
122
+ end
123
+
124
+ def view_assigns
125
+ assigns = super
126
+ AsyncQueries.current&.rendering(assigns)
127
+ assigns
128
+ end
129
+ end
130
+
131
+ module RelationHook
132
+ def load(&)
133
+ request = AsyncQueries.current
134
+ return super unless request && !loaded?
135
+
136
+ request.loading(self) { super }
137
+ end
138
+ end
139
+
140
+ # Adds up the log lines from sampled requests.
141
+ class Report
142
+ Seen = Data.define(:name, :model, :query_ms, :offset_ms) do
143
+ def loaded? = !query_ms.nil?
144
+ end
145
+ Sample = Data.define(:action, :sql_ms, :seen) do
146
+ # Milliseconds the request would have saved with every loaded
147
+ # candidate started when rendering began: in the order the view used
148
+ # them, each one waits only for what's left of its query.
149
+ def saving
150
+ saved = 0.0
151
+ seen.select(&:loaded?).sort_by(&:offset_ms).each do |c|
152
+ wait = [c.query_ms - (c.offset_ms - saved), 0.0].max
153
+ saved += c.query_ms - wait
154
+ end
155
+ saved
156
+ end
157
+ end
158
+
159
+ LINE = /#{Regexp.escape(TAG)} (\S+) sql=([\d.]+) (.+)$/
160
+ ITEM = /\A@(\w+)=([\w:]+):(?:-|([\d.]+)@([\d.]+))\z/
161
+
162
+ attr_reader :samples
163
+
164
+ def initialize(lines)
165
+ @samples = lines.filter_map do |line|
166
+ m = LINE.match(line.scrub) or next
167
+ seen = m[3].split.filter_map do |item|
168
+ i = ITEM.match(item) or next
169
+ Seen.new(i[1], i[2], i[3]&.to_f, i[4]&.to_f)
170
+ end
171
+ Sample.new(m[1], m[2].to_f, seen)
172
+ end
173
+ end
174
+
175
+ def report(io = $stdout, actions: 10)
176
+ if @samples.empty?
177
+ io.puts "no #{TAG} lines: set config.rails_minify.async_queries (e.g. 0.01) and collect the production log"
178
+ return
179
+ end
180
+
181
+ io.puts "#{@samples.size} sampled requests with relations left unloaded for the view"
182
+ by_action = @samples.group_by(&:action)
183
+ ranked = by_action.max_by(actions) { |_, ss| ss.sum(&:saving) }
184
+ ranked.each do |action, ss|
185
+ savings = ss.map(&:saving)
186
+ io.puts
187
+ io.puts format("%s n %d saving mean %.1f ms, p90 %.1f ms (foreground SQL mean %.1f ms)",
188
+ action, ss.size, mean(savings), pct(savings, 90), mean(ss.map(&:sql_ms)))
189
+ ss.flat_map(&:seen).group_by(&:name).each do |name, cs|
190
+ loaded = cs.select(&:loaded?)
191
+ share = loaded.size * 100.0 / cs.size
192
+ detail = if loaded.empty?
193
+ "never loaded in the view"
194
+ else
195
+ format("loaded in %3.0f%% query %5.1f ms first used %5.1f ms into rendering",
196
+ share, mean(loaded.map(&:query_ms)), mean(loaded.map(&:offset_ms)))
197
+ end
198
+ io.puts format(" @%-20s %-20s %s", name, cs.first.model, detail)
199
+ end
200
+ end
201
+ io.puts
202
+ io.puts "Saving: with each loaded relation's main query started with load_async in the controller, as an " \
203
+ "estimate that assumes the database answers as fast in parallel. Worth it where it's a few ms or more " \
204
+ "and the relation is loaded in most requests (otherwise load_async runs a query nobody reads). " \
205
+ "load_async needs config.active_record.async_query_executor; check the result with minify:ab:report."
206
+ end
207
+
208
+ private
209
+
210
+ def mean(xs) = xs.empty? ? 0.0 : xs.sum / xs.size
211
+
212
+ def pct(xs, p)
213
+ xs = xs.sort
214
+ xs[[xs.size - 1, ((p / 100.0) * (xs.size - 1)).round].min]
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "pathname"
5
+ require "shellwords"
6
+ require "yaml"
7
+
8
+ module RailsMinify
9
+ # Production configuration that costs speed or memory, found by reading
10
+ # the app's files and environment (bin/rails minify:doctor). It only
11
+ # reports: nothing here fails a build.
12
+ #
13
+ # The environment is read from this process, config/deploy.yml (Kamal's
14
+ # env.clear, top level and per role), the Dockerfile's ENV lines and
15
+ # bin/docker-entrypoint's exports, since
16
+ # doctor usually runs somewhere other than the server.
17
+ class Doctor
18
+ Finding = Data.define(:check, :found, :why, :fix) do
19
+ def to_s = "#{found}\n Why: #{why}\n Fix: #{fix}"
20
+ end
21
+
22
+ CHECKS = %i[jit_before_boot jit_options falcon_preload puma_preload heap_warmup worker_count sentry_breadcrumbs load_async_executor].freeze
23
+ ENV_KEYS = %w[RUBYOPT RUBY_YJIT_ENABLE RUBY_ZJIT_ENABLE WEB_CONCURRENCY].freeze
24
+
25
+ # jit_at_boot: the JITs already on when rails-minify was required, before
26
+ # Rails configured anything (RailsMinify.jit_at_boot).
27
+ # gems: name => version of the bundled gems.
28
+ # sentry_breadcrumbs: Sentry's configured breadcrumbs_logger, if Sentry is set up.
29
+ # async_executor: ActiveRecord.async_query_executor (:global_thread_pool, ...); false when unknown.
30
+ def initialize(root:, env: ENV, jit_at_boot: [], gems: {}, sentry_breadcrumbs: nil, async_executor: false)
31
+ @root = Pathname(root)
32
+ @env = env
33
+ @jit_at_boot = jit_at_boot
34
+ @gems = gems
35
+ @sentry_breadcrumbs = sentry_breadcrumbs
36
+ @async_executor = async_executor
37
+ end
38
+
39
+ def findings = CHECKS.flat_map { Array(send(it)) }
40
+
41
+ def report(io = $stdout)
42
+ found = findings
43
+ io.puts "minify:doctor: #{CHECKS.size} checks, #{found.empty? ? "nothing found" : "#{found.size} found"}"
44
+ found.each.with_index(1) { |finding, i| io.puts "\n#{i}. #{finding}" }
45
+ found
46
+ end
47
+
48
+ # [label, {key => value}] for each place the environment is set.
49
+ def env_sources
50
+ @env_sources ||= [
51
+ ["this process", @env.to_h.slice(*ENV_KEYS)],
52
+ ["config/deploy.yml", deploy_env],
53
+ ["Dockerfile", dockerfile_env],
54
+ ["bin/docker-entrypoint", entrypoint_env],
55
+ ].reject { |_, values| values.empty? }
56
+ end
57
+
58
+ private
59
+
60
+ JIT_NAMES = { "yjit" => "YJIT", "zjit" => "ZJIT" }.freeze
61
+ WHY_BOOT = "a JIT turned on at startup also compiles what only runs while booting " \
62
+ "(RubyGems, requiring, eager loading). On Japan Voyage, ZJIT turned on after boot " \
63
+ "compiled 12,612 → 9,557 methods and used about 25 MB less memory per process, at the same speed."
64
+
65
+ def jit_before_boot
66
+ found = env_sources.flat_map do |label, values|
67
+ names = []
68
+ names << "RUBY_YJIT_ENABLE=#{values["RUBY_YJIT_ENABLE"]}" if truthy?(values["RUBY_YJIT_ENABLE"])
69
+ names << "RUBY_ZJIT_ENABLE=#{values["RUBY_ZJIT_ENABLE"]}" if truthy?(values["RUBY_ZJIT_ENABLE"])
70
+ names += rubyopt(values).grep(/\A--(yjit|zjit|jit)\z/)
71
+ names.map { "#{it} (#{label})" }
72
+ end
73
+ found += @jit_at_boot.map { "#{JIT_NAMES.fetch(it.to_s, it.to_s)} was already on when Rails loaded (this process)" }
74
+ return if found.empty?
75
+
76
+ Finding.new(:jit_before_boot, "JIT turned on before boot: #{found.join(", ")}", WHY_BOOT,
77
+ "drop it and turn the JIT on after boot: config.yjit = true for YJIT, " \
78
+ "config.zjit = true for ZJIT (Rails main).")
79
+ end
80
+
81
+ def jit_options
82
+ env_sources.filter_map do |label, values|
83
+ tokens = rubyopt(values)
84
+ JIT_NAMES.filter_map do |jit, name|
85
+ options = tokens.grep(/\A--#{jit}-/) - ["--#{jit}-disable"]
86
+ next if options.empty? || tokens.include?("--#{jit}-disable") || tokens.include?("--#{jit}")
87
+
88
+ Finding.new(:jit_options, "#{name} options without --#{jit}-disable: RUBYOPT=#{values["RUBYOPT"]} (#{label})",
89
+ "any --#{jit}-* option turns #{name} on at startup, before boot. #{WHY_BOOT}",
90
+ "put --#{jit}-disable before them (RUBYOPT=\"--#{jit}-disable #{options.join(" ")}\"); " \
91
+ "they still apply when #{name} is turned on after boot.")
92
+ end
93
+ end.flatten
94
+ end
95
+
96
+ def falcon_preload
97
+ return unless falcon_config && falcon_count != 1 && !falcon_config[/^\s*preload\b/]
98
+
99
+ preload = %w[config/preload.rb preload.rb].find { @root.join(it).exist? }
100
+ found = preload ? "#{preload} exists, but #{falcon_path} doesn't preload it" : "#{falcon_path} doesn't preload the app"
101
+ Finding.new(:falcon_preload, found,
102
+ "without preload every worker boots the app and holds its own copy; preloaded, the workers " \
103
+ "share the parent's memory. On Japan Voyage, preloading with the heap warmed up, ZJIT " \
104
+ "after boot and one query fix together took HTML p50 from 75 to 45 ms.",
105
+ "add `preload \"config/preload.rb\"` to the service in #{falcon_path}, with that file requiring " \
106
+ "config/environment and calling Process.warmup.")
107
+ end
108
+
109
+ def puma_preload
110
+ config = read("config/puma.rb") or return
111
+ return unless config[/^\s*workers\b/] && config[/^\s*preload_app!\s*\(?\s*false/]
112
+
113
+ Finding.new(:puma_preload, "config/puma.rb has workers and preload_app! false",
114
+ "every worker boots the app and holds its own copy instead of sharing the parent's.",
115
+ "remove preload_app! false (Puma preloads by default with more than one worker).")
116
+ end
117
+
118
+ def heap_warmup
119
+ return unless falcon_config
120
+ return unless (version = @gems["async-service"]) && Gem::Version.new(version) <= Gem::Version.new("0.25.0")
121
+
122
+ path = falcon_config[/^\s*preload\s*\(?\s*["']([^"']+)["']/, 1] or return
123
+ script = read(path) or return
124
+ return if script.include?("Process.warmup")
125
+
126
+ Finding.new(:heap_warmup, "#{path} doesn't call Process.warmup (async-service #{version})",
127
+ "async-service #{version} warms the heap up after forking the workers " \
128
+ "(socketry/async-service#13), so each worker compacts its own copy and loses copy-on-write sharing.",
129
+ "end #{path} with Process.warmup, after requiring config/environment.")
130
+ end
131
+
132
+ def worker_count
133
+ return unless falcon_config && !falcon_config[/^\s*count\b/]
134
+
135
+ set = env_sources.filter_map { |label, values| "WEB_CONCURRENCY=#{values["WEB_CONCURRENCY"]} (#{label})" if values["WEB_CONCURRENCY"] }
136
+ return if set.empty?
137
+
138
+ Finding.new(:worker_count, "#{falcon_path} sets no count, but #{set.join(", ")}",
139
+ "Falcon doesn't read WEB_CONCURRENCY: it starts one worker per CPU.",
140
+ "add `count ENV.fetch(\"WEB_CONCURRENCY\", Etc.nprocessors).to_i` to the service.")
141
+ end
142
+
143
+ def sentry_breadcrumbs
144
+ loggers = Array(@sentry_breadcrumbs).map(&:to_sym)
145
+ configured = loggers.include?(:active_support_logger) ? "config.breadcrumbs_logger" : nil
146
+ configured ||= Dir[@root.join("config/initializers/*.rb").to_s].sort.find do |file|
147
+ File.read(file).match?(/breadcrumbs_logger\s*=.*[:"']active_support_logger\b/)
148
+ end&.then { Pathname(it).relative_path_from(@root).to_s }
149
+ return unless configured
150
+
151
+ Finding.new(:sentry_breadcrumbs, "Sentry breadcrumbs_logger includes :active_support_logger (#{configured})",
152
+ "it subscribes to every notification (each SQL query, render, instantiation) and reads the clock " \
153
+ "twice per event, whether Sentry is sending anything or not.",
154
+ "use :monotonic_active_support_logger (no clock calls), or drop it if you don't read those breadcrumbs.")
155
+ end
156
+
157
+ def load_async_executor
158
+ return unless @async_executor.nil?
159
+
160
+ file = Dir[@root.join("app/**/*.rb").to_s].sort.find { File.read(it).include?("load_async") } or return
161
+ Finding.new(:load_async_executor, "#{Pathname(file).relative_path_from(@root)} calls load_async, " \
162
+ "but config.active_record.async_query_executor isn't set",
163
+ "without an executor load_async runs the query in the foreground, where it is, as if it weren't there.",
164
+ "set config.active_record.async_query_executor = :global_thread_pool, and make the database pool " \
165
+ "big enough for the requests plus global_executor_concurrency (default 4).")
166
+ end
167
+
168
+ def falcon_path = %w[falcon.rb config/falcon.rb].find { @root.join(it).exist? }
169
+ def falcon_config = (@falcon_config ||= [falcon_path && read(falcon_path)]).first
170
+
171
+ # nil when it can't be told from the file.
172
+ def falcon_count
173
+ value = falcon_config[/^\s*count\s*\(?\s*(\d+)\s*\)?\s*$/, 1]
174
+ value&.to_i
175
+ end
176
+
177
+ def read(path)
178
+ file = @root.join(path)
179
+ file.read if file.file?
180
+ end
181
+
182
+ def rubyopt(values) = values["RUBYOPT"].to_s.split
183
+ def truthy?(value) = value && !%w[0 false no].include?(value.to_s.strip.downcase) && !value.to_s.strip.empty?
184
+
185
+ def deploy_env
186
+ text = read("config/deploy.yml") or return {}
187
+ config = YAML.safe_load(ERB.new(text).result, aliases: true)
188
+ return {} unless config.is_a?(Hash)
189
+
190
+ roles = config["servers"].is_a?(Hash) ? config["servers"].values.grep(Hash) : []
191
+ [config, *roles].each_with_object({}) do |section, env|
192
+ clear = section.dig("env", "clear") if section["env"].is_a?(Hash)
193
+ env.merge!(clear.transform_values(&:to_s).slice(*ENV_KEYS)) if clear.is_a?(Hash)
194
+ end
195
+ rescue StandardError, SyntaxError
196
+ {}
197
+ end
198
+
199
+ def entrypoint_env
200
+ text = read("bin/docker-entrypoint") or return {}
201
+ text.scan(/^\s*export\s+(\w+)=(.*)$/).each_with_object({}) do |(key, value), env|
202
+ env[key] = (Shellwords.split(value).first.to_s rescue value) if ENV_KEYS.include?(key)
203
+ end
204
+ end
205
+
206
+ def dockerfile_env
207
+ text = read("Dockerfile") or return {}
208
+ text.gsub(/\\\r?\n/, " ").lines.each_with_object({}) do |line, env|
209
+ next unless (args = line[/\A\s*ENV\s+(.*)/i, 1])
210
+
211
+ words = Shellwords.split(args) rescue next
212
+ if words.first&.include?("=")
213
+ words.each { |word| key, value = word.split("=", 2); env[key] = value.to_s if ENV_KEYS.include?(key) }
214
+ elsif ENV_KEYS.include?(words.first)
215
+ env[words.first] = words.drop(1).join(" ")
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -6,22 +6,33 @@ module RailsMinify
6
6
 
7
7
  # After config/environments/*.rb, before any initializer reads the
8
8
  # minimizers' options.
9
- config.before_initialize { |app| Railtie.apply_enabled(app.config) }
10
-
11
- # `enabled` turns on what made no page slower in production and is worth
12
- # having for its own sake: strict mode (fewer generated methods, production
13
- # runs what the tests run), enums and ZJIT after boot. Everything else stays
14
- # off unless set on its own. ZJIT, like Rails' config.yjit default, not in
15
- # development and test, and only from Ruby 4.1: 4.0's ZJIT isn't meant for
16
- # production yet.
17
- def self.apply_enabled(config, env: Rails.env, ruby: RUBY_VERSION)
18
- options = config.rails_minify
19
- return unless options.enabled
20
-
21
- ar = config.ar_minimizer
22
- ar.slim = true if ar.slim.nil?
23
- ar.enums = true if ar.enums.nil?
24
- options.zjit = !env.local? && Gem::Version.new(ruby) >= Gem::Version.new("4.1") if options.zjit.nil?
9
+ config.before_initialize do |app|
10
+ Railtie.apply_ab(app.config)
11
+ Railtie.warn_removed(app.config)
12
+ end
13
+
14
+ # In an A/B run (rails-minify-ab), group B gets config.rails_minify.ab
15
+ # (called with the app's config) and both groups a log tag, after the
16
+ # request id, which minify:ab:report reads.
17
+ def self.apply_ab(config, group: AB.group)
18
+ return unless group
19
+
20
+ config.rails_minify.ab&.call(config) if group == "B"
21
+ tags = Array(config.log_tags)
22
+ tags = [:request_id, *tags] unless tags.include?(:request_id)
23
+ config.log_tags = [*tags, AB.log_tag(group)]
24
+ end
25
+
26
+ REMOVED = {
27
+ enabled: "set config.ar_minimizer.slim = true for strict mode, and Rails' config.zjit = true for ZJIT",
28
+ zjit: "use Rails' config.zjit = true (Rails main), which turns ZJIT on after boot the same way",
29
+ }.freeze
30
+
31
+ # Options earlier versions read; setting one now would quietly do nothing.
32
+ def self.warn_removed(config, out: $stderr)
33
+ REMOVED.each do |name, instead|
34
+ out.puts "rails-minify: config.rails_minify.#{name} was removed; #{instead}." unless config.rails_minify[name].nil?
35
+ end
25
36
  end
26
37
 
27
38
  PATCHES = {
@@ -44,16 +55,17 @@ module RailsMinify
44
55
  end
45
56
  end
46
57
 
47
- # ZJIT from after boot, as Rails does for YJIT (config.yjit): the code
48
- # that only runs while booting (requiring, eager loading, the minimizers'
49
- # compiling) isn't compiled. Japan Voyage, per process: 24% fewer
50
- # compiled methods, 18 MB less JIT memory, the same speed.
51
- initializer "rails_minify.zjit" do |app|
52
- config.after_initialize do
53
- next unless app.config.rails_minify.zjit && defined?(RubyVM::ZJIT.enable) && !RubyVM::ZJIT.enabled?
58
+ # Sampled: which relations the views load that load_async could start
59
+ # in the controller (RailsMinify::AsyncQueries).
60
+ initializer "rails_minify.async_queries" do |app|
61
+ rate = app.config.rails_minify.async_queries
62
+ AsyncQueries.install!(rate: rate) if rate
63
+ end
54
64
 
55
- RubyVM::ZJIT.enable
56
- end
65
+ # Sampled: each query's time, rows and call site (RailsMinify::SqlProfile).
66
+ initializer "rails_minify.sql_profile" do |app|
67
+ rate = app.config.rails_minify.sql_profile
68
+ SqlProfile.install!(rate: rate) if rate
57
69
  end
58
70
 
59
71
  rake_tasks do
@@ -85,6 +97,56 @@ module RailsMinify
85
97
  end
86
98
  end
87
99
 
100
+ namespace :ab do
101
+ desc "Compare groups A and B in a request log from an A/B run (LOG=file, - for stdin; ACTIONS=10)"
102
+ task :report do
103
+ path = ENV.fetch("LOG") { abort "LOG=path/to/log (or LOG=- for stdin)" }
104
+ lines = path == "-" ? $stdin.each_line : File.foreach(path)
105
+ RailsMinify::AB::Report.new(lines).report(actions: Integer(ENV.fetch("ACTIONS", "10")))
106
+ end
107
+
108
+ desc "Memory (RSS, PSS) of each A/B group's processes, from /proc; run where the server runs"
109
+ task :memory do
110
+ RailsMinify::AB::Memory.report
111
+ end
112
+ end
113
+
114
+ namespace :sql do
115
+ desc "Queries ranked by total time, from a log with config.rails_minify.sql_profile set " \
116
+ "(LOG=file, - for stdin; QUERIES=20, ACTIONS=10; EXPLAIN=1 adds SQLite query plans)"
117
+ task :report do
118
+ path = ENV.fetch("LOG") { abort "LOG=path/to/log (or LOG=- for stdin)" }
119
+ explain = nil
120
+ if ENV["EXPLAIN"] == "1"
121
+ Rake::Task[:environment].invoke
122
+ explain = RailsMinify::SqlProfile::Report.sqlite_explainer
123
+ end
124
+ lines = path == "-" ? $stdin.each_line : File.foreach(path)
125
+ RailsMinify::SqlProfile::Report.new(lines).report(queries: Integer(ENV.fetch("QUERIES", "20")),
126
+ actions: Integer(ENV.fetch("ACTIONS", "10")), explain: explain)
127
+ end
128
+ end
129
+
130
+ namespace :async do
131
+ desc "Relations the views load that load_async could start in the controller, from a log with " \
132
+ "config.rails_minify.async_queries set (LOG=file, - for stdin; ACTIONS=10)"
133
+ task :report do
134
+ path = ENV.fetch("LOG") { abort "LOG=path/to/log (or LOG=- for stdin)" }
135
+ lines = path == "-" ? $stdin.each_line : File.foreach(path)
136
+ RailsMinify::AsyncQueries::Report.new(lines).report(actions: Integer(ENV.fetch("ACTIONS", "10")))
137
+ end
138
+ end
139
+
140
+ desc "Report production configuration that costs speed or memory (JIT before boot, " \
141
+ "no preload, heap warmed up after the fork, ...); never fails"
142
+ task doctor: :environment do
143
+ app = Rails.application
144
+ sentry = Sentry.configuration&.breadcrumbs_logger if defined?(Sentry) && Sentry.respond_to?(:initialized?) && Sentry.initialized?
145
+ RailsMinify::Doctor.new(root: app.root, jit_at_boot: RailsMinify::JIT_AT_BOOT,
146
+ gems: Gem.loaded_specs.transform_values { it.version.to_s }, sentry_breadcrumbs: sentry,
147
+ async_executor: (ActiveRecord.async_query_executor if defined?(ActiveRecord))).report
148
+ end
149
+
88
150
  desc "Fail on callback problems (missing targets, chains longer than callback_limit, " \
89
151
  "callbacks that save their own record) and an out-of-date manifest"
90
152
  task check: :environment do