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 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])