diff --git a/Gemfile.lock b/Gemfile.lock index 26a6bb8..734f29e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,7 @@ PATH singed (0.2.2) colorize stackprof (>= 0.2.13) + vernier GEM remote: https://rubygems.org/ @@ -67,6 +68,7 @@ GEM lint_roller (~> 1.1) rubocop-performance (~> 1.20.2) unicode-display_width (2.5.0) + vernier (1.0.1) PLATFORMS ruby diff --git a/lib/singed.rb b/lib/singed.rb index 1091aa3..3886fa8 100644 --- a/lib/singed.rb +++ b/lib/singed.rb @@ -3,6 +3,7 @@ require "json" require "stackprof" require "colorize" +require "vernier" module Singed extend self @@ -34,6 +35,10 @@ def backtrace_cleaner @backtrace_cleaner end + def vernier_hooks + @vernier_hooks ||= [] + end + def silence_line?(line) return backtrace_cleaner.silence_line?(line) if backtrace_cleaner @@ -46,6 +51,39 @@ def filter_line(line) line end + def profiler_class_for(profiler) + case profiler + when :stackprof, nil then Singed::Flamegraph::Stackprof + when :vernier then Singed::Flamegraph::Vernier + else + raise ArgumentError, "Unknown profiler: #{profiler}" + end + end + + def profile(label = "flamegraph", profiler: nil, open: true, announce_io: $stdout, **profiler_options, &) + profiler_class = profiler_class_for(profiler) + + fg = profiler_class.new( + label: label, + announce_io: announce_io, + **profiler_options + ) + + result = fg.record(&) + fg.save + fg.open if open + + result + end + + def stackprof(label = "stackprof", open: true, announce_io: $stdout, **stackprof_options, &) + profile(label, profiler: :stackprof, open: open, announce_io: announce_io, **stackprof_options, &) + end + + def vernier(label = "vernier", open: true, announce_io: $stdout, **vernier_options, &) + profile(label, profiler: :vernier, open: open, announce_io: announce_io, **vernier_options, &) + end + autoload :Flamegraph, "singed/flamegraph" autoload :Report, "singed/report" autoload :RackMiddleware, "singed/rack_middleware" diff --git a/lib/singed/flamegraph.rb b/lib/singed/flamegraph.rb index 6f6518a..1488444 100644 --- a/lib/singed/flamegraph.rb +++ b/lib/singed/flamegraph.rb @@ -1,55 +1,37 @@ module Singed class Flamegraph - attr_accessor :profile, :filename + attr_accessor :profile, :filename, :announce_io - def initialize(label: nil, ignore_gc: false, interval: 1000, filename: nil) - # it's been created elsewhere, ie rbspy - if filename - if ignore_gc - raise ArgumentError, "ignore_gc not supported when given an existing file" - end - - if label - raise ArgumentError, "label not supported when given an existing file" - end - - @filename = filename - else - @ignore_gc = ignore_gc - @interval = interval - @time = Time.now # rubocop:disable Rails/TimeZone - @filename = self.class.generate_filename(label: label, time: @time) - end + def initialize(label: nil, announce_io: $stdout) + @time = Time.now + @announce_io = announce_io + @filename ||= self.class.generate_filename(label: label, time: @time) end - def record - return yield unless Singed.enabled? - return yield if filename.exist? # file existing means its been captured already + def record(&block) + raise NotImplementedError + end - result = nil - @profile = StackProf.run(mode: :wall, raw: true, ignore_gc: @ignore_gc, interval: @interval) do - result = yield - end - result + def record? + Singed.enabled? end def save - if filename.exist? - raise ArgumentError, "File #{filename} already exists" - end - - report = Singed::Report.new(@profile) - report.filter! - filename.dirname.mkpath - filename.open("w") { |f| report.print_json(f) } + raise NotImplementedError end - def open - system open_command + def open_command + raise NotImplementedError end - def open_command - @open_command ||= "npx speedscope #{@filename}" + def open(open: true) + if open + # use npx, so we don't have to add it as a dependency + announce_io.puts "🔥📈 #{"Captured flamegraph, opening with".colorize(:bold).colorize(:red)}: #{open_command}" + system open_command + else + announce_io.puts "🔥📈 #{"Captured flamegraph to file".colorize(:bold).colorize(:red)}: #{filename}" + end end def self.generate_filename(label: nil, time: Time.now) # rubocop:disable Rails/TimeZone @@ -62,5 +44,77 @@ def self.generate_filename(label: nil, time: Time.now) # rubocop:disable Rails/T file = file.relative_path_from(pwd) if file.absolute? && file.to_s.start_with?(pwd.to_s) file end + + def self.validate_options(klass, method_name, options) + method = klass.instance_method(:method_name) + options.each do |key, value| + if method.parameters.none? { |type, name| type == :key && name == key } + raise ArgumentError, "Unknown option #{key} for #{klass}.#{method_name}" + end + end + end + + class Stackprof < Flamegraph + DEFAULT_OPTIONS = { + mode: :wall, + raw: true + }.freeze + + def initialize(label: nil, announce_io: $stdout, **stackprof_options) + super(label: label) + @stackprof_options = stackprof_options + end + + def record(&block) + result = nil + stackprof_options = DEFAULT_OPTIONS.merge(@stackprof_options) + @profile = ::StackProf.run(**stackprof_options) do + result = yield + end + result + end + + def save + if filename.exist? + raise ArgumentError, "File #{filename} already exists" + end + + report = Singed::Report.new(@profile) + report.filter! + filename.dirname.mkpath + filename.open("w") { |f| report.print_json(f) } + end + + def open_command + # use npx, so we don't have to add it as a dependency + @open_command ||= "npx speedscope #{@filename}" + end + + + end + + class Vernier < Flamegraph + def initialize(label: nil, announce_io: $stdout, **vernier_options) + super(label: label, announce_io: announce_io) + + @vernier_options = {hooks: Singed.vernier_hooks}.merge(vernier_options) + end + + def record + vernier_options = {out: filename.to_s}.merge(@vernier_options) + validate_options(::Vernier, :run, vernier_options) + ::Vernier.run(**vernier_options) do + yield + end + end + + def open_command + @open_command ||= "profile-viewer #{@filename}" + end + + def save + # no-op, since it already writes out + end + end end end diff --git a/lib/singed/kernel_ext.rb b/lib/singed/kernel_ext.rb index 3320ee9..317adf7 100644 --- a/lib/singed/kernel_ext.rb +++ b/lib/singed/kernel_ext.rb @@ -1,17 +1,5 @@ module Kernel - def flamegraph(label = nil, open: true, ignore_gc: false, interval: 1000, io: $stdout, &) - fg = Singed::Flamegraph.new(label: label, ignore_gc: ignore_gc, interval: interval) - result = fg.record(&) - fg.save - - if open - # use npx, so we don't have to add it as a dependency - io.puts "🔥📈 #{"Captured flamegraph, opening with".colorize(:bold).colorize(:red)}: #{fg.open_command}" - fg.open - else - io.puts "🔥📈 #{"Captured flamegraph to file".colorize(:bold).colorize(:red)}: #{fg.filename}" - end - - result + def flamegraph(label = nil, profiler: nil, open: true, io: $stdout, **profiler_options, &) + Singed.profile(label, profiler: profiler, open: open, announce_io: io, **profiler_options, &) end end diff --git a/lib/singed/railtie.rb b/lib/singed/railtie.rb index 2c7122b..8200ab8 100644 --- a/lib/singed/railtie.rb +++ b/lib/singed/railtie.rb @@ -11,6 +11,8 @@ class Railtie < Rails::Railtie ActiveSupport.on_load(:action_controller) do ActionController::Base.include(Singed::ControllerExt) end + + Singed.vernier_hooks << :rails end def self.init! diff --git a/singed.gemspec b/singed.gemspec index 071f570..47cb378 100644 --- a/singed.gemspec +++ b/singed.gemspec @@ -18,6 +18,7 @@ Gem::Specification.new do |spec| spec.add_dependency "colorize" spec.add_dependency "stackprof", ">= 0.2.13" + spec.add_dependency "vernier" spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rspec"