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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 70201420261ecfa6d0995fe4d99998a2790c33ab4569bbc80055a99eec4a6720
4
+ data.tar.gz: 5b4fba65fef01f18828f28efba572a4aa683625b307f4d11041b528332f794c5
5
+ SHA512:
6
+ metadata.gz: f6bea9fe2b633032130e6fc71ac051d7f9d65f98282af7edc357bdaec8c2860ff6fa1fc2f1d8bee3203c527a08ac2db6597d1786be0082d3ea0d4593c9ed0143
7
+ data.tar.gz: 85fb1950f0d0c82caf684d00c16dff4b94d6e30fd6914144f7dc298071fde7a758580f89dd23261bb52bfe29bceb473eace37786a62d070fecc5f385a57e7e68
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-11-08
4
+
5
+ - `hayaoki boot-report` measures boot time over several runs and reports the median and range, in text, JSON, or speedscope format.
6
+ - The probe, injected with `ruby -r`, records require, require_relative, load, initializer, on_load, eager_load, `Bundler.require`, and `initialize!` as spans.
7
+ - `config.eager_load_namespaces` entries (e.g. `TZInfo.eager_load!`) are recorded as eager_load spans and attributed to the gem that defines them, instead of disappearing into the `eager_load!` initializer body.
8
+ - Requires are matched to loaded files for `./x`, `../x`, `x.so` (loaded as `.bundle` on macOS) and built-in extensions such as `prism/prism.so`. Before, these fell back to the last loaded file, which could be another thread's. Remaining fallbacks are counted and reported as a warning.
9
+ - Before measuring, the child `ruby` is checked to be Ruby 3.2 or later, with a one-line error that points to `--ruby` otherwise.
10
+ - Phase breakdown (vm boot, bundler require, initialize) and per-gem and per-initializer rankings.
11
+ - Improvement suggestions, printed as candidates: bootsnap effectiveness, heavy app initializers, unused heavy gems, dev gems in production, and eager load trade-offs. Disable with `--no-suggestions`.
12
+ - `tzinfo_eager_load` suggestion: `TZInfo.eager_load!` takes 200–330 ms in Linux containers (see `bench/tzinfo_eager_load.rb`), and it can be made lazy with one line.
13
+ - `devise_reload_routes` and `controller_action_methods` suggestions, measured on Mastodon and Redmine with `bench/boot_variants.rb`.
14
+ - A ranking of `config.eager_load_namespaces` entries in the text output, and `eager_namespaces` in the JSON output.
15
+ - Options for runs, environment, bootsnap mode (`warm`, `cold`, `both`), entry file, timeout, output file, and rows per ranking, plus thresholds for suggestions.
16
+ - The probe has no runtime dependencies and writes JSON with its own encoder.
17
+ - `--redact` replaces absolute paths in the output (text, JSON, and speedscope): the app directory with `<app>`, gem paths (including bundler git gems) with `<gem:name>/`, and the home directory with `~`.
18
+ - A category table in the text and JSON output, with an `unattributed` row (boot time minus attributed self time) so the totals add up.
19
+ - Applications that are themselves gems (`gemspec`, engine dummy apps, `path: "."`) are categorized as `app` for files under `app`, `config`, `lib`, and `db`.
20
+ - JSON output omits the stderr of failed runs, which may contain secrets. It keeps the message, exit status, and error class.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Takato Hano
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # hayaoki
2
+
3
+ Wake your Rails app up early: find out where its boot time goes, and what to change.
4
+
5
+ *hayaoki* (早起き) means "getting up early" in Japanese. It is a diagnostic tool: it measures and explains boot time, and does not make boot faster just by being installed.
6
+
7
+ hayaoki measures where a Rails application's boot time goes and reports it by phase, by gem, and by initializer. The current release provides `hayaoki boot-report`.
8
+
9
+ ## Installation
10
+
11
+ ```sh
12
+ gem install hayaoki
13
+ ```
14
+
15
+ hayaoki is a standalone command. You do not need to add it to your Gemfile.
16
+
17
+ ## Usage
18
+
19
+ ```sh
20
+ hayaoki boot-report [APP_DIR]
21
+ ```
22
+
23
+ `APP_DIR` defaults to the current directory.
24
+
25
+ | Option | Description |
26
+ | --- | --- |
27
+ | `--runs N` | number of measured runs (default 5) |
28
+ | `--env ENV` | `RAILS_ENV` (default `production`) |
29
+ | `--bootsnap MODE` | `warm`, `cold`, or `both` (default `warm`) |
30
+ | `--ruby PATH` | ruby executable (default `ruby`) |
31
+ | `--entry PATH` | boot entry file (default `config/environment`) |
32
+ | `--timeout SEC` | per-run timeout in seconds (default 120) |
33
+ | `--format FORMAT` | `text`, `json`, or `speedscope` (default `text`) |
34
+ | `--output FILE` | write to FILE instead of stdout |
35
+ | `--top N` | rows per ranking (default 10) |
36
+ | `--no-suggestions` | do not print improvement suggestions |
37
+ | `--initializer-threshold MS` | app initializer threshold for suggestions (default 50) |
38
+ | `--gem-threshold MS` | gem self time threshold for suggestions (default 30) |
39
+ | `--redact` | replace absolute paths in the output (all formats): the app directory (and its realpath) with `<app>`, gem paths with `<gem:name>/`, the home directory with `~` |
40
+ | `--version` | show version |
41
+ | `-h`, `--help` | show help |
42
+
43
+ Example output:
44
+
45
+ ```
46
+ total[warm]: 687.8 ms [684.3-699.4]
47
+ Phases (median)
48
+ vm boot █ 28.2 ms
49
+ bundler require ████████ 195.6 ms
50
+ initialize ███████████ 267.0 ms
51
+ ```
52
+
53
+ ## Suggestions
54
+
55
+ | Rule | Confidence | What it looks for |
56
+ | --- | --- | --- |
57
+ | `dev_gems_in_production` | high / medium / low | development or test gems loaded in production (high: direct and not in a group; low: transitive) |
58
+ | `bootsnap_ineffective` | high | bootsnap missing, or its cache not helping |
59
+ | `heavy_app_initializer` | medium | app initializers slower than `--initializer-threshold` |
60
+ | `tzinfo_eager_load` | medium | `TZInfo.eager_load!` loading every time zone at boot (see below) |
61
+ | `devise_reload_routes` | medium (Devise 5+) / low | Devise reloading the routes before eager loading; they are loaded again later in boot |
62
+ | `controller_action_methods` | low | `AbstractController.eager_load!` computing `action_methods` for every controller at boot |
63
+ | `unused_heavy_gem` | low | heavy gems with no references in app code (candidates for `require: false`) |
64
+ | `eager_load_tradeoff` | info | eager loading taking a large share of boot |
65
+
66
+ Estimated savings are upper bounds. Findings can overlap, and the output marks which ones do, so do not add them up.
67
+
68
+ ### TZInfo eager loading
69
+
70
+ In production, Rails 7.2 and later add `TZInfo` to `config.eager_load_namespaces`, and `TZInfo.eager_load!` reads all ~600 time zones at boot. Measured with [`bench/tzinfo_eager_load.rb`](https://github.com/TakatoHano/hayaoki/blob/main/bench/tzinfo_eager_load.rb) (median of 7 fresh processes on a GitHub-hosted runner, Ruby 3.4):
71
+
72
+ | Environment | `TZInfo.eager_load!` |
73
+ | --- | --- |
74
+ | `ruby:3.4-slim`, system zoneinfo / with `tzinfo-data` | 243 ms / 194 ms |
75
+ | `ruby:3.4-alpine` + `tzdata` / with `tzinfo-data` | 330 ms / 228 ms |
76
+ | AWS Lambda `ruby:3.4`, system zoneinfo / with `tzinfo-data` | 319 ms / 202 ms |
77
+ | macOS (Apple silicon, local) | 112 ms |
78
+
79
+ Removing it (`Rails.application.config.eager_load_namespaces.delete(TZInfo)` in an initializer) leaves 5–15 ms at boot. Each zone is then loaded on first use, in under 1 ms. The trade-off is that forking servers no longer share the zone data copy-on-write.
80
+
81
+ ### Measured on real applications
82
+
83
+ [`bench/boot_variants.rb`](https://github.com/TakatoHano/hayaoki/blob/main/bench/boot_variants.rb) boots an application with one setting changed at a time (median of fresh processes, GitHub-hosted runner, official Docker images, `RAILS_ENV=production`). The `real-apps` workflow runs it. Boot times vary by several hundred ms between runners, so compare within one run.
84
+
85
+ | Change | Mastodon 4.7.5 | Redmine 7.0 | Cost moved to later |
86
+ | --- | --- | --- | --- |
87
+ | `eager_load_namespaces.delete(TZInfo)` | −128 ms | −193 to −328 ms | under 1 ms per zone used |
88
+ | Devise `config.reload_routes = false` | −194 ms | (no Devise) | none: the routes are loaded once, later in boot |
89
+ | `eager_load_namespaces.delete(AbstractController)` | −201 ms | −31 ms | 217 ms in total across 336 controllers, about 0.65 ms on each controller's first request |
90
+ | `eager_load_namespaces.delete(I18n)` | −108 ms | −286 ms | 182 ms / 317 ms on the first translation |
91
+ | `config.i18n.available_locales = [:en]` | no gain | no gain | every locale file is still parsed |
92
+
93
+ hayaoki has no I18n suggestion: making it lazy only moves the whole cost to the first translation, which nearly every request needs.
94
+
95
+ ## How it works
96
+
97
+ hayaoki runs your application in a child process with a probe injected through `ruby -r`. The probe hooks the require family of methods with an alias chain, not `prepend`, so it coexists with bootsnap, Zeitwerk, and bundled_gems. Rails-specific hooks (initializers, on_load hooks, eager loading, `Bundler.require`, and `initialize!`) are installed lazily, when the corresponding file is loaded. The measured overhead is about 2%.
98
+
99
+ The design is described in [docs/design/probe.md](https://github.com/TakatoHano/hayaoki/blob/main/docs/design/probe.md).
100
+
101
+ ## Notes
102
+
103
+ - The application is booted with the `ruby` found on `PATH` in the application directory (so rbenv, asdf, or mise pick the app's version), or with `--ruby PATH`. It must be Ruby 3.2 or later; hayaoki checks this before measuring.
104
+ - By default hayaoki boots the application with `RAILS_ENV=production`. Boot may then need access to external services or credentials. If so, use `SECRET_KEY_BASE_DUMMY=1` for a dummy secret key, and `--env` to choose another environment.
105
+ - Suggestions are candidates. Verify each one before applying it.
106
+ - Output contains absolute paths unless you pass `--redact`. `--redact` rewrites paths only. It does not remove other content, so review the output before sharing it.
107
+ - JSON output does not include the stderr of failed runs, because it may contain secrets. It keeps the error message, exit status, and error class. When every run fails, hayaoki prints the stderr tail to the terminal.
108
+ - The category table (`gem`, `app`, `stdlib`, `other`, `init_body`, `unattributed`) sums to boot time. `init_body` is the own time of initializer blocks (not blamed on the gem that defines them). `unattributed` is boot time minus the self time attributed to files, initializer bodies, and on_load hooks. It covers time in `run_load_hooks` itself, the self time of phase and Zeitwerk eager load spans, and time outside any span. Background-thread spans are excluded.
109
+
110
+ ## Requirements
111
+
112
+ - Ruby 3.2 or later
113
+ - Rails 7.0 or later
114
+ - macOS or Linux
115
+
116
+ ## License
117
+
118
+ MIT. See [LICENSE](LICENSE).
data/exe/hayaoki ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "hayaoki/cli"
6
+
7
+ exit Hayaoki::CLI.start(ARGV)
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rules/base"
4
+
5
+ module Hayaoki
6
+ module BootReport
7
+ # 改善候補 1 件。evidence は String キーの Hash の配列。
8
+ Suggestion = Struct.new(:rule, :confidence, :title, :evidence, :estimated_savings_ms, :action, :id, :overlaps, keyword_init: true) do
9
+ # 各 finding の節約見込みは重複しうるので足し算では出さず、上限 (単純和) として扱う。
10
+ def self.savings_upper_bound(list)
11
+ v = list.filter_map(&:estimated_savings_ms)
12
+ v.empty? ? nil : v.sum.round(1)
13
+ end
14
+
15
+ def to_h
16
+ { "id" => id, "overlaps" => Array(overlaps), "rule" => rule.to_s, "confidence" => confidence.to_s, "title" => title, "evidence" => evidence,
17
+ "estimated_savings_ms" => estimated_savings_ms, "action" => action }
18
+ end
19
+ end
20
+
21
+ # Report (と RunResult) から Suggestion を作る。ルールの例外は警告にして続行する。
22
+ class Advisor
23
+ CONFIDENCE_ORDER = %i[high medium low info].freeze
24
+ DEFAULT_INITIALIZER_THRESHOLD_MS = 50
25
+ DEFAULT_GEM_THRESHOLD_MS = 30
26
+
27
+ attr_reader :warnings
28
+
29
+ def self.rules
30
+ require_relative "rules/dev_gems_in_production"
31
+ require_relative "rules/bootsnap_ineffective"
32
+ require_relative "rules/heavy_app_initializer"
33
+ require_relative "rules/unused_heavy_gem"
34
+ require_relative "rules/eager_load_tradeoff"
35
+ require_relative "rules/tzinfo_eager_load"
36
+ require_relative "rules/devise_reload_routes"
37
+ require_relative "rules/controller_action_methods"
38
+ [Rules::DevGemsInProduction, Rules::BootsnapIneffective, Rules::HeavyAppInitializer,
39
+ Rules::UnusedHeavyGem, Rules::EagerLoadTradeoff, Rules::TzinfoEagerLoad,
40
+ Rules::DeviseReloadRoutes, Rules::ControllerActionMethods]
41
+ end
42
+
43
+ def initialize(report, app_dir:, result: nil, initializer_threshold_ms: DEFAULT_INITIALIZER_THRESHOLD_MS,
44
+ gem_threshold_ms: DEFAULT_GEM_THRESHOLD_MS, rules: self.class.rules)
45
+ @ctx = Context.new(report: report, app_dir: app_dir, result: result,
46
+ initializer_threshold_ms: initializer_threshold_ms, gem_threshold_ms: gem_threshold_ms)
47
+ @rules = rules
48
+ @warnings = []
49
+ end
50
+
51
+ def suggestions
52
+ @suggestions ||= run
53
+ end
54
+
55
+ private
56
+
57
+ def run
58
+ out = @rules.flat_map do |klass|
59
+ Array(klass.new(@ctx).call)
60
+ rescue StandardError, NotImplementedError => e
61
+ id = klass.respond_to?(:rule_id) ? klass.rule_id : klass.to_s
62
+ @warnings << "suggestion rule #{id} failed: #{e.class}: #{e.message}"[0, 300]
63
+ []
64
+ end
65
+ sorted = out.each_with_index.sort_by { |s, i| [CONFIDENCE_ORDER.index(s.confidence) || 99, -(s.estimated_savings_ms || 0), i] }.map(&:first)
66
+ mark_overlaps(sorted)
67
+ end
68
+
69
+ GEM_EVIDENCE_KEY = { dev_gems_in_production: "name", unused_heavy_gem: "gem" }.freeze
70
+ GEM_RULES = GEM_EVIDENCE_KEY.keys.freeze
71
+
72
+ # id を振り、節約見込みが重なりうる finding を overlaps に載せる。
73
+ # - 同じ gem を別ルールが指している
74
+ # - app initializer の inclusive 時間には gem の require が含まれうるので、gem 系の finding と重なる
75
+ # - TZInfo.eager_load! などの時間は eager_load フェーズに含まれる
76
+ def mark_overlaps(list)
77
+ seen = Hash.new(0)
78
+ list.each do |s|
79
+ seen[s.rule] += 1
80
+ s.id = seen[s.rule] == 1 ? s.rule.to_s : "#{s.rule}##{seen[s.rule]}"
81
+ end
82
+ gems = {}.compare_by_identity
83
+ list.each { |s| gems[s] = gem_subjects(s) }
84
+ list.each do |s|
85
+ s.overlaps = list.filter_map do |o|
86
+ next if o.equal?(s)
87
+ o.id if (gems[s] & gems[o]).any? || initializer_vs_gem?(s, o) || eager_phase_pair?(s, o)
88
+ end
89
+ end
90
+ list
91
+ end
92
+
93
+ # eager_load フェーズの中の処理を指す finding は、eager_load_tradeoff と節約見込みが重なる
94
+ EAGER_PHASE_RULES = %i[tzinfo_eager_load controller_action_methods].freeze
95
+
96
+ def eager_phase_pair?(a, b)
97
+ pair = [a.rule, b.rule]
98
+ pair.include?(:eager_load_tradeoff) && pair.any? { |r| EAGER_PHASE_RULES.include?(r) }
99
+ end
100
+
101
+ def initializer_vs_gem?(a, b)
102
+ pair = [a.rule, b.rule]
103
+ pair.include?(:heavy_app_initializer) && pair.any? { |r| GEM_RULES.include?(r) }
104
+ end
105
+
106
+ def gem_subjects(s)
107
+ key = GEM_EVIDENCE_KEY[s.rule]
108
+ return [] unless key
109
+ Array(s.evidence).filter_map { |e| e[key] if e.is_a?(Hash) }
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "report"
4
+
5
+ module Hayaoki
6
+ module BootReport
7
+ # probe の payload (Runner が集めた複数回分) を、設計書 §3.4 の正規化をかけた木にして集計する。
8
+ # 時間はすべて ms。各指標は { "median", "min", "max" } (偶数個なら中央2つの平均)。
9
+ class Aggregator
10
+ K_REQ = 0; K_RR = 1; K_LOAD = 2; K_INIT = 3; K_ONLOAD = 4; K_HOOKS = 5; K_EAGER = 6; K_PHASE = 7
11
+
12
+ Span = Struct.new(:idx, :kind, :name, :parent, :t0, :t1, :res, :tid, :cpu, :children, :incomplete, :merged, :owner) do
13
+ def require_kind? = kind <= K_LOAD
14
+ def dur_us = t1 - t0
15
+ def raised? = res == "raised"
16
+ # require 系は feature (res)。raised や非 String は nil
17
+ def feature = require_kind? && res.is_a?(String) && !raised? ? res : nil
18
+ end
19
+
20
+ Owner = Struct.new(:category, :name) # category: "gem" / "app" / "stdlib" / "other"
21
+
22
+ def initialize(top_files: 30, top_initializers: 30, top_on_loads: 30)
23
+ @top_files = top_files
24
+ @top_initializers = top_initializers
25
+ @top_on_loads = top_on_loads
26
+ end
27
+
28
+ # RunResult → { condition(String) => Report }
29
+ def aggregate_result(result)
30
+ result.payloads.to_h do |cond, list|
31
+ [cond, aggregate(list, condition: cond, meta: result.meta)]
32
+ end
33
+ end
34
+
35
+ def aggregate(payloads, condition: nil, meta: {})
36
+ ok, bad = payloads.partition { |p| p["status"] == "ok" }
37
+ warnings = Array(meta["warnings"]).dup
38
+ warnings << "#{bad.size} failed run(s) excluded from aggregation" unless bad.empty?
39
+ warnings.concat(ok.flat_map { |p| Array(p["probe_warnings"]) + Array(p["probe_errors"]).map { |e| "probe error: #{e}" } }.uniq)
40
+ fb = ok.map { |p| p.dig("resolve_fallbacks", "count").to_i }.max.to_i
41
+ if fb.positive?
42
+ ex = ok.flat_map { |p| Array(p.dig("resolve_fallbacks", "examples")) }.uniq.first(5)
43
+ warnings << "#{fb} require(s) could not be matched to a loaded file and were attributed to the last loaded file; " \
44
+ "attribution may be off (e.g. #{ex.join(", ")})"
45
+ end
46
+ analyses = ok.map { |p| analyze(p) }
47
+ thread_spans = analyses.map { |a| a[:background_spans] }.max.to_i
48
+ warnings << "#{thread_spans} span(s) recorded on background threads; attribution is approximate and they are excluded from totals" if thread_spans.positive?
49
+ merged = analyses.sum { |a| a[:merged] }
50
+
51
+ Report.new(
52
+ condition: condition, runs: ok.size, failed: bad.size,
53
+ ruby: ok.first && ok.first["ruby"], features: stat(analyses.map { |a| a[:features] }),
54
+ phases: phases(analyses),
55
+ categories: keyed_stats(analyses.map { |a| a[:categories] }, order_by: "self_ms"),
56
+ gems: gems(analyses),
57
+ files: files(analyses),
58
+ initializers: initializers(analyses),
59
+ app_initializer_times: app_initializer_times(analyses),
60
+ on_loads: on_loads(analyses),
61
+ eager_namespaces: eager_namespaces(analyses),
62
+ false_require: {
63
+ "count" => stat(analyses.map { |a| a[:false_count] }), "time_ms" => stat(analyses.map { |a| a[:false_ms] })
64
+ },
65
+ threads: { "background_spans" => thread_spans, "merged_duplicates" => merged },
66
+ meta: meta, warnings: warnings.uniq
67
+ )
68
+ end
69
+
70
+ # 1 回分の payload を解析する。テストや formatter 用に公開しておく。
71
+ def analyze(payload)
72
+ spans = build_spans(payload)
73
+ merged = normalize!(spans)
74
+ spans = spans.reject(&:merged)
75
+ resolver = OwnerResolver.new(payload)
76
+ main = spans.select { |s| s.tid.to_i == 0 }
77
+ pmap = spans.to_h { |s| [s.idx, s] }
78
+ main.each { |s| s.owner = owner_of(s, resolver) }
79
+ selfs = {}
80
+ spans.each { |s| selfs[s] = s.dur_us - s.children.sum { |c| c.dur_us } }
81
+
82
+ a = {
83
+ merged: merged, features: payload["features"], background_spans: spans.size - main.size,
84
+ false_count: payload.dig("false_require", "count").to_i, false_ms: payload.dig("false_require", "time_us").to_i / 1000.0,
85
+ gems: Hash.new { |h, k| h[k] = { "self_ms" => 0.0, "require_ms" => 0.0, "on_load_ms" => 0.0, "eager_ms" => 0.0, "initializer_count" => 0, "category" => nil } },
86
+ categories: Hash.new { |h, k| h[k] = { "self_ms" => 0.0 } },
87
+ files: Hash.new { |h, k| h[k] = { "self_ms" => 0.0, "inclusive_ms" => 0.0, "owner" => nil, "category" => nil, "count" => 0 } },
88
+ initializers: Hash.new { |h, k| h[k] = { "inclusive_ms" => 0.0, "init_body_ms" => 0.0 } },
89
+ on_loads: Hash.new { |h, k| h[k] = { "inclusive_ms" => 0.0, "self_ms" => 0.0 } },
90
+ eager_namespaces: Hash.new { |h, k| h[k] = { "inclusive_ms" => 0.0, "self_ms" => 0.0 } },
91
+ phase: {}
92
+ }
93
+ spawn_at = payload["spawn_at"]&.to_f
94
+ boot_us = payload["boot_end_us"] || payload["exit_us"]
95
+ a[:phase]["vm_boot"] = (payload["t0_real"] - spawn_at) * 1000.0 if spawn_at && payload["t0_real"]
96
+ a[:phase]["boot"] = boot_us / 1000.0 if boot_us
97
+ a[:phase]["total"] = (a[:phase]["vm_boot"] || 0.0) + boot_us / 1000.0 if boot_us
98
+
99
+ main.each do |s|
100
+ self_ms = selfs[s] / 1000.0
101
+ dur_ms = s.dur_us / 1000.0
102
+ o = s.owner
103
+ case s.kind
104
+ when K_REQ, K_RR, K_LOAD
105
+ add_owner(a, o, self_ms, "require_ms")
106
+ f = a[:files][s.feature || s.name.to_s]
107
+ f["self_ms"] += self_ms
108
+ f["inclusive_ms"] += dur_ms
109
+ f["owner"] = o.name
110
+ f["category"] = o.category
111
+ f["count"] += 1
112
+ when K_ONLOAD
113
+ add_owner(a, o, self_ms, "on_load_ms")
114
+ e = a[:on_loads][[s.name, s.res.is_a?(String) ? s.res : nil]]
115
+ e["inclusive_ms"] += dur_ms
116
+ e["self_ms"] += self_ms
117
+ e["owner"] = o.name
118
+ e["category"] = o.category
119
+ when K_INIT
120
+ # initializer 本体の自己時間 (require や子に帰属できない分) は定義元 gem に付けず、init_body_ms として別掲する
121
+ ctx = s.res.is_a?(Array) ? s.res[0] : nil
122
+ e = a[:initializers][[s.name, ctx]]
123
+ e["inclusive_ms"] += dur_ms
124
+ e["init_body_ms"] += self_ms
125
+ a[:categories]["init_body"]["self_ms"] += self_ms
126
+ e["owner"] = o.name
127
+ # 時間は付けないが、initializer を定義していること (= Railtie/Engine) は記録する
128
+ g = a[:gems][o.name]
129
+ g["category"] = o.category
130
+ g["initializer_count"] += 1
131
+ e["category"] = o.category
132
+ when K_PHASE
133
+ key = s.name
134
+ a[:phase][key] = a[:phase][key].to_f + dur_ms if %w[bundler_require initialize].include?(key)
135
+ when K_EAGER
136
+ # eager_load_namespaces の span (meta = 定義位置) は自己時間を持ち主に付ける。Zeitwerk の span は従来どおり。
137
+ if s.res.is_a?(String)
138
+ add_owner(a, o, self_ms, "eager_ms")
139
+ e = a[:eager_namespaces][s.name]
140
+ e["inclusive_ms"] += dur_ms
141
+ e["self_ms"] += self_ms
142
+ e["owner"] = o.name
143
+ e["category"] = o.category
144
+ end
145
+ a[:phase]["eager_load"] = a[:phase]["eager_load"].to_f + dur_ms unless eager_ancestor?(s, pmap)
146
+ end
147
+ end
148
+ # 帰属されなかった時間 (run_load_hooks 自体、phase / eager_load span の自己時間、span の外) を別掲する
149
+ if boot_us
150
+ attributed = a[:categories].values.sum { |c| c["self_ms"] }
151
+ a[:categories]["unattributed"]["self_ms"] = [boot_us / 1000.0 - attributed, 0.0].max
152
+ end
153
+ a
154
+ end
155
+
156
+ private
157
+
158
+ def stat(values)
159
+ v = values.compact
160
+ return nil if v.empty?
161
+ s = v.sort
162
+ n = s.size
163
+ med = n.odd? ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2.0
164
+ { "median" => r(med), "min" => r(s.first), "max" => r(s.last) }
165
+ end
166
+
167
+ def r(x) = x.is_a?(Float) ? x.round(3) : x
168
+
169
+ def phases(analyses)
170
+ keys = %w[vm_boot bundler_require initialize eager_load boot total]
171
+ keys.to_h { |k| [k, stat(analyses.map { |a| a[:phase][k] })] }
172
+ end
173
+
174
+ # runs 個の Hash (key => { metric => number, ... }) を key ごとに metric ごとの stat にする。
175
+ # 欠けた run は 0 として扱う。非数値の属性 (owner など) は最初に見つかった値を使う。
176
+ def merge_runs(list)
177
+ keys = list.flat_map(&:keys).uniq
178
+ keys.to_h do |k|
179
+ rows = list.map { |h| h[k] }
180
+ sample = rows.compact.first
181
+ out = {}
182
+ sample.each do |m, v|
183
+ out[m] = v.is_a?(Numeric) ? stat(rows.map { |row| row ? row[m] : 0 }) : v
184
+ end
185
+ [k, out]
186
+ end
187
+ end
188
+
189
+ def keyed_stats(list, order_by:)
190
+ merge_runs(list).sort_by { |_, v| -v[order_by]["median"] }.to_h
191
+ end
192
+
193
+ def gems(analyses)
194
+ merged = merge_runs(analyses.map { |a| a[:gems].to_h })
195
+ merged.sort_by { |k, v| [-v["self_ms"]["median"], k] }.map { |k, v| { "name" => k }.merge(v) }
196
+ end
197
+
198
+ def files(analyses)
199
+ merged = merge_runs(analyses.map { |a| a[:files].to_h })
200
+ merged.sort_by { |k, v| [-v["self_ms"]["median"], k] }.first(@top_files).map { |k, v| { "path" => k }.merge(v) }
201
+ end
202
+
203
+ def initializers(analyses)
204
+ merged = merge_runs(analyses.map { |a| a[:initializers].to_h })
205
+ merged.sort_by { |k, v| [-v["inclusive_ms"]["median"], k.to_s] }.first(@top_initializers).map do |(name, ctx), v|
206
+ { "name" => name, "context" => ctx }.merge(v)
207
+ end
208
+ end
209
+
210
+ # Advisor (heavy_app_initializer) 用。表示用の top-N 切り詰めをかけない app 側の inclusive 時間。
211
+ def app_initializer_times(analyses)
212
+ inits = merge_runs(analyses.map { |a| a[:initializers].to_h }).select { |_, v| v["category"] == "app" }
213
+ files = merge_runs(analyses.map { |a| a[:files].to_h }).select { |k, v| v["category"] == "app" && k.include?("/config/initializers/") }
214
+ {
215
+ "initializers" => inits.map { |(name, ctx), v| { "name" => name, "context" => ctx, "category" => "app", "inclusive_ms" => v["inclusive_ms"] } },
216
+ "files" => files.map { |k, v| { "path" => k, "category" => "app", "inclusive_ms" => v["inclusive_ms"] } }
217
+ }
218
+ end
219
+
220
+ def on_loads(analyses)
221
+ merged = merge_runs(analyses.map { |a| a[:on_loads].to_h })
222
+ merged.sort_by { |k, v| [-v["inclusive_ms"]["median"], k.to_s] }.first(@top_on_loads).map do |(name, loc), v|
223
+ { "name" => name, "source" => loc }.merge(v)
224
+ end
225
+ end
226
+
227
+ # config.eager_load_namespaces の eager_load! (TZInfo.eager_load! など)。件数は数十なので切り詰めない。
228
+ def eager_namespaces(analyses)
229
+ merged = merge_runs(analyses.map { |a| a[:eager_namespaces].to_h })
230
+ merged.sort_by { |k, v| [-v["inclusive_ms"]["median"], k.to_s] }.map { |name, v| { "name" => name }.merge(v) }
231
+ end
232
+
233
+ def add_owner(a, owner, self_ms, kind_key)
234
+ # app / stdlib / other も gems の表に並べる (ランキングで比較できるように)
235
+ g = a[:gems][owner.name]
236
+ g["category"] = owner.category
237
+ g["self_ms"] += self_ms
238
+ g[kind_key] += self_ms
239
+ a[:categories][owner.category]["self_ms"] += self_ms
240
+ end
241
+
242
+ # 入れ子の eager_load (eager_load_all が各 loader の eager_load を呼ぶ形) を二重に数えない
243
+ def eager_ancestor?(s, pmap)
244
+ while (s = pmap[s.parent])
245
+ return true if s.kind == K_EAGER
246
+ end
247
+ false
248
+ end
249
+
250
+ # --- 木の構築 -----------------------------------------------------------
251
+
252
+ def build_spans(payload)
253
+ w = payload["width"] || 8
254
+ ev = payload["events"] || []
255
+ exit_us = payload["exit_us"] || ev.each_slice(w).filter_map { |e| e[4] }.max || 0
256
+ spans = []
257
+ by_idx = {}
258
+ (0...(ev.size / w)).each do |i|
259
+ kind, name, par, t0, t1, res, tid, cpu = ev[i * w, w]
260
+ incomplete = t1.nil?
261
+ s = Span.new(i * w, kind, name, par, t0, t1 || [exit_us, t0].max, res, tid, cpu, [], incomplete, false, nil)
262
+ spans << s
263
+ by_idx[s.idx] = s
264
+ end
265
+ spans.each do |s|
266
+ p = by_idx[s.parent]
267
+ p.children << s if p
268
+ end
269
+ spans
270
+ end
271
+
272
+ # §3.4: 唯一の子が同じ feature の require 系なら 1 つにまとめる。まとめた件数を返す。
273
+ def normalize!(spans)
274
+ count = 0
275
+ spans.each do |s|
276
+ next if s.merged
277
+ while s.require_kind? && s.feature && s.children.size == 1
278
+ c = s.children.first
279
+ break unless c.require_kind? && c.feature == s.feature && c.tid == s.tid
280
+ c.merged = true
281
+ s.children = c.children
282
+ s.children.each { |g| g.parent = s.idx }
283
+ count += 1
284
+ end
285
+ end
286
+ # 外側から順に処理するので、親が先に処理される (EV は先行順)
287
+ count
288
+ end
289
+
290
+ # --- 帰属 ---------------------------------------------------------------
291
+
292
+ def owner_of(s, resolver)
293
+ case s.kind
294
+ when K_REQ, K_RR, K_LOAD
295
+ path = s.feature || (s.name.to_s.start_with?("/") ? s.name.to_s : nil)
296
+ path ? resolver.owner(path) : Owner.new("other", "other")
297
+ when K_ONLOAD
298
+ loc = s.res.is_a?(String) ? s.res.sub(/:\d+\z/, "") : nil
299
+ loc ? resolver.owner(loc) : Owner.new("other", "other")
300
+ when K_EAGER
301
+ s.res.is_a?(String) ? resolver.owner(s.res.sub(/:\d+\z/, "")) : Owner.new("other", "other")
302
+ when K_INIT
303
+ loc = s.res.is_a?(Array) && s.res[1].is_a?(String) ? s.res[1].sub(/:\d+\z/, "") : nil
304
+ loc ? resolver.owner(loc) : Owner.new("other", "other")
305
+ else
306
+ Owner.new("other", "other")
307
+ end
308
+ end
309
+
310
+ # specs / rails_root / libdir から、パスの持ち主を最長前方一致で引く。
311
+ class OwnerResolver
312
+ def initialize(payload)
313
+ @pwd = payload["pwd_realpath"]
314
+ @cache = {}
315
+ @real = {}
316
+ gem_prefixes = []
317
+ (payload["specs"] || {}).each do |name, (_ver, path, ext)|
318
+ [path, ext].compact.each { |pth| prefixes(pth).each { |pf| gem_prefixes << [pf, name] } }
319
+ end
320
+ @gems = gem_prefixes.uniq.sort_by { |pf, _| -pf.size }
321
+ root = payload["rails_root"] || @pwd
322
+ @app = root && prefixes(root)
323
+ # アプリ自身が gem (gemspec / path: "." / engine の dummy app) のとき、gem の prefix がアプリ root を含んでしまう。
324
+ # その場合は app/config/lib/db 配下を gem より先に app とみなす。
325
+ @app_dirs = nil
326
+ if @app && @gems.any? { |pf, _| @app.any? { |a| under?(a, pf) } }
327
+ @app_dirs = @app.flat_map { |a| %w[app config lib db].map { |d| File.join(a, d) } }
328
+ end
329
+ @stdlib = Array(payload["libdir"]).flat_map { |d| prefixes(d) }.uniq
330
+ end
331
+
332
+ def owner(path)
333
+ path = File.expand_path(path, @pwd || Dir.pwd) unless path.start_with?("/")
334
+ dir = File.dirname(path)
335
+ @cache[dir] ||= resolve_dir(dir)
336
+ end
337
+
338
+ private
339
+
340
+ def prefixes(path)
341
+ [path, realpath(path)].compact.uniq
342
+ end
343
+
344
+ def realpath(path)
345
+ @real[path] ||= begin
346
+ File.realpath(path)
347
+ rescue SystemCallError
348
+ path
349
+ end
350
+ end
351
+
352
+ def under?(dir, prefix) = dir == prefix || dir.start_with?(prefix.end_with?("/") ? prefix : "#{prefix}/")
353
+
354
+ def resolve_dir(dir)
355
+ dirs = [dir, realpath(dir)].uniq
356
+ return Owner.new("app", "app") if @app_dirs&.any? { |pf| dirs.any? { |d| under?(d, pf) } }
357
+ # 最長一致: gem の full_gem_path / extension_dir
358
+ hit = @gems.find { |pf, _| dirs.any? { |d| under?(d, pf) } }
359
+ return Owner.new("gem", hit[1]) if hit
360
+ return Owner.new("app", "app") if @app&.any? { |pf| dirs.any? { |d| under?(d, pf) } }
361
+ return Owner.new("stdlib", "stdlib") if @stdlib.any? { |pf| dirs.any? { |d| under?(d, pf) } }
362
+ Owner.new("other", "other")
363
+ end
364
+ end
365
+ end
366
+ end
367
+ end