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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +118 -0
- data/exe/hayaoki +7 -0
- data/lib/hayaoki/boot_report/advisor.rb +113 -0
- data/lib/hayaoki/boot_report/aggregator.rb +367 -0
- data/lib/hayaoki/boot_report/formatters/json.rb +52 -0
- data/lib/hayaoki/boot_report/formatters/redact.rb +32 -0
- data/lib/hayaoki/boot_report/formatters/speedscope.rb +118 -0
- data/lib/hayaoki/boot_report/formatters/text.rb +275 -0
- data/lib/hayaoki/boot_report/gemfile.rb +146 -0
- data/lib/hayaoki/boot_report/report.rb +22 -0
- data/lib/hayaoki/boot_report/rules/base.rb +113 -0
- data/lib/hayaoki/boot_report/rules/bootsnap_ineffective.rb +88 -0
- data/lib/hayaoki/boot_report/rules/controller_action_methods.rb +33 -0
- data/lib/hayaoki/boot_report/rules/dev_gems_in_production.rb +80 -0
- data/lib/hayaoki/boot_report/rules/devise_reload_routes.rb +43 -0
- data/lib/hayaoki/boot_report/rules/eager_load_tradeoff.rb +32 -0
- data/lib/hayaoki/boot_report/rules/heavy_app_initializer.rb +59 -0
- data/lib/hayaoki/boot_report/rules/tzinfo_eager_load.rb +33 -0
- data/lib/hayaoki/boot_report/rules/unused_heavy_gem.rb +149 -0
- data/lib/hayaoki/boot_report/runner.rb +472 -0
- data/lib/hayaoki/cli.rb +210 -0
- data/lib/hayaoki/probe.rb +513 -0
- data/lib/hayaoki/version.rb +5 -0
- data/lib/hayaoki.rb +28 -0
- metadata +70 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../gemfile"
|
|
4
|
+
|
|
5
|
+
module Hayaoki
|
|
6
|
+
module BootReport
|
|
7
|
+
# Report / RunResult は Hash のキーが String でも Symbol でも動くように、値の取り出しはこの補助を通す。
|
|
8
|
+
module Access
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# obj が Hash なら String / Symbol 両方のキーを試す。それ以外は同名のメソッドを呼ぶ。
|
|
12
|
+
def get(obj, *keys)
|
|
13
|
+
keys.reduce(obj) do |cur, key|
|
|
14
|
+
return nil if cur.nil?
|
|
15
|
+
if cur.is_a?(Hash)
|
|
16
|
+
if cur.key?(key.to_s) then cur[key.to_s]
|
|
17
|
+
elsif cur.key?(key.to_sym) then cur[key.to_sym]
|
|
18
|
+
end
|
|
19
|
+
elsif cur.respond_to?(key)
|
|
20
|
+
cur.public_send(key)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# { "median" => x, ... } でも数値でもよい。
|
|
26
|
+
def med(stat)
|
|
27
|
+
v = stat.is_a?(Hash) ? get(stat, :median) : stat
|
|
28
|
+
v.is_a?(Numeric) ? v.to_f : nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def round(x) = x.nil? ? nil : x.round(1)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# ルールが受け取る入力一式。
|
|
35
|
+
Context = Struct.new(:report, :app_dir, :result, :initializer_threshold_ms, :gem_threshold_ms, keyword_init: true) do
|
|
36
|
+
def gems = Array(Access.get(report, :gems))
|
|
37
|
+
def meta = Access.get(report, :meta) || {}
|
|
38
|
+
def env = (Access.get(meta, :env) || Access.get(result, :meta, :env)).to_s
|
|
39
|
+
|
|
40
|
+
def warnings
|
|
41
|
+
(Array(Access.get(report, :warnings)) + Array(Access.get(result, :meta, :warnings))).map(&:to_s)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def phase_ms(name) = Access.med(Access.get(report, :phases, name))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
module Rules
|
|
48
|
+
# 各ルールの基底。call は Suggestion の配列を返す。
|
|
49
|
+
class Base
|
|
50
|
+
include Hayaoki::BootReport::Access
|
|
51
|
+
|
|
52
|
+
def self.rule_id = name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
|
|
53
|
+
|
|
54
|
+
def initialize(ctx)
|
|
55
|
+
@ctx = ctx
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def call = raise(NotImplementedError)
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
attr_reader :ctx
|
|
63
|
+
|
|
64
|
+
def suggestion(confidence, title:, evidence:, action:, savings: nil)
|
|
65
|
+
Suggestion.new(rule: self.class.rule_id, confidence: confidence, title: title, evidence: evidence,
|
|
66
|
+
estimated_savings_ms: savings && savings.round(1), action: action)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Gemfile のパス。BUNDLE_GEMFILE (絶対 / app からの相対) > <app>/Gemfile > <app>/gems.rb。無ければ nil
|
|
70
|
+
def gemfile_path
|
|
71
|
+
return @gemfile_path if defined?(@gemfile_path)
|
|
72
|
+
app = ctx.app_dir.to_s
|
|
73
|
+
env = ENV["BUNDLE_GEMFILE"].to_s
|
|
74
|
+
candidates = []
|
|
75
|
+
candidates << File.expand_path(env, app) unless env.empty?
|
|
76
|
+
candidates << File.join(app, "Gemfile") << File.join(app, "gems.rb")
|
|
77
|
+
@gemfile_path = candidates.find { File.file?(_1) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Gemfile の中身 (無ければ nil)
|
|
81
|
+
def gemfile
|
|
82
|
+
path = gemfile_path
|
|
83
|
+
path && File.read(path)
|
|
84
|
+
rescue SystemCallError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Gemfile を解析した { gem 名 => Gemfile::Entry } (eval_gemfile 先を含む)。Gemfile が無ければ nil。
|
|
89
|
+
def gemfile_entries
|
|
90
|
+
return @gemfile_entries if defined?(@gemfile_entries)
|
|
91
|
+
src = gemfile
|
|
92
|
+
@gemfile_entries = src && Hayaoki::BootReport::Gemfile.parse(src, dir: File.dirname(gemfile_path))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# 子プロセスの payload が記録した gem の full_gem_path ({ name => path })。計測対象 app の bundle を指す。
|
|
96
|
+
def gem_paths = @gem_paths ||= spec_field(1)
|
|
97
|
+
|
|
98
|
+
# 子プロセスが記録した gem のバージョン (アプリの bundle のもの)
|
|
99
|
+
def gem_versions = @gem_versions ||= spec_field(0)
|
|
100
|
+
|
|
101
|
+
def spec_field(i)
|
|
102
|
+
pl = get(ctx.result, :payloads)
|
|
103
|
+
lists = pl.is_a?(Hash) ? pl.values : []
|
|
104
|
+
lists.flatten.each_with_object({}) do |p, h|
|
|
105
|
+
specs = get(p, :specs)
|
|
106
|
+
next unless specs.is_a?(Hash)
|
|
107
|
+
specs.each { |name, info| h[name.to_s] ||= info[i] if info.is_a?(Array) && info[i] }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Hayaoki
|
|
6
|
+
module BootReport
|
|
7
|
+
module Rules
|
|
8
|
+
# bootsnap が無い / 有効になっていない可能性。
|
|
9
|
+
class BootsnapIneffective < Base
|
|
10
|
+
MIN_DIFF_RATIO = 0.05 # cold と warm の差がこれ未満なら有効になっていないとみなす
|
|
11
|
+
CACHE_WARNING = /BOOTSNAP_CACHE_DIR was not used/
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
loaded = ctx.gems.any? { |g| get(g, :name).to_s == "bootsnap" }
|
|
15
|
+
return [missing(loaded_in_gemfile?)] unless loaded
|
|
16
|
+
out = []
|
|
17
|
+
out << small_diff
|
|
18
|
+
out << cache_unused
|
|
19
|
+
out.compact
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def loaded_in_gemfile? = !!(gemfile.to_s =~ /^\s*gem\s+["']bootsnap["']/)
|
|
25
|
+
|
|
26
|
+
def missing(in_gemfile)
|
|
27
|
+
if in_gemfile
|
|
28
|
+
suggestion(:high,
|
|
29
|
+
title: "bootsnap is in the Gemfile but does not seem to be loaded at boot",
|
|
30
|
+
evidence: [{ "bootsnap_in_gemfile" => true, "loaded_at_boot" => false }],
|
|
31
|
+
action: "Check that config/boot.rb contains `require \"bootsnap/setup\"` (after bundler/setup), " \
|
|
32
|
+
"or remove `require: false` handling that skips it.")
|
|
33
|
+
else
|
|
34
|
+
suggestion(:high,
|
|
35
|
+
title: "bootsnap was not loaded; adding it can speed up boot",
|
|
36
|
+
evidence: [{ "bootsnap_loaded" => false }],
|
|
37
|
+
action: "Add to the Gemfile: gem \"bootsnap\", require: false\n" \
|
|
38
|
+
"and to config/boot.rb (after `require \"bundler/setup\"`): require \"bootsnap/setup\"")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def small_diff
|
|
43
|
+
warm = boot_ms(:warm)
|
|
44
|
+
cold = boot_ms(:cold)
|
|
45
|
+
return nil unless warm && cold && cold.positive?
|
|
46
|
+
diff = cold - warm
|
|
47
|
+
return nil unless diff / cold < MIN_DIFF_RATIO
|
|
48
|
+
suggestion(:high,
|
|
49
|
+
title: "bootsnap's warm cache shows little benefit over a cold cache (#{(diff / cold * 100).round(1)}%)",
|
|
50
|
+
evidence: [{ "cold_ms" => round(cold), "warm_ms" => round(warm), "diff_ms" => round(diff),
|
|
51
|
+
"threshold_pct" => MIN_DIFF_RATIO * 100 }],
|
|
52
|
+
action: "Bootsnap may not be configured as expected. Check that Bootsnap.setup (or bootsnap/setup) " \
|
|
53
|
+
"runs before the app loads, that cache_dir is writable and persisted between boots, " \
|
|
54
|
+
"and that load_path_cache / compile_cache are enabled.")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def cache_unused
|
|
58
|
+
hit = ctx.warnings.grep(CACHE_WARNING)
|
|
59
|
+
return nil if hit.empty?
|
|
60
|
+
suggestion(:high,
|
|
61
|
+
title: "bootsnap's cache directory stayed empty during measurement",
|
|
62
|
+
evidence: hit.map { |w| { "warning" => w } },
|
|
63
|
+
action: "The app may call Bootsnap.setup with its own cache_dir, or bootsnap is not active. " \
|
|
64
|
+
"Make sure the cache is actually written (default tmp/cache/bootsnap) and kept between deploys/boots.")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# 条件ごとの boot 時間 (ms, 中央値)。payload の boot_end_us / exit_us から求める。
|
|
68
|
+
def boot_ms(cond)
|
|
69
|
+
list = payloads(cond)
|
|
70
|
+
vals = list.filter_map do |p|
|
|
71
|
+
us = get(p, :boot_end_us) || get(p, :exit_us)
|
|
72
|
+
us && us / 1000.0
|
|
73
|
+
end
|
|
74
|
+
return nil if vals.empty?
|
|
75
|
+
s = vals.sort
|
|
76
|
+
n = s.size
|
|
77
|
+
n.odd? ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2.0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def payloads(cond)
|
|
81
|
+
pl = get(ctx.result, :payloads)
|
|
82
|
+
return [] unless pl.is_a?(Hash)
|
|
83
|
+
Array(pl[cond] || pl[cond.to_s]).select { |p| get(p, :status).to_s == "ok" }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Hayaoki
|
|
6
|
+
module BootReport
|
|
7
|
+
module Rules
|
|
8
|
+
# AbstractController.eager_load! は全コントローラの action_methods を起動時に計算する。
|
|
9
|
+
# 遅延にすると、各コントローラの最初のリクエストで自分の分だけ計算する
|
|
10
|
+
# (Mastodon: 起動 -201ms、336 コントローラ合計 217ms = 1 個あたり約 0.65ms)。
|
|
11
|
+
class ControllerActionMethods < Base
|
|
12
|
+
NAME = "AbstractController.eager_load!"
|
|
13
|
+
MIN_MS = 100.0
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
e = Array(get(ctx.report, :eager_namespaces)).find { |x| get(x, :name) == NAME } or return []
|
|
17
|
+
ms = med(get(e, :inclusive_ms))
|
|
18
|
+
return [] unless ms && ms >= MIN_MS
|
|
19
|
+
[suggestion(:low,
|
|
20
|
+
title: "#{NAME} computes action_methods for every controller at boot (#{round(ms)} ms)",
|
|
21
|
+
evidence: [{ "namespace" => NAME, "inclusive_ms" => round(ms), "threshold_ms" => MIN_MS }],
|
|
22
|
+
savings: ms,
|
|
23
|
+
action: "To compute each controller's action_methods on its first request instead, add\n" \
|
|
24
|
+
" # config/initializers/lazy_action_methods.rb\n" \
|
|
25
|
+
" Rails.application.config.eager_load_namespaces.delete(AbstractController)\n" \
|
|
26
|
+
"The work moves to requests, but each request pays only for its own controller " \
|
|
27
|
+
"(the total divided by the number of controllers). Useful for serverless and short-lived " \
|
|
28
|
+
"processes; for long-running servers, keeping it is usually better.")]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Hayaoki
|
|
6
|
+
module BootReport
|
|
7
|
+
module Rules
|
|
8
|
+
# 開発・テスト用の既知の gem が、計測した環境 (既定は production) で読み込まれている。
|
|
9
|
+
class DevGemsInProduction < Base
|
|
10
|
+
# 完全一致
|
|
11
|
+
DEV_GEMS = %w[
|
|
12
|
+
pry byebug debug rubocop web-console listen spring rspec factory_bot factory_bot_rails faker
|
|
13
|
+
better_errors binding_of_caller bullet annotate annotate_models brakeman guard minitest-reporters
|
|
14
|
+
capybara selenium-webdriver webdrivers rails-controller-testing shoulda-matchers simplecov
|
|
15
|
+
database_cleaner database_cleaner-active_record rack-mini-profiler letter_opener derailed_benchmarks
|
|
16
|
+
rubocop-ast
|
|
17
|
+
].freeze
|
|
18
|
+
# 前方一致 (pry-rails, rubocop-rails, rspec-core, spring-watcher-listen ...)
|
|
19
|
+
DEV_GEM_PREFIXES = %w[pry- rubocop- rspec- spring- factory_bot_ capybara- guard- web-console- debug_].freeze
|
|
20
|
+
# 開発・テスト以外の環境ではこの rule は対象外
|
|
21
|
+
NON_PRODUCTION_ENVS = %w[development test].freeze
|
|
22
|
+
|
|
23
|
+
def self.dev_gem?(name)
|
|
24
|
+
DEV_GEMS.include?(name) || DEV_GEM_PREFIXES.any? { |p| name.start_with?(p) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# テスト用データ生成系は本番で読み込まれると実害 (メモリ・起動時間) が大きいので medium に引き上げる
|
|
28
|
+
MEDIUM_GEMS = %w[faker factory_bot factory_bot_rails].freeze
|
|
29
|
+
DEV_GROUPS = %w[development test].freeze
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
return [] if NON_PRODUCTION_ENVS.include?(ctx.env)
|
|
33
|
+
entries = gemfile_entries
|
|
34
|
+
tiers = Hash.new { |h, k| h[k] = [] }
|
|
35
|
+
ctx.gems.each do |g|
|
|
36
|
+
name = get(g, :name).to_s
|
|
37
|
+
next unless get(g, :category).to_s == "gem" && self.class.dev_gem?(name)
|
|
38
|
+
entry = entries && entries[name]
|
|
39
|
+
# development/test の group だけに属する gem は本番では読み込まれない
|
|
40
|
+
next if entry && !entry.groups.empty? && entry.groups.all? { DEV_GROUPS.include?(_1) }
|
|
41
|
+
ms = med(get(g, :self_ms))
|
|
42
|
+
row = { "name" => name, "self_ms" => round(ms || 0.0),
|
|
43
|
+
"gemfile" => entry ? (entry.groups.empty? ? "direct, no group" : "direct, groups: #{entry.groups.join(",")}") : (entries ? "transitive" : "unknown (no Gemfile)") }
|
|
44
|
+
tiers[tier(name, entry)] << row
|
|
45
|
+
end
|
|
46
|
+
%i[high medium low].filter_map { |t| tiers.key?(t) && build(t, tiers[t]) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Gemfile に直接書かれ group 無し (= production で読み込まれる) なら high、
|
|
52
|
+
# 間接依存や Gemfile 不明は確認できないので low。
|
|
53
|
+
def tier(name, entry)
|
|
54
|
+
return :low unless entry
|
|
55
|
+
MEDIUM_GEMS.include?(name) ? :medium : :high
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build(tier, found)
|
|
59
|
+
total = found.sum { |f| f["self_ms"] }
|
|
60
|
+
names = found.map { |f| f["name"] }
|
|
61
|
+
env = ctx.env.empty? ? "the measured environment" : ctx.env
|
|
62
|
+
title = if tier == :low
|
|
63
|
+
"Development/test gems may be loaded in #{env} via other dependencies (#{names.first(3).join(", ")}#{names.size > 3 ? ", ..." : ""})"
|
|
64
|
+
else
|
|
65
|
+
"Development/test gems are loaded in #{env} (#{names.first(3).join(", ")}#{names.size > 3 ? ", ..." : ""})"
|
|
66
|
+
end
|
|
67
|
+
action = if tier == :low
|
|
68
|
+
"These were not found as direct Gemfile entries (transitive dependency or Gemfile not found). " \
|
|
69
|
+
"Check which gem depends on them (`bundle viz` / `bundle why`) before acting."
|
|
70
|
+
else
|
|
71
|
+
"Move them to the development/test groups in the Gemfile, e.g.:\n" \
|
|
72
|
+
" group :development, :test do\n #{names.first(3).map { |n| %(gem "#{n}") }.join("\n ")}\n end\n" \
|
|
73
|
+
"Check first that nothing in production code depends on them."
|
|
74
|
+
end
|
|
75
|
+
suggestion(tier, title: title, evidence: found.sort_by { |f| -f["self_ms"] }, savings: total, action: action)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Hayaoki
|
|
6
|
+
module BootReport
|
|
7
|
+
module Rules
|
|
8
|
+
# Devise は before_eager_load で app.reload_routes! を呼ぶ (Devise.reload_routes = true が初期値)。
|
|
9
|
+
# production では set_routes_reloader_hook でもルーティングを読むので、起動中に 2 回読むことになる。
|
|
10
|
+
# Mastodon で 194ms 短くなった (bench/boot_variants.rb)。Devise 5.0 からは Devise.mappings を参照したときに
|
|
11
|
+
# 必要ならルーティングを読むので、false にしても mappings は使える。4.x にはこの処理が無い。
|
|
12
|
+
class DeviseReloadRoutes < Base
|
|
13
|
+
SOURCE = "/lib/devise/rails.rb"
|
|
14
|
+
MIN_MS = 50.0
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
hook = Array(get(ctx.report, :on_loads)).find do |h|
|
|
18
|
+
get(h, :name).to_s == "before_eager_load" && get(h, :source).to_s.include?(SOURCE)
|
|
19
|
+
end or return []
|
|
20
|
+
ms = med(get(hook, :inclusive_ms))
|
|
21
|
+
return [] unless ms && ms >= MIN_MS
|
|
22
|
+
version = gem_versions["devise"]
|
|
23
|
+
modern = version && Gem::Version.new(version) >= Gem::Version.new("5.0.0.rc")
|
|
24
|
+
caveat = if modern
|
|
25
|
+
"Devise #{version} loads the routes on demand when Devise.mappings is first used, so this is safe " \
|
|
26
|
+
"unless your own code reads Devise.mappings during eager loading."
|
|
27
|
+
else
|
|
28
|
+
"Devise #{version || "(version unknown)"} builds Devise.mappings only when the routes are loaded. " \
|
|
29
|
+
"Routes are still loaded later in boot (set_routes_reloader_hook), but code that reads " \
|
|
30
|
+
"Devise.mappings during eager loading would see none. Devise 5.0+ removes this risk."
|
|
31
|
+
end
|
|
32
|
+
[suggestion(modern ? :medium : :low,
|
|
33
|
+
title: "Devise reloads the routes before eager loading (#{round(ms)} ms); they are loaded again later in boot",
|
|
34
|
+
evidence: [{ "hook" => "before_eager_load", "source" => "devise/rails.rb", "inclusive_ms" => round(ms),
|
|
35
|
+
"devise" => version, "threshold_ms" => MIN_MS }],
|
|
36
|
+
savings: ms,
|
|
37
|
+
action: "In config/initializers/devise.rb:\n" \
|
|
38
|
+
" config.reload_routes = false\n" + caveat)]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Hayaoki
|
|
6
|
+
module BootReport
|
|
7
|
+
module Rules
|
|
8
|
+
# eager_load の割合が大きいときの判断材料 (情報のみ)。
|
|
9
|
+
class EagerLoadTradeoff < Base
|
|
10
|
+
MIN_RATIO = 0.15
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
eager = ctx.phase_ms("eager_load")
|
|
14
|
+
total = ctx.phase_ms("boot") || ctx.phase_ms("total")
|
|
15
|
+
return [] unless eager && total && total.positive?
|
|
16
|
+
ratio = eager / total
|
|
17
|
+
return [] if ratio < MIN_RATIO
|
|
18
|
+
[suggestion(:info,
|
|
19
|
+
title: "eager_load accounts for #{(ratio * 100).round(1)}% of boot time",
|
|
20
|
+
evidence: [{ "eager_load_ms" => round(eager), "boot_ms" => round(total), "ratio" => ratio.round(3),
|
|
21
|
+
"threshold" => MIN_RATIO }],
|
|
22
|
+
savings: eager,
|
|
23
|
+
action: "For serverless or short-lived processes where boot latency matters, you could consider " \
|
|
24
|
+
"config.eager_load = false: boot gets faster, but classes are then loaded on first use, " \
|
|
25
|
+
"so the first requests may be slower and thread-safety/CoW benefits of eager loading are lost. " \
|
|
26
|
+
"For long-running servers, keeping eager_load enabled is usually preferable. " \
|
|
27
|
+
"The estimated saving moves work to the first request; it is not a net saving.")]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Hayaoki
|
|
6
|
+
module BootReport
|
|
7
|
+
module Rules
|
|
8
|
+
# アプリ自身の initializer (config/initializers 配下など) が重い。
|
|
9
|
+
class HeavyAppInitializer < Base
|
|
10
|
+
def call
|
|
11
|
+
items = candidates.select { |i| i["inclusive_ms"] > ctx.initializer_threshold_ms }
|
|
12
|
+
return [] if items.empty?
|
|
13
|
+
items.sort_by! { |i| -i["inclusive_ms"] }
|
|
14
|
+
[suggestion(:medium,
|
|
15
|
+
title: "#{items.size} app initializer(s) take more than #{fmt(ctx.initializer_threshold_ms)} ms; " \
|
|
16
|
+
"they may be deferrable (slowest: #{items.first["name"]})",
|
|
17
|
+
evidence: items,
|
|
18
|
+
savings: items.sum { |i| i["inclusive_ms"] },
|
|
19
|
+
action: "Consider (the time may include gem requires counted elsewhere):\n" \
|
|
20
|
+
" - run it later: Rails.application.config.after_initialize { ... }\n" \
|
|
21
|
+
" - require heavy libraries lazily, inside the code that uses them\n" \
|
|
22
|
+
" - register it with ActiveSupport.on_load(:active_record) { ... } so it runs only when needed\n" \
|
|
23
|
+
"Savings are an upper bound: deferred work still runs, just later.")]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def candidates
|
|
29
|
+
by_name = {}
|
|
30
|
+
# 切り詰め前の app_initializer_times があればそれを使う (無ければ表示用の上位 N 件)
|
|
31
|
+
full = get(ctx.report, :app_initializer_times)
|
|
32
|
+
inits = full ? get(full, :initializers) : get(ctx.report, :initializers)
|
|
33
|
+
files = full ? get(full, :files) : get(ctx.report, :files)
|
|
34
|
+
Array(inits).each do |i|
|
|
35
|
+
next unless get(i, :category).to_s == "app"
|
|
36
|
+
ms = med(get(i, :inclusive_ms)) or next
|
|
37
|
+
name = [get(i, :name), get(i, :context)].compact.join(" ")
|
|
38
|
+
add(by_name, name, ms, "initializer")
|
|
39
|
+
end
|
|
40
|
+
Array(files).each do |f|
|
|
41
|
+
path = get(f, :path).to_s
|
|
42
|
+
next unless get(f, :category).to_s == "app" && path.include?("/config/initializers/")
|
|
43
|
+
ms = med(get(f, :inclusive_ms)) or next
|
|
44
|
+
add(by_name, path.sub(%r{\A.*?/(config/initializers/)}, '\1'), ms, "file")
|
|
45
|
+
end
|
|
46
|
+
by_name.values
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def add(h, name, ms, kind)
|
|
50
|
+
cur = h[name]
|
|
51
|
+
return if cur && cur["inclusive_ms"] >= ms
|
|
52
|
+
h[name] = { "name" => name, "kind" => kind, "inclusive_ms" => round(ms) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fmt(x) = x == x.to_i ? x.to_i.to_s : x.to_s
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Hayaoki
|
|
6
|
+
module BootReport
|
|
7
|
+
module Rules
|
|
8
|
+
# Rails は active_support.initialize_time_zone で TZInfo を eager_load_namespaces に加え、
|
|
9
|
+
# production の eager_load! で全タイムゾーン (約 600) を読み込む。Linux のコンテナでは 200〜330ms かかる
|
|
10
|
+
# (bench/tzinfo_eager_load.rb)。遅延にすると、使うゾーンだけを初回に読む (1 ゾーン 0.3ms 未満)。
|
|
11
|
+
class TzinfoEagerLoad < Base
|
|
12
|
+
NAME = "TZInfo.eager_load!"
|
|
13
|
+
MIN_MS = 20.0
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
e = Array(get(ctx.report, :eager_namespaces)).find { |x| get(x, :name) == NAME } or return []
|
|
17
|
+
ms = med(get(e, :inclusive_ms))
|
|
18
|
+
return [] unless ms && ms >= MIN_MS
|
|
19
|
+
[suggestion(:medium,
|
|
20
|
+
title: "#{NAME} loads every time zone at boot (#{round(ms)} ms)",
|
|
21
|
+
evidence: [{ "namespace" => NAME, "inclusive_ms" => round(ms), "threshold_ms" => MIN_MS }],
|
|
22
|
+
savings: ms,
|
|
23
|
+
action: "Rails eager loads all ~600 TZInfo zones in production. To load zones on first use instead, add\n" \
|
|
24
|
+
" # config/initializers/tzinfo_lazy.rb\n" \
|
|
25
|
+
" Rails.application.config.eager_load_namespaces.delete(TZInfo)\n" \
|
|
26
|
+
"Each zone is then read when first used (typically well under 1 ms). TZInfo's lazy loading is thread-safe.\n" \
|
|
27
|
+
"Trade-off: with a forking server (Puma workers + preload_app!), zones are no longer loaded before fork, " \
|
|
28
|
+
"so they are not shared copy-on-write. Most useful for serverless and short-lived processes.")]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "set"
|
|
3
|
+
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "dev_gems_in_production"
|
|
6
|
+
|
|
7
|
+
module Hayaoki
|
|
8
|
+
module BootReport
|
|
9
|
+
module Rules
|
|
10
|
+
# 重い gem の定数が app/ config/ lib/ から参照されていない (require: false の候補)。
|
|
11
|
+
class UnusedHeavyGem < Base
|
|
12
|
+
SEARCH_DIRS = %w[app config lib].freeze
|
|
13
|
+
SEARCH_EXTS = %w[.rb .erb .rake .haml .slim .jbuilder .ru].freeze
|
|
14
|
+
SEARCH_ROOT_FILES = %w[config.ru Rakefile].freeze
|
|
15
|
+
MAX_FILE_BYTES = 2_000_000
|
|
16
|
+
|
|
17
|
+
EXCLUDED = (%w[
|
|
18
|
+
rails railties activesupport activerecord activemodel actionpack actionview actionmailer actioncable
|
|
19
|
+
activejob activestorage actiontext actionmailbox bootsnap zeitwerk bundler rubygems rack i18n
|
|
20
|
+
concurrent-ruby tzinfo mutex_m bigdecimal drb base64 logger
|
|
21
|
+
]).freeze
|
|
22
|
+
# Railtie やアダプタ経由で使われ、定数を直接書かないことが多い gem (誤検出を避けるため対象外)
|
|
23
|
+
IMPLICIT_USE = %w[
|
|
24
|
+
puma pg mysql2 sqlite3 sprockets sprockets-rails sass-rails sassc sassc-rails propshaft importmap-rails
|
|
25
|
+
turbo-rails stimulus-rails jbuilder tailwindcss-rails jsbundling-rails cssbundling-rails
|
|
26
|
+
solid_cache solid_queue solid_cable tzinfo-data kamal thruster net-imap net-smtp net-pop
|
|
27
|
+
].freeze
|
|
28
|
+
# APM / 監視系: Railtie や Rack middleware 経由で動くため定数参照が無くても使われている
|
|
29
|
+
APM_GEMS = %w[newrelic_rpm ddtrace datadog scout_apm skylight honeybadger bugsnag rollbar appsignal].freeze
|
|
30
|
+
APM_PREFIXES = %w[sentry- opentelemetry-].freeze
|
|
31
|
+
# gem が提供する DSL 名 (定数でなくクラス本体のマクロとして使われるもの)。小さく明示的に保つ
|
|
32
|
+
DSL_NAMES = {
|
|
33
|
+
"paper_trail" => %w[has_paper_trail], "carrierwave" => %w[mount_uploader mount_uploaders],
|
|
34
|
+
"friendly_id" => %w[friendly_id], "acts-as-taggable-on" => %w[acts_as_taggable_on acts_as_taggable],
|
|
35
|
+
"acts_as_list" => %w[acts_as_list], "ancestry" => %w[has_ancestry], "rolify" => %w[rolify],
|
|
36
|
+
"searchkick" => %w[searchkick], "counter_culture" => %w[counter_culture],
|
|
37
|
+
"devise" => %w[devise_for devise], "audited" => %w[audited], "aasm" => %w[aasm],
|
|
38
|
+
"enumerize" => %w[enumerize],
|
|
39
|
+
}.freeze
|
|
40
|
+
# 既知の定数名 (gem 名から機械的に導けないもの)
|
|
41
|
+
KNOWN_CONSTANTS = {
|
|
42
|
+
"httparty" => %w[HTTParty], "aws-sdk-s3" => %w[Aws::S3 Aws], "aws-sdk" => %w[Aws],
|
|
43
|
+
"nokogiri" => %w[Nokogiri], "rest-client" => %w[RestClient], "activeadmin" => %w[ActiveAdmin],
|
|
44
|
+
"google-cloud-storage" => %w[Google::Cloud::Storage], "mini_magick" => %w[MiniMagick],
|
|
45
|
+
"ruby-vips" => %w[Vips], "image_processing" => %w[ImageProcessing], "rqrcode" => %w[RQRCode],
|
|
46
|
+
"redcarpet" => %w[Redcarpet], "kaminari" => %w[Kaminari], "elasticsearch" => %w[Elasticsearch],
|
|
47
|
+
"stripe" => %w[Stripe], "sentry-ruby" => %w[Sentry], "sentry-rails" => %w[Sentry],
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
def call
|
|
51
|
+
return [] unless gemfile_entries # Gemfile が無ければ直接依存か判定できないので提案しない
|
|
52
|
+
cands = candidates
|
|
53
|
+
return [] if cands.empty?
|
|
54
|
+
used = referenced(cands)
|
|
55
|
+
cands.reject { |c| used.include?(c[:name]) }.sort_by { |c| -c[:ms] }.map do |c|
|
|
56
|
+
suggestion(:low,
|
|
57
|
+
title: "No references found to #{c[:name]} in app code (#{round(c[:ms])} ms at boot)",
|
|
58
|
+
evidence: [{ "gem" => c[:name], "self_ms" => round(c[:ms]), "searched_tokens" => c[:tokens] }],
|
|
59
|
+
savings: c[:ms],
|
|
60
|
+
action: "If it is not needed at boot, consider loading it lazily in the Gemfile:\n" \
|
|
61
|
+
" gem \"#{c[:name]}\", require: false\n" \
|
|
62
|
+
"and `require \"#{c[:name]}\"` where it is first needed. This is a static text search: " \
|
|
63
|
+
"it can miss use via metaprogramming, other gems, rake tasks outside the searched " \
|
|
64
|
+
"directories, or Railtie hooks, so please verify before changing.")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def candidates
|
|
71
|
+
entries = gemfile_entries
|
|
72
|
+
ctx.gems.filter_map do |g|
|
|
73
|
+
name = get(g, :name).to_s
|
|
74
|
+
ms = med(get(g, :self_ms))
|
|
75
|
+
next unless get(g, :category).to_s == "gem" && ms && ms > ctx.gem_threshold_ms
|
|
76
|
+
next if EXCLUDED.include?(name) || IMPLICIT_USE.include?(name) || DevGemsInProduction.dev_gem?(name)
|
|
77
|
+
next if apm?(name) || railtie?(g, name)
|
|
78
|
+
if entries
|
|
79
|
+
entry = entries[name]
|
|
80
|
+
next unless entry # 間接依存は Gemfile で require: false にできない
|
|
81
|
+
next if entry.require == false # 既に lazy
|
|
82
|
+
next if entry.require.is_a?(Array) && !entry.require.empty? # 別名で require されている
|
|
83
|
+
end
|
|
84
|
+
given = Array(get(g, :constants)).map(&:to_s).reject(&:empty?)
|
|
85
|
+
tokens = (given.empty? ? guess_constants(name) : given) + DSL_NAMES.fetch(name, [])
|
|
86
|
+
{ name: name, ms: ms, tokens: tokens.uniq }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def apm?(name) = APM_GEMS.include?(name) || APM_PREFIXES.any? { name.start_with?(_1) }
|
|
91
|
+
|
|
92
|
+
# Railtie / Engine を持つ gem は初期化時に自動で組み込まれるので、定数参照が無くても使われている。
|
|
93
|
+
# 計測結果 (gem が initializer を定義している / on_load の時間が gem に計上されている) か、gem 内の railtie.rb / engine.rb で判定する。
|
|
94
|
+
def railtie?(g, name)
|
|
95
|
+
return true if (med(get(g, :initializer_count)) || 0) > 0 || (med(get(g, :on_load_ms)) || 0) > 0
|
|
96
|
+
railtie_files?(name)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def railtie_files?(name)
|
|
100
|
+
path = gem_paths[name] || Gem.loaded_specs[name]&.full_gem_path or return false # 子プロセスの記録を優先
|
|
101
|
+
Dir.glob(File.join(path, "lib", "**", "{railtie,engine}.rb")).any?
|
|
102
|
+
rescue StandardError
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# gem 名 → 定数名の候補 (ゆるい方に倒す: 一つでも見つかれば「使われている」とみなす)
|
|
107
|
+
def guess_constants(name)
|
|
108
|
+
return KNOWN_CONSTANTS[name] if KNOWN_CONSTANTS.key?(name)
|
|
109
|
+
parts = name.split(/[-]/)
|
|
110
|
+
camel = ->(s) { s.split("_").map { |w| w[0].to_s.upcase + w[1..].to_s }.join }
|
|
111
|
+
toks = [parts.map(&camel).join("::"), parts.map(&camel).join]
|
|
112
|
+
# rack-attack → Rack のように先頭だけでは広すぎるので、単一セグメントの gem 名だけ
|
|
113
|
+
toks << camel.call(parts.first) if parts.size == 1
|
|
114
|
+
toks << name # require "foo-bar" の文字列
|
|
115
|
+
toks.uniq
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# 各ファイルを 1 回だけ読み、全トークンをまとめた正規表現で比べる。使われていた gem 名の Set を返す。
|
|
119
|
+
def referenced(cands)
|
|
120
|
+
owner = Hash.new { |h, k| h[k] = [] }
|
|
121
|
+
cands.each { |c| c[:tokens].each { |t| owner[t.downcase] << c[:name] } }
|
|
122
|
+
re = Regexp.new("(?<![A-Za-z0-9_])(#{owner.keys.sort_by { |k| -k.size }.map { |k| Regexp.escape(k) }.join("|")})(?![A-Za-z0-9_])", Regexp::IGNORECASE)
|
|
123
|
+
used = Set.new
|
|
124
|
+
each_source_file do |path|
|
|
125
|
+
text = File.binread(path).force_encoding(Encoding::UTF_8).scrub
|
|
126
|
+
text.scan(re) { |(tok)| owner[tok.downcase].each { |n| used << n } }
|
|
127
|
+
return used if used.size >= cands.size
|
|
128
|
+
end
|
|
129
|
+
used
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def each_source_file
|
|
133
|
+
root = ctx.app_dir.to_s
|
|
134
|
+
files = SEARCH_DIRS.flat_map do |d|
|
|
135
|
+
Dir.glob(File.join(root, d, "**", "*{#{SEARCH_EXTS.join(",")}}"), File::FNM_DOTMATCH)
|
|
136
|
+
end
|
|
137
|
+
files += SEARCH_ROOT_FILES.map { |f| File.join(root, f) }
|
|
138
|
+
files.uniq.each do |path|
|
|
139
|
+
next unless File.file?(path)
|
|
140
|
+
next if File.size(path) > MAX_FILE_BYTES
|
|
141
|
+
yield path
|
|
142
|
+
rescue SystemCallError
|
|
143
|
+
next
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|