Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
## [Unreleased]

### Added

- `Pf2.profile` can now directly write a Firefox Profiler-compatible profile into a file or an IO-ish object. Check out `Pf2.profile`'s `out:` and `format:` option.

### Changed

- `Pf2::Reporter::FirefoxProfilerSer2` now emits a JSON string, not a Hash. Parse the JSON to obtain the original representaion.

### Fixed

- Fixed a crash when a delayed SIGPROF is received after `Pf2.stop` is called.


## [0.13.0] - 2026-01-18

### Added
Expand Down
41 changes: 38 additions & 3 deletions lib/pf2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@
module Pf2
class Error < StandardError; end

KNOWN_FORMATS = [:pf2prof, :firefox] # :nodoc:
private_constant :KNOWN_FORMATS

# Start a profiling session.
#
# Parameters:
# interval_ms - Sampling interval in milliseconds (default: 9)
# time_mode - :cpu or :wall (default: :cpu)
def self.start(...)
Expand All @@ -22,18 +26,49 @@ def self.stop

# Profiles the given block of code.
#
# Parameters:
# interval_ms - Sampling interval in milliseconds (default: 9)
# time_mode - :cpu or :wall (default: :cpu)
# out - String or IO-like object specifying where to write profile data.
# - nil (default): do not write to file
# - String: file path to write the profile data
# - IO-like object: an object responding to #write
# format - Output format. Possible values are:
# - :firefox (default): JSON for profiler.firefox.com
# - :pf2prof: Raw profile dump loadable by 'pf2 report'
#
# Example:
#
# profile = Pf2.profile(interval_ms: 42) do
# your_code_here
# end
#
def self.profile(**kwargs, &block)
def self.profile(interval_ms: 9, time_mode: :cpu, out: nil, format: :firefox, &block)
raise ArgumentError, "block required" unless block_given?
start(**kwargs)
raise ArgumentError, "Unknown format: #{format}" unless KNOWN_FORMATS.include?(format)
if !(out.nil? || out.is_a?(String) || (out.respond_to?(:write) && out.respond_to?(:close)))
raise ArgumentError, "'out' must be an IO-like object"
end

start(interval_ms:, time_mode:)
yield
result = stop
result = stop()
@@session = nil # let GC clean up the session

if out
is_path_passed = out.is_a?(String)
io = is_path_passed ? File.open(out, "wb") : out
case format
in :firefox
require 'pf2/reporter'
reporter = Reporter::FirefoxProfilerSer2.new(result)
io.write(reporter.emit)
in :pf2prof
io.write(Marshal.dump(result))
end
io.close if is_path_passed
end

result
ensure
if defined?(@@session) && @@session != nil
Expand Down
1 change: 0 additions & 1 deletion lib/pf2/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ def subcommand_report(argv)

profile = Marshal.load(File.read(argv[0]))
report = Pf2::Reporter::FirefoxProfilerSer2.new(profile).emit
report = JSON.generate(report)

if options[:output_file]
File.write(options[:output_file], report)
Expand Down
2 changes: 1 addition & 1 deletion lib/pf2/reporter/firefox_profiler_ser2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def emit
counters: [],
threads: thread_reports,
}
FirefoxProfilerSer2.deep_camelize_keys(report)
JSON.generate(FirefoxProfilerSer2.deep_camelize_keys(report))
end

class ThreadReport
Expand Down
2 changes: 1 addition & 1 deletion lib/pf2/serve.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def self.start
profile = Pf2.stop
res.header['Content-Type'] = 'application/json'
res.header['Access-Control-Allow-Origin'] = '*'
res.body = JSON.generate(Pf2::Reporter::FirefoxProfilerSer2.new(profile).emit)
res.body = Pf2::Reporter::FirefoxProfilerSer2.new(profile).emit
Pf2.start
end

Expand Down
46 changes: 46 additions & 0 deletions test/pf2_test.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# frozen_string_literal: true

require 'minitest/autorun'
require 'json'
require 'stringio'
require 'tempfile'
require 'tmpdir'

require 'pf2'

Expand Down Expand Up @@ -51,4 +55,46 @@ def test_profile_stops_session_even_if_given_block_raises

assert_nil Pf2.class_variable_get(:@@session)
end

def test_profile_writes_firefox_report_to_io
Tempfile.create do |file|
file.close # Pf2.profile will open by path
Pf2.profile(out: file.path, format: :firefox) { 1 + 1 }
parsed = JSON.parse(File.read(file.path))

assert_kind_of Hash, parsed
assert parsed.key?('threads')
end
end

def test_profile_writes_firefox_report_to_stringio
io = StringIO.new(+'', 'r+')
Pf2.profile(out: io, format: :firefox) { 1 + 1 }
io.rewind
parsed = JSON.parse(io.read)

assert_kind_of Hash, parsed
assert parsed.key?('threads')
end

def test_profile_writes_pf2prof_report_to_path
Dir.mktmpdir do |dir|
path = File.join(dir, 'profile.pf2prof')
profile = Pf2.profile(out: path, format: :pf2prof) { 1 + 1 }
written = File.binread(path)
assert_equal profile, Marshal.load(written)
end
end

def test_profile_rejects_non_io_out
assert_raises(ArgumentError, "'out' must be an IO-like object") do
Pf2.profile(out: 42) { 1 + 1 }
end
end

def test_profile_raises_error_for_unknown_format
assert_raises(ArgumentError, "Unknown format: :invalid") do
Pf2.profile(format: :invalid) { 1 + 1 }
end
end
end
8 changes: 4 additions & 4 deletions test/reporter/firefox_profiler_ser2_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@

class FirefoxProfilerSer2Test < Minitest::Test
def test_empty
report = Pf2::Reporter::FirefoxProfilerSer2.new({
report = JSON.parse(Pf2::Reporter::FirefoxProfilerSer2.new({
start_timestamp_ns: 1737730800000000,
duration_ns: 15000000000,
samples: [],
locations: [],
functions: [],
}).emit
}).emit, symbolize_names: true)

assert_equal([], report[:threads])
end

def test_simple
report = Pf2::Reporter::FirefoxProfilerSer2.new({
report = JSON.parse(Pf2::Reporter::FirefoxProfilerSer2.new({
start_timestamp_ns: 1737730800000000,
duration_ns: 15000000000,
samples: [
Expand All @@ -40,7 +40,7 @@ def test_simple
{ implementation: :ruby, name: 'qux', filename: 'main.rb', start_lineno: 40, start_address: nil },
{ implementation: :ruby, name: 'quux', filename: 'main.rb', start_lineno: 50, start_address: nil },
],
}).emit
}).emit, symbolize_names: true)

assert_equal(1, report[:threads].length)
assert_equal(5, report[:threads][0][:stackTable][:length])
Expand Down