diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 121272ca..93785f82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,14 +19,14 @@ jobs: ports: - 6379:6379 memcached: - image: memcached:1.6.9 + image: memcached:1.6 ports: - 11211:11211 options: --health-cmd "timeout 5 bash -c 'cat < /dev/null > /dev/udp/127.0.0.1/11211'" --health-interval 10s --health-timeout 5s --health-retries 5 strategy: fail-fast: false matrix: - ruby: ["3.4", "3.3", "3.2"] + ruby: ["4.0", "3.4", "3.3", "3.2"] rack: ["2.2.0", "3.1.0", "3.2.0"] env: RACK_VERSION: ${{ matrix.rack }} diff --git a/docs/screenshots/rmp_01_toolbar.png b/docs/screenshots/rmp_01_toolbar.png new file mode 100644 index 00000000..9f4fb8be Binary files /dev/null and b/docs/screenshots/rmp_01_toolbar.png differ diff --git a/docs/screenshots/rmp_02_expanded.png b/docs/screenshots/rmp_02_expanded.png new file mode 100644 index 00000000..9f4fb8be Binary files /dev/null and b/docs/screenshots/rmp_02_expanded.png differ diff --git a/docs/screenshots/rmp_03_flamegraph.png b/docs/screenshots/rmp_03_flamegraph.png new file mode 100644 index 00000000..0c9acf4a Binary files /dev/null and b/docs/screenshots/rmp_03_flamegraph.png differ diff --git a/lib/mini_profiler.rb b/lib/mini_profiler.rb index 3eb012bc..736fc750 100644 --- a/lib/mini_profiler.rb +++ b/lib/mini_profiler.rb @@ -283,7 +283,50 @@ def call(env) env['HTTP_ACCEPT_ENCODING'] = 'identity' if config.suppress_encoding if matches_action?('flamegraph', env) || matches_action?('async-flamegraph', env) || env['HTTP_REFERER'] =~ /pp=async-flamegraph/ - if defined?(StackProf) && StackProf.respond_to?(:run) + profiler_param = action_parameters(env)['flamegraph_profiler'] + stackprof_available = defined?(StackProf) && StackProf.respond_to?(:run) + use_rperf = if profiler_param + profiler_param == 'rperf' + else + config.flamegraph_profiler == :rperf || + (config.flamegraph_profiler == :auto && + !stackprof_available && + defined?(Rperf) && Rperf.respond_to?(:start)) + end + + if use_rperf + if defined?(Rperf) && Rperf.respond_to?(:start) + current.measure = false + match_data = action_parameters(env)['flamegraph_sample_rate'] + frequency = if match_data && !match_data[1].to_f.zero? + match_data[1].to_f + else + config.flamegraph_sample_rate + end + frequency = frequency.to_i + frequency = 500 if frequency < 1 + + mode_match = action_parameters(env)['flamegraph_mode'] + mode = if mode_match && [:cpu, :wall].include?(mode_match.to_sym) + mode_match.to_sym + else + config.flamegraph_mode == :object ? :wall : config.flamegraph_mode + end + + Rperf.start(mode: mode, frequency: frequency) + status, headers, body = @app.call(env) + raw_data = Rperf.stop + flamegraph = rperf_to_speedscope(raw_data) + else + message = "Please install the rperf gem and require it: add gem 'rperf' to your Gemfile" + status, headers, body = @app.call(env) + body.close if body.respond_to? :close + + return client_settings.handle_cookie( + text_result(message, status: status, headers: headers) + ) + end + elsif stackprof_available # do not sully our profile with mini profiler timings current.measure = false match_data = action_parameters(env)['flamegraph_sample_rate'] @@ -619,6 +662,40 @@ def cache_control_value 86400 end + def rperf_to_speedscope(data) + frame_index = {} + frames = [] + samples = [] + weights = [] + + (data[:samples] || []).each do |stack_frames, weight_ns, _thread| + sample_indices = stack_frames.map do |file, name| + key = "#{file}\0#{name}" + unless frame_index.key?(key) + frame_index[key] = frames.size + frames << { "name" => name.to_s, "file" => file.to_s } + end + frame_index[key] + end + samples << sample_indices + weights << weight_ns.to_i + end + + { + "$schema" => "https://www.speedscope.app/file-format-schema.json", + "profiles" => [{ + "type" => "sampled", + "name" => "rperf #{data[:mode]}", + "unit" => "nanoseconds", + "startValue" => 0, + "endValue" => (data[:duration_ns] || 0).to_i, + "samples" => samples, + "weights" => weights, + }], + "shared" => { "frames" => frames }, + } + end + private def rails_route_from_path(path, method) diff --git a/lib/mini_profiler/config.rb b/lib/mini_profiler/config.rb index 737399a0..2014fa81 100644 --- a/lib/mini_profiler/config.rb +++ b/lib/mini_profiler/config.rb @@ -31,6 +31,7 @@ def self.default @flamegraph_sample_rate = 0.5 @flamegraph_mode = :wall @flamegraph_ignore_gc = false + @flamegraph_profiler = :auto @storage_failure = Proc.new do |exception| if @logger @logger.warn("MiniProfiler storage failure: #{exception.message}") @@ -77,7 +78,7 @@ def self.default :storage_options, :user_provider, :enable_advanced_debugging_tools, :skip_sql_param_names, :suppress_encoding, :max_sql_param_length, :content_security_policy_nonce, :enable_hotwire_turbo_drive_support, - :flamegraph_mode, :flamegraph_ignore_gc, :profile_parameter + :flamegraph_mode, :flamegraph_ignore_gc, :flamegraph_profiler, :profile_parameter # ui accessors attr_accessor :collapse_results, :max_traces_to_show, :position, diff --git a/lib/mini_profiler/storage/memcache_store.rb b/lib/mini_profiler/storage/memcache_store.rb index 5a143049..347140e0 100644 --- a/lib/mini_profiler/storage/memcache_store.rb +++ b/lib/mini_profiler/storage/memcache_store.rb @@ -67,7 +67,7 @@ def get_unviewed_ids(user) end def flush_tokens - @client.set("#{@prefix}-tokens", nil) + @client.delete("#{@prefix}-tokens") end def allowed_tokens diff --git a/lib/mini_profiler/views.rb b/lib/mini_profiler/views.rb index 87566881..2e4bdcc7 100644 --- a/lib/mini_profiler/views.rb +++ b/lib/mini_profiler/views.rb @@ -167,6 +167,8 @@ def help(client_settings, env) #{make_link "flamegraph&flamegraph_sample_rate=1", env}: creates a flamegraph with the specified sample rate (in ms). Overrides value set in config #{make_link "flamegraph&flamegraph_mode=cpu", env}: creates a flamegraph with the specified mode (one of cpu, wall, object, or custom). Overrides value set in config #{make_link "flamegraph&flamegraph_ignore_gc=true", env}: ignore garbage collection frames in flamegraphs. Overrides value set in config + #{make_link "flamegraph&flamegraph_profiler=rperf", env} : flamegraph using rperf (requires the rperf gem, Ruby >= 3.4). + #{make_link "flamegraph&flamegraph_profiler=rperf&flamegraph_mode=wall", env} : rperf wall-mode flamegraph (shows GVL/GC time attribution). #{make_link "flamegraph_embed", env} : a graph representing sampled activity (requires the stackprof gem), embedded resources for use on an intranet. #{make_link "trace-exceptions", env} : will return all the spots where your application raises exceptions #{make_link "analyze-memory", env} : will perform basic memory analysis of heap diff --git a/lib/mini_profiler_rails/railtie.rb b/lib/mini_profiler_rails/railtie.rb index b3f2c8cb..d8f4bac7 100644 --- a/lib/mini_profiler_rails/railtie.rb +++ b/lib/mini_profiler_rails/railtie.rb @@ -34,7 +34,7 @@ def self.initialize!(app) c.skip_paths << wp_assets_path if wp_assets_path end - unless Rails.env.development? || Rails.env.test? + unless Rails.env.local? c.authorization_mode = :allow_authorized end @@ -55,7 +55,7 @@ def self.initialize!(app) # Quiet the SQL stack traces c.backtrace_remove = Rails.root.to_s + "/" c.backtrace_includes = [/^\/?(app|config|lib|test)/] - c.skip_schema_queries = (Rails.env.development? || Rails.env.test?) + c.skip_schema_queries = (Rails.env.local?) # Install the Middleware app.middleware.insert(0, Rack::MiniProfiler) diff --git a/spec/lib/rperf_flamegraph_spec.rb b/spec/lib/rperf_flamegraph_spec.rb new file mode 100644 index 00000000..c690598b --- /dev/null +++ b/spec/lib/rperf_flamegraph_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "rperf flamegraph integration" do # rubocop:disable RSpec/DescribeClass + before do + skip "rperf not available" unless defined?(Rperf) && Rperf.respond_to?(:start) + end + + let(:profiler) { Rack::MiniProfiler.new(app) } + let(:app) { lambda { |_env| [200, {}, ["ok"]] } } + + before do + Rack::MiniProfiler.reset_config + Rack::MiniProfiler.config.storage = Rack::MiniProfiler::MemoryStore + Rack::MiniProfiler.config.flamegraph_profiler = :rperf + end + + it "returns error when rperf is not installed" do + hide_const("Rperf") + response = profiler.call({ "PATH_INFO" => "/", "QUERY_STRING" => "pp=flamegraph" }) + expect(response[2]).to include("Please install the rperf gem") + end + + it "generates valid speedscope JSON for flamegraph" do + require "rperf" + Rack::MiniProfiler.config.flamegraph_profiler = :rperf + response = profiler.call({ "PATH_INFO" => "/", "QUERY_STRING" => "pp=flamegraph" }) + expect(response[0]).to eq(200) + body = response[2].join + expect(body).to include("speedscope") + end +end