Skip to content
Draft
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ PATH
singed (0.2.2)
colorize
stackprof (>= 0.2.13)
vernier

GEM
remote: https://rubygems.org/
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions lib/singed.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require "json"
require "stackprof"
require "colorize"
require "vernier"

module Singed
extend self
Expand Down Expand Up @@ -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

Expand All @@ -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"
Expand Down
132 changes: 93 additions & 39 deletions lib/singed/flamegraph.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
16 changes: 2 additions & 14 deletions lib/singed/kernel_ext.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions lib/singed/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
1 change: 1 addition & 0 deletions singed.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down