vernier 1.7.1 → 1.8.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 356fbbcbb8c60616fe3a92c4eed26d7cac97f3a1e230e4904571f9666f4b64aa
4
- data.tar.gz: cb7f9542080591e8fe680ab7f1b5688f0c5265e8d32d8ce2d8d56efab639ded0
3
+ metadata.gz: 4be5edc549ea93934096d98e46c4ffcabc140d06e27303f6ec6b01de8201983c
4
+ data.tar.gz: a6322324723a2f31f15a0b7e98a6de72bf5c555e29e21951b7f466466ecedd6f
5
5
  SHA512:
6
- metadata.gz: 2bf9a5882a9494b23c6a695836ee253a04054c501bab46b20b12b73516d7b21c103d1bb306ccb2a6ac3b5dce61d882620e6abbc53e056d0fd3e9260e68e97c44
7
- data.tar.gz: e82f4becfb5a9e30cafebbe5271bd6b2e9883c3422eb5e61ee2ffae8589a0861f55a19b190c9d5a829894755a227438c7f4d2aa2225d55697f0f954738a49f10
6
+ metadata.gz: 92377006ae54cc5cc89934cc3c393fdfe02746035235b507196ef9d360b6d843c50db4382e0b53ec253f1d08f10f78e28d7daf10979f26187bb7c925933dec56
7
+ data.tar.gz: 0e963a838d3c319d93d73ae224c9e11caa51562583ef37f2d5fff40781281d30ee714273a9ce021c9e8285942825a3356388d2f68b51315fb5275c5bb96b49a6
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.6
data/Rakefile CHANGED
@@ -7,6 +7,7 @@ Rake::TestTask.new(:test) do |t|
7
7
  t.libs << "test"
8
8
  t.libs << "lib"
9
9
  t.test_files = FileList["test/**/test_*.rb"]
10
+ t.deps << :compile
10
11
  end
11
12
 
12
13
  task :console => :compile do
data/exe/vernier CHANGED
@@ -9,10 +9,9 @@ module Vernier
9
9
  module CLI
10
10
  class Metadata < Array
11
11
  require 'json'
12
- require 'base64'
13
12
 
14
13
  def to_s
15
- Base64.encode64(to_json)
14
+ [to_json].pack("m")
16
15
  end
17
16
  end
18
17
 
@@ -52,6 +51,9 @@ FLAGS:
52
51
  options[:metadata] ||= Metadata.new
53
52
  options[:metadata] << [key, value]
54
53
  end
54
+ o.on('--format [FORMAT]', String, "output format: firefox (default) or cpuprofile") do |output_format|
55
+ options[:format] = output_format
56
+ end
55
57
  end
56
58
  end
57
59
 
@@ -77,7 +79,7 @@ FLAGS:
77
79
 
78
80
  parsed_profile = Vernier::ParsedProfile.read_file(file)
79
81
 
80
- puts Vernier::Output::Top.new(parsed_profile).output
82
+ puts Vernier::Output::Top.new(parsed_profile, top).output
81
83
  puts Vernier::Output::FileListing.new(parsed_profile).output
82
84
  end
83
85
  end
@@ -88,6 +90,8 @@ run = Vernier::CLI.run(options)
88
90
  view = Vernier::CLI.view(options)
89
91
 
90
92
  case ARGV.shift
93
+ when "-v", "--version"
94
+ puts Vernier::VERSION
91
95
  when "run"
92
96
  run.parse!
93
97
  run.abort(run.help) if ARGV.empty?
@@ -1,6 +1,5 @@
1
1
  require "tempfile"
2
2
  require "vernier"
3
- require "base64"
4
3
  require "json"
5
4
 
6
5
  module Vernier
@@ -22,7 +21,7 @@ module Vernier
22
21
  allocation_interval = options.fetch(:allocation_interval, 0).to_i
23
22
  hooks = options.fetch(:hooks, "").split(",")
24
23
  metadata = if options[:metadata]
25
- JSON.parse(Base64.decode64(@options[:metadata])).to_h { |k, v| [k.to_sym, v] }
24
+ JSON.parse(@options[:metadata].unpack1("m")).to_h { |k, v| [k.to_sym, v] }
26
25
  else
27
26
  {}
28
27
  end
@@ -49,12 +48,16 @@ module Vernier
49
48
  end
50
49
  prefix = "profile-"
51
50
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
52
- suffix = ".vernier.json.gz"
51
+ suffix = if options[:format] == "cpuprofile"
52
+ ".vernier.cpuprofile"
53
+ else
54
+ ".vernier.json.gz"
55
+ end
53
56
 
54
57
  output_path = File.expand_path("#{output_dir}/#{prefix}#{timestamp}-#{$$}#{suffix}")
55
58
  end
56
59
 
57
- result.write(out: output_path)
60
+ result.write(out: output_path, format: options[:format] || "firefox")
58
61
 
59
62
  STDERR.puts(result.inspect)
60
63
  STDERR.puts("written to #{output_path}")
@@ -11,6 +11,7 @@ module Vernier
11
11
 
12
12
  @mode = mode
13
13
  @out = options[:out]
14
+ @format = options[:format]
14
15
 
15
16
  @markers = []
16
17
  @hooks = []
@@ -142,7 +143,7 @@ module Vernier
142
143
  #result.instance_variable_set(:@markers, markers)
143
144
 
144
145
  if @out
145
- result.write(out: @out)
146
+ result.write(out: @out, format: @format)
146
147
  end
147
148
 
148
149
  result
@@ -18,7 +18,7 @@ module Vernier
18
18
  result = Vernier.trace(interval:, allocation_interval:, hooks: [:rails]) do
19
19
  @app.call(env)
20
20
  end
21
- body = result.to_gecko(gzip: true)
21
+ body = result.to_firefox(gzip: true)
22
22
  filename = "#{request.path.gsub("/", "_")}_#{DateTime.now.strftime("%Y-%m-%d-%H-%M-%S")}.vernier.json.gz"
23
23
  headers = {
24
24
  "Content-Type" => "application/octet-stream",
@@ -0,0 +1,145 @@
1
+ require "json"
2
+
3
+ module Vernier
4
+ module Output
5
+ class Cpuprofile
6
+ def initialize(profile)
7
+ @profile = profile
8
+ end
9
+
10
+ def output
11
+ JSON.generate(data)
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :profile
17
+
18
+ def ns_to_us(timestamp)
19
+ (timestamp / 1_000.0).to_i
20
+ end
21
+
22
+ def data
23
+ # Get the main thread data (cpuprofile format is single-threaded)
24
+ main_thread = profile.main_thread
25
+ return empty_profile if main_thread.nil?
26
+
27
+ samples = main_thread[:samples]
28
+ timestamps = main_thread[:timestamps] || []
29
+
30
+ nodes = build_nodes
31
+ sample_node_ids = samples.map { |stack_idx| stack_to_node_id(stack_idx) }
32
+ time_deltas = calculate_time_deltas(timestamps)
33
+
34
+ {
35
+ nodes: nodes,
36
+ startTime: ns_to_us(profile.started_at),
37
+ endTime: ns_to_us(profile.end_time),
38
+ samples: sample_node_ids,
39
+ timeDeltas: time_deltas
40
+ }
41
+ end
42
+
43
+ def empty_profile
44
+ {
45
+ nodes: [root_node],
46
+ startTime: 0,
47
+ endTime: 0,
48
+ samples: [],
49
+ timeDeltas: []
50
+ }
51
+ end
52
+
53
+ def build_nodes
54
+ stack_table = profile.stack_table
55
+
56
+ nodes = []
57
+ @node_id_map = {}
58
+
59
+ root = root_node
60
+ nodes << root
61
+ @node_id_map[nil] = 0
62
+
63
+ stack_table.stack_count.times do |stack_idx|
64
+ create_node_for_stack(stack_idx, nodes, stack_table)
65
+ end
66
+
67
+ nodes
68
+ end
69
+
70
+ def root_node
71
+ {
72
+ id: 0,
73
+ callFrame: {
74
+ functionName: "(root)",
75
+ scriptId: "0",
76
+ url: "",
77
+ lineNumber: -1,
78
+ columnNumber: -1,
79
+ },
80
+ hitCount: 0,
81
+ children: []
82
+ }
83
+ end
84
+
85
+ def create_node_for_stack(stack_idx, nodes, stack_table)
86
+ return @node_id_map[stack_idx] if @node_id_map.key?(stack_idx)
87
+
88
+ frame_idx = stack_table.stack_frame_idx(stack_idx)
89
+ parent_stack_idx = stack_table.stack_parent_idx(stack_idx)
90
+
91
+ parent_node_id = if parent_stack_idx.nil?
92
+ 0 # root node
93
+ else
94
+ create_node_for_stack(parent_stack_idx, nodes, stack_table)
95
+ end
96
+
97
+ func_idx = stack_table.frame_func_idx(frame_idx)
98
+ line = stack_table.frame_line_no(frame_idx) - 1
99
+
100
+ func_name = stack_table.func_name(func_idx)
101
+ filename = stack_table.func_filename(func_idx)
102
+
103
+ node_id = nodes.length
104
+ node = {
105
+ id: node_id,
106
+ callFrame: {
107
+ functionName: func_name || "(anonymous)",
108
+ scriptId: func_idx.to_s,
109
+ url: filename || "",
110
+ lineNumber: line || 0,
111
+ columnNumber: 0
112
+ },
113
+ hitCount: 0,
114
+ children: []
115
+ }
116
+
117
+ nodes << node
118
+ @node_id_map[stack_idx] = node_id
119
+
120
+ parent_node = nodes[parent_node_id]
121
+ parent_node[:children] << node_id unless parent_node[:children].include?(node_id)
122
+ end
123
+
124
+ def stack_to_node_id(stack_idx)
125
+ @node_id_map[stack_idx] || 0
126
+ end
127
+
128
+ def calculate_time_deltas(timestamps)
129
+ return [] if timestamps.empty?
130
+
131
+ deltas = []
132
+
133
+ timestamps.each_with_index do |timestamp, i|
134
+ if i == 0
135
+ deltas << 0
136
+ else
137
+ deltas << ns_to_us(timestamp - timestamps[i - 1])
138
+ end
139
+ end
140
+
141
+ deltas
142
+ end
143
+ end
144
+ end
145
+ end
@@ -8,35 +8,59 @@ require_relative "filename_filter"
8
8
  module Vernier
9
9
  module Output
10
10
  # https://profiler.firefox.com/
11
- # https://github.com/firefox-devtools/profiler/blob/main/src/types/profile.js
11
+ # https://github.com/firefox-devtools/profiler/blob/main/src/types/profile.ts
12
12
  class Firefox
13
13
  class Categorizer
14
+ RAILS_COMPONENTS = %w[ activesupport activemodel activerecord actionview
15
+ actionpack activejob actionmailer actioncable
16
+ activestorage actionmailbox actiontext railties ]
17
+
18
+ AVAILABLE_COLORS = %w[ transparent purple green orange yellow lightblue
19
+ blue brown magenta red lightred darkgrey grey ]
20
+
21
+ ORDERED_CATEGORIES = %w[ Kernel Rails gem Ruby ] # This is in the order of preference
22
+
14
23
  attr_reader :categories
24
+
15
25
  def initialize
16
26
  @categories = []
17
27
  @categories_by_name = {}
18
28
 
19
- add_category(name: "Ruby", color: "grey") do |c|
20
- rails_components = %w[ activesupport activemodel activerecord
21
- actionview actionpack activejob actionmailer actioncable
22
- activestorage actionmailbox actiontext railties ]
29
+ add_category(name: "Kernel", color: "magenta") do |c|
23
30
  c.add_subcategory(
24
- name: "Rails",
25
- matcher: gem_path(*rails_components)
31
+ name: "Kernel",
32
+ matcher: starts_with("<internal")
26
33
  )
34
+ end
35
+
36
+ add_category(name: "gem", color: "lightblue") do |c|
27
37
  c.add_subcategory(
28
38
  name: "gem",
29
39
  matcher: starts_with(*Gem.path)
30
40
  )
41
+ end
42
+
43
+ add_category(name: "Rails", color: "red") do |c|
44
+ RAILS_COMPONENTS.each do |subcategory|
45
+ c.add_subcategory(
46
+ name: subcategory,
47
+ matcher: gem_path(subcategory)
48
+ )
49
+ end
50
+ end
51
+
52
+ add_category(name: "Ruby", color: "purple") do |c|
31
53
  c.add_subcategory(
32
54
  name: "stdlib",
33
55
  matcher: starts_with(RbConfig::CONFIG["rubylibdir"])
34
56
  )
35
57
  end
58
+
36
59
  add_category(name: "Idle", color: "transparent")
37
60
  add_category(name: "Stalled", color: "transparent")
38
61
 
39
62
  add_category(name: "GC", color: "red")
63
+
40
64
  add_category(name: "cfunc", color: "yellow", matcher: "<cfunc>")
41
65
 
42
66
  add_category(name: "Thread", color: "grey")
@@ -69,7 +93,10 @@ module Vernier
69
93
 
70
94
  class Category
71
95
  attr_reader :idx, :name, :color, :matcher, :subcategories
96
+
72
97
  def initialize(idx, name:, color:, matcher: nil)
98
+ raise ArgumentError, "invalid color: #{color}" if color && AVAILABLE_COLORS.none?(color)
99
+
73
100
  @idx = idx
74
101
  @name = name
75
102
  @color = color
@@ -315,19 +342,13 @@ module Vernier
315
342
  func_implementations[func_idx]
316
343
  end
317
344
 
318
- cfunc_category = @categorizer.get_category("cfunc")
319
- ruby_category = @categorizer.get_category("Ruby")
320
345
  func_categories, func_subcategories = [], []
321
346
  filenames.each do |filename|
322
- if filename == "<cfunc>"
323
- func_categories << cfunc_category
324
- func_subcategories << 0
325
- else
326
- func_categories << ruby_category
327
- subcategory = ruby_category.subcategories.detect {|c| c.matches?(filename) }&.idx || 0
328
- func_subcategories << subcategory
329
- end
347
+ category, subcategory = categorize_filename(filename)
348
+ func_categories << category
349
+ func_subcategories << subcategory
330
350
  end
351
+
331
352
  @frame_categories = @stack_table_hash[:frame_table].fetch(:func).map do |func_idx|
332
353
  func_categories[func_idx]
333
354
  end
@@ -336,6 +357,32 @@ module Vernier
336
357
  end
337
358
  end
338
359
 
360
+ def categorize_filename(filename)
361
+ return cfunc_category_and_subcategory if filename == "<cfunc>"
362
+
363
+ category, subcategory = find_category_and_subcategory(filename, Categorizer::ORDERED_CATEGORIES)
364
+ return category, subcategory if subcategory
365
+
366
+ ruby_category_and_subcategory
367
+ end
368
+
369
+ def cfunc_category_and_subcategory
370
+ [@categorizer.get_category("cfunc"), 0]
371
+ end
372
+
373
+ def ruby_category_and_subcategory
374
+ [@categorizer.get_category("Ruby"), 0]
375
+ end
376
+
377
+ def find_category_and_subcategory(filename, categories)
378
+ categories.each do |category_name|
379
+ category = @categorizer.get_category(category_name)
380
+ subcategory = category.subcategories.detect {|c| c.matches?(filename) }&.idx
381
+ return category, subcategory if subcategory
382
+ end
383
+ [nil, nil]
384
+ end
385
+
339
386
  def filter_filenames(filenames)
340
387
  filter = FilenameFilter.new
341
388
  filenames.map do |filename|
@@ -3,14 +3,16 @@
3
3
  module Vernier
4
4
  module Output
5
5
  class Top
6
- def initialize(profile)
6
+ def initialize(profile, row_limit)
7
7
  @profile = profile
8
+ @row_limit = row_limit
8
9
  end
9
10
 
10
11
  class Table
11
- def initialize(header)
12
+ def initialize(header, row_limit)
12
13
  @header = header
13
14
  @rows = []
15
+ @row_limit = row_limit
14
16
  yield self
15
17
  end
16
18
 
@@ -24,7 +26,7 @@ module Vernier
24
26
  row_separator,
25
27
  format_row(@header),
26
28
  row_separator
27
- ] + @rows.map do |row|
29
+ ] + @rows.first(@row_limit).map do |row|
28
30
  format_row(row)
29
31
  end + [row_separator]
30
32
  ).join("\n")
@@ -70,7 +72,7 @@ module Vernier
70
72
  top_by_self[name] += weight
71
73
  end
72
74
 
73
- Table.new %w[Samples % name] do |t|
75
+ Table.new %w[Samples % name], @row_limit do |t|
74
76
  top_by_self.sort_by(&:last).reverse.each do |frame, samples|
75
77
  pct = 100.0 * samples / total
76
78
  t << [samples.to_s, pct.round(1).to_s, frame]
@@ -29,13 +29,25 @@ module Vernier
29
29
  (current_time_real_ns - current_time_mono_ns + started_at_mono_ns)
30
30
  end
31
31
 
32
- def to_gecko(gzip: false)
32
+ def to_firefox(gzip: false)
33
33
  Output::Firefox.new(self).output(gzip:)
34
34
  end
35
+ alias_method :to_gecko, :to_firefox
35
36
 
36
- def write(out:)
37
- gzip = out.end_with?(".gz")
38
- File.binwrite(out, to_gecko(gzip:))
37
+ def to_cpuprofile
38
+ Output::Cpuprofile.new(self).output
39
+ end
40
+
41
+ def write(out:, format: "firefox")
42
+ case format
43
+ when "cpuprofile"
44
+ File.binwrite(out, to_cpuprofile)
45
+ when nil, "firefox"
46
+ gzip = out.end_with?(".gz")
47
+ File.binwrite(out, to_firefox(gzip:))
48
+ else
49
+ raise ArgumentError, "unknown format: #{format}"
50
+ end
39
51
  end
40
52
 
41
53
  def elapsed_seconds
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vernier
4
- VERSION = "1.7.1"
4
+ VERSION = "1.8.1"
5
5
  end
data/lib/vernier.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "vernier/result"
8
8
  require_relative "vernier/hooks"
9
9
  require_relative "vernier/vernier"
10
10
  require_relative "vernier/output/firefox"
11
+ require_relative "vernier/output/cpuprofile"
11
12
  require_relative "vernier/output/top"
12
13
  require_relative "vernier/output/file_listing"
13
14
  require_relative "vernier/output/filename_filter"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vernier
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.1
4
+ version: 1.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Hawthorn
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-05-16 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -61,6 +61,7 @@ extensions:
61
61
  - ext/vernier/extconf.rb
62
62
  extra_rdoc_files: []
63
63
  files:
64
+ - ".ruby-version"
64
65
  - CODE_OF_CONDUCT.md
65
66
  - Gemfile
66
67
  - LICENSE.txt
@@ -93,6 +94,7 @@ files:
93
94
  - lib/vernier/hooks/memory_usage.rb
94
95
  - lib/vernier/marker.rb
95
96
  - lib/vernier/middleware.rb
97
+ - lib/vernier/output/cpuprofile.rb
96
98
  - lib/vernier/output/file_listing.rb
97
99
  - lib/vernier/output/filename_filter.rb
98
100
  - lib/vernier/output/firefox.rb
@@ -103,7 +105,6 @@ files:
103
105
  - lib/vernier/stack_table_helpers.rb
104
106
  - lib/vernier/thread_names.rb
105
107
  - lib/vernier/version.rb
106
- - vernier.gemspec
107
108
  homepage: https://github.com/jhawthorn/vernier
108
109
  licenses:
109
110
  - MIT
@@ -125,7 +126,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
125
126
  - !ruby/object:Gem::Version
126
127
  version: '0'
127
128
  requirements: []
128
- rubygems_version: 3.6.2
129
+ rubygems_version: 3.7.2
129
130
  specification_version: 4
130
131
  summary: A next generation CRuby profiler
131
132
  test_files: []
data/vernier.gemspec DELETED
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/vernier/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "vernier"
7
- spec.version = Vernier::VERSION
8
- spec.authors = ["John Hawthorn"]
9
- spec.email = ["john@hawthorn.email"]
10
-
11
- spec.summary = "A next generation CRuby profiler"
12
- spec.description = "Next-generation Ruby 3.2.1+ sampling profiler. Tracks multiple threads, GVL activity, GC pauses, idle time, and more."
13
- spec.homepage = "https://github.com/jhawthorn/vernier"
14
- spec.license = "MIT"
15
-
16
- unless ENV["IGNORE_REQUIRED_RUBY_VERSION"]
17
- spec.required_ruby_version = ">= 3.2.1"
18
- end
19
-
20
- spec.metadata["homepage_uri"] = spec.homepage
21
- spec.metadata["source_code_uri"] = spec.homepage
22
- spec.metadata["changelog_uri"] = spec.homepage
23
-
24
- # Specify which files should be added to the gem when it is released.
25
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
26
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
27
- `git ls-files -z`.split("\x0").reject do |f|
28
- (f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
29
- end
30
- end
31
- spec.bindir = "exe"
32
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
- spec.require_paths = ["lib"]
34
- spec.extensions = ["ext/vernier/extconf.rb"]
35
-
36
- spec.add_development_dependency "activesupport"
37
- spec.add_development_dependency "gvltest"
38
- spec.add_development_dependency "rack"
39
- end