require-profiler 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: d5b1e34052677db0605367fd1940681029cffc965f7d1db3604e6964ee95a41a
4
+ data.tar.gz: acbc21ea3bbd7e120bf328287b216fc68ca7be9beb41260925bbca3a70c712cf
5
+ SHA512:
6
+ metadata.gz: 67480173af46da1a7dba97b15f5a252b66b20504af35a1397f66662dc65e97e64381419d0d175a71aba3d99d8646ad0be482e3423de72ca4ef4c822b49c6cb28
7
+ data.tar.gz: e91be6bf3e684cfb9c969e2bfa495ba71b1a782dddfa4cdaa1f9c3d7573047c50dd3b0a308c377996b4405e1f38f32c8604eea5993e7c6b0a8369ec27044515e
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Change log
2
+
3
+ ## master
4
+
5
+ ## 0.1.0 (2026-04-22)
6
+
7
+ - Initial
data/LICENSE.txt ADDED
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2026 Vladimir Dementyev
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
+
data/README.md ADDED
@@ -0,0 +1,131 @@
1
+ [![Gem Version](https://badge.fury.io/rb/require-profiler.svg)](https://rubygems.org/gems/require-profiler)
2
+ [![Build](https://github.com/palkan/require-profiler/workflows/Build/badge.svg)](https://github.com/palkan/require-profiler/actions)
3
+
4
+ # Require Profiler
5
+
6
+ Require Profiler is a tool for profiling Ruby's code loading—`Kernel#require`, `Kernel#require_relative`, and `Kernel#load`. It captures the call tree, measures how long each file takes to load, and can export the results as a [Speedscope][speedscope]-compatible JSON profile.
7
+
8
+ It's built on top of [Require Hooks][require-hooks], so it works anywhere Require Hooks does (MRI/JRuby/TruffleRuby).
9
+
10
+ <a href="https://evilmartians.com/">
11
+ <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
12
+
13
+ ## Installation
14
+
15
+ Add to your Gemfile:
16
+
17
+ ```ruby
18
+ gem "require-profiler"
19
+ ```
20
+
21
+ ### Supported Ruby versions
22
+
23
+ - Ruby (MRI) >= 3.1
24
+
25
+ ## Usage
26
+
27
+ Wrap the code you want to profile with `RequireProfiler.start` and `RequireProfiler.stop`:
28
+
29
+ ```ruby
30
+ require "require_profiler"
31
+
32
+ RequireProfiler.start(output: "path/to/profile.txt")
33
+ require "my_app"
34
+ RequireProfiler.stop
35
+ ```
36
+
37
+ Only `require`/`load` calls that happen between `.start` and `.stop` are captured. Put `.start` as early as possible (e.g., at the top of `config/boot.rb` or an entry-point script) to see the full loading tree.
38
+
39
+ `RequireProfiler.stop` returns a totals hash: `{count: <number of files loaded>, time: <seconds>}`.
40
+
41
+ ### Scoping via patterns
42
+
43
+ Use `patterns:` and `exclude_patterns:` to limit what gets profiled. Both accept globs as recognized by [`File.fnmatch`](https://rubyapi.org/4.0/o/file#method-c-fnmatch) and are passed through to Require Hooks:
44
+
45
+ ```ruby
46
+ RequireProfiler.start(
47
+ patterns: ["#{Dir.pwd}/app/**/*.rb", "#{Dir.pwd}/lib/**/*.rb"],
48
+ exclude_patterns: ["*/vendor/*"]
49
+ )
50
+ ```
51
+
52
+ ### Output
53
+
54
+ `RequireProfiler.start` accepts the following keyword arguments:
55
+
56
+ - `output:` — `$stdout` (default), any IO-like object, or a file path (string).
57
+ - `format:` — `:text` (default), `:call_stack`, or `:json`. When `output:` is a file path with a `.json` extension, the JSON format is picked automatically.
58
+ - `patterns:` / `exclude_patterns:` — see above.
59
+
60
+ ## Output formats
61
+
62
+ ### Text (default)
63
+
64
+ A human-readable indented tree, one line per file, with self+children duration in milliseconds:
65
+
66
+ ```
67
+ lib/my_app.rb — 42.137ms
68
+ lib/my_app/config.rb — 3.211ms
69
+ lib/my_app/router.rb — 12.004ms
70
+ lib/my_app/routes.rb — 9.882ms
71
+ ```
72
+
73
+ Paths are printed relative to `Dir.pwd`, `Gem.dir`, or `Bundler.bundle_path` when they match—so vendored gem loads stay readable.
74
+
75
+ ### Collapsed call stacks
76
+
77
+ ```ruby
78
+ RequireProfiler.start(output: "tmp/require-profile.txt", format: :call_stack)
79
+ ```
80
+
81
+ Emits one line per stack in [Brendan Gregg's collapsed format](https://github.com/brendangregg/FlameGraph#2-fold-stacks) with per-frame self time in milliseconds. Pipe it to `flamegraph.pl`, [`inferno`](https://github.com/jonhoo/inferno), or any tool that consumes folded stacks.
82
+
83
+ ### JSON / Speedscope
84
+
85
+ ```ruby
86
+ RequireProfiler.start(output: "tmp/require-profile.json")
87
+ # or:
88
+ RequireProfiler.start(output: io, format: :json)
89
+ ```
90
+
91
+ Emits a profile that conforms to the [Speedscope file format schema](https://www.speedscope.app/file-format-schema.json).
92
+
93
+ **Note:** Writing JSON straight to `$stdout` is not supported.
94
+
95
+ ## Using with Speedscope
96
+
97
+ [Speedscope][speedscope] is an interactive flamegraph viewer that runs entirely in your browser (nothing is uploaded—the file is parsed locally).
98
+
99
+ 1. Generate a JSON profile:
100
+
101
+ ```ruby
102
+ RequireProfiler.start(output: "tmp/require-profile.json")
103
+ require "my_app"
104
+ RequireProfiler.stop
105
+ ```
106
+
107
+ 2. Open [https://www.speedscope.app/](https://www.speedscope.app/) and drag-and-drop `tmp/require-profile.json` onto the page (or use the "Browse" button).
108
+
109
+ 3. Switch to the **Left Heavy** view to see which `require` chains cost the most time, and use **Sandwich** view to find individual files that show up repeatedly.
110
+
111
+ Prefer to stay local? Install the CLI and it will open a local viewer for you:
112
+
113
+ ```sh
114
+ npm install -g speedscope
115
+ speedscope tmp/require-profile.json
116
+ ```
117
+
118
+ ## Contributing
119
+
120
+ Bug reports and pull requests are welcome on GitHub at [https://github.com/palkan/require-profiler](https://github.com/palkan/require-profiler).
121
+
122
+ ## Credits
123
+
124
+ This gem is generated via [`newgem` template](https://github.com/palkan/newgem) by [@palkan](https://github.com/palkan).
125
+
126
+ ## License
127
+
128
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
129
+
130
+ [speedscope]: https://www.speedscope.app/
131
+ [require-hooks]: https://github.com/ruby-next/require-hooks
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "require_profiler/version"
4
+ require "require_profiler"
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RequireProfiler
4
+ module Printer
5
+ # CallStack formatter prints collapsed stacks (Brendan Gregg's format)
6
+ class CallStack < Base
7
+ def flush(node, parts: [])
8
+ path = node.path.sub(prefix_stripper, "")
9
+ self_parts = path.split("/")
10
+
11
+ parts += self_parts.size.times.map { self_parts.take(_1 + 1).join("/") }
12
+ # We only show self-time, so exclude children
13
+ val = ((node.time - node.children.sum(&:time)) * 1000).round(3)
14
+
15
+ output << "#{parts.join(";")} #{val}\n"
16
+
17
+ node.children.each { flush(_1, parts:) }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RequireProfiler
6
+ module Printer
7
+ # JSON formatter converts call tacks into Speedscope
8
+ # compatible JSON on finish.
9
+ #
10
+ # Can only be used with rewindable IO.
11
+ class JSON < CallStack
12
+ def finish
13
+ output.rewind
14
+ stacks = output.read
15
+ output.rewind
16
+
17
+ frames = []
18
+ frame_index = {}
19
+
20
+ samples = []
21
+ weights = []
22
+
23
+ stacks.each_line do |line|
24
+ line = line.strip
25
+ next if line.empty?
26
+
27
+ sep = line.rindex(" ")
28
+ next unless sep
29
+
30
+ stack = line[0...sep]
31
+ weight = line[(sep + 1)..].to_f
32
+
33
+ sample = stack.split(";").map do |name|
34
+ frame_index[name] ||= begin
35
+ frames << {name: name}
36
+ frames.size - 1
37
+ end
38
+ end
39
+
40
+ samples << sample
41
+ weights << weight
42
+ end
43
+
44
+ total = weights.sum
45
+
46
+ profile = {
47
+ "$schema" => "https://www.speedscope.app/file-format-schema.json",
48
+ :shared => {frames: frames},
49
+ :profiles => [
50
+ {
51
+ type: "sampled",
52
+ unit: "milliseconds",
53
+ startValue: 0,
54
+ endValue: total,
55
+ samples:,
56
+ weights:
57
+ }
58
+ ]
59
+ }
60
+
61
+ output.write(::JSON.pretty_generate(profile))
62
+ super
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RequireProfiler
4
+ module Printer
5
+ class Text < Base
6
+ PAD = " "
7
+
8
+ def flush(node, indent: 0)
9
+ path = node.path.sub(prefix_stripper, "")
10
+ output << "#{PAD * indent}#{path} — #{time_to_duration(node.time)}\n"
11
+ node.children.each { flush(_1, indent: indent + 1) }
12
+
13
+ output.flush
14
+ end
15
+
16
+ private
17
+
18
+ # Converts seconds to human-readable milliseconds
19
+ def time_to_duration(time) = "#{(time * 1000).round(3)}ms"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RequireProfiler
4
+ module Printer
5
+ class Base
6
+ private attr_reader :output
7
+ private attr_reader :prefix_stripper
8
+
9
+ def initialize(output)
10
+ @output = output
11
+
12
+ # Identify prefixes for the project and the gems
13
+ prefixes = [::Dir.pwd]
14
+ prefixes << ::Gem.dir if defined?(::Gem.dir)
15
+ prefixes << ::Bundler.bundle_path if defined?(::Bundler.bundle_path)
16
+
17
+ @prefix_stripper = %r{^(#{prefixes.join("|")})/}
18
+ end
19
+
20
+ def flush(node)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def finish
25
+ output.close if output.respond_to?(:close) && output != $stdout
26
+ end
27
+ end
28
+
29
+ autoload :Text, "require_profiler/printer/text"
30
+ autoload :CallStack, "require_profiler/printer/call_stack"
31
+ autoload :JSON, "require_profiler/printer/json"
32
+
33
+ class << self
34
+ def resolve(output, format)
35
+ format ||= (output.is_a?(String) && File.extname(output) == ".json") ? :json : :text
36
+ output = File.open(output, "w+") if output.is_a?(String)
37
+
38
+ case format.to_sym
39
+ when :json then JSON.new(output)
40
+ when :call_stack then CallStack.new(output)
41
+ when :text then Text.new(output)
42
+ else
43
+ raise ArgumentError, "Unknown format specified: #{format}. Available formats: text, json, call_stack"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RequireProfiler
4
+ class Reporter
5
+ class Event < Struct.new(:type, :path, :time, keyword_init: true)
6
+ end
7
+
8
+ class Node < Struct.new(:path, :time, :children, keyword_init: true)
9
+ def initialize(...)
10
+ super
11
+ self.children ||= []
12
+ end
13
+ end
14
+
15
+ private attr_reader :stack, :totals, :printer, :processor, :queue
16
+
17
+ def initialize(printer:)
18
+ @stack = []
19
+ @totals = {count: 0, time: 0.0}
20
+ @printer = printer
21
+ @processor = nil
22
+ @queue = Queue.new
23
+
24
+ start_processor
25
+ end
26
+
27
+ def handle_event(event)
28
+ queue << event
29
+ end
30
+
31
+ def handle_event_sync(event)
32
+ if event.type == :start
33
+ node = Node.new(path: event.path, children: [])
34
+ stack.last&.children&.push(node)
35
+
36
+ stack << node
37
+ elsif event.type == :end
38
+ last = stack.pop
39
+ last.time = event.time
40
+
41
+ printer.flush(last) if stack.empty?
42
+
43
+ totals[:count] += 1
44
+ totals[:time] += event.time if stack.empty?
45
+ end
46
+ end
47
+
48
+ def finish
49
+ handle_event(Event.new(type: :stop))
50
+ processor.join
51
+
52
+ warn "Finished in the middle of requiring a file" unless stack.empty?
53
+
54
+ printer.finish
55
+ totals
56
+ end
57
+
58
+ private
59
+
60
+ def start_processor
61
+ @processor = Thread.new do
62
+ Thread.current.priority = -1
63
+
64
+ loop do
65
+ event = queue.pop
66
+
67
+ break if event.type == :stop
68
+
69
+ handle_event_sync(event)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RequireProfiler # :nodoc:
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RequireProfiler
4
+ autoload :Reporter, "require_profiler/reporter"
5
+ autoload :Printer, "require_profiler/printer"
6
+
7
+ class << self
8
+ attr_reader :reporter
9
+
10
+ def start(output: $stdout, format: nil, patterns: nil, exclude_patterns: nil)
11
+ raise ArgumentError, "There is already profiling in progress" if reporter
12
+
13
+ reporter = @reporter = Reporter.new(printer: Printer.resolve(output, format))
14
+
15
+ require "require-hooks/setup"
16
+
17
+ ::RequireHooks.around_load(patterns:, exclude_patterns:) do |path, &block|
18
+ start = Time.now
19
+
20
+ reporter.handle_event(Reporter::Event.new(type: :start, path:))
21
+
22
+ block.call
23
+ ensure
24
+ time = Time.now - start
25
+ reporter.handle_event(Reporter::Event.new(type: :end, path:, time:))
26
+ end
27
+ end
28
+
29
+ def stop
30
+ raise "No reporter defined. Are you sure you called RequireProfiler.start?" unless reporter
31
+
32
+ reporter.finish.tap do
33
+ @reporter = nil
34
+ end
35
+ end
36
+ end
37
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: require-profiler
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Vladimir Dementyev
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: require-hooks
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: bundler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.15'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.15'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '3.9'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '3.9'
68
+ description: 'Profile Ruby #require/#load/etc calls'
69
+ email:
70
+ - Vladimir Dementyev
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - CHANGELOG.md
76
+ - LICENSE.txt
77
+ - README.md
78
+ - lib/require-profiler.rb
79
+ - lib/require_profiler.rb
80
+ - lib/require_profiler/printer.rb
81
+ - lib/require_profiler/printer/call_stack.rb
82
+ - lib/require_profiler/printer/json.rb
83
+ - lib/require_profiler/printer/text.rb
84
+ - lib/require_profiler/reporter.rb
85
+ - lib/require_profiler/version.rb
86
+ homepage: https://github.com/palkan/require-profiler
87
+ licenses:
88
+ - MIT
89
+ metadata:
90
+ bug_tracker_uri: https://github.com/palkan/require-profiler/issues
91
+ changelog_uri: https://github.com/palkan/require-profiler/blob/master/CHANGELOG.md
92
+ documentation_uri: https://github.com/palkan/require-profiler
93
+ homepage_uri: https://github.com/palkan/require-profiler
94
+ source_code_uri: https://github.com/palkan/require-profiler
95
+ rdoc_options: []
96
+ require_paths:
97
+ - lib
98
+ required_ruby_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '3.1'
103
+ required_rubygems_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ requirements: []
109
+ rubygems_version: 3.6.9
110
+ specification_version: 4
111
+ summary: 'Profile Ruby #require/#load/etc calls'
112
+ test_files: []