From 2ce4a3ea62b5a27dea7a3436f87fbb0f2e3c8f03 Mon Sep 17 00:00:00 2001 From: Andrey Novikov Date: Wed, 25 Feb 2026 21:18:13 +0900 Subject: [PATCH 1/2] Methods for explicit start and stop --- README.md | 14 ++++++++++++++ lib/singed.rb | 24 ++++++++++++++++++++++++ lib/singed/flamegraph.rb | 31 ++++++++++++++++++++++++------- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a52fee2..ff12560 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,20 @@ flamegraph(open: false) { } ``` +### Explicit start and stop + +You can also start and stop the flamegraph explicitly: + +```ruby +# config/boot.rb +require "singed" +Singed.output_directory ||= Dir.pwd + "/tmp/speedscope" +Singed.start +# Let some code to run here... +# and then stop the flamegraph with e.g. rails runner 'Singed.stop' +Singed.stop +``` + ### RSpec If you are using RSpec, you can use the `flamegraph` metadata to capture it for you. diff --git a/lib/singed.rb b/lib/singed.rb index a4fd033..5782799 100644 --- a/lib/singed.rb +++ b/lib/singed.rb @@ -7,6 +7,8 @@ module Singed extend self + attr_reader :current_flamegraph + # Where should flamegraphs be saved? def output_directory=(directory) @output_directory = Pathname.new(directory) @@ -46,6 +48,28 @@ def filter_line(line) line end + def start(label = nil, ignore_gc: false, interval: 1000) + return unless enabled? + return if profiling? + + @current_flamegraph = Flamegraph.new(label: label, ignore_gc: ignore_gc, interval: interval) + @current_flamegraph.start + end + + def stop + return nil unless profiling? + + flamegraph = @current_flamegraph + @current_flamegraph = nil + flamegraph.stop + flamegraph.save + flamegraph + end + + def profiling? + @current_flamegraph&.started? || false + 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..dec8f6e 100644 --- a/lib/singed/flamegraph.rb +++ b/lib/singed/flamegraph.rb @@ -23,14 +23,31 @@ def initialize(label: nil, ignore_gc: false, interval: 1000, filename: nil) end def record - return yield unless Singed.enabled? - return yield if filename.exist? # file existing means its been captured already + start + yield + ensure + stop + end - result = nil - @profile = StackProf.run(mode: :wall, raw: true, ignore_gc: @ignore_gc, interval: @interval) do - result = yield - end - result + def start + return false unless Singed.enabled? + return false if filename.exist? # file existing means its been captured already + return false if started? + + StackProf.start(mode: :wall, raw: true, ignore_gc: @ignore_gc, interval: @interval) + @started = true + end + + def stop + return nil unless started? + + @started = false + StackProf.stop + @profile = StackProf.results + end + + def started? + !!@started end def save From 358dcdbf9d344035e63bf00b7a27f5b550216b85 Mon Sep 17 00:00:00 2001 From: Andrey Novikov Date: Thu, 26 Feb 2026 00:31:28 +0900 Subject: [PATCH 2/2] add some tests --- spec/singed_spec.rb | 85 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 spec/singed_spec.rb diff --git a/spec/singed_spec.rb b/spec/singed_spec.rb new file mode 100644 index 0000000..4bc6936 --- /dev/null +++ b/spec/singed_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "tempfile" +require "pathname" + +RSpec.describe Singed do + around do |example| + original_output_directory = Singed.output_directory + original_enabled = Singed.enabled? + begin + example.run + ensure + Singed.output_directory = original_output_directory if original_output_directory + Singed.enabled = original_enabled + Singed.instance_variable_set(:@current_flamegraph, nil) + end + end + + describe ".start" do + before do + Singed.enabled = true + Singed.output_directory = Dir.mktmpdir("singed-spec") + end + + it "creates a current flamegraph and starts profiling" do + Singed.start + + expect(Singed.current_flamegraph).to be_a(Singed::Flamegraph) + expect(Singed.profiling?).to be true + expect(Singed.current_flamegraph.started?).to be true + end + + it "does nothing when already profiling" do + Singed.start + first = Singed.current_flamegraph + Singed.start + + expect(Singed.current_flamegraph).to be first + end + + it "does nothing when disabled" do + Singed.enabled = false + Singed.start + + expect(Singed.current_flamegraph).to be_nil + expect(Singed.profiling?).to be false + end + end + + describe ".stop" do + before do + Singed.enabled = true + Singed.output_directory = Dir.mktmpdir("singed-spec") + end + + it "returns nil when not profiling" do + expect(Singed.stop).to be_nil + end + + it "stops profiling, saves the result file, and returns the flamegraph with profile data" do + Singed.start + # Run some code to generate profile samples + 100.times { 2**10 } + flamegraph = Singed.stop + + expect(flamegraph).to be_a(Singed::Flamegraph) + expect(Singed.profiling?).to be false + expect(Singed.current_flamegraph).to be_nil + + # Profile data is returned (StackProf results hash) + expect(flamegraph.profile).to be_a(Hash) + expect(flamegraph.profile).to include(:mode, :version, :interval) + expect(flamegraph.profile[:mode]).to eq(:wall) + expect(flamegraph.profile[:samples]).to be >= 0 + end + + it "creates the result file on disk" do + Singed.start + 100.times { 2**10 } + flamegraph = Singed.stop + + expect(Pathname(flamegraph.filename)).to exist + end + end +end