singed 0.3.0 → 0.4.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.
@@ -1,9 +1,14 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
1
4
  require "singed/backtrace_cleaner_ext"
2
5
  require "singed/controller_ext"
3
6
 
4
7
  module Singed
5
8
  class Railtie < Rails::Railtie
6
9
  initializer "singed.configure_rails_initialization" do |app|
10
+ # Rails instance_execs initializer blocks on the railtie instance.
11
+ #: self as Singed::Railtie
7
12
  self.class.init!
8
13
 
9
14
  app.middleware.use Singed::RackMiddleware
@@ -13,6 +18,7 @@ module Singed
13
18
  end
14
19
  end
15
20
 
21
+ #: () -> void
16
22
  def self.init!
17
23
  Singed.output_directory ||= Rails.root.join("tmp/speedscope")
18
24
  Singed.backtrace_cleaner = Rails.backtrace_cleaner
data/lib/singed/report.rb CHANGED
@@ -1,5 +1,9 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
1
4
  module Singed
2
5
  class Report < StackProf::Report
6
+ #: () -> void
3
7
  def filter!
4
8
  # copy and paste from StackProf::Report#print_graphviz that does filtering
5
9
  # mark_stack = []
@@ -27,11 +31,13 @@ module Singed
27
31
  # list.each{ |_addr, frame| frame[:edges]&.delete_if{ |k,v| list[k].nil? } }
28
32
  # end copy-pasted section
29
33
 
30
- list.each do |_addr, frame|
34
+ list.each_value do |frame|
31
35
  frame[:file] = Singed.filter_line(frame[:file])
32
36
  end
33
37
 
34
- @data[:frames] = list
38
+ # frames(true) above has already raised NoMethodError if @data were nil.
39
+ data = @data #: as !nil
40
+ data[:frames] = list
35
41
  end
36
42
  end
37
43
  end
data/lib/singed/rspec.rb CHANGED
@@ -1,3 +1,6 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
1
4
  require "singed"
2
5
 
3
6
  RSpec.configure do |config|
@@ -0,0 +1,54 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Singed
5
+ module Sidekiq
6
+ class ServerMiddleware
7
+ include ::Sidekiq::ServerMiddleware
8
+
9
+ TRUTHY_STRINGS = %w(true 1 yes).freeze
10
+
11
+ #: [Result] (::Sidekiq::Job, Hash[String, untyped], String) { () -> Result } -> Result
12
+ def call(job_instance, job_payload, _queue, &block)
13
+ return block.call unless capture_flamegraph?(job_instance, job_payload)
14
+
15
+ flamegraph(flamegraph_label(job_instance, job_payload), &block)
16
+ end
17
+
18
+ private
19
+
20
+ # A job class's capture_flamegraph? hook may return any value; only its truthiness counts.
21
+ #: (::Sidekiq::Job, Hash[String, untyped]) -> top
22
+ def capture_flamegraph?(job_instance, job_payload)
23
+ return TRUTHY_STRINGS.include?(job_payload["x-singed"].to_s) if job_payload.key?("x-singed")
24
+
25
+ # The optional capture_flamegraph? hook is duck-typed, which Sorbet can't express.
26
+ job_class = job_class(job_instance, job_payload) #: as untyped
27
+ return false unless job_class
28
+ return job_class.capture_flamegraph?(job_payload) if job_class.respond_to?(:capture_flamegraph?)
29
+
30
+ TRUTHY_STRINGS.include?(ENV.fetch("SINGED_MIDDLEWARE_ALWAYS_CAPTURE", "false"))
31
+ end
32
+
33
+ #: (::Sidekiq::Job, Hash[String, untyped]) -> String
34
+ def flamegraph_label(job_instance, job_payload)
35
+ [job_class(job_instance, job_payload), job_payload["jid"]].compact.join("--")
36
+ end
37
+
38
+ #: (::Sidekiq::Job, Hash[String, untyped]) -> Class[top]?
39
+ def job_class(job_instance, job_payload)
40
+ job_class = job_payload.fetch("wrapped", job_instance) # ActiveJob
41
+ return job_class if job_class.is_a?(Class)
42
+ return job_class.class if job_class.is_a?(::Sidekiq::Job)
43
+ # Sidekiq payloads carry the job class as a string, so it can only be resolved at runtime.
44
+ # rubocop:disable Sorbet/ConstantsFromStrings
45
+ return job_class.constantize if job_class.respond_to?(:constantize)
46
+
47
+ Object.const_get(job_class.to_s)
48
+ # rubocop:enable Sorbet/ConstantsFromStrings
49
+ rescue NameError
50
+ nil
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,88 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "rbconfig"
5
+ require "tmpdir"
6
+
7
+ module Singed
8
+ module Speedscope
9
+ # Take latest version from https://github.com/jlfwong/speedscope/releases
10
+ # that have ZIP archive with self-contained version published
11
+ VERSION = "1.24.0"
12
+
13
+ class << self
14
+ #: () -> String
15
+ def bundled_index_html
16
+ File.join(File.expand_path("../..", __dir__), "vendor", "speedscope", "index.html")
17
+ end
18
+
19
+ #: (String | Pathname) -> String
20
+ def open_command(profile_path)
21
+ if File.exist?(bundled_index_html)
22
+ "#{os_open_command} file://#{bundled_index_html}#localProfilePath=#{profile_path}"
23
+ else
24
+ "npx speedscope #{profile_path}"
25
+ end
26
+ end
27
+
28
+ #: (String | Pathname) -> bool?
29
+ def open(profile_path)
30
+ profile_path = profile_path.to_s
31
+
32
+ if File.exist?(bundled_index_html)
33
+ open_with_bundled_speedscope(profile_path)
34
+ else
35
+ open_with_npx(profile_path)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ #: (String) -> bool?
42
+ def open_with_npx(profile_path)
43
+ system("npx", "speedscope", profile_path)
44
+ end
45
+
46
+ # Based on speedscope CLI code (MIT license)
47
+ # See https://github.com/jlfwong/speedscope/blob/3613918de0dd55a263d0d04f85b0c8c2039c7bee/bin/cli.mjs
48
+ #: (String) -> bool?
49
+ def open_with_bundled_speedscope(profile_path)
50
+ source_buffer = File.binread(profile_path)
51
+ filename = File.basename(profile_path)
52
+
53
+ source_base64 = [source_buffer].pack("m0")
54
+ js_source = "speedscope.loadFileFromBase64(#{filename.inspect}, #{source_base64.inspect})"
55
+
56
+ file_prefix = "speedscope-#{Time.now.to_i}-#{Process.pid}"
57
+ js_path = File.join(Dir.tmpdir, "#{file_prefix}.js")
58
+ File.write(js_path, js_source)
59
+
60
+ url_to_open = "file://#{File.expand_path(bundled_index_html)}#localProfilePath=#{js_path}"
61
+
62
+ # See https://github.com/jlfwong/speedscope/blob/3613918de0dd55a263d0d04f85b0c8c2039c7bee/bin/cli.mjs#L96-L105
63
+ host_os = RbConfig::CONFIG["host_os"]
64
+ if host_os =~ /mswin|mingw|cygwin/ || host_os =~ /darwin/
65
+ html_path = File.join(Dir.tmpdir, "#{file_prefix}.html")
66
+ File.write(html_path, "<script>window.location=#{url_to_open.inspect}</script>")
67
+ url_to_open = "file://#{html_path}"
68
+ end
69
+
70
+ system os_open_command, url_to_open
71
+ end
72
+
73
+ #: () -> String
74
+ def os_open_command
75
+ case host_os = RbConfig::CONFIG["host_os"]
76
+ when /mswin|mingw|cygwin/
77
+ "start"
78
+ when /darwin/
79
+ "open"
80
+ when /linux|bsd/
81
+ "xdg-open"
82
+ else
83
+ raise "Unsupported OS to open browser: #{host_os}"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,122 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Singed
5
+ # Converts a Vernier::Result to speedscope's file format, with a profile for each thread:
6
+ # https://github.com/jlfwong/speedscope/blob/v1.24.0/src/lib/file-format-spec.ts
7
+ class VernierReport
8
+ # Vernier keeps sampling threads that are waiting, and categorizes those samples. Topping their
9
+ # stacks with one of these frames keeps waiting from reading as time spent running Ruby code.
10
+ CATEGORY_FRAMES = {
11
+ 1 => { name: "(idle)" }, # sleeping, or waiting on I/O or a lock
12
+ 2 => { name: "(waiting for GVL)" }, # ready to run, but another thread holds the GVL
13
+ }.freeze #: Hash[Integer, Hash[Symbol, String]]
14
+
15
+ # Not Vernier::Result: apps' Tapioca evaluates these sigs even when Vernier isn't loaded.
16
+ #: (untyped) -> void
17
+ def initialize(result)
18
+ @result = result
19
+ @frames = [] #: Array[Hash[Symbol, untyped]]
20
+ @func_frame_indexes = {} #: Hash[Integer, Integer]
21
+ @category_frame_indexes = {} #: Hash[Integer, Integer]
22
+ @stacks = {} #: Hash[[Integer, Integer], Array[Integer]]
23
+ end
24
+
25
+ #: (IO | StringIO) -> void
26
+ def print_json(io)
27
+ io.write(JSON.generate(to_h))
28
+ end
29
+
30
+ #: () -> Hash[Symbol, untyped]
31
+ def to_h
32
+ interval = @result.meta.fetch(:interval)
33
+ # Threads that never ran while profiling have no samples, so would only add empty profiles.
34
+ threads = @result.threads.values.select { |thread| thread[:is_start] || thread[:samples].any? }
35
+ profiles = threads.map { |thread| profile(thread, interval) }
36
+
37
+ {
38
+ "$schema": "https://www.speedscope.app/file-format-schema.json",
39
+ shared: { frames: @frames },
40
+ profiles:,
41
+ # The thread that started profiling is the one that ran the profiled code.
42
+ activeProfileIndex: threads.index { |thread| thread[:is_start] },
43
+ }
44
+ end
45
+
46
+ private
47
+
48
+ #: (Hash[Symbol, untyped], Integer) -> Hash[Symbol, untyped]
49
+ def profile(thread, interval)
50
+ samples = thread[:samples].zip(thread[:sample_categories]).map do |stack_idx, category|
51
+ stack(stack_idx, category)
52
+ end
53
+ # Vernier merges consecutive samples of the same stack into one, counting them in its weight.
54
+ weights = thread[:weights].map { |weight| weight * interval }
55
+
56
+ {
57
+ type: "sampled",
58
+ name: utf8(thread[:name]),
59
+ unit: "microseconds",
60
+ startValue: 0,
61
+ endValue: weights.sum,
62
+ samples:,
63
+ weights:,
64
+ }
65
+ end
66
+
67
+ # Vernier links each stack to its parent, but speedscope lists a stack's frames from the root.
68
+ #: (Integer, Integer) -> Array[Integer]
69
+ def stack(stack_idx, category)
70
+ @stacks[[stack_idx, category]] ||= begin
71
+ frames = [] #: Array[Integer]
72
+ idx = stack_idx #: Integer?
73
+ while idx
74
+ frames << func_frame_index(stack_table.frame_func_idx(stack_table.stack_frame_idx(idx)))
75
+ idx = stack_table.stack_parent_idx(idx)
76
+ end
77
+ frames.reverse!
78
+ frames << category_frame_index(category) if CATEGORY_FRAMES.key?(category)
79
+ frames
80
+ end
81
+ end
82
+
83
+ # One frame per method rather than per line, so each method is a single box in the flamegraph.
84
+ #: (Integer) -> Integer
85
+ def func_frame_index(func_idx)
86
+ @func_frame_indexes[func_idx] ||= begin
87
+ frame = {
88
+ name: utf8(stack_table.func_name(func_idx)),
89
+ file: Singed.filter_line(utf8(stack_table.func_filename(func_idx))),
90
+ } #: Hash[Symbol, untyped]
91
+ line = stack_table.func_first_lineno(func_idx)
92
+ frame[:line] = line if line.positive? # C functions have no line
93
+ add_frame(frame)
94
+ end
95
+ end
96
+
97
+ #: (Integer) -> Integer
98
+ def category_frame_index(category)
99
+ @category_frame_indexes[category] ||= add_frame(CATEGORY_FRAMES.fetch(category))
100
+ end
101
+
102
+ #: (Hash[Symbol, untyped]) -> Integer
103
+ def add_frame(frame)
104
+ @frames << frame
105
+ @frames.size - 1
106
+ end
107
+
108
+ # JSON needs valid UTF-8. Vernier guesses its stack table's strings are UTF-8, so they may need scrubbing:
109
+ # https://github.com/jhawthorn/vernier/blob/v1.11.0/ext/vernier/stack_table.cc#L179-L191
110
+ # It names threads that have no name after Thread#inspect, which is binary.
111
+ #: (String) -> String
112
+ def utf8(string)
113
+ string = string.dup.force_encoding(Encoding::UTF_8) if string.encoding == Encoding::BINARY
114
+ string.scrub
115
+ end
116
+
117
+ #: () -> untyped
118
+ def stack_table
119
+ @result.stack_table
120
+ end
121
+ end
122
+ end
data/lib/singed.rb CHANGED
@@ -1,53 +1,108 @@
1
+ # typed: strict
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require "json"
4
5
  require "stackprof"
5
6
 
6
7
  module Singed
7
- extend self
8
+ # Methods defined with plain `def` below are both module methods (Singed.start) and public
9
+ # instance methods of Singed, which is how the gem has shipped since its first release.
10
+ # `class << self` would remove those instance methods for anyone who includes or extends Singed.
11
+ extend self # rubocop:disable Style/ModuleFunction
8
12
 
9
13
  # Where should flamegraphs be saved?
14
+ #: (String | Pathname | nil) -> void
10
15
  def output_directory=(directory)
11
- @output_directory = Pathname.new(directory)
16
+ @output_directory = directory && Pathname.new(directory) #: Pathname?
12
17
  end
13
18
 
19
+ #: () -> Pathname?
14
20
  def self.output_directory
15
21
  @output_directory
16
22
  end
17
23
 
24
+ #: (bool?) -> void
18
25
  def enabled=(enabled)
19
- @enabled = enabled
26
+ @enabled = enabled #: bool?
20
27
  end
21
28
 
29
+ #: () -> bool?
22
30
  def enabled?
23
31
  return @enabled if defined?(@enabled)
24
32
 
25
33
  @enabled = true
26
34
  end
27
35
 
36
+ # Which profiler records flamegraphs that aren't given one: :stackprof or :vernier.
37
+ #: (Symbol) -> void
38
+ def profiler=(profiler)
39
+ Flamegraph.load_profiler(profiler)
40
+ @profiler = profiler #: Symbol?
41
+ end
42
+
43
+ #: () -> Symbol
44
+ def profiler
45
+ @profiler || :stackprof
46
+ end
47
+
48
+ # Not ActiveSupport::BacktraceCleaner: apps' Tapioca evaluates these sigs even when ActiveSupport isn't loaded.
49
+ #: (untyped) -> void
28
50
  def backtrace_cleaner=(backtrace_cleaner)
29
- @backtrace_cleaner = backtrace_cleaner
51
+ @backtrace_cleaner = backtrace_cleaner #: untyped
30
52
  end
31
53
 
54
+ #: () -> untyped
32
55
  def backtrace_cleaner
33
56
  @backtrace_cleaner
34
57
  end
35
58
 
59
+ #: (String) -> bool
36
60
  def silence_line?(line)
37
- return backtrace_cleaner.silence_line?(line) if backtrace_cleaner
61
+ cleaner = backtrace_cleaner
62
+ return cleaner.silence_line?(line) if cleaner
38
63
 
39
64
  false
40
65
  end
41
66
 
67
+ #: (String) -> String
42
68
  def filter_line(line)
43
- return backtrace_cleaner.filter_line(line) if backtrace_cleaner
69
+ cleaner = backtrace_cleaner
70
+ return cleaner.filter_line(line) if cleaner
44
71
 
45
72
  line
46
73
  end
47
74
 
75
+ #: (?String?, ?ignore_gc: bool, ?interval: Integer, ?profiler: Symbol?) -> Flamegraph?
76
+ def start(label = nil, ignore_gc: false, interval: 1000, profiler: nil)
77
+ return unless enabled?
78
+ return if profiling?
79
+
80
+ @current_flamegraph = Flamegraph.new(label:, ignore_gc:, interval:, profiler:)
81
+ @current_flamegraph.tap(&:start)
82
+ end
83
+
84
+ #: () -> Flamegraph?
85
+ def stop
86
+ return nil unless profiling?
87
+
88
+ # profiling? is only true while @current_flamegraph is set.
89
+ flamegraph = @current_flamegraph #: as !nil
90
+ @current_flamegraph = nil #: Flamegraph?
91
+ flamegraph.stop
92
+ flamegraph.save
93
+ flamegraph
94
+ end
95
+
96
+ #: () -> bool
97
+ def profiling?
98
+ @current_flamegraph&.started? || false
99
+ end
100
+
48
101
  autoload :Flamegraph, "singed/flamegraph"
49
102
  autoload :Report, "singed/report"
50
103
  autoload :RackMiddleware, "singed/rack_middleware"
104
+ autoload :Speedscope, "singed/speedscope"
105
+ autoload :VernierReport, "singed/vernier_report"
51
106
  end
52
107
 
53
108
  require "singed/kernel_ext"
data/singed.gemspec CHANGED
@@ -3,26 +3,23 @@
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "singed"
5
5
 
6
- spec.version = "0.3.0"
6
+ spec.version = "0.4.0"
7
7
  spec.license = "MIT"
8
- spec.authors = ["Josh Nichols"]
9
- spec.email = ["josh.nichols@gusto.com"]
8
+ spec.authors = ['Gusto Engineers']
9
+ spec.email = ['dev@gusto.com']
10
10
  spec.summary = "Quick and easy way to get flamegraphs from a specific part of your code base"
11
- spec.required_ruby_version = ">= 2.7.0"
11
+ spec.required_ruby_version = ">= 3.3"
12
12
  spec.homepage = "https://github.com/rubyatscale/singed"
13
13
  spec.metadata = {
14
14
  "source_code_uri" => "https://github.com/rubyatscale/singed",
15
15
  "bug_tracker_uri" => "https://github.com/rubyatscale/singed/issues",
16
- "homepage_uri" => "https://github.com/rubyatscale/singed"
16
+ "homepage_uri" => "https://github.com/rubyatscale/singed",
17
17
  }
18
18
 
19
- spec.files = Dir["README.md", "*.gemspec", "lib/**/*", "exe/**/*"]
19
+ spec.files = Dir["README.md", "*.gemspec", "lib/**/*", "exe/**/*", "vendor/speedscope/**/*"]
20
20
  spec.bindir = "exe"
21
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
21
+ spec.executables = spec.files.grep(%r(\Aexe/)) { |f| File.basename(f) }
22
22
  spec.require_paths = ["lib"]
23
23
 
24
24
  spec.add_dependency "stackprof", ">= 0.2.13"
25
-
26
- spec.add_development_dependency "rake", "~> 13.0"
27
- spec.add_development_dependency "rspec"
28
25
  end
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2018 Jamie Wong
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.
@@ -0,0 +1,2 @@
1
+ This is a self-contained release of https://github.com/jlfwong/speedscope.
2
+ To use it, open index.html in Chrome or Firefox.
@@ -0,0 +1,2 @@
1
+ (()=>{var D="./favicon-16x16-VSI62OPJ.png";})();
2
+ //# sourceMappingURL=favicon-16x16-V2DMIAZS.js.map
@@ -0,0 +1,2 @@
1
+ (()=>{var T="./favicon-32x32-3EB2YCUY.png";})();
2
+ //# sourceMappingURL=favicon-32x32-THY3JDJL.js.map
@@ -0,0 +1,2 @@
1
+ (()=>{var m="./favicon-FOKUP5Y5.ico";})();
2
+ //# sourceMappingURL=favicon-M34RF7BI.js.map