From dc5704e5564fb11f6d735067dc702147b1d46084 Mon Sep 17 00:00:00 2001 From: Daisuke Aritomo Date: Sat, 24 Jan 2026 21:24:33 +0900 Subject: [PATCH 1/2] Pf2.profile: Add 'out' and 'format' option to control output This patch adds convienience options for direct output from Pf2.profile. 'out' controls where to write the profile data, and 'format' controls the output format. Pf2.profile(out: , format: :firefox) is expected to be a convienient way to replace manual saving + 'pf2 report' steps. Pf2.start/stop users still need to operate on raw profile data. This patch also changes Pf2::Reporter::FirefoxProfilerSer2 to emit a JSON string instead of a Hash, as its responsibility is to produce a representation that Firefox Profiler can directly comsume. --- lib/pf2.rb | 41 ++++++++++++++++-- lib/pf2/cli.rb | 1 - lib/pf2/reporter/firefox_profiler_ser2.rb | 2 +- lib/pf2/serve.rb | 2 +- test/pf2_test.rb | 46 +++++++++++++++++++++ test/reporter/firefox_profiler_ser2_test.rb | 8 ++-- 6 files changed, 90 insertions(+), 10 deletions(-) diff --git a/lib/pf2.rb b/lib/pf2.rb index 5d2f42b..cb4dc03 100644 --- a/lib/pf2.rb +++ b/lib/pf2.rb @@ -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(...) @@ -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 diff --git a/lib/pf2/cli.rb b/lib/pf2/cli.rb index 0ab7cd8..e629ad0 100644 --- a/lib/pf2/cli.rb +++ b/lib/pf2/cli.rb @@ -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) diff --git a/lib/pf2/reporter/firefox_profiler_ser2.rb b/lib/pf2/reporter/firefox_profiler_ser2.rb index 8b818bb..46628dc 100644 --- a/lib/pf2/reporter/firefox_profiler_ser2.rb +++ b/lib/pf2/reporter/firefox_profiler_ser2.rb @@ -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 diff --git a/lib/pf2/serve.rb b/lib/pf2/serve.rb index a0f39d7..8b361ae 100644 --- a/lib/pf2/serve.rb +++ b/lib/pf2/serve.rb @@ -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 diff --git a/test/pf2_test.rb b/test/pf2_test.rb index 8964efc..b3e3328 100644 --- a/test/pf2_test.rb +++ b/test/pf2_test.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true require 'minitest/autorun' +require 'json' +require 'stringio' +require 'tempfile' +require 'tmpdir' require 'pf2' @@ -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 diff --git a/test/reporter/firefox_profiler_ser2_test.rb b/test/reporter/firefox_profiler_ser2_test.rb index 4072835..b2d3325 100644 --- a/test/reporter/firefox_profiler_ser2_test.rb +++ b/test/reporter/firefox_profiler_ser2_test.rb @@ -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: [ @@ -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]) From 19bf249bc6b8d84b2323a49ffa41db5cd3c478e6 Mon Sep 17 00:00:00 2001 From: Daisuke Aritomo Date: Sat, 24 Jan 2026 23:30:59 +0900 Subject: [PATCH 2/2] Update CHANGELOG --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f10afc8..769b94d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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