hayaoki 0.1.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,472 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../../hayaoki"
5
+ require "tmpdir"
6
+ require "fileutils"
7
+
8
+ module Hayaoki
9
+ module BootReport
10
+ PROBE_PATH = File.expand_path("../probe.rb", __dir__)
11
+
12
+ # 子プロセスの失敗。stderr の末尾と、あれば部分結果 (payload) を持つ。
13
+ class RunError < Hayaoki::Error
14
+ attr_reader :reason, :stderr_tail, :partial, :exit_status, :open_span, :probe_error
15
+
16
+ # reason: :exit_status / :no_output / :timeout / :boot_error
17
+ def initialize(reason, summary, stderr_tail: "", partial: nil, exit_status: nil)
18
+ @reason = reason
19
+ @stderr_tail = stderr_tail.to_s
20
+ @partial = partial
21
+ @exit_status = exit_status
22
+ @probe_error = partial && partial["error"]
23
+ @open_span = partial && RunError.find_open_span(partial)
24
+ super(build_message(summary))
25
+ end
26
+
27
+ # 最後に開いていた span (未完了のうち最も深いもの、無ければ最後の raised)。
28
+ def self.find_open_span(payload)
29
+ w = payload["width"] || 8
30
+ ev = payload["events"] || []
31
+ open = nil
32
+ raised = nil
33
+ (0...(ev.size / w)).each do |i|
34
+ kind, name, _par, _t0, t1, res = ev[i * w, 6]
35
+ open = [kind, name] if t1.nil?
36
+ raised = [kind, name] if res == "raised"
37
+ end
38
+ found = open || raised
39
+ found && { "kind" => found[0], "name" => found[1] }
40
+ end
41
+
42
+ private
43
+
44
+ def build_message(summary)
45
+ msg = +summary
46
+ if @probe_error
47
+ msg << "\nerror: #{@probe_error["class"]}: #{@probe_error["message"]}"
48
+ end
49
+ msg << "\nlast open span: kind=#{@open_span["kind"]} #{@open_span["name"]}" if @open_span
50
+ msg << "\n--- stderr (tail) ---\n#{@stderr_tail}" unless @stderr_tail.empty?
51
+ msg
52
+ end
53
+ end
54
+
55
+ # 条件ごと ("warm" / "cold") の payload の配列と、メタデータ。
56
+ class RunResult
57
+ attr_reader :payloads, :meta
58
+
59
+ def initialize(payloads, meta)
60
+ @payloads = payloads
61
+ @meta = meta
62
+ end
63
+
64
+ def conditions = payloads.keys
65
+ end
66
+
67
+ class Runner
68
+ STDERR_TAIL_BYTES = 4096
69
+ KILL_GRACE = 3.0
70
+ BOOTSNAP_MODES = %i[warm cold both].freeze
71
+
72
+ attr_reader :app_dir, :runs, :env, :bootsnap, :ruby, :entry, :timeout
73
+
74
+ def initialize(app_dir:, runs: 5, env: "production", bootsnap: :warm, ruby: "ruby", entry: "config/environment", timeout: 120, on_progress: nil)
75
+ raise ArgumentError, "bootsnap must be one of #{BOOTSNAP_MODES.inspect}" unless BOOTSNAP_MODES.include?(bootsnap)
76
+ raise ArgumentError, "runs must be >= 1" unless runs.is_a?(Integer) && runs >= 1
77
+ @app_dir = File.expand_path(app_dir)
78
+ @runs = runs
79
+ @env = env
80
+ @bootsnap = bootsnap
81
+ @ruby = ruby
82
+ @entry = entry
83
+ @timeout = timeout
84
+ @on_progress = on_progress
85
+ end
86
+
87
+ def command(probe: true)
88
+ [ruby, *(probe ? ["-r", PROBE_PATH] : []), "-e", script(probe)]
89
+ end
90
+
91
+ # 基準実行は entry の require 直後の実時刻を一時ファイル (HAYAOKI_BASE_MARK) に書く。
92
+ def script(probe)
93
+ req = "require File.expand_path(#{entry.inspect}, Dir.pwd)"
94
+ return req if probe
95
+ "#{req}; File.write(ENV.fetch('HAYAOKI_BASE_MARK'), Process.clock_gettime(Process::CLOCK_REALTIME).to_s)"
96
+ end
97
+
98
+ # 全 run が失敗した場合 (条件ごと) などに RunError を raise する。一部の run の失敗は meta["failed_runs"] に記録して続行する。
99
+ def run
100
+ check_app!
101
+ check_ruby!
102
+ @gemfile_present = nil
103
+ gemfile_present?
104
+ conditions = bootsnap == :both ? %i[warm cold] : [bootsnap]
105
+ payloads = {}
106
+ overhead = {}
107
+ warnings = []
108
+ @failures = []
109
+ @interrupted = nil
110
+ @child = nil
111
+ old_traps = install_traps
112
+ begin
113
+ Dir.mktmpdir("hayaoki-run") do |tmp|
114
+ @tmp = tmp
115
+ @seq = 0
116
+ conditions.each do |cond|
117
+ payloads[cond.to_s], ov = measure(cond, warnings)
118
+ overhead[cond] = ov if ov
119
+ end
120
+ end
121
+ rescue Interrupted
122
+ # 子の後始末は済んでいる。元の trap に戻してから、そのシグナルを元のハンドラに渡す
123
+ olds = old_traps
124
+ old_traps = nil
125
+ restore_traps(olds)
126
+ redeliver(@interrupted, olds[Signal.signame(@interrupted)])
127
+ raise RunError.new(:interrupted, "interrupted by SIG#{Signal.signame(@interrupted)}")
128
+ ensure
129
+ restore_traps(old_traps) if old_traps
130
+ end
131
+ meta = {
132
+ "app_dir" => app_dir, "runs" => runs, "env" => env, "bootsnap" => bootsnap.to_s, "ruby" => ruby,
133
+ "entry" => entry, "conditions" => conditions.map(&:to_s), "warnings" => warnings.uniq,
134
+ "overhead" => overhead.transform_keys(&:to_s), "failed_runs" => @failures,
135
+ }
136
+ RunResult.new(payloads, meta)
137
+ end
138
+
139
+ private
140
+
141
+ def measure(cond, warnings)
142
+ warm_dir = cond == :warm ? new_dir("cache-warm") : nil
143
+ # ウォームアップ (捨てる 1 回): OS のファイルキャッシュと、warm 条件では bootsnap のキャッシュを温める
144
+ progress(:warmup, cond, 0)
145
+ guarded(cond, :warmup, 0) { execute(probe: true, cache_dir: warm_dir || new_dir("cache-cold")) }
146
+ results = []
147
+ base_ms = []
148
+ cache_used = false
149
+ first_error = nil
150
+ runs.times do |i|
151
+ cache = warm_dir || new_dir("cache-cold")
152
+ progress(:probe, cond, i + 1)
153
+ r = guarded(cond, :probe, i + 1) { execute(probe: true, cache_dir: cache) }
154
+ if r.is_a?(RunError)
155
+ first_error ||= r
156
+ results << failed_payload(r)
157
+ else
158
+ results << r[:payload]
159
+ end
160
+ cache_used ||= !Dir.empty?(cache)
161
+ progress(:baseline, cond, i + 1)
162
+ b = guarded(cond, :baseline, i + 1) { execute(probe: false, cache_dir: warm_dir || new_dir("cache-cold")) }
163
+ base_ms << b[:boot_ms] unless b.is_a?(RunError)
164
+ end
165
+ ok = results.select { |p| p["status"] == "ok" }
166
+ if ok.empty?
167
+ # 全ての run が失敗: 最初の失敗を (記録済みの内容ごと) 返して終了する
168
+ raise first_error
169
+ end
170
+ cache_used ||= !Dir.empty?(warm_dir) if warm_dir
171
+ unless cache_used
172
+ warnings << "[#{cond}] BOOTSNAP_CACHE_DIR was not used (the app may call Bootsnap.setup with its own cache_dir, or bootsnap is not loaded); bootsnap #{cond} measurement may not be meaningful"
173
+ end
174
+ [results, base_ms.empty? ? nil : overhead(ok, base_ms)]
175
+ end
176
+
177
+ # RunError を meta 用に記録して返す (Interrupted は素通し)。成功ならブロックの値。
178
+ def guarded(cond, phase, index)
179
+ yield
180
+ rescue RunError => e
181
+ @failures << { "condition" => cond.to_s, "phase" => phase.to_s, "index" => index, "reason" => e.reason.to_s,
182
+ "message" => e.message.lines.first.to_s.strip, "exit_status" => e.exit_status,
183
+ "error_class" => e.probe_error && e.probe_error["class"], "stderr_tail" => e.stderr_tail }
184
+ e
185
+ end
186
+
187
+ # 失敗した run を集計側が除外できる形 (status が ok 以外) にする。
188
+ def failed_payload(err)
189
+ base = err.partial.is_a?(Hash) ? err.partial.dup : {}
190
+ base["status"] = "failed" if base["status"].nil? || base["status"] == "ok"
191
+ base["failure_reason"] = err.reason.to_s
192
+ base
193
+ end
194
+
195
+ # index: warmup は 0、probe / baseline は 1 始まり。total は計測 run 数。
196
+ def progress(phase, cond, index)
197
+ @on_progress&.call({ "phase" => phase, "bootsnap" => cond, "index" => index, "total" => runs })
198
+ end
199
+
200
+ def overhead(payloads, base_ms)
201
+ probe_ms = payloads.map do |p|
202
+ boot_us = p["boot_end_us"] || p["exit_us"]
203
+ lead = p["spawn_at"] ? (p["t0_real"] - p["spawn_at"].to_f) * 1000.0 : 0.0
204
+ lead + boot_us / 1000.0
205
+ end
206
+ b = median(base_ms)
207
+ p = median(probe_ms)
208
+ { "baseline_ms" => b.round(3), "probe_ms" => p.round(3), "diff_ms" => (p - b).round(3), "ratio" => b.positive? ? (p / b).round(4) : nil,
209
+ "baseline_samples_ms" => base_ms.map { _1.round(3) }, "probe_samples_ms" => probe_ms.map { _1.round(3) } }
210
+ end
211
+
212
+ def median(a)
213
+ s = a.sort
214
+ n = s.size
215
+ n.odd? ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2.0
216
+ end
217
+
218
+ def new_dir(prefix)
219
+ d = File.join(@tmp, "#{prefix}-#{@seq += 1}")
220
+ FileUtils.mkdir_p(d)
221
+ d
222
+ end
223
+
224
+ # 1 回実行する。probe 有りなら payload を読んで返す。{ payload:, wall: }
225
+ def execute(probe:, cache_dir:)
226
+ pid = nil
227
+ run_child(probe: probe, cache_dir: cache_dir) { |p| pid = p }
228
+ ensure
229
+ reap_group(pid) if pid
230
+ end
231
+
232
+ def run_child(probe:, cache_dir:)
233
+ raise Interrupted if @interrupted
234
+ out = File.join(@tmp, "out-#{@seq += 1}.json")
235
+ errf = File.join(@tmp, "err-#{@seq}.log")
236
+ child_env = { "RAILS_ENV" => env, "RACK_ENV" => env, "BOOTSNAP_CACHE_DIR" => cache_dir, "DISABLE_SPRING" => "1" }
237
+ mark = File.join(@tmp, "mark-#{@seq}.txt")
238
+ child_env["HAYAOKI_PROBE_OUT"] = out if probe
239
+ child_env["HAYAOKI_BASE_MARK"] = mark unless probe
240
+ child_env["BUNDLE_FROZEN"] = "true" if gemfile_present?
241
+ pid = nil
242
+ t0 = nil
243
+ spawn_real = nil
244
+ with_clean_env do |base|
245
+ e = base.merge(child_env)
246
+ e["HAYAOKI_SPAWN_AT"] = nil
247
+ opts = { chdir: app_dir, in: File::NULL, out: File::NULL, err: errf, pgroup: true }
248
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
249
+ spawn_real = Process.clock_gettime(Process::CLOCK_REALTIME)
250
+ e["HAYAOKI_SPAWN_AT"] = spawn_real.to_s if probe
251
+ pid = Process.spawn(e, *command(probe: probe), **opts)
252
+ @child = pid
253
+ yield pid
254
+ end
255
+ status, timed_out = wait(pid)
256
+ @child = nil
257
+ @reaped_pid = pid
258
+ raise Interrupted if @interrupted
259
+ wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
260
+ tail = read_tail(errf)
261
+ payload = File.exist?(out) ? parse(out) : nil
262
+ corrupt = payload.nil? && File.exist?(out)
263
+
264
+ if timed_out
265
+ raise RunError.new(:timeout, "boot did not finish within #{timeout}s (killed)", stderr_tail: tail, partial: payload)
266
+ end
267
+ unless status.success?
268
+ raise RunError.new(payload ? :boot_error : :exit_status, "child exited with status #{status.exitstatus.inspect}#{status.signaled? ? " (signal #{status.termsig})" : ""}",
269
+ stderr_tail: tail, partial: payload, exit_status: status.exitstatus)
270
+ end
271
+ if probe
272
+ if payload.nil?
273
+ msg = if corrupt
274
+ "probe output was not valid JSON (the file was truncated or overwritten, e.g. the app wrote to HAYAOKI_PROBE_OUT or the process was killed while writing)"
275
+ else
276
+ "probe wrote no output (the app may have called exit!/exec, or replaced the process)"
277
+ end
278
+ raise RunError.new(:no_output, msg, stderr_tail: tail, exit_status: 0)
279
+ end
280
+ if payload["status"] != "ok"
281
+ raise RunError.new(:boot_error, "boot failed (probe status=#{payload["status"]})", stderr_tail: tail, partial: payload, exit_status: 0)
282
+ end
283
+ end
284
+ boot_ms = nil
285
+ unless probe
286
+ end_at = (Float(File.read(mark)) rescue nil)
287
+ raise RunError.new(:no_output, "baseline run did not reach the end of entry (marker missing)", stderr_tail: tail, exit_status: 0) unless end_at
288
+ boot_ms = (end_at - spawn_real) * 1000.0
289
+ end
290
+ { payload: payload, wall: wall, boot_ms: boot_ms }
291
+ end
292
+
293
+ def parse(path)
294
+ JSON.parse(File.read(path))
295
+ rescue JSON::ParserError
296
+ nil
297
+ end
298
+
299
+ def read_tail(path)
300
+ File.open(path, "rb") do |f|
301
+ size = f.size
302
+ f.seek([size - STDERR_TAIL_BYTES, 0].max)
303
+ f.read.to_s.scrub
304
+ end
305
+ rescue SystemCallError
306
+ ""
307
+ end
308
+
309
+ # [Process::Status, timed_out]
310
+ def wait(pid)
311
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
312
+ loop do
313
+ r = Process.wait2(pid, Process::WNOHANG)
314
+ return [r[1], false] if r
315
+ break if @interrupted || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
316
+ sleep 0.005
317
+ end
318
+ # SIGTERM で probe に部分結果を書かせ、だめなら KILL
319
+ signal_group("TERM", pid)
320
+ grace = Process.clock_gettime(Process::CLOCK_MONOTONIC) + KILL_GRACE
321
+ until (r = Process.wait2(pid, Process::WNOHANG))
322
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > grace
323
+ signal_group("KILL", pid)
324
+ return [Process.wait2(pid)[1], true]
325
+ end
326
+ sleep 0.01
327
+ end
328
+ [r[1], true]
329
+ end
330
+
331
+ def signal_group(sig, pid)
332
+ Process.kill(sig, -pid)
333
+ rescue SystemCallError
334
+ begin
335
+ Process.kill(sig, pid)
336
+ rescue SystemCallError
337
+ nil
338
+ end
339
+ end
340
+
341
+ # 内部用: シグナルで中断された (子の後始末は済んでいる)。
342
+ class Interrupted < StandardError; end
343
+
344
+ def check_app!
345
+ return if File.directory?(app_dir)
346
+ raise RunError.new(:app_dir, "APP_DIR not found: #{app_dir}")
347
+ end
348
+
349
+ MIN_RUBY = Gem::Version.new("3.2")
350
+
351
+ # 子プロセスの ruby (既定は PATH 上の ruby。rbenv などはアプリのディレクトリで版を選ぶ) が probe を読めるか。
352
+ # 古い ruby だと probe の構文エラーが大量に出るだけで原因が分からないので、先に版を確かめる。
353
+ def check_ruby!
354
+ out, st = with_clean_env do |base|
355
+ IO.popen(base, [ruby, "-e", "print RUBY_VERSION"], chdir: app_dir, err: File::NULL, &:read).then { [_1, $?] }
356
+ end
357
+ v = out.to_s.strip
358
+ raise RunError.new(:ruby, "could not run `#{ruby} -e 'print RUBY_VERSION'` in #{app_dir}") unless st&.success? && v.match?(/\A\d+\.\d+/)
359
+ return if Gem::Version.new(v) >= MIN_RUBY
360
+ raise RunError.new(:ruby, "`#{ruby}` in #{app_dir} is Ruby #{v}, but the probe needs Ruby #{MIN_RUBY} or later. " \
361
+ "Pass the app's ruby with --ruby PATH, or fix PATH / .ruby-version.")
362
+ rescue SystemCallError => e
363
+ raise RunError.new(:ruby, "ruby executable not found: #{ruby} (#{e.class}). Pass it with --ruby PATH.")
364
+ end
365
+
366
+ # Gemfile があるアプリは Gemfile.lock が必須 (子に BUNDLE_FROZEN=true を渡すので、lock の生成・更新をさせない)。
367
+ def gemfile_present?
368
+ return @gemfile_present unless @gemfile_present.nil?
369
+ gemfile = with_clean_env { |base| base["BUNDLE_GEMFILE"].to_s.empty? ? File.join(app_dir, "Gemfile") : File.expand_path(base["BUNDLE_GEMFILE"], app_dir) }
370
+ @gemfile_present = File.file?(gemfile)
371
+ if @gemfile_present
372
+ lock = "#{gemfile}.lock"
373
+ unless File.file?(lock)
374
+ raise RunError.new(:no_lockfile, "#{lock} not found. hayaoki does not modify your app, so run `bundle lock` in #{app_dir} first (or `bundle install`).")
375
+ end
376
+ end
377
+ @gemfile_present
378
+ end
379
+
380
+ def install_traps
381
+ %w[INT TERM].to_h do |name|
382
+ signo = Signal.list.fetch(name)
383
+ old = Signal.trap(name) do
384
+ @interrupted ||= signo
385
+ signal_group("TERM", @child) if @child
386
+ end
387
+ [name, old]
388
+ end
389
+ end
390
+
391
+ def restore_traps(olds)
392
+ olds.each { |name, old| Signal.trap(name, old || "DEFAULT") }
393
+ end
394
+
395
+ # 元のハンドラに、受け取ったシグナルを渡す。DEFAULT ならそのシグナルで自分が終了する (終了ステータスが保たれる)。
396
+ def redeliver(signo, old)
397
+ name = Signal.signame(signo)
398
+ case old
399
+ when Proc then old.call(signo)
400
+ when "DEFAULT", "SYSTEM_DEFAULT", nil, ""
401
+ Signal.trap(name, "DEFAULT")
402
+ Process.kill(name, Process.pid)
403
+ sleep 1 # シグナルが配送されるまで
404
+ end
405
+ end
406
+
407
+ # 子のプロセスグループが残っていれば TERM → 猶予 → KILL で片付け、回収する。
408
+ def reap_group(pid)
409
+ return if @reaped_pid == pid
410
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + KILL_GRACE
411
+ signal_group("TERM", pid)
412
+ loop do
413
+ begin
414
+ r = Process.wait2(pid, Process::WNOHANG)
415
+ rescue Errno::ECHILD
416
+ break
417
+ end
418
+ break if r
419
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
420
+ signal_group("KILL", pid)
421
+ Process.wait2(pid) rescue nil
422
+ break
423
+ end
424
+ sleep 0.01
425
+ end
426
+ @reaped_pid = pid
427
+ end
428
+
429
+ # アプリ側の bundle exec が注入したものだけを除く。利用者が設定した BUNDLE_* (BUNDLE_WITHOUT / GEMFILE / PATH / DEPLOYMENT など) は残す。
430
+ # ブロックには環境変数の Hash を渡す (spawn はこの中で行う)。
431
+ def with_clean_env(&block)
432
+ if defined?(::Bundler) && ::Bundler.respond_to?(:with_original_env)
433
+ ::Bundler.with_original_env { block.call(strip_injected(ENV.to_h)) }
434
+ else
435
+ block.call(strip_injected(ENV.to_h))
436
+ end
437
+ end
438
+
439
+ def bundler_lib_dirs
440
+ dirs = []
441
+ if defined?(::Bundler) && ::Bundler.respond_to?(:with_original_env)
442
+ loc = ::Bundler.method(:with_original_env).source_location
443
+ dirs << File.dirname(loc[0]) if loc
444
+ end
445
+ dirs
446
+ end
447
+
448
+ def strip_injected(h)
449
+ h = h.reject { |k, _| %w[BUNDLE_BIN_PATH BUNDLER_SETUP BUNDLER_VERSION BUNDLER_ORIG_RUBYOPT BUNDLER_ORIG_RUBYLIB].include?(k) }
450
+ if (rl = h["RUBYLIB"])
451
+ dirs = bundler_lib_dirs
452
+ kept = rl.split(File::PATH_SEPARATOR).reject { |d| dirs.include?(d) || d =~ %r{/bundler[^/]*/lib/?\z} }
453
+ kept.empty? ? h.delete("RUBYLIB") : h["RUBYLIB"] = kept.join(File::PATH_SEPARATOR)
454
+ end
455
+ if (ro = h["RUBYOPT"])
456
+ toks = ro.split(/\s+/).reject(&:empty?)
457
+ kept = []
458
+ until toks.empty?
459
+ t = toks.shift
460
+ if t == "-r" && toks.first == "bundler/setup"
461
+ toks.shift
462
+ elsif t != "-rbundler/setup"
463
+ kept << t
464
+ end
465
+ end
466
+ kept.empty? ? h.delete("RUBYOPT") : h["RUBYOPT"] = kept.join(" ")
467
+ end
468
+ h
469
+ end
470
+ end
471
+ end
472
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../hayaoki"
5
+ require_relative "version"
6
+
7
+ module Hayaoki
8
+ class CLI
9
+ FORMATS = %w[text json speedscope].freeze
10
+ USAGE = "Usage: hayaoki boot-report [options] [APP_DIR]"
11
+
12
+ def self.start(argv = ARGV, stdout: $stdout, stderr: $stderr)
13
+ new(stdout: stdout, stderr: stderr).run(argv)
14
+ end
15
+
16
+ def initialize(stdout: $stdout, stderr: $stderr)
17
+ @out = stdout
18
+ @err = stderr
19
+ end
20
+
21
+ # 終了コードを返す。
22
+ def run(argv)
23
+ argv = argv.dup
24
+ cmd = argv.shift
25
+ case cmd
26
+ when "boot-report" then boot_report(argv)
27
+ when "--version", "-v", "version" then @out.puts("hayaoki #{Hayaoki::VERSION}"); 0
28
+ when nil, "-h", "--help", "help" then @out.puts(top_help); cmd.nil? ? 1 : 0
29
+ else
30
+ @err.puts "hayaoki: unknown command '#{cmd}'\n#{top_help}"
31
+ 1
32
+ end
33
+ end
34
+
35
+ def default_options
36
+ { runs: 5, env: "production", bootsnap: :warm, ruby: "ruby", entry: "config/environment", timeout: 120,
37
+ format: "text", output: nil, top: 10 }
38
+ end
39
+
40
+ # オプション解析だけ行う。[options, app_dir] を返す (--help / --version は :exit を返す)
41
+ def parse(argv)
42
+ opts = default_options
43
+ parser = OptionParser.new do |o|
44
+ o.banner = USAGE
45
+ o.on("--runs N", Integer, "number of measured runs (default 5)") { |v| opts[:runs] = v }
46
+ o.on("--env ENV", "RAILS_ENV (default production)") { |v| opts[:env] = v }
47
+ o.on("--bootsnap MODE", %w[warm cold both], "warm|cold|both (default warm)") { |v| opts[:bootsnap] = v.to_sym }
48
+ o.on("--ruby PATH", "ruby executable (default ruby)") { |v| opts[:ruby] = v }
49
+ o.on("--entry PATH", "boot entry file (default config/environment)") { |v| opts[:entry] = v }
50
+ o.on("--timeout SEC", Float, "per-run timeout in seconds (default 120)") { |v| opts[:timeout] = v }
51
+ o.on("--format FORMAT", FORMATS, "text|json|speedscope (default text)") { |v| opts[:format] = v }
52
+ o.on("--output FILE", "write to FILE (default stdout)") { |v| opts[:output] = v }
53
+ o.on("--top N", Integer, "rows per ranking (default 10)") { |v| opts[:top] = v }
54
+ o.on("--no-suggestions", "do not print improvement suggestions") { opts[:suggestions] = false }
55
+ o.on("--initializer-threshold MS", Float, "app initializer threshold for suggestions (default 50)") { |v| opts[:initializer_threshold] = v }
56
+ o.on("--gem-threshold MS", Float, "gem self time threshold for suggestions (default 30)") { |v| opts[:gem_threshold] = v }
57
+ o.on("--redact", "replace home dir with ~, app root with <app>, gem paths with <gem:name> (all formats)") { opts[:redact] = true }
58
+ o.on("--version", "show version") { opts[:action] = :version }
59
+ o.on("-h", "--help", "show this help") { opts[:action] = :help }
60
+ end
61
+ rest = parser.parse(argv)
62
+ raise OptionParser::InvalidArgument, "--runs must be >= 1" if opts[:runs] < 1
63
+ raise OptionParser::InvalidArgument, "--top must be >= 1" if opts[:top] < 1
64
+ raise OptionParser::NeedlessArgument, rest[1..].join(" ") if rest.size > 1
65
+ @parser = parser
66
+ [opts, rest.first || Dir.pwd]
67
+ end
68
+
69
+ private
70
+
71
+ def top_help = "#{USAGE}\n hayaoki --version\n\nRun `hayaoki boot-report --help` for options."
72
+
73
+ def boot_report(argv)
74
+ opts, app_dir = begin
75
+ parse(argv)
76
+ rescue OptionParser::ParseError => e
77
+ @err.puts "hayaoki: #{e.message}\n#{USAGE}"
78
+ return 1
79
+ end
80
+ case opts[:action]
81
+ when :help then @out.puts(@parser.help); return 0
82
+ when :version then @out.puts("hayaoki #{Hayaoki::VERSION}"); return 0
83
+ end
84
+ unless File.directory?(app_dir)
85
+ @err.puts "hayaoki: APP_DIR not found: #{app_dir}"
86
+ return 1
87
+ end
88
+
89
+ require_relative "boot_report/runner"
90
+ require_relative "boot_report/aggregator"
91
+ runner = BootReport::Runner.new(app_dir: app_dir, runs: opts[:runs], env: opts[:env], bootsnap: opts[:bootsnap],
92
+ ruby: opts[:ruby], entry: opts[:entry], timeout: opts[:timeout], on_progress: progress_printer)
93
+ result = runner.run
94
+ text = render(result, opts, app_dir)
95
+ if opts[:output]
96
+ begin
97
+ File.write(opts[:output], text)
98
+ rescue SystemCallError => e
99
+ @err.puts "hayaoki: cannot write #{opts[:output]}: #{e.message}"
100
+ return 1
101
+ end
102
+ @err.puts "wrote #{opts[:output]}"
103
+ else
104
+ @out.write(text)
105
+ end
106
+ 0
107
+ rescue BootReport::RunError => e
108
+ report_error(e, opts)
109
+ 1
110
+ rescue Errno::ENOENT => e
111
+ @err.puts "hayaoki: could not start the child process (--ruby #{opts[:ruby].inspect}, APP_DIR #{app_dir.inspect}): #{e.message}"
112
+ @err.puts "Check that the --ruby executable exists and is on PATH."
113
+ 1
114
+ rescue SystemCallError => e
115
+ @err.puts "hayaoki: #{e.message}"
116
+ 1
117
+ end
118
+
119
+ def render(result, opts, app_dir = Dir.pwd)
120
+ case opts[:format]
121
+ when "speedscope"
122
+ require_relative "boot_report/formatters/speedscope"
123
+ BootReport::Formatters::Speedscope.new.format(result)
124
+ else
125
+ reports = BootReport::Aggregator.new.aggregate_result(result)
126
+ if opts[:format] == "json"
127
+ require_relative "boot_report/formatters/json"
128
+ BootReport::Formatters::Json.new.with_suggestions(*suggest(result, reports, opts, app_dir)).format(reports)
129
+ else
130
+ require_relative "boot_report/formatters/text"
131
+ redact = opts[:redact] && begin
132
+ require_relative "boot_report/formatters/redact"
133
+ BootReport::Formatters::Redact.new(app_dir: app_dir).method(:apply)
134
+ end
135
+ BootReport::Formatters::Text.new(top: opts[:top], redact: redact).with_suggestions(*suggest(result, reports, opts, app_dir)).format(reports)
136
+ end
137
+ end
138
+ end
139
+
140
+ # [Suggestion の配列, 警告の配列]。--no-suggestions なら [nil, []] (formatter は節を出さない)。
141
+ def suggest(result, reports, opts, app_dir)
142
+ return [nil, []] if opts[:suggestions] == false
143
+ require_relative "boot_report/advisor"
144
+ kw = {}
145
+ kw[:initializer_threshold_ms] = opts[:initializer_threshold] if opts[:initializer_threshold]
146
+ kw[:gem_threshold_ms] = opts[:gem_threshold] if opts[:gem_threshold]
147
+ advisor = BootReport::Advisor.new(reports.values.first, app_dir: File.expand_path(app_dir), result: result, **kw)
148
+ [advisor.suggestions, advisor.warnings]
149
+ end
150
+
151
+ # Runner の on_progress から stderr に 1 行ずつ出す。
152
+ def progress_printer
153
+ lambda do |ev|
154
+ case ev["phase"]
155
+ when :warmup then @err.puts "warmup (#{ev["bootsnap"]})..."
156
+ when :probe then @err.puts "run #{ev["index"]}/#{ev["total"]} (#{ev["bootsnap"]})..."
157
+ end
158
+ end
159
+ end
160
+
161
+ SECRET_HINT = /credentials|secret_key_base|master[ _.]?key|RAILS_MASTER_KEY/i
162
+
163
+ def report_error(e, opts = {})
164
+ case e.reason
165
+ when :app_dir, :no_lockfile, :interrupted, :ruby # 起動前の確認で止まったもの
166
+ @err.puts "hayaoki: #{e.message.lines.first.to_s.strip}"
167
+ return
168
+ end
169
+ @err.puts "hayaoki: boot failed (#{e.reason})"
170
+ @err.puts e.message.lines.first.to_s.strip
171
+ if e.probe_error
172
+ @err.puts "error: #{e.probe_error["class"]}: #{e.probe_error["message"]}"
173
+ end
174
+ if e.open_span
175
+ @err.puts "last open span: kind=#{e.open_span["kind"]} #{e.open_span["name"]}"
176
+ end
177
+ unless e.stderr_tail.strip.empty?
178
+ @err.puts "--- stderr (tail) ---"
179
+ @err.puts e.stderr_tail.lines.last(20).join
180
+ end
181
+ production_hint(e, opts)
182
+ end
183
+
184
+ # production で credentials / secret_key_base / master key 周りで落ちたときの案内。
185
+ def production_hint(e, opts)
186
+ return unless (opts[:env] || "production") == "production"
187
+ text = "#{e.stderr_tail}\n#{e.message}\n#{e.probe_error && e.probe_error["message"]}"
188
+ return unless text.match?(SECRET_HINT)
189
+ @err.puts "hint: booting with RAILS_ENV=production failed on credentials / secret_key_base / master key."
190
+ @err.puts " - set SECRET_KEY_BASE_DUMMY=1 (Rails 7.1+) or RAILS_MASTER_KEY in the environment, then re-run"
191
+ @err.puts " - or measure another environment with --env (e.g. --env staging)"
192
+ @err.puts " - note: the measurement boots your app with the production configuration, which may connect to external services"
193
+ end
194
+ end
195
+ end
196
+
197
+ # --redact: render の結果 (text / json / speedscope) のパスを伏せる。既存の render を書き換えずに差し込む。
198
+ module Hayaoki
199
+ module CLIRedact
200
+ private
201
+
202
+ def render(result, opts, app_dir = Dir.pwd)
203
+ text = super
204
+ return text unless opts[:redact]
205
+ require_relative "boot_report/formatters/redact"
206
+ BootReport::Formatters::Redact.new(app_dir: app_dir).apply(text)
207
+ end
208
+ end
209
+ CLI.prepend(CLIRedact)
210
+ end