lib/mini_profiler.rb
# frozen_string_literal: true
require 'cgi'
require 'json'
require 'erb'
require 'mini_profiler/timer_struct'
require 'mini_profiler/storage'
require 'mini_profiler/config'
require 'mini_profiler/profiling_methods'
require 'mini_profiler/context'
require 'mini_profiler/client_settings'
require 'mini_profiler/gc_profiler'
require 'mini_profiler/snapshots_transporter'
require 'mini_profiler/views'
require 'mini_profiler/actions'
module Rack
class MiniProfiler
include Actions
include Views
class << self
include Rack::MiniProfiler::ProfilingMethods
attr_accessor :subscribe_sql_active_record
def patch_rails?
!!defined?(Rack::MINI_PROFILER_ENABLE_RAILS_PATCHES)
end
def generate_id
rand(36**20).to_s(36)
end
def reset_config
@config = Config.default
end
# So we can change the configuration if we want
def config
@config ||= Config.default
end
def current
Thread.current[:mini_profiler_private]
end
def current=(c)
# we use TLS cause we need access to this from sql blocks and code blocks that have no access to env
Thread.current[:mini_profiler_snapshot_custom_fields] = nil
Thread.current[:mp_ongoing_snapshot] = nil
Thread.current[:mini_profiler_private] = c
end
def add_snapshot_custom_field(key, value)
thread_var_key = :mini_profiler_snapshot_custom_fields
Thread.current[thread_var_key] ||= {}
Thread.current[thread_var_key][key] = value
end
def get_snapshot_custom_fields
Thread.current[:mini_profiler_snapshot_custom_fields]
end
# discard existing results, don't track this request
def discard_results
self.current.discard = true if current
end
def create_current(env = {}, options = {})
# profiling the request
context = Context.new
context.inject_js = config.auto_inject && (!env['HTTP_X_REQUESTED_WITH'].eql? 'XMLHttpRequest')
context.page_struct = TimerStruct::Page.new(env)
context.current_timer = context.page_struct[:root]
self.current = context
end
def authorize_request
Thread.current[:mp_authorized] = true
end
def deauthorize_request
Thread.current[:mp_authorized] = nil
end
def request_authorized?
Thread.current[:mp_authorized]
end
def advanced_tools_message
<<~TEXT
This feature is disabled by default, to enable set the enable_advanced_debugging_tools option to true in Mini Profiler config.
TEXT
end
def binds_to_params(binds)
return if binds.nil? || config.max_sql_param_length == 0
# map ActiveRecord::Relation::QueryAttribute to [name, value]
params = binds.map { |c| c.kind_of?(Array) ? [c.first, c.last] : [c.name, c.value] }
if (skip = config.skip_sql_param_names)
params.map { |(n, v)| n =~ skip ? [n, nil] : [n, v] }
else
params
end
end
def snapshots_transporter?
!!config.snapshots_transport_destination_url &&
!!config.snapshots_transport_auth_key
end
def redact_sql_queries?
Thread.current[:mp_ongoing_snapshot] == true &&
Rack::MiniProfiler.config.snapshots_redact_sql_queries
end
end
#
# options:
# :auto_inject - should script be automatically injected on every html page (not xhr)
def initialize(app, config = nil)
MiniProfiler.config.merge!(config)
@config = MiniProfiler.config
@app = app
@config.base_url_path += "/" unless @config.base_url_path.end_with? "/"
unless @config.storage_instance
@config.storage_instance = @config.storage.new(@config.storage_options)
end
@storage = @config.storage_instance
end
def user(env)
@config.user_provider.call(env)
end
def current
MiniProfiler.current
end
def current=(c)
MiniProfiler.current = c
end
def config
@config
end
def advanced_debugging_enabled?
config.enable_advanced_debugging_tools
end
def tool_disabled_message(client_settings)
client_settings.handle_cookie(text_result(Rack::MiniProfiler.advanced_tools_message))
end
def call(env)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
client_settings = ClientSettings.new(env, @storage, start)
MiniProfiler.deauthorize_request if @config.authorization_mode == :allow_authorized
status = headers = body = nil
path = env['PATH_INFO'].sub('//', '/')
# Someone (e.g. Rails engine) could change the SCRIPT_NAME so we save it
env['RACK_MINI_PROFILER_ORIGINAL_SCRIPT_NAME'] = ENV['PASSENGER_BASE_URI'] || env['SCRIPT_NAME']
skip_it = matches_action?('skip', env) || (
@config.skip_paths &&
@config.skip_paths.any? do |p|
if p.instance_of?(String)
path.start_with?(p)
elsif p.instance_of?(Regexp)
p.match?(path)
end
end
)
if skip_it
return client_settings.handle_cookie(@app.call(env))
end
skip_it = (@config.pre_authorize_cb && !@config.pre_authorize_cb.call(env))
if skip_it || (
@config.authorization_mode == :allow_authorized &&
!client_settings.has_valid_cookie?
)
if take_snapshot?(path)
return client_settings.handle_cookie(take_snapshot(env, start))
else
return client_settings.handle_cookie(@app.call(env))
end
end
# handle all /mini-profiler requests here
if path.start_with? @config.base_url_path
file_name = path.sub(@config.base_url_path, '')
case file_name
when 'results'
return serve_results(env)
when 'snapshots'
self.current = nil
return serve_snapshot(env)
when 'flamegraph'
return serve_flamegraph(env)
end
return client_settings.handle_cookie(serve_file(env, file_name: file_name))
end
has_disable_cookie = client_settings.disable_profiling?
# manual session disable / enable
if matches_action?('disable', env) || has_disable_cookie
skip_it = true
end
if matches_action?('enable', env)
skip_it = false
config.enabled = true
end
if skip_it || !config.enabled
status, headers, body = @app.call(env)
client_settings.disable_profiling = true
return client_settings.handle_cookie([status, headers, body])
end
# remember that profiling is not disabled (ie enabled)
client_settings.disable_profiling = false
# profile gc
if matches_action?('profile-gc', env)
current.measure = false if current
return serve_profile_gc(env, client_settings)
end
# profile memory
if matches_action?('profile-memory', env)
return serve_profile_memory(env, client_settings)
end
# any other requests past this point are going to the app to be profiled
MiniProfiler.create_current(env, @config)
if matches_action?('normal-backtrace', env)
client_settings.backtrace_level = ClientSettings::BACKTRACE_DEFAULT
elsif matches_action?('no-backtrace', env)
current.skip_backtrace = true
client_settings.backtrace_level = ClientSettings::BACKTRACE_NONE
elsif matches_action?('full-backtrace', env) || client_settings.backtrace_full?
current.full_backtrace = true
client_settings.backtrace_level = ClientSettings::BACKTRACE_FULL
elsif client_settings.backtrace_none?
current.skip_backtrace = true
end
flamegraph = nil
trace_exceptions = matches_action?('trace-exceptions', env) && defined? TracePoint
status, headers, body, exceptions, trace = nil
if trace_exceptions
exceptions = []
trace = TracePoint.new(:raise) do |tp|
exceptions << tp.raised_exception
end
trace.enable
end
begin
# Strip all the caching headers so we don't get 304s back
# This solves a very annoying bug where rack mini profiler never shows up
if config.disable_caching
env['HTTP_IF_MODIFIED_SINCE'] = ''
env['HTTP_IF_NONE_MATCH'] = ''
end
orig_accept_encoding = env['HTTP_ACCEPT_ENCODING']
# Prevent response body from being compressed
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)
# do not sully our profile with mini profiler timings
current.measure = false
match_data = action_parameters(env)['flamegraph_sample_rate']
if match_data && !match_data[1].to_f.zero?
sample_rate = match_data[1].to_f
else
sample_rate = config.flamegraph_sample_rate
end
mode_match_data = action_parameters(env)['flamegraph_mode']
if mode_match_data && [:cpu, :wall, :object, :custom].include?(mode_match_data[1].to_sym)
mode = mode_match_data[1].to_sym
else
mode = config.flamegraph_mode
end
ignore_gc_match_data = action_parameters(env)['flamegraph_ignore_gc']
if ignore_gc_match_data
ignore_gc = ignore_gc_match_data == 'true'
else
ignore_gc = config.flamegraph_ignore_gc
end
flamegraph = StackProf.run(
mode: mode,
raw: true,
aggregate: false,
interval: (sample_rate * 1000).to_i,
ignore_gc: ignore_gc
) do
status, headers, body = @app.call(env)
end
else
message = "Please install the stackprof gem and require it: add gem 'stackprof' 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 path == '/rack-mini-profiler/requests'
status, headers, body = [200, { 'Content-Type' => 'text/html' }, [blank_page_html]]
else
status, headers, body = @app.call(env)
end
ensure
trace.disable if trace
env['HTTP_ACCEPT_ENCODING'] = orig_accept_encoding if config.suppress_encoding
end
skip_it = current.discard
if (config.authorization_mode == :allow_authorized && !MiniProfiler.request_authorized?)
skip_it = true
end
return client_settings.handle_cookie([status, headers, body]) if skip_it
# we must do this here, otherwise current[:discard] is not being properly treated
if trace_exceptions
body.close if body.respond_to? :close
query_params = action_parameters(env)
trace_exceptions_filter = query_params['trace_exceptions_filter']
if trace_exceptions_filter
trace_exceptions_regex = Regexp.new(trace_exceptions_filter)
exceptions.reject! { |ex| ex.class.name =~ trace_exceptions_regex }
end
return client_settings.handle_cookie(dump_exceptions exceptions)
end
if matches_action?("env", env)
return tool_disabled_message(client_settings) if !advanced_debugging_enabled?
body.close if body.respond_to? :close
return client_settings.handle_cookie(dump_env env)
end
if matches_action?("analyze-memory", env)
return tool_disabled_message(client_settings) if !advanced_debugging_enabled?
body.close if body.respond_to? :close
return client_settings.handle_cookie(analyze_memory)
end
if matches_action?("help", env)
body.close if body.respond_to? :close
return client_settings.handle_cookie(help(client_settings, env))
end
page_struct = current.page_struct
page_struct[:user] = user(env)
page_struct[:root].record_time((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000)
if flamegraph && matches_action?("flamegraph", env)
body.close if body.respond_to? :close
return client_settings.handle_cookie(self.flamegraph(flamegraph, path, env))
elsif flamegraph # async-flamegraph
page_struct[:has_flamegraph] = true
page_struct[:flamegraph] = flamegraph
end
begin
@storage.save(page_struct)
# no matter what it is, it should be unviewed, otherwise we will miss POST
@storage.set_unviewed(page_struct[:user], page_struct[:id])
# inject headers, script
if status >= 200 && status < 300
result = inject_profiler(env, status, headers, body)
return client_settings.handle_cookie(result) if result
end
rescue Exception => e
if @config.storage_failure != nil
@config.storage_failure.call(e)
end
end
client_settings.handle_cookie([status, headers, body])
ensure
# Make sure this always happens
self.current = nil
end
def matches_action?(action, env)
env['QUERY_STRING'] =~ /#{@config.profile_parameter}=#{action}/ ||
env['HTTP_X_RACK_MINI_PROFILER'] == action
end
def action_parameters(env)
query_params = Rack::Utils.parse_nested_query(env['QUERY_STRING'])
end
def inject_profiler(env, status, headers, body)
# mini profiler is meddling with stuff, we can not cache cause we will get incorrect data
# Rack::ETag has already inserted some nonesense in the chain
content_type = headers['Content-Type']
if config.disable_caching
headers.delete('ETag')
headers.delete('Date')
end
headers['X-MiniProfiler-Original-Cache-Control'] = headers['Cache-Control'] unless headers['Cache-Control'].nil?
headers['Cache-Control'] = "#{"no-store, " if config.disable_caching}must-revalidate, private, max-age=0"
# inject header
if headers.is_a? Hash
headers['X-MiniProfiler-Ids'] = ids_comma_separated(env)
headers['X-MiniProfiler-Flamegraph-Path'] = flamegraph_path(env) if current.page_struct[:has_flamegraph]
end
if current.inject_js && content_type =~ /text\/html/
response = Rack::Response.new([], status, headers)
script = self.get_profile_script(env, headers)
if String === body
response.write inject(body, script)
else
body.each { |fragment| response.write inject(fragment, script) }
end
body.close if body.respond_to? :close
response.finish
else
nil
end
end
def inject(fragment, script)
# find explicit or implicit body
index = fragment.rindex(/<\/body>/i) || fragment.rindex(/<\/html>/i)
if index
# if for whatever crazy reason we dont get a utf string,
# just force the encoding, no utf in the mp scripts anyway
if script.respond_to?(:encoding) && script.respond_to?(:force_encoding)
script = script.force_encoding(fragment.encoding)
end
safe_script = script
if script.respond_to?(:html_safe)
safe_script = script.html_safe
end
fragment.insert(index, safe_script)
else
fragment
end
end
def dump_exceptions(exceptions)
body = "Exceptions raised during request\n\n".dup
if exceptions.empty?
body << "No exceptions raised"
else
body << "Exceptions: (#{exceptions.size} total)\n"
exceptions.group_by(&:class).each do |klass, exceptions_per_class|
body << " #{klass.name} (#{exceptions_per_class.size})\n"
end
body << "\nBacktraces\n"
exceptions.each_with_index do |e, i|
body << "##{i + 1}: #{e.class} - \"#{e.message.lines.first.chomp}\"\n #{e.backtrace.join("\n ")}\n\n"
end
end
text_result(body)
end
def dump_env(env)
body = "Rack Environment\n---------------\n".dup
env.each do |k, v|
body << "#{k}: #{v}\n"
end
body << "\n\nEnvironment\n---------------\n"
ENV.each do |k, v|
body << "#{k}: #{v}\n"
end
body << "\n\nRuby Version\n---------------\n"
body << "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL}\n"
body << "\n\nInternals\n---------------\n"
body << "Storage Provider #{config.storage_instance}\n"
body << "User #{user(env)}\n"
body << config.storage_instance.diagnostics(user(env)) rescue "no diagnostics implemented for storage"
text_result(body)
end
def trim_strings(strings, max_size)
strings.sort! { |a, b| b[1] <=> a[1] }
i = 0
strings.delete_if { |_| (i += 1) > max_size }
end
def analyze_memory
require 'objspace'
utf8 = "utf-8"
GC.start
trunc = lambda do |str|
str = str.length > 200 ? str : str[0..200]
if str.encoding != Encoding::UTF_8
str = str.dup
str.force_encoding(utf8)
unless str.valid_encoding?
# work around bust string with a double conversion
str.encode!("utf-16", "utf-8", invalid: :replace)
str.encode!("utf-8", "utf-16")
end
end
str
end
body = "ObjectSpace stats:\n\n".dup
counts = ObjectSpace.count_objects
total_strings = counts[:T_STRING]
body << counts
.sort { |a, b| b[1] <=> a[1] }
.map { |k, v| "#{k}: #{v}" }
.join("\n")
strings = []
string_counts = Hash.new(0)
sample_strings = []
max_size = 1000
sample_every = total_strings / max_size
i = 0
ObjectSpace.each_object(String) do |str|
i += 1
string_counts[str] += 1
strings << [trunc.call(str), str.length]
sample_strings << [trunc.call(str), str.length] if i % sample_every == 0
if strings.length > max_size * 2
trim_strings(strings, max_size)
end
end
trim_strings(strings, max_size)
body << "\n\n\n1000 Largest strings:\n\n"
body << strings.map { |s, len| "#{s[0..1000]}\n(len: #{len})\n\n" }.join("\n")
body << "\n\n\n1000 Sample strings:\n\n"
body << sample_strings.map { |s, len| "#{s[0..1000]}\n(len: #{len})\n\n" }.join("\n")
body << "\n\n\n1000 Most common strings:\n\n"
body << string_counts.sort { |a, b| b[1] <=> a[1] }[0..max_size].map { |s, len| "#{trunc.call(s)}\n(x #{len})\n\n" }.join("\n")
text_result(body)
end
def text_result(body, status: 200, headers: nil)
headers = (headers || {}).merge('Content-Type' => 'text/plain; charset=utf-8')
[status, headers, [body]]
end
def ids(env)
all = ([current.page_struct[:id]] + (@storage.get_unviewed_ids(user(env)) || [])).uniq
if all.size > @config.max_traces_to_show
all = all[0...@config.max_traces_to_show]
@storage.set_all_unviewed(user(env), all)
end
all
end
def ids_comma_separated(env)
ids(env).join(",")
end
def flamegraph_path(env)
@config.base_url_path + 'flamegraph?id=' + current.page_struct[:id]
end
# cancels automatic injection of profile script for the current page
def cancel_auto_inject(env)
current.inject_js = false
end
def cache_control_value
86400
end
private
def rails_route_from_path(path, method)
if defined?(Rails) && defined?(ActionController::RoutingError)
hash = Rails.application.routes.recognize_path(path, method: method)
if hash && hash[:controller] && hash[:action]
"#{hash[:controller]}##{hash[:action]}"
end
end
rescue ActionController::RoutingError
nil
end
def take_snapshot?(path)
@config.snapshot_every_n_requests > 0 &&
!path.start_with?(@config.base_url_path) &&
@storage.should_take_snapshot?(@config.snapshot_every_n_requests)
end
def take_snapshot(env, start)
MiniProfiler.create_current(env, @config)
Thread.current[:mp_ongoing_snapshot] = true
results = @app.call(env)
status = results[0].to_i
if status >= 200 && status < 300
page_struct = current.page_struct
page_struct[:root].record_time(
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
)
custom_fields = MiniProfiler.get_snapshot_custom_fields
page_struct[:custom_fields] = custom_fields if custom_fields
if Rack::MiniProfiler.snapshots_transporter?
Rack::MiniProfiler::SnapshotsTransporter.transport(page_struct)
else
group_name = rails_route_from_path(page_struct[:request_path], page_struct[:request_method])
group_name ||= page_struct[:request_path]
group_name = "#{page_struct[:request_method]} #{group_name}"
@storage.push_snapshot(
page_struct,
group_name,
@config
)
end
end
self.current = nil
results
end
end
end