diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30426faa..52b31aa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ jobs: - "2.7" - "3.0" - "3.1" + - "3.2" steps: - uses: actions/checkout@v3 - name: Set up Ruby ${{ matrix.ruby }} @@ -20,7 +21,6 @@ jobs: ruby-version: ${{ matrix.ruby }} - name: Build run: | - gem install bundler bundle install --jobs 4 --retry 3 - name: Test on ${{ matrix.ruby }} run: | diff --git a/Gemfile b/Gemfile index c89a6423..b524710b 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ gemspec # Specify the same dependency sources as the application Gemfile gem("activesupport", "~> 5.2") gem("railties", "~> 5.2") +gem("vernier", "~> 0.4.0") if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2.1") gem("google-cloud-storage", "~> 1.21") gem("rubocop", require: false) diff --git a/Gemfile.lock b/Gemfile.lock index eb0db76e..e5fbff96 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -179,6 +179,7 @@ GEM thread_safe (~> 0.1) uber (0.1.0) unicode-display_width (2.1.0) + vernier (0.4.0) webrick (1.7.0) PLATFORMS @@ -197,6 +198,7 @@ DEPENDENCIES rubocop rubocop-performance rubocop-shopify + vernier (~> 0.4.0) BUNDLED WITH 2.4.18 diff --git a/README.md b/README.md index 8925458b..2477edc0 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,14 @@ Rails.application.config.app_profiler.profile_header = "X-Profile" | Key | Value | Notes | | --- | ----- | ----- | -| profile/mode | Supported profiling modes: `cpu`, `wall`, `object`. | Use `profile` in (1), and `mode` in (2). | +| profile/mode | Supported profiling modes: `cpu`, `wall`, `object` for stackprof. | Use `profile` in (1), and `mode` in (2). Vernier backend only supports `wall` and `retained` at present time| | async | Upload profile in a background thread. When this is set, profile redirect headers are not present in the response. | interval | Sampling interval in microseconds. | | | ignore_gc | Ignore garbage collection frames | | | autoredirect | Redirect request automatically to Speedscope's page after profiling. | | | context | Directory within the specified bucket in the selected storage where raw profile data should be written. | Only supported in (2). Defaults to `Rails.env` if not specified. | +| backend | Profiler to use, either `stackprof` or `vernier`. Defaults to `stackprof`. Note that Vernier requires Ruby 3.2.1+ | + Note that the `autoredirect` feature can be turned on for all requests by doing the following: @@ -280,11 +282,16 @@ report = AppProfiler.run(mode: :cpu) do # ... end -report.view # opens the profile locally in speedscope. +report.view # opens the profile locally in speedscope or firefox profiler, as appropriate ``` Profile files can be found locally in your rails app at `tmp/app_profiler/*.json`. +**Note** In development, if using the SpeedscopeRemoteViewer for stackprof +or if using Vernier, a route for `/app_profiler` will be added to the application. +If using Vernier, a route for `/from-url` is also added. These will be handled +in middlewares, before any application routing logic. There is a small chance +that these could shadow existing routes in the application. ## Storage backends @@ -312,6 +319,22 @@ Note that in `development` and `test` modes the file isn't uploaded. Instead, it Rails.application.config.app_profiler.middleware_action = AppProfiler::Middleware::UploadAction ``` +## Profiler backends + +It is possible to configure AppProfiler to use the [`vernier`](https://github.com/jhawthorn/vernier) or [`stackprof`](https://github.com/tmm1/stackprof). To use `vernier`, it must be added separately in the application Gemfile. + +The backend can be selected dynamically at runtime using the `backend` parameter. The default backend to use when this parameter is not specified can be configured with: + +```ruby +AppProfiler.backend = AppProfiler::StackprofBackend # or AppProfiler::VernierBackend +# OR +Rails.application.config.app_profiler.backend = AppProfiler::StackprofBackend # or AppProfiler::VernierBackend +``` + +By default, the stackprof backend will be used. + +In local development, changing the backend will change whether the profile is viewed in speedscope or firefox-profiler. + ## Running tests ``` diff --git a/Rakefile b/Rakefile index 8decebf5..5168d32c 100644 --- a/Rakefile +++ b/Rakefile @@ -3,6 +3,8 @@ require "bundler/gem_tasks" require "rake/testtask" +load "lib/tasks/firefox_profiler_compile.rake" + Rake::TestTask.new(:test) do |t| t.libs << "test" t.libs << "lib" diff --git a/dev.yml b/dev.yml index 9c95af0c..1e16d909 100644 --- a/dev.yml +++ b/dev.yml @@ -3,7 +3,7 @@ name: app-profiler type: ruby up: - - ruby: 3.1.2 + - ruby: 3.2.1 - bundler commands: diff --git a/lib/app_profiler.rb b/lib/app_profiler.rb index 49405faa..a015306b 100644 --- a/lib/app_profiler.rb +++ b/lib/app_profiler.rb @@ -9,6 +9,9 @@ module AppProfiler class ConfigurationError < StandardError end + class BackendError < StandardError + end + DefaultProfileFormatter = proc do |upload| "#{AppProfiler.speedscope_host}#profileURL=#{upload.url}" end @@ -25,15 +28,16 @@ module Storage module Viewer autoload :BaseViewer, "app_profiler/viewer/base_viewer" - autoload :SpeedscopeViewer, "app_profiler/viewer/speedscope_viewer" - autoload :SpeedscopeRemoteViewer, "app_profiler/viewer/speedscope_remote_viewer" + autoload :SpeedscopeViewer, "app_profiler/viewer/speedscope" + autoload :SpeedscopeRemoteViewer, "app_profiler/viewer/remote/speedscope" + autoload :FirefoxRemoteViewer, "app_profiler/viewer/remote/firefox" end require "app_profiler/middleware" require "app_profiler/parameters" require "app_profiler/request_parameters" - require "app_profiler/profiler" require "app_profiler/profile" + require "app_profiler/backend" require "app_profiler/server" mattr_accessor :logger, default: Logger.new($stdout) @@ -48,8 +52,10 @@ module Viewer mattr_reader :profile_url_formatter, default: DefaultProfileFormatter + mattr_accessor :gecko_viewer_package, default: "https://github.com/tenderlove/profiler#v0.0.2" mattr_accessor :storage, default: Storage::FileStorage - mattr_accessor :viewer, default: Viewer::SpeedscopeViewer + mattr_accessor :viewer, default: Viewer::SpeedscopeViewer # DEPRECATED + mattr_accessor :speedscope_viewer, default: Viewer::SpeedscopeViewer mattr_accessor :middleware, default: Middleware mattr_accessor :server, default: Server mattr_accessor :upload_queue_max_length, default: 10 @@ -60,17 +66,73 @@ module Viewer mattr_reader :after_process_queue, default: nil class << self - def run(*args, &block) - Profiler.run(*args, &block) + def run(*args, backend: nil, **kwargs, &block) + orig_backend = self.backend + begin + self.backend = backend if backend + profiler.run(*args, **kwargs, &block) + rescue BackendError + yield + end + ensure + AppProfiler.backend = orig_backend end def start(*args) - Profiler.start(*args) + profiler.start(*args) end def stop - Profiler.stop - Profiler.results + profiler.stop + profiler.results + end + + def running? + @backend&.running? + end + + def profiler + @backend ||= backend.new + end + + def backend=(new_backend) + new_profiler_backend = if new_backend.is_a?(String) + backend_for(new_backend) + elsif new_backend&.< Backend + new_backend + else + raise BackendError, "unsupportend backend type #{new_backend.class}" + end + + if running? + raise BackendError, + "cannot change backend to #{new_backend::NAME} while #{backend::NAME} backend is running" + end + + return if @profiler_backend == new_backend + + clear + @profiler_backend = new_profiler_backend + end + + def backend_for(backend_name) + if defined?(AppProfiler::VernierBackend::NAME) && + backend_name == AppProfiler::VernierBackend::NAME + AppProfiler::VernierBackend + elsif backend_name == AppProfiler::StackprofBackend::NAME + AppProfiler::StackprofBackend + else + raise BackendError, "unknown backend #{backend_name}" + end + end + + def backend + @profiler_backend ||= DefaultBackend + end + + def clear + @backend.stop if @backend&.running? + @backend = nil end def profile_header=(profile_header) @@ -120,6 +182,17 @@ def profile_url(upload) AppProfiler.profile_url_formatter.call(upload) end + + # DEPRECATIONS + def viewer + ActiveSupport::Deprecation.warn("viewer is deprecated, use speedscope_viewer instead") + @viewer + end + + def viewer=(viewer) + ActiveSupport::Deprecation.warn("viewer= is deprecated, use speedscope_viewer= instead") + @viewer = viewer + end end require "app_profiler/railtie" if defined?(Rails::Railtie) diff --git a/lib/app_profiler/backend.rb b/lib/app_profiler/backend.rb new file mode 100644 index 00000000..300c0c62 --- /dev/null +++ b/lib/app_profiler/backend.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module AppProfiler + class Backend + def run(params = {}, &block) + raise NotImplementedError + end + + def start(params = {}) + raise NotImplementedError + end + + def stop + raise NotImplementedError + end + + def results + raise NotImplementedError + end + + def running? + raise NotImplementedError + end + + class << self + def run_lock + @run_lock ||= Mutex.new + end + end + + protected + + def acquire_run_lock + self.class.run_lock.try_lock + end + + def release_run_lock + self.class.run_lock.unlock + rescue ThreadError + AppProfiler.logger.warn("[AppProfiler] run lock not released as it was never acquired") + end + end + + autoload :StackprofBackend, "app_profiler/backend/stackprof" + autoload :VernierBackend, "app_profiler/backend/vernier" + DefaultBackend = AppProfiler::StackprofBackend +end diff --git a/lib/app_profiler/backend/stackprof.rb b/lib/app_profiler/backend/stackprof.rb new file mode 100644 index 00000000..edddcb11 --- /dev/null +++ b/lib/app_profiler/backend/stackprof.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "stackprof" + +module AppProfiler + class StackprofBackend < Backend + NAME = "stackprof" + DEFAULTS = { + mode: :cpu, + raw: true, + }.freeze + + AVAILABLE_MODES = [ + :wall, + :cpu, + :object, + ].freeze + + def run(params = {}) + started = start(params) + + yield + + return unless started + + stop + results + ensure + # Only stop the profiler if profiling was started in this context. + stop if started + end + + def start(params = {}) + # Do not start the profiler if StackProf was started somewhere else. + return false if running? + return false unless acquire_run_lock + + clear + + ::StackProf.start(**DEFAULTS, **params) + rescue => error + AppProfiler.logger.info( + "[Profiler] failed to start the profiler error_class=#{error.class} error_message=#{error.message}" + ) + release_run_lock + # This is a boolean instead of nil because StackProf#start returns a + # boolean as well. + false + end + + def stop + ::StackProf.stop + ensure + release_run_lock + end + + def results + stackprof_profile = backend_results + + return unless stackprof_profile + + AppProfiler::AbstractProfile.from_stackprof(stackprof_profile) + rescue => error + AppProfiler.logger.info( + "[Profiler] failed to obtain the profile error_class=#{error.class} error_message=#{error.message}" + ) + nil + end + + def running? + ::StackProf.running? + end + + private + + def backend_results + ::StackProf.results + end + + # Clears the previous profiling session. + # + # StackProf will attempt to reuse frames from the previous profiling + # session if the results are not collected. This is usually called before + # StackProf#start is invoked to ensure that new profiling sessions do + # not reuse previous frames if they exist. + # + # Ref: https://github.com/tmm1/stackprof/blob/0ded6c/ext/stackprof/stackprof.c#L118-L123 + # + def clear + backend_results + end + end +end diff --git a/lib/app_profiler/backend/vernier.rb b/lib/app_profiler/backend/vernier.rb new file mode 100644 index 00000000..50557621 --- /dev/null +++ b/lib/app_profiler/backend/vernier.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +gem("vernier", ">= 0.4.0") +require "vernier" + +module AppProfiler + class VernierBackend < Backend + NAME = "vernier" + + DEFAULTS = { + mode: :wall, + }.freeze + + AVAILABLE_MODES = [ + :wall, + :retained, + ].freeze + + def run(params = {}) + started = start(params) + + yield + + return unless started + + stop + results + ensure + # Only stop the profiler if profiling was started in this context. + stop if started + end + + def start(params = {}) + # Do not start the profiler if we already have a collector started somewhere else. + return false if running? + return false unless acquire_run_lock + + @mode = params.delete(:mode) || DEFAULTS[:mode] + raise ArgumentError unless AVAILABLE_MODES.include?(@mode) + + @metadata = params.delete(:metadata) + clear + + @collector ||= ::Vernier::Collector.new(@mode, **params) + @collector.start + rescue => error + AppProfiler.logger.info( + "[Profiler] failed to start the profiler error_class=#{error.class} error_message=#{error.message}" + ) + release_run_lock + # This is a boolean instead of nil to be consistent with the stackprof backend behaviour + # boolean as well. + false + end + + def stop + return false unless running? + + @results = @collector&.stop + @collector = nil + !@results.nil? + ensure + release_run_lock + end + + def results + vernier_profile = backend_results + clear + + return unless vernier_profile + + # HACK: - "data" is private, but we want to avoid serializing to JSON then + # parsing back from JSON by just directly getting the hash + data = ::Vernier::Output::Firefox.new(vernier_profile).send(:data) + data[:meta][:mode] = @mode # TODO: https://github.com/jhawthorn/vernier/issues/30 + data[:meta].merge!(@metadata) if @metadata + @mode = nil + @metadata = nil + + AppProfiler::AbstractProfile.from_vernier(data) + rescue => error + AppProfiler.logger.info( + "[Profiler] failed to obtain the profile error_class=#{error.class} error_message=#{error.message}" + ) + nil + end + + def running? + @collector != nil + end + + private + + def backend_results + @results + end + + def clear + @results = nil + end + end +end diff --git a/lib/app_profiler/middleware.rb b/lib/app_profiler/middleware.rb index 232ead57..63947e1d 100644 --- a/lib/app_profiler/middleware.rb +++ b/lib/app_profiler/middleware.rb @@ -31,7 +31,7 @@ def profile(env, params) return yield unless before_profile(env, params_hash) - profile = AppProfiler.run(params_hash) do + profile = AppProfiler.run(params_hash, backend: params.backend) do response = yield end diff --git a/lib/app_profiler/middleware/base_action.rb b/lib/app_profiler/middleware/base_action.rb index 7fdb3970..69d6713c 100644 --- a/lib/app_profiler/middleware/base_action.rb +++ b/lib/app_profiler/middleware/base_action.rb @@ -9,7 +9,7 @@ def call(_profile, _params = {}) end def cleanup - profile = Profiler.results + profile = AppProfiler.profiler.results call(profile) if profile end end diff --git a/lib/app_profiler/parameters.rb b/lib/app_profiler/parameters.rb index ffffaf9e..42c197bc 100644 --- a/lib/app_profiler/parameters.rb +++ b/lib/app_profiler/parameters.rb @@ -4,17 +4,18 @@ module AppProfiler class Parameters - DEFAULT_INTERVALS = { "cpu" => 1000, "wall" => 1000, "object" => 2000 }.freeze - MIN_INTERVALS = { "cpu" => 200, "wall" => 200, "object" => 400 }.freeze - MODES = DEFAULT_INTERVALS.keys.freeze + DEFAULT_INTERVALS = { "cpu" => 1000, "wall" => 1000, "object" => 2000, "retained" => 0 }.freeze + MIN_INTERVALS = { "cpu" => 200, "wall" => 200, "object" => 400, "retained" => 0 }.freeze - attr_reader :autoredirect, :async + attr_reader :autoredirect, :async, :backend - def initialize(mode: :wall, interval: nil, ignore_gc: false, autoredirect: false, async: false, metadata: {}) + def initialize(mode: :wall, interval: nil, ignore_gc: false, autoredirect: false, + async: false, backend: nil, metadata: {}) @mode = mode.to_sym @interval = [interval&.to_i || DEFAULT_INTERVALS.fetch(@mode.to_s), MIN_INTERVALS.fetch(@mode.to_s)].max @ignore_gc = !!ignore_gc @autoredirect = autoredirect + @backend = backend @metadata = { context: AppProfiler.context }.merge(metadata) @async = async end diff --git a/lib/app_profiler/profile.rb b/lib/app_profiler/profile.rb index 9fa323bb..2fea1ba6 100644 --- a/lib/app_profiler/profile.rb +++ b/lib/app_profiler/profile.rb @@ -1,42 +1,43 @@ # frozen_string_literal: true module AppProfiler - class Profile + autoload :StackprofProfile, "app_profiler/profile/stackprof" + autoload :VernierProfile, "app_profiler/profile/vernier" + + class AbstractProfile INTERNAL_METADATA_KEYS = [:id, :context] private_constant :INTERNAL_METADATA_KEYS class UnsafeFilename < StandardError; end - delegate :[], to: :@data attr_reader :id, :context + delegate :[], to: :@data + # This function should not be called if `StackProf.results` returns nil. def self.from_stackprof(data) options = INTERNAL_METADATA_KEYS.map { |key| [key, data[:metadata]&.delete(key)] }.to_h - new(data, **options).tap do |profile| + StackprofProfile.new(data, **options).tap do |profile| + raise ArgumentError, "invalid profile data" unless profile.valid? + end + end + + def self.from_vernier(data) + options = INTERNAL_METADATA_KEYS.map { |key| [key, data[:meta]&.delete(key)] }.to_h + + VernierProfile.new(data, **options).tap do |profile| raise ArgumentError, "invalid profile data" unless profile.valid? end end - # `data` is assumed to be a Hash. + # `data` is assumed to be a Hash for Stackprof, + # a vernier "result" object for vernier def initialize(data, id: nil, context: nil) @id = id.presence || SecureRandom.hex @context = context @data = data end - def valid? - mode.present? - end - - def mode - @data[:mode] - end - - def view(params = {}) - AppProfiler.viewer.view(self, **params) - end - def upload AppProfiler.storage.upload(self).tap do |upload| if upload && defined?(upload.url) @@ -60,6 +61,10 @@ def enqueue_upload AppProfiler.storage.enqueue_upload(self) end + def valid? + mode.present? + end + def file @file ||= path.tap do |p| p.dirname.mkpath @@ -71,6 +76,18 @@ def to_h @data end + def mode + raise NotImplementedError + end + + def format + raise NotImplementedError + end + + def view(params = {}) + raise NotImplementedError + end + private def path @@ -79,11 +96,14 @@ def path mode, id, Socket.gethostname, - ].compact.join("-") << ".json" + ].compact.join("-") << format raise UnsafeFilename if /[^0-9A-Za-z.\-\_]/.match?(filename) AppProfiler.profile_root.join(filename) end end + + Profile = AbstractProfile + deprecate_constant :Profile end diff --git a/lib/app_profiler/profile/stackprof.rb b/lib/app_profiler/profile/stackprof.rb new file mode 100644 index 00000000..b66338af --- /dev/null +++ b/lib/app_profiler/profile/stackprof.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module AppProfiler + class StackprofProfile < AbstractProfile + FILE_EXTENSION = ".stackprof.json" + + def mode + @data[:mode] + end + + def format + FILE_EXTENSION + end + + def view(params = {}) + AppProfiler.speedscope_viewer.view(self, **params) + end + end +end diff --git a/lib/app_profiler/profile/vernier.rb b/lib/app_profiler/profile/vernier.rb new file mode 100644 index 00000000..d0008796 --- /dev/null +++ b/lib/app_profiler/profile/vernier.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module AppProfiler + class VernierProfile < AbstractProfile + FILE_EXTENSION = ".gecko.json" + + def mode + @data[:meta][:mode] + end + + def format + FILE_EXTENSION + end + + def view(params = {}) + Viewer::FirefoxRemoteViewer.view(self, **params) + end + end +end diff --git a/lib/app_profiler/profiler.rb b/lib/app_profiler/profiler.rb deleted file mode 100644 index bbb922df..00000000 --- a/lib/app_profiler/profiler.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -require "stackprof" - -module AppProfiler - module Profiler - DEFAULTS = { - mode: :cpu, - raw: true, - }.freeze - - class << self - def run(params = {}) - started = start(params) - - yield - - return unless started - - stop - results - ensure - # Only stop the profiler if profiling was started in this context. - stop if started - end - - def start(params = {}) - # Do not start the profiler if StackProf was started somewhere else. - return false if running? - - clear - - StackProf.start(**DEFAULTS, **params) - rescue => error - AppProfiler.logger.info( - "[Profiler] failed to start the profiler error_class=#{error.class} error_message=#{error.message}" - ) - # This is a boolean instead of nil because StackProf#start returns a - # boolean as well. - false - end - - def stop - StackProf.stop - end - - def results - stackprof_profile = stackprof_results - - return unless stackprof_profile - - Profile.from_stackprof(stackprof_profile) - rescue => error - AppProfiler.logger.info( - "[Profiler] failed to obtain the profile error_class=#{error.class} error_message=#{error.message}" - ) - nil - end - - private - - def stackprof_results - StackProf.results - end - - # Clears the previous profiling session. - # - # StackProf will attempt to reuse frames from the previous profiling - # session if the results are not collected. This is usually called before - # StackProf#start is invoked to ensure that new profiling sessions do - # not reuse previous frames if they exist. - # - # Ref: https://github.com/tmm1/stackprof/blob/0ded6c/ext/stackprof/stackprof.c#L118-L123 - # - def clear - stackprof_results - end - - def running? - StackProf.running? - end - end - end - - private_constant :Profiler -end diff --git a/lib/app_profiler/railtie.rb b/lib/app_profiler/railtie.rb index 98ad041d..75974cfd 100644 --- a/lib/app_profiler/railtie.rb +++ b/lib/app_profiler/railtie.rb @@ -11,7 +11,8 @@ class Railtie < Rails::Railtie AppProfiler.logger = app.config.app_profiler.logger || Rails.logger AppProfiler.root = app.config.app_profiler.root || Rails.root AppProfiler.storage = app.config.app_profiler.storage || Storage::FileStorage - AppProfiler.viewer = app.config.app_profiler.viewer || Viewer::SpeedscopeViewer + AppProfiler.viewer = app.config.app_profiler.viewer || Viewer::SpeedscopeRemoteViewer + AppProfiler.speedscope_viewer = app.config.app_profiler.speedscope_viewer || AppProfiler.viewer AppProfiler.storage.bucket_name = app.config.app_profiler.storage_bucket_name || "profiles" AppProfiler.storage.credentials = app.config.app_profiler.storage_credentials || {} AppProfiler.middleware = app.config.app_profiler.middleware || Middleware @@ -40,12 +41,17 @@ class Railtie < Rails::Railtie AppProfiler.profile_enqueue_success = app.config.app_profiler.profile_enqueue_success AppProfiler.profile_enqueue_failure = app.config.app_profiler.profile_enqueue_failure AppProfiler.after_process_queue = app.config.app_profiler.after_process_queue + AppProfiler.backend = app.config.app_profiler.profiler_backend || AppProfiler::DefaultBackend + AppProfiler.gecko_viewer_package = app.config.app_profiler.gecko_viewer_package || "https://github.com/firefox-devtools/profiler" end initializer "app_profiler.add_middleware" do |app| unless AppProfiler.middleware.disabled - if AppProfiler.viewer == Viewer::SpeedscopeRemoteViewer - app.middleware.insert_before(0, Viewer::SpeedscopeRemoteViewer::Middleware) + if Rails.env.development? || Rails.env.test? + if AppProfiler.speedscope_viewer == Viewer::SpeedscopeRemoteViewer + app.middleware.insert_before(0, Viewer::SpeedscopeRemoteViewer::Middleware) + end + app.middleware.insert_before(0, Viewer::FirefoxRemoteViewer::Middleware) end app.middleware.insert_before(0, AppProfiler.middleware) end diff --git a/lib/app_profiler/request_parameters.rb b/lib/app_profiler/request_parameters.rb index 40afcc99..377d7155 100644 --- a/lib/app_profiler/request_parameters.rb +++ b/lib/app_profiler/request_parameters.rb @@ -16,17 +16,29 @@ def async query_param("async") end + def backend + query_param("backend") || profile_header_param("backend") || + AppProfiler.backend::NAME + end + def valid? if mode.blank? return false end - unless Parameters::MODES.include?(mode) - AppProfiler.logger.info("[Profiler] unsupported profiling mode=#{mode}") + return false if backend != AppProfiler::StackprofBackend::NAME && !defined?(AppProfiler::VernierBackend::NAME) + + if defined?(AppProfiler::VernierBackend::NAME) && backend == AppProfiler::VernierBackend::NAME && + !AppProfiler::VernierBackend::AVAILABLE_MODES.include?(mode.to_sym) + AppProfiler.logger.info("[AppProfiler] unsupported profiling mode=#{mode} for backend #{backend}") + return false + elsif backend == AppProfiler::StackprofBackend::NAME && + !AppProfiler::StackprofBackend::AVAILABLE_MODES.include?(mode.to_sym) + AppProfiler.logger.info("[AppProfiler] unsupported profiling mode=#{mode} for backend #{backend}") return false end - if interval.to_i < Parameters::MIN_INTERVALS[mode] + if interval.to_i < Parameters::MIN_INTERVALS[mode.to_s] return false end @@ -56,7 +68,7 @@ def ignore_gc end def interval - query_param("interval") || profile_header_param("interval") || Parameters::DEFAULT_INTERVALS[mode] + query_param("interval") || profile_header_param("interval") || Parameters::DEFAULT_INTERVALS[mode.to_s] end def request_id diff --git a/lib/app_profiler/viewer/base_viewer.rb b/lib/app_profiler/viewer/base_viewer.rb index 98f06618..6008f925 100644 --- a/lib/app_profiler/viewer/base_viewer.rb +++ b/lib/app_profiler/viewer/base_viewer.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +gem "rails-html-sanitizer", ">= 1.6.0" +require "rails-html-sanitizer" + module AppProfiler module Viewer class BaseViewer @@ -12,6 +15,101 @@ def view(profile, params = {}) def view(_params = {}) raise NotImplementedError end + + class BaseMiddleware + class Sanitizer < Rails::HTML::Sanitizer.best_supported_vendor.safe_list_sanitizer + self.allowed_tags = Set.new([ + "strong", "em", "b", "i", "p", "code", "pre", "tt", "samp", "kbd", "var", "sub", + "sup", "dfn", "cite", "big", "small", "address", "hr", "br", "div", "span", "h1", + "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "dl", "dt", "dd", "abbr", "acronym", + "a", "img", "blockquote", "del", "ins", "script", + ]) + end + + private_constant(:Sanitizer) + + def self.id(file) + file.basename.to_s + end + + def initialize(app) + @app = app + end + + def call(env) + request = Rack::Request.new(env) + + return index(env) if %r(\A/app_profiler/?\z).match?(request.path_info) + + @app.call(env) + end + + protected + + def id(file) + self.class.id(file) + end + + def profile_files + AppProfiler.profile_root.glob("**/*.json") + end + + def render(html) + [ + 200, + { "Content-Type" => "text/html" }, + [ + +<<~HTML, + + +
++ + #{id(file)} + +
+ HTML + end + end + ) + end + end + + private_constant(:BaseMiddleware) end end end diff --git a/lib/app_profiler/viewer/middleware/firefox.rb b/lib/app_profiler/viewer/middleware/firefox.rb new file mode 100644 index 00000000..3b2ec9c1 --- /dev/null +++ b/lib/app_profiler/viewer/middleware/firefox.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "app_profiler/yarn/command" +require "app_profiler/yarn/with_firefox_profiler" + +module AppProfiler + module Viewer + class FirefoxRemoteViewer < BaseViewer + class Middleware < BaseMiddleware + include Yarn::WithFirefoxProfile + + def initialize(app) + super + @firefox_profiler = Rack::File.new( + File.join(AppProfiler.root, "node_modules/firefox-profiler/dist") + ) + end + + def call(env) + request = Rack::Request.new(env) + @app.call(env) if request.path_info.end_with?(AppProfiler::StackprofProfile::FILE_EXTENSION) + # Firefox profiler *really* doesn't like for /from-url/ to be at any other mount point + # so with this enabled, we take over both /app_profiler and /from-url in the app in development. + return from(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/from-url(.*)\z) + return viewer(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/firefox/viewer/(.*)\z) + return show(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/firefox/(.*)\z) + + super + end + + protected + + attr_reader(:firefox_profiler) + + def viewer(env, path) + setup_yarn unless yarn_setup + + if path.end_with?(AppProfiler::VernierProfile::FILE_EXTENSION) + proto = env["rack.url_scheme"] + host = env["HTTP_HOST"] + source = "#{proto}://#{host}/app_profiler/firefox/#{path}" + + target = "/from-url/#{CGI.escape(source)}" + + [302, { "Location" => target }, []] + else + env[Rack::PATH_INFO] = path.delete_prefix("/app_profiler") + firefox_profiler.call(env) + end + end + + def from(env, path) + setup_yarn unless yarn_setup + index = File.read(File.join(AppProfiler.root, "node_modules/firefox-profiler/dist/index.html")) + [200, { "Content-Type" => "text/html" }, [index]] + end + + def show(_env, name) + profile = profile_files.find do |file| + id(file) == name + end || raise(ArgumentError) + + [200, { "Content-Type" => "application/json" }, [profile.read]] + end + end + end + end +end diff --git a/lib/app_profiler/viewer/middleware/speedscope.rb b/lib/app_profiler/viewer/middleware/speedscope.rb new file mode 100644 index 00000000..cdc19429 --- /dev/null +++ b/lib/app_profiler/viewer/middleware/speedscope.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "app_profiler/yarn/command" +require "app_profiler/yarn/with_speedscope" + +module AppProfiler + module Viewer + class SpeedscopeRemoteViewer < BaseViewer + class Middleware < BaseMiddleware + include Yarn::WithSpeedscope + + def initialize(app) + super + @speedscope = Rack::File.new( + File.join(AppProfiler.root, "node_modules/speedscope/dist/release") + ) + end + + def call(env) + request = Rack::Request.new(env) + @app.call(env) if request.path_info.end_with?(AppProfiler::VernierProfile::FILE_EXTENSION) + return viewer(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/speedscope/viewer/(.*)\z) + return show(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/speedscope/(.*)\z) + + super + end + + protected + + attr_reader(:speedscope) + + def viewer(env, path) + setup_yarn unless yarn_setup + + if path.end_with?(AppProfiler::StackprofProfile::FILE_EXTENSION) + source = "/app_profiler/speedscope/#{path}" + target = "/app_profiler/speedscope/viewer/index.html#profileURL=#{CGI.escape(source)}" + + [302, { "Location" => target }, []] + else + env[Rack::PATH_INFO] = path.delete_prefix("/app_profiler/speedscope") + speedscope.call(env) + end + end + + def show(_env, name) + profile = profile_files.find do |file| + id(file) == name + end || raise(ArgumentError) + + [200, { "Content-Type" => "application/json" }, [profile.read]] + end + end + end + end +end diff --git a/lib/app_profiler/viewer/remote/firefox.rb b/lib/app_profiler/viewer/remote/firefox.rb new file mode 100644 index 00000000..2099dd55 --- /dev/null +++ b/lib/app_profiler/viewer/remote/firefox.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "app_profiler/viewer/middleware/firefox" + +module AppProfiler + module Viewer + class FirefoxRemoteViewer < BaseViewer + NAME = "firefox" + + class << self + def view(profile, params = {}) + new(profile).view(**params) + end + end + + def initialize(profile) + super() + @profile = profile + end + + def view(response: nil, autoredirect: nil, async: false) + id = Middleware.id(@profile.file) + + if response && response[0].to_i < 500 + response[1]["Location"] = "/app_profiler/#{NAME}/viewer/#{id}" + response[0] = 303 + else + AppProfiler.logger.info("[Profiler] Profile available at /app_profiler/#{id}\n") + end + end + end + end +end diff --git a/lib/app_profiler/viewer/speedscope_remote_viewer.rb b/lib/app_profiler/viewer/remote/speedscope.rb similarity index 76% rename from lib/app_profiler/viewer/speedscope_remote_viewer.rb rename to lib/app_profiler/viewer/remote/speedscope.rb index ced8bbce..4b72fca5 100644 --- a/lib/app_profiler/viewer/speedscope_remote_viewer.rb +++ b/lib/app_profiler/viewer/remote/speedscope.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true -require "app_profiler/viewer/speedscope_remote_viewer/base_middleware" -require "app_profiler/viewer/speedscope_remote_viewer/middleware" +require "app_profiler/viewer/middleware/speedscope" module AppProfiler module Viewer class SpeedscopeRemoteViewer < BaseViewer + NAME = "speedscope" + class << self def view(profile, params = {}) new(profile).view(**params) @@ -21,7 +22,7 @@ def view(response: nil, autoredirect: nil, async: false) id = Middleware.id(@profile.file) if response && response[0].to_i < 500 - response[1]["Location"] = "/app_profiler/#{id}" + response[1]["Location"] = "/app_profiler/#{NAME}/viewer/#{id}" response[0] = 303 else AppProfiler.logger.info("[Profiler] Profile available at /app_profiler/#{id}\n") diff --git a/lib/app_profiler/viewer/speedscope_viewer.rb b/lib/app_profiler/viewer/speedscope.rb similarity index 100% rename from lib/app_profiler/viewer/speedscope_viewer.rb rename to lib/app_profiler/viewer/speedscope.rb diff --git a/lib/app_profiler/viewer/speedscope_remote_viewer/base_middleware.rb b/lib/app_profiler/viewer/speedscope_remote_viewer/base_middleware.rb deleted file mode 100644 index a6fba235..00000000 --- a/lib/app_profiler/viewer/speedscope_remote_viewer/base_middleware.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -gem "rails-html-sanitizer", ">= 1.6.0" -require "rails-html-sanitizer" - -module AppProfiler - module Viewer - class SpeedscopeRemoteViewer < BaseViewer - class BaseMiddleware - class Sanitizer < Rails::HTML::Sanitizer.best_supported_vendor.safe_list_sanitizer - self.allowed_tags = Set.new([ - "strong", "em", "b", "i", "p", "code", "pre", "tt", "samp", "kbd", "var", "sub", - "sup", "dfn", "cite", "big", "small", "address", "hr", "br", "div", "span", "h1", - "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "dl", "dt", "dd", "abbr", "acronym", - "a", "img", "blockquote", "del", "ins", "script", - ]) - end - - private_constant(:Sanitizer) - - def self.id(file) - file.basename.to_s.delete_suffix(".json") - end - - def initialize(app) - @app = app - end - - def call(env) - request = Rack::Request.new(env) - - return index(env) if request.path_info =~ %r(\A/app_profiler/?\z) - return viewer(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/viewer/(.*)\z) - return show(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/(.*)\z) - - @app.call(env) - end - - protected - - def id(file) - self.class.id(file) - end - - def profile_files - AppProfiler.profile_root.glob("**/*.json") - end - - def render(html) - [ - 200, - { "Content-Type" => "text/html" }, - [ - +<<~HTML, - - - -- - #{id(file)} - -
- HTML - end - end - ) - end - - def show(env, id) - raise NotImplementedError - end - end - - private_constant(:BaseMiddleware) - end - end -end diff --git a/lib/app_profiler/viewer/speedscope_remote_viewer/middleware.rb b/lib/app_profiler/viewer/speedscope_remote_viewer/middleware.rb deleted file mode 100644 index 516f83b9..00000000 --- a/lib/app_profiler/viewer/speedscope_remote_viewer/middleware.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require "app_profiler/yarn/command" -require "app_profiler/yarn/with_speedscope" - -module AppProfiler - module Viewer - class SpeedscopeRemoteViewer < BaseViewer - class Middleware < BaseMiddleware - include Yarn::WithSpeedscope - - def initialize(app) - super - @speedscope = Rack::File.new( - File.join(AppProfiler.root, "node_modules/speedscope/dist/release") - ) - end - - protected - - attr_reader(:speedscope) - - def viewer(env, path) - setup_yarn unless yarn_setup - env[Rack::PATH_INFO] = path.delete_prefix("/app_profiler") - - speedscope.call(env) - end - - def show(_env, name) - profile = profile_files.find do |file| - id(file) == name - end || raise(ArgumentError) - - render( - <<~HTML - - HTML - ) - end - end - end - end -end diff --git a/lib/app_profiler/yarn/command.rb b/lib/app_profiler/yarn/command.rb index 3866325a..a1800302 100644 --- a/lib/app_profiler/yarn/command.rb +++ b/lib/app_profiler/yarn/command.rb @@ -7,13 +7,16 @@ class YarnError < StandardError; end VALID_COMMANDS = [ ["which", "yarn"], + ["which", "gcloud"], ["yarn", "init", "--yes"], ["yarn", "add", "speedscope", "--dev", "--ignore-workspace-root-check"], ["yarn", "run", "speedscope", /.*\.json/], + ["yarn", "add", "--dev", %r{.*/firefox-profiler}], + ["yarn", "--cwd", %r{.*/firefox-profiler}], + ["yarn", "--cwd", %r{.*/firefox-profiler}, "build-prod"], ] private_constant(:VALID_COMMANDS) - mattr_accessor(:yarn_setup, default: false) def yarn(command, *options) setup_yarn unless yarn_setup @@ -29,6 +32,14 @@ def setup_yarn yarn("init", "--yes") unless package_json_exists? end + def yarn_setup + @yarn_initialized || false + end + + def yarn_setup=(state) + @yarn_initialized = state + end + private def ensure_command_valid(command) @@ -39,6 +50,8 @@ def ensure_command_valid(command) def valid_command?(command) VALID_COMMANDS.any? do |valid_command| + next unless valid_command.size == command.size + valid_command.zip(command).all? do |valid_part, part| part.match?(valid_part) end @@ -55,7 +68,7 @@ def ensure_yarn_installed MSG ) end - self.yarn_setup = true + @yarn_initialized = true end def package_json_exists? diff --git a/lib/app_profiler/yarn/with_firefox_profiler.rb b/lib/app_profiler/yarn/with_firefox_profiler.rb new file mode 100644 index 00000000..f0d5d8ce --- /dev/null +++ b/lib/app_profiler/yarn/with_firefox_profiler.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "rubygems/package" +require "zlib" +require "open-uri" + +module AppProfiler + module Yarn + module WithFirefoxProfile + include Command + class CommandError < StandardError; end + + INSTALL_DIRECTORY = "./tmp" + + def setup_yarn + super + return if firefox_profiler_added? + + fetch_firefox_profiler + end + + private + + def firefox_profiler_added? + AppProfiler.root.join("node_modules/firefox-profiler/dist").exist? + end + + def github_source? + AppProfiler.gecko_viewer_package.start_with?("https://github.com") + end + + def compiled_source? + AppProfiler.gecko_viewer_package.start_with?("https://") && + AppProfiler.gecko_viewer_package.end_with?("_compiled.tar.gz") + end + + def append_auth_header(opts) + if AppProfiler.gecko_viewer_package.start_with?("https://storage.googleapis.com/") + exec("which", "gcloud", silent: true) do + raise( + CommandError, + <<~MSG.squish + `gcloud` command not found, but gcloud auth required. + Please install `gcloud` or make it available in PATH. + MSG + ) + end + + opts["Authorization"] = "Bearer " + %x(gcloud auth print-access-token) + end + end + + def fetch_firefox_profiler + dir = INSTALL_DIRECTORY + + if github_source? + fetch_from_github(dir) + elsif compiled_source? + fetch_pre_compiled("#{dir}/firefox-profiler") + else + raise ArgumentError, "#{AppProfiler.gecko_viewer_package} is not a valid source for firefox profiler" + end + + yarn("add", "--dev", "#{dir}/firefox-profiler") + end + + def fetch_pre_compiled(dir) + opts = {} + append_auth_header(opts) + tar_gz_file = URI.parse(AppProfiler.gecko_viewer_package).open(opts) + + Gem::Package::TarReader.new(Zlib::GzipReader.open(tar_gz_file)) do |tar| + tar.each do |entry| + next if entry.directory? + + target_file = File.join(dir, entry.full_name) + + FileUtils.mkdir_p(File.dirname(target_file)) + + File.open(target_file, "wb") do |f| + f.write(entry.read) + end + end + end + end + + def fetch_from_github(dir) + repo, branch = AppProfiler.gecko_viewer_package.to_s.split("#") + + FileUtils.mkdir_p(dir) + Dir.chdir(dir) do + clone_args = ["git", "clone", repo, "firefox-profiler"] + clone_args.push("--branch=#{branch}") unless branch.nil? || branch&.empty? + system(*clone_args) + package_contents = File.read("firefox-profiler/package.json") + package_json = JSON.parse(package_contents) + package_json["name"] ||= "firefox-profiler" + package_json["version"] ||= "0.0.1" + File.write("firefox-profiler/package.json", package_json.to_json) + end + yarn("--cwd", "#{dir}/firefox-profiler") + + patch_firefox_profiler(dir) + yarn("--cwd", "#{dir}/firefox-profiler", "build-prod") + patch_file("#{dir}/firefox-profiler/dist/index.html", 'href="locales/en-US/app.ftl"', + 'href="/app_profiler/firefox/viewer/locales/en-US/app.ftl"') + end + + def patch_firefox_profiler(dir) + # Patch the publicPath so that the app can be "mounted" at the right location + patch_file("#{dir}/firefox-profiler/webpack.config.js", "publicPath: '/'", + "publicPath: '/app_profiler/firefox/viewer/'") + patch_file("#{dir}/firefox-profiler/src/app-logic/l10n.js", "fetch(`/locales/", + "fetch(`/app_profiler/firefox/viewer/locales/") + end + + def patch_file(file, find, replace) + contents = File.read(file) + new_contents = contents.gsub(find, replace) + File.write(file, new_contents) + end + end + end +end diff --git a/lib/tasks/firefox_profiler_compile.rake b/lib/tasks/firefox_profiler_compile.rake new file mode 100644 index 00000000..6a9b20fb --- /dev/null +++ b/lib/tasks/firefox_profiler_compile.rake @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "rubygems/package" +require "zlib" +require "fileutils" + +require "app_profiler" +require "app_profiler/yarn/command" +require "app_profiler/yarn/with_firefox_profiler" + +# The bundle is already compiled, so we can ignore most of the source contents +PACKAGE_INCLUDE = [ + /^package\.json$/, + /^dist/, +].freeze + +# Hack to make the package.json more portable by removing some constraints +# contains arrays of diggable hash keys +DELETE_KEYS = [ + ["engines"], + ["scripts", "preinstall"], +] + +class CompileShim + include AppProfiler::Yarn::WithFirefoxProfile +end + +namespace :firefox_profiler do + desc "Compile firefox profiler" + task :compile do + AppProfiler.root = Pathname.getwd + CompileShim.new.setup_yarn + end + desc "Package firefox profiler" + task package: :compile do + package_directory("node_modules/firefox-profiler", "out.tar.gz") + end +end + +def package_directory(source_dir, output) + File.open(output, "wb") do |tar_gz_file| + Zlib::GzipWriter.wrap(tar_gz_file) do |gzip_file| + Dir.chdir(source_dir) do + Gem::Package::TarWriter.new(gzip_file) do |tar| + Dir["**/*", ".*/**/*"].each do |file| + next unless PACKAGE_INCLUDE.any? { |pattern| file =~ pattern } + + mode = File.stat(file).mode + + if File.directory?(file) + tar.mkdir(file, mode) + else + fp = File.open(file, "rb") + fp = prune_keys(fp) if file == "package.json" + tar.add_file_simple(file, mode, fp.size) do |tar_file| + IO.copy_stream(fp, tar_file) + end + fp.close + end + end + end + end + end + end +end + +def prune_keys(orig_fp) + package_contents = JSON.parse(orig_fp.read) + orig_fp.close + DELETE_KEYS.each do |keys| + next unless package_contents.dig(*keys) + + to_delete = keys.pop + if keys.empty? + package_contents.delete(to_delete) + else + nested_hash = package_contents.dig(*keys) + nested_hash.delete(to_delete) + end + end + tmp = Tempfile.new("relaxed_package.json") + tmp.unlink + tmp.write(JSON.dump(package_contents)) + tmp.flush + tmp.rewind + tmp +end diff --git a/test/app_profiler/backend/stackprof_backend_test.rb b/test/app_profiler/backend/stackprof_backend_test.rb new file mode 100644 index 00000000..605161fa --- /dev/null +++ b/test/app_profiler/backend/stackprof_backend_test.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require "test_helper" + +module AppProfiler + class StackprofBackendTest < TestCase + def setup + AppProfiler.backend = AppProfiler::StackprofBackend + end + + def teardown + AppProfiler.clear + end + + test ".run prints error when failed" do + AppProfiler.logger.expects(:info).with { |value| value =~ /failed to start the profiler/ } + profile = AppProfiler.run(mode: :unsupported) do + sleep(0.1) + end + + assert_nil(profile) + end + + test ".run raises when yield raises" do + error = StandardError.new("An error occurred.") + exception = assert_raises(StandardError) do + AppProfiler.profiler.run(stackprof_profile) do + assert_predicate(AppProfiler.profiler, :running?) + raise error + end + end + + assert_equal(error, exception) + assert_not_predicate(AppProfiler.profiler, :running?) + end + + test ".run does not stop the profiler when it is already running" do + AppProfiler.logger.expects(:info).never + + assert_equal(true, AppProfiler.profiler.send(:start, stackprof_profile)) + + profile = AppProfiler.profiler.run(stackprof_profile) do + sleep(0.1) + end + + assert_nil(profile) + assert_predicate(AppProfiler.profiler, :running?) + ensure + AppProfiler.profiler.stop + end + + test ".run uses cpu profile by default" do + profile = AppProfiler.profiler.run(stackprof_profile) do + sleep(0.1) + end + + assert_instance_of(AppProfiler::StackprofProfile, profile) + assert_equal(:cpu, profile[:mode]) + assert_equal(1000, profile[:interval]) + end + + test ".run assigns metadata to profiles" do + profile = AppProfiler.profiler.run(stackprof_profile(metadata: { id: "wowza", context: "bar" })) do + sleep(0.1) + end + + assert_instance_of(AppProfiler::StackprofProfile, profile) + assert_equal("wowza", profile.id) + assert_equal("bar", profile.context) + end + + test ".run cpu profile" do + profile = AppProfiler.profiler.run(stackprof_profile(mode: :cpu, interval: 2000)) do + sleep(0.1) + end + + assert_instance_of(AppProfiler::StackprofProfile, profile) + assert_equal(:cpu, profile[:mode]) + assert_equal(2000, profile[:interval]) + end + + test ".run wall profile" do + profile = AppProfiler.profiler.run(stackprof_profile(mode: :wall, interval: 2000)) do + sleep(0.1) + end + + assert_instance_of(AppProfiler::StackprofProfile, profile) + assert_equal(:wall, profile[:mode]) + assert_equal(2000, profile[:interval]) + end + + test ".run object profile" do + profile = AppProfiler.profiler.run(stackprof_profile(mode: :object, interval: 2)) do + sleep(0.1) + end + + assert_instance_of(AppProfiler::StackprofProfile, profile) + assert_equal(:object, profile[:mode]) + assert_equal(2, profile[:interval]) + end + + test ".start uses cpu profile by default" do + AppProfiler.profiler.start(stackprof_profile) + AppProfiler.profiler.stop + + profile = AppProfiler.profiler.results + + assert_instance_of(AppProfiler::StackprofProfile, profile) + assert_equal(:cpu, profile[:mode]) + assert_equal(1000, profile[:interval]) + end + + test ".start assigns metadata to profiles" do + AppProfiler.profiler.start(stackprof_profile(metadata: { id: "wowza", context: "bar" })) + AppProfiler.profiler.stop + + profile = AppProfiler.profiler.results + + assert_instance_of(AppProfiler::StackprofProfile, profile) + assert_equal("wowza", profile.id) + assert_equal("bar", profile.context) + end + + test ".start cpu profile" do + AppProfiler.profiler.start(stackprof_profile(mode: :cpu, interval: 2000)) + AppProfiler.profiler.stop + + profile = AppProfiler.profiler.results + + assert_instance_of(AppProfiler::StackprofProfile, profile) + assert_equal(:cpu, profile[:mode]) + assert_equal(2000, profile[:interval]) + end + + test ".start wall profile" do + AppProfiler.profiler.start(stackprof_profile(mode: :wall, interval: 2000)) + AppProfiler.profiler.stop + + profile = AppProfiler.profiler.results + + assert_instance_of(AppProfiler::StackprofProfile, profile) + assert_equal(:wall, profile[:mode]) + assert_equal(2000, profile[:interval]) + end + + test ".start object profile" do + AppProfiler.profiler.start(stackprof_profile(mode: :object, interval: 2)) + AppProfiler.profiler.stop + + profile = AppProfiler.profiler.results + + assert_instance_of(AppProfiler::StackprofProfile, profile) + assert_equal(:object, profile[:mode]) + assert_equal(2, profile[:interval]) + end + + test ".stop" do + StackProf.expects(:stop) + AppProfiler.stop + end + + test ".results prints error when failed" do + AppProfiler.profiler.expects(:backend_results).returns({}) + AppProfiler.logger.expects(:info).with { |value| value =~ /failed to obtain the profile/ } + + assert_nil(AppProfiler.profiler.results) + end + + test ".results returns nil when profiling is still active" do + AppProfiler.profiler.run(stackprof_profile) do + assert_nil(AppProfiler.profiler.results) + end + end + + test ".start, .stop, and .results interact well" do + AppProfiler.logger.expects(:info).never + + assert_equal(true, AppProfiler.profiler.start(stackprof_profile)) + assert_equal(false, AppProfiler.profiler.start(stackprof_profile)) + assert_equal(true, AppProfiler.profiler.send(:running?)) + assert_nil(AppProfiler.profiler.results) + assert_equal(true, AppProfiler.profiler.stop) + assert_equal(false, AppProfiler.profiler.stop) + assert_equal(false, AppProfiler.profiler.send(:running?)) + + profile = AppProfiler.profiler.results + assert_instance_of(AppProfiler::StackprofProfile, profile) + assert_predicate(profile, :valid?) + + assert_nil(AppProfiler.profiler.results) + end + end +end diff --git a/test/app_profiler/backend/vernier_backend_test.rb b/test/app_profiler/backend/vernier_backend_test.rb new file mode 100644 index 00000000..6f81f79f --- /dev/null +++ b/test/app_profiler/backend/vernier_backend_test.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require "test_helper" + +return unless defined?(AppProfiler::VernierBackend::NAME) + +module AppProfiler + class VernierBackendTest < TestCase + def setup + AppProfiler.clear + @orig_backend = AppProfiler.backend + AppProfiler.backend = AppProfiler::VernierBackend + end + + def teardown + AppProfiler.backend = @orig_backend + AppProfiler.clear + end + + test ".run prints error when failed" do + AppProfiler.logger.expects(:info).with { |value| value =~ /failed to start the profiler/ } + profile = AppProfiler.profiler.run(mode: :unsupported) do + sleep(0.1) + end + + assert_nil(profile) + end + + test ".run raises when yield raises" do + error = StandardError.new("An error occurred.") + exception = assert_raises(StandardError) do + AppProfiler.profiler.run(vernier_params) do + assert_predicate(AppProfiler.profiler, :running?) + raise error + end + end + + assert_equal(error, exception) + assert_not_predicate(AppProfiler.profiler, :running?) + end + + test ".run does not stop the profiler when it is already running" do + AppProfiler.logger.expects(:info).never + + assert_equal(true, AppProfiler.profiler.send(:start, vernier_params)) + + profile = AppProfiler.profiler.run(vernier_params) do + sleep(0.1) + end + + assert_nil(profile) + assert_predicate(AppProfiler.profiler, :running?) + ensure + AppProfiler.profiler.stop + end + + test ".run uses wall profile by default" do + profile = AppProfiler.profiler.run do + sleep(0.1) + end + + assert_instance_of(AppProfiler::VernierProfile, profile) + assert_equal(:wall, profile[:meta][:mode]) + # assert_equal(1000, profile[:interval]) # TODO https://github.com/jhawthorn/vernier/issues/30 + end + + test ".run assigns metadata to profiles" do + profile = AppProfiler.profiler.run( + vernier_params(metadata: { + id: "wowza", + context: "bar", + extrameta: "spam", + }) + ) do + sleep(0.1) + end + + assert_instance_of(AppProfiler::VernierProfile, profile) + assert_equal("wowza", profile.id) + assert_equal("bar", profile.context) + assert_equal("spam", profile[:meta][:extrameta]) + end + + test ".run wall profile" do + profile = AppProfiler.profiler.run(vernier_params(mode: :wall, interval: 2000)) do + sleep(0.1) + end + + assert_instance_of(AppProfiler::VernierProfile, profile) + assert_equal(:wall, profile[:meta][:mode]) + # assert_equal(2000, profile[:interval]) # TODO as above + end + + test ".run retained profile" do + retained = [] + objects = 10 + profile = AppProfiler.profiler.run(vernier_params(mode: :retained)) do + objects.times do + retained << Object.new + end + end + + assert_instance_of(AppProfiler::VernierProfile, profile) + assert_equal(:retained, profile[:meta][:mode]) + + num_samples = profile[:threads].flat_map { _1[:samples] }.sum { |s| s[:length] } + assert_operator(num_samples, :>=, objects) + end + + test ".run works for supported modes" do + profile = AppProfiler.profiler.run(vernier_params(mode: :wall)) do + sleep(0.1) + end + refute_equal(false, profile) + + profile = AppProfiler.profiler.run(vernier_params(mode: :retained)) do + sleep(0.1) + end + refute_equal(false, profile) + end + + test ".run fails for unsupported modes" do + unsupported_modes = [:cpu, :object, :garbage, :unsupported] + + unsupported_modes.each do |unsupported| + profile = AppProfiler.profiler.run(vernier_params(mode: unsupported)) do + sleep(0.1) + end + assert_nil(profile) + end + end + + test ".start uses wall profile by default" do + AppProfiler.profiler.start + AppProfiler.profiler.stop + + profile = AppProfiler.profiler.results + + assert_instance_of(AppProfiler::VernierProfile, profile) + assert_equal(:wall, profile[:meta][:mode]) + # assert_equal(1000, profile[:interval]) + end + + test ".start assigns metadata to profiles" do + AppProfiler.profiler.start(vernier_params(metadata: { id: "wowza", context: "bar" })) + AppProfiler.profiler.stop + + profile = AppProfiler.profiler.results + + assert_instance_of(AppProfiler::VernierProfile, profile) + assert_equal("wowza", profile.id) + assert_equal("bar", profile.context) + end + + test ".start wall profile" do + AppProfiler.profiler.start(vernier_params(mode: :wall, interval: 2000)) + AppProfiler.profiler.stop + + profile = AppProfiler.profiler.results + + assert_instance_of(AppProfiler::VernierProfile, profile) + assert_equal(:wall, profile[:meta][:mode]) + # assert_equal(2000, profile[:interval]) + end + + test ".stop" do + AppProfiler.start + AppProfiler::VernierBackend.any_instance.expects(:stop) + AppProfiler.stop + ensure + AppProfiler::VernierBackend.any_instance.unstub(:stop) + AppProfiler.stop + end + + test ".results prints error when failed" do + AppProfiler.profiler.expects(:backend_results).returns({}) + AppProfiler.logger.expects(:info).with { |value| value =~ /failed to obtain the profile/ } + + assert_nil(AppProfiler.profiler.results) + end + + test ".results returns nil when profiling is still active" do + AppProfiler.profiler.run do + assert_nil(AppProfiler.profiler.results) + end + end + + test ".start, .stop, and .results interact well" do + AppProfiler.logger.expects(:info).never + + assert_equal(true, AppProfiler.profiler.start) + assert_equal(false, AppProfiler.profiler.start) + assert_equal(true, AppProfiler.profiler.send(:running?)) + assert_nil(AppProfiler.profiler.results) + assert_equal(true, AppProfiler.profiler.stop) + assert_equal(false, AppProfiler.profiler.stop) + assert_equal(false, AppProfiler.profiler.send(:running?)) + + profile = AppProfiler.profiler.results + assert_instance_of(AppProfiler::VernierProfile, profile) + assert_predicate(profile, :valid?) + + assert_nil(AppProfiler.profiler.results) + end + end +end diff --git a/test/app_profiler/backend_test.rb b/test/app_profiler/backend_test.rb new file mode 100644 index 00000000..02f11d6f --- /dev/null +++ b/test/app_profiler/backend_test.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "test_helper" + +module AppProfiler + class BackendTest < TestCase + test ".backend= fails to update the backend if already profiling" do + skip("Vernier not supported") unless defined?(AppProfiler::VernierBackend::NAME) + assert(AppProfiler.backend = AppProfiler::StackprofBackend) + AppProfiler.start + assert(AppProfiler.running?) + assert_raises(BackendError) { AppProfiler.backend = AppProfiler::VernierBackend } + ensure + AppProfiler.stop + end + + test ".backend= updates the backend if not already profiling" do + orig_backend = AppProfiler.backend + skip("Vernier not supported") unless defined?(AppProfiler::VernierBackend::NAME) + refute(AppProfiler.running?) + assert(AppProfiler.backend = AppProfiler::StackprofBackend) + assert_equal(AppProfiler.backend, AppProfiler::StackprofBackend) + refute(AppProfiler.running?) + assert(AppProfiler.backend = AppProfiler::VernierBackend) + assert_equal(AppProfiler.backend, AppProfiler::VernierBackend) + ensure + AppProfiler.backend = orig_backend + end + + test ".backend= accepts a string with the backend name" do + orig_backend = AppProfiler.backend + skip("Vernier not supported") unless defined?(AppProfiler::VernierBackend::NAME) + refute(AppProfiler.running?) + assert(AppProfiler.backend = AppProfiler::StackprofBackend::NAME) + assert_equal(AppProfiler.backend, AppProfiler::StackprofBackend) + refute(AppProfiler.running?) + assert(AppProfiler.backend = AppProfiler::VernierBackend::NAME) + assert_equal(AppProfiler.backend, AppProfiler::VernierBackend) + ensure + AppProfiler.backend = orig_backend + end + + test ".backend= accepts a backend class" do + orig_backend = AppProfiler.backend + skip("Vernier not supported") unless defined?(AppProfiler::VernierBackend::NAME) + refute(AppProfiler.running?) + assert(AppProfiler.backend = AppProfiler::StackprofBackend) + assert_equal(AppProfiler.backend, AppProfiler::StackprofBackend) + refute(AppProfiler.running?) + assert(AppProfiler.backend = AppProfiler::VernierBackend) + assert_equal(AppProfiler.backend, AppProfiler::VernierBackend) + ensure + AppProfiler.backend = orig_backend + end + + test ".backend_for= provides the backend class given a string" do + assert_equal(AppProfiler::StackprofBackend, AppProfiler.backend_for(AppProfiler::StackprofBackend::NAME)) + return unless defined?(AppProfiler::VernierBackend::NAME) + + assert_equal(AppProfiler::VernierBackend, AppProfiler.backend_for(AppProfiler::VernierBackend::NAME)) + end + + test ".backend_for= raises if an unknown backend is requested" do + assert_raises(BackendError) { AppProfiler.backend_for("not a real backend") } + end + end +end diff --git a/test/app_profiler/middleware/upload_action_test.rb b/test/app_profiler/middleware/upload_action_test.rb index f17f7953..003bf36b 100644 --- a/test/app_profiler/middleware/upload_action_test.rb +++ b/test/app_profiler/middleware/upload_action_test.rb @@ -6,12 +6,12 @@ module AppProfiler class Middleware class UploadActionTest < AppProfiler::TestCase setup do - @profile = Profile.new(stackprof_profile) + @profile = StackprofProfile.new(stackprof_profile) @response = [200, {}, ["OK"]] end test ".cleanup" do - Profiler.expects(:results).returns(@profile) + AppProfiler.profiler.expects(:results).returns(@profile) assert_nothing_raised do UploadAction.cleanup end diff --git a/test/app_profiler/middleware/view_action_test.rb b/test/app_profiler/middleware/view_action_test.rb index ed9fc984..4460af3d 100644 --- a/test/app_profiler/middleware/view_action_test.rb +++ b/test/app_profiler/middleware/view_action_test.rb @@ -6,12 +6,12 @@ module AppProfiler class Middleware class ViewActionTest < AppProfiler::TestCase setup do - @profile = Profile.new(stackprof_profile) + @profile = StackprofProfile.new(stackprof_profile) @response = [200, {}, ["OK"]] end test ".cleanup" do - Profiler.expects(:results).returns(@profile) + AppProfiler.profiler.expects(:results).returns(@profile) @profile.expects(:view) ViewAction.cleanup diff --git a/test/app_profiler/middleware_test.rb b/test/app_profiler/middleware_test.rb index c5a80301..cc25457c 100644 --- a/test/app_profiler/middleware_test.rb +++ b/test/app_profiler/middleware_test.rb @@ -20,8 +20,8 @@ class MiddlewareTest < TestCase end end - AppProfiler::Parameters::MODES.each do |mode| - test "profile mode #{mode} is supported" do + AppProfiler::StackprofBackend::AVAILABLE_MODES.each do |mode| + test "profile mode #{mode} is supported by stackprof backend" do assert_profiles_dumped do assert_profiles_uploaded do middleware = AppProfiler::Middleware.new(app_env) @@ -31,6 +31,43 @@ class MiddlewareTest < TestCase end end + if defined?(AppProfiler::VernierBackend::NAME) + AppProfiler::VernierBackend::AVAILABLE_MODES.each do |mode| + test "profile mode #{mode} is supported by vernier backend" do + assert_profiles_dumped do + assert_profiles_uploaded do + middleware = AppProfiler::Middleware.new(app_env) + middleware.call(mock_request_env(path: "/?profile=#{mode}&backend=vernier")) + end + end + end + end + end + + if defined?(AppProfiler::VernierBackend::NAME) + test "the backend can be toggled between requests" do + assert_profiles_dumped(3) do + assert_profiles_uploaded do + middleware = AppProfiler::Middleware.new(app_env) + middleware.call(mock_request_env(path: "/?profile=wall&backend=stackprof")) + end + + assert_profiles_uploaded do + middleware = AppProfiler::Middleware.new(app_env) + middleware.call(mock_request_env(path: "/?profile=wall&backend=vernier")) + end + + assert_profiles_uploaded do + middleware = AppProfiler::Middleware.new(app_env) + middleware.call(mock_request_env(path: "/?profile=wall&backend=stackprof")) + end + + assert_equal(2, tmp_profiles.count { |p| p.to_s =~ /#{AppProfiler::StackprofProfile::FILE_EXTENSION}$/ }) + assert_equal(1, tmp_profiles.count { |p| p.to_s =~ /#{AppProfiler::VernierProfile::FILE_EXTENSION}$/ }) + end + end + end + test "profile interval is supported" do assert_profiles_dumped do assert_profiles_uploaded do @@ -145,7 +182,7 @@ class MiddlewareTest < TestCase end end - AppProfiler::Parameters::MODES.each do |mode| + AppProfiler::StackprofBackend::AVAILABLE_MODES.each do |mode| test "profile mode #{mode} through headers is supported" do assert_profiles_dumped do assert_profiles_uploaded do @@ -157,6 +194,20 @@ class MiddlewareTest < TestCase end end + if defined?(AppProfiler::VernierBackend::NAME) + AppProfiler::VernierBackend::AVAILABLE_MODES.each do |mode| + test "profile mode #{mode} is supported through headers by vernier backend" do + assert_profiles_dumped do + assert_profiles_uploaded do + middleware = AppProfiler::Middleware.new(app_env) + opt = { AppProfiler.request_profile_header => "mode=#{mode};backend=vernier" } + middleware.call(mock_request_env(opt: opt)) + end + end + end + end + end + test "profile interval through headers is supported" do assert_profiles_dumped do assert_profiles_uploaded do @@ -259,7 +310,7 @@ class MiddlewareTest < TestCase test "#after_profile called with env and profile data" do request_env = mock_request_env(path: "/?profile=cpu") AppProfiler.middleware.any_instance.expects(:after_profile).with do |env, profile| - request_env == env && profile.is_a?(AppProfiler::Profile) + request_env == env && profile.is_a?(AppProfiler::AbstractProfile) end.returns(false) middleware = AppProfiler::Middleware.new(app_env) middleware.call(request_env) @@ -278,7 +329,7 @@ class MiddlewareTest < TestCase end.returns(true) AppProfiler.middleware.any_instance.expects(:after_profile).with do |env, profile| - return false unless request_env == env && profile.is_a?(AppProfiler::Profile) + return false unless request_env == env && profile.is_a?(AppProfiler::AbstractProfile) profile[:metadata][:test_key] == "test_value" end.returns(true) diff --git a/test/app_profiler/profile_test.rb b/test/app_profiler/profile/stackprof_test.rb similarity index 68% rename from test/app_profiler/profile_test.rb rename to test/app_profiler/profile/stackprof_test.rb index d66f34e6..264d91fa 100644 --- a/test/app_profiler/profile_test.rb +++ b/test/app_profiler/profile/stackprof_test.rb @@ -3,17 +3,17 @@ require "test_helper" module AppProfiler - class ProfileTest < TestCase + class StackprofProfileTest < TestCase test ".from_stackprof raises ArgumentError when mode is not present" do error = assert_raises(ArgumentError) do profile_without_mode = stackprof_profile.tap { |data| data.delete(:mode) } - Profile.from_stackprof(profile_without_mode) + AbstractProfile.from_stackprof(profile_without_mode) end assert_equal("invalid profile data", error.message) end test ".from_stackprof assigns id and context metadata" do - profile = Profile.from_stackprof(stackprof_profile(metadata: { id: "foo", context: "bar" })) + profile = AbstractProfile.from_stackprof(stackprof_profile(metadata: { id: "foo", context: "bar" })) assert_equal("foo", profile.id) assert_equal("bar", profile.context) @@ -23,20 +23,20 @@ class ProfileTest < TestCase SecureRandom.expects(:hex).returns("mock") params_without_id = stackprof_profile.tap { |data| data[:metadata].delete(:id) } - profile = Profile.from_stackprof(params_without_id) + profile = AbstractProfile.from_stackprof(params_without_id) assert_equal("mock", profile.id) end test ".from_stackprof removes id and context metadata from profile data" do - profile = Profile.from_stackprof(stackprof_profile(metadata: { id: "foo", context: "bar" })) + profile = AbstractProfile.from_stackprof(stackprof_profile(metadata: { id: "foo", context: "bar" })) assert_not_operator(profile[:metadata], :key?, :id) assert_not_operator(profile[:metadata], :key?, :context) end test "#id" do - profile = Profile.new(stackprof_profile, id: "pass") + profile = StackprofProfile.new(stackprof_profile, id: "pass") assert_equal("pass", profile.id) end @@ -44,7 +44,7 @@ class ProfileTest < TestCase test "#id is random hex by default" do SecureRandom.expects(:hex).returns("mock") - profile = Profile.new(stackprof_profile) + profile = StackprofProfile.new(stackprof_profile) assert_equal("mock", profile.id) end @@ -52,46 +52,45 @@ class ProfileTest < TestCase test "#id is random hex when passed as empty string" do SecureRandom.expects(:hex).returns("mock") - profile = Profile.new(stackprof_profile, id: "") + profile = StackprofProfile.new(stackprof_profile, id: "") assert_equal("mock", profile.id) end test "#context" do - profile = Profile.new(stackprof_profile, context: "development") + profile = StackprofProfile.new(stackprof_profile, context: "development") assert_equal("development", profile.context) end test "#valid? is false when mode is not present" do - profile = Profile.new({}) + profile = StackprofProfile.new({}) assert_not_predicate(profile, :valid?) end test "#valid? is true when mode is present" do - profile = Profile.new({ mode: :cpu }) + profile = StackprofProfile.new({ mode: :cpu }) assert_predicate(profile, :valid?) end test "#mode" do - profile = Profile.new(stackprof_profile(mode: "object")) + profile = StackprofProfile.new(stackprof_profile(mode: "object")) assert_equal("object", profile.mode) end test "#view" do - profile = Profile.new(stackprof_profile) + profile = StackprofProfile.new(stackprof_profile) - AppProfiler.stubs(:viewer).returns(Viewer::SpeedscopeViewer) Viewer::SpeedscopeViewer.expects(:view).with(profile) profile.view end test "#upload" do - profile = Profile.new(stackprof_profile) + profile = StackprofProfile.new(stackprof_profile) AppProfiler.stubs(:storage).returns(MockStorage) MockStorage.expects(:upload).with(profile).returns("some data") @@ -100,7 +99,7 @@ class ProfileTest < TestCase end test "#upload returns nil if an error was raised" do - profile = Profile.new(stackprof_profile) + profile = StackprofProfile.new(stackprof_profile) AppProfiler.storage.stubs(:upload).raises(StandardError, "upload error") @@ -109,14 +108,14 @@ class ProfileTest < TestCase test "#file creates json file" do profile_data = stackprof_profile(mode: "wall") - profile = Profile.new(profile_data) + profile = StackprofProfile.new(profile_data) assert_match(/.*\.json/, profile.file.to_s) assert_equal(profile_data, JSON.parse(profile.file.read, symbolize_names: true)) end test "#file creates file only once" do - profile = Profile.new(stackprof_profile) + profile = StackprofProfile.new(stackprof_profile) assert_predicate(profile.file, :exist?) @@ -125,28 +124,21 @@ class ProfileTest < TestCase assert_not_predicate(profile.file, :exist?) end - test "#to_h returns profile data" do - profile_data = stackprof_profile - profile = Profile.new(profile_data) - - assert_equal(profile_data, profile.to_h) - end - test "#[] forwards to profile data" do - profile = Profile.new(stackprof_profile(interval: 10_000)) + profile = StackprofProfile.new(stackprof_profile(interval: 10_000)) assert_equal(10_000, profile[:interval]) end test "#path raises an UnsafeFilename exception given chars not in allow list" do - assert_raises(AppProfiler::Profile::UnsafeFilename) do - profile = Profile.from_stackprof(stackprof_profile(metadata: { id: "|`@${}", context: "bar" })) + assert_raises(AppProfiler::AbstractProfile::UnsafeFilename) do + profile = AbstractProfile.from_stackprof(stackprof_profile(metadata: { id: "|`@${}", context: "bar" })) profile.file end end test "#file uses custom profile_file_prefix block when provided" do - profile = Profile.new(stackprof_profile) + profile = StackprofProfile.new(stackprof_profile) AppProfiler.stubs(:profile_file_prefix).returns(-> { "want-something-different" }) assert_match(/want-something-different-/, File.basename(profile.file.to_s)) @@ -154,7 +146,7 @@ class ProfileTest < TestCase test "#file uses default prefix format when no custom profile_file_prefix block is provided" do travel_to Time.zone.local(2022, 10, 06, 12, 11, 10) do - profile = Profile.new(stackprof_profile) + profile = StackprofProfile.new(stackprof_profile) assert_match(/^20221006-121110/, File.basename(profile.file.to_s)) end end diff --git a/test/app_profiler/profile/vernier_test.rb b/test/app_profiler/profile/vernier_test.rb new file mode 100644 index 00000000..8b5d9242 --- /dev/null +++ b/test/app_profiler/profile/vernier_test.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "test_helper" + +module AppProfiler + class VernierProfileTest < TestCase + test ".from_vernier assigns id and context metadata" do + profile = AbstractProfile.from_vernier(vernier_profile(meta: { id: "foo", context: "bar" })) + + assert_equal("foo", profile.id) + assert_equal("bar", profile.context) + end + + test ".from_vernier assigns random id when id is not present" do + SecureRandom.expects(:hex).returns("mock") + + params_without_id = vernier_profile(vernier_params).tap { |data| data[:meta].delete(:id) } + profile = AbstractProfile.from_vernier(params_without_id) + + assert_equal("mock", profile.id) + end + + test "#id" do + profile = VernierProfile.new(vernier_profile, id: "pass") + + assert_equal("pass", profile.id) + end + + test "#id is random hex by default" do + SecureRandom.expects(:hex).returns("mock") + + profile = VernierProfile.new(vernier_profile) + + assert_equal("mock", profile.id) + end + + test "#id is random hex when passed as empty string" do + SecureRandom.expects(:hex).returns("mock") + + profile = VernierProfile.new(vernier_profile, id: "") + + assert_equal("mock", profile.id) + end + + test "#context" do + profile = VernierProfile.new(vernier_profile, context: "development") + + assert_equal("development", profile.context) + end + + test "#valid? is true when mode is present" do + profile = VernierProfile.new(vernier_profile({ mode: :cpu })) + + assert_predicate(profile, :valid?) + end + + test "#mode" do + profile = VernierProfile.new(vernier_profile(meta: { mode: "retained" })) + + assert_equal("retained", profile.mode) + end + + test "#upload" do + profile = VernierProfile.new(vernier_profile) + + AppProfiler.stubs(:storage).returns(MockStorage) + MockStorage.expects(:upload).with(profile).returns("some data") + + assert_equal("some data", profile.upload) + end + + test "#upload returns nil if an error was raised" do + profile = VernierProfile.new(vernier_profile) + + AppProfiler.storage.stubs(:upload).raises(StandardError, "upload error") + + assert_nil(profile.upload) + end + + test "#file creates json file" do + profile_data = vernier_profile(mode: "wall") + profile = VernierProfile.new(profile_data) + + assert_match(/.*\.json/, profile.file.to_s) + assert_equal(profile_data.to_h, JSON.parse(profile.file.read, symbolize_names: true)) + end + + test "#file creates file only once" do + profile = VernierProfile.new(vernier_profile) + + assert_predicate(profile.file, :exist?) + + profile.file.delete + + assert_not_predicate(profile.file, :exist?) + end + + test "#to_h returns profile data" do + profile_data = vernier_profile + profile = VernierProfile.new(profile_data) + + assert_equal(profile_data.to_h, profile.to_h) + end + + test "#[] forwards to profile metadata" do + profile = VernierProfile.new(vernier_profile(meta: { interval: 10_000 })) + + assert_equal(10_000, profile[:meta][:interval]) + end + + test "#path raises an UnsafeFilename exception given chars not in allow list" do + assert_raises(AppProfiler::AbstractProfile::UnsafeFilename) do + profile = AbstractProfile.from_vernier(vernier_profile(meta: { id: "|`@${}", context: "bar" })) + profile.file + end + end + + test "#file uses custom profile_file_prefix block when provided" do + profile = VernierProfile.new(vernier_profile) + + AppProfiler.stubs(:profile_file_prefix).returns(-> { "want-something-different" }) + assert_match(/want-something-different-/, File.basename(profile.file.to_s)) + end + + test "#file uses default prefix format when no custom profile_file_prefix block is provided" do + travel_to Time.zone.local(2022, 10, 06, 12, 11, 10) do + profile = VernierProfile.new(vernier_profile) + assert_match(/^20221006-121110/, File.basename(profile.file.to_s)) + end + end + end +end diff --git a/test/app_profiler/profiler_test.rb b/test/app_profiler/profiler_test.rb deleted file mode 100644 index c902729a..00000000 --- a/test/app_profiler/profiler_test.rb +++ /dev/null @@ -1,185 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module AppProfiler - class ProfilerTest < TestCase - test ".run prints error when failed" do - AppProfiler.logger.expects(:info).with { |value| value =~ /failed to start the profiler/ } - profile = Profiler.run(mode: :unsupported) do - sleep(0.1) - end - - assert_nil(profile) - end - - test ".run raises when yield raises" do - error = StandardError.new("An error occurred.") - exception = assert_raises(StandardError) do - Profiler.run(stackprof_profile) do - assert_predicate(Profiler, :running?) - raise error - end - end - - assert_equal(error, exception) - assert_not_predicate(Profiler, :running?) - end - - test ".run does not stop the profiler when it is already running" do - AppProfiler.logger.expects(:info).never - - assert_equal(true, Profiler.send(:start, stackprof_profile)) - - profile = Profiler.run(stackprof_profile) do - sleep(0.1) - end - - assert_nil(profile) - assert_predicate(Profiler, :running?) - ensure - StackProf.stop - end - - test ".run uses cpu profile by default" do - profile = Profiler.run(stackprof_profile) do - sleep(0.1) - end - - assert_instance_of(AppProfiler::Profile, profile) - assert_equal(:cpu, profile[:mode]) - assert_equal(1000, profile[:interval]) - end - - test ".run assigns metadata to profiles" do - profile = Profiler.run(stackprof_profile(metadata: { id: "wowza", context: "bar" })) do - sleep(0.1) - end - - assert_instance_of(AppProfiler::Profile, profile) - assert_equal("wowza", profile.id) - assert_equal("bar", profile.context) - end - - test ".run cpu profile" do - profile = Profiler.run(stackprof_profile(mode: :cpu, interval: 2000)) do - sleep(0.1) - end - - assert_instance_of(AppProfiler::Profile, profile) - assert_equal(:cpu, profile[:mode]) - assert_equal(2000, profile[:interval]) - end - - test ".run wall profile" do - profile = Profiler.run(stackprof_profile(mode: :wall, interval: 2000)) do - sleep(0.1) - end - - assert_instance_of(AppProfiler::Profile, profile) - assert_equal(:wall, profile[:mode]) - assert_equal(2000, profile[:interval]) - end - - test ".run object profile" do - profile = Profiler.run(stackprof_profile(mode: :object, interval: 2)) do - sleep(0.1) - end - - assert_instance_of(AppProfiler::Profile, profile) - assert_equal(:object, profile[:mode]) - assert_equal(2, profile[:interval]) - end - - test ".start uses cpu profile by default" do - Profiler.start(stackprof_profile) - Profiler.stop - - profile = Profiler.results - - assert_instance_of(AppProfiler::Profile, profile) - assert_equal(:cpu, profile[:mode]) - assert_equal(1000, profile[:interval]) - end - - test ".start assigns metadata to profiles" do - Profiler.start(stackprof_profile(metadata: { id: "wowza", context: "bar" })) - Profiler.stop - - profile = Profiler.results - - assert_instance_of(AppProfiler::Profile, profile) - assert_equal("wowza", profile.id) - assert_equal("bar", profile.context) - end - - test ".start cpu profile" do - Profiler.start(stackprof_profile(mode: :cpu, interval: 2000)) - Profiler.stop - - profile = Profiler.results - - assert_instance_of(AppProfiler::Profile, profile) - assert_equal(:cpu, profile[:mode]) - assert_equal(2000, profile[:interval]) - end - - test ".start wall profile" do - Profiler.start(stackprof_profile(mode: :wall, interval: 2000)) - Profiler.stop - - profile = Profiler.results - - assert_instance_of(AppProfiler::Profile, profile) - assert_equal(:wall, profile[:mode]) - assert_equal(2000, profile[:interval]) - end - - test ".start object profile" do - Profiler.start(stackprof_profile(mode: :object, interval: 2)) - Profiler.stop - - profile = Profiler.results - - assert_instance_of(AppProfiler::Profile, profile) - assert_equal(:object, profile[:mode]) - assert_equal(2, profile[:interval]) - end - - test ".stop" do - StackProf.expects(:stop) - AppProfiler.stop - end - - test ".results prints error when failed" do - Profiler.expects(:stackprof_results).returns({}) - AppProfiler.logger.expects(:info).with { |value| value =~ /failed to obtain the profile/ } - - assert_nil(Profiler.results) - end - - test ".results returns nil when profiling is still active" do - Profiler.run(stackprof_profile) do - assert_nil(Profiler.results) - end - end - - test ".start, .stop, and .results interact well" do - AppProfiler.logger.expects(:info).never - - assert_equal(true, Profiler.start(stackprof_profile)) - assert_equal(false, Profiler.start(stackprof_profile)) - assert_equal(true, Profiler.send(:running?)) - assert_nil(Profiler.results) - assert_equal(true, Profiler.stop) - assert_equal(false, Profiler.stop) - assert_equal(false, Profiler.send(:running?)) - - profile = Profiler.results - assert_instance_of(AppProfiler::Profile, profile) - assert_predicate(profile, :valid?) - - assert_nil(Profiler.results) - end - end -end diff --git a/test/app_profiler/request_parameters_test.rb b/test/app_profiler/request_parameters_test.rb index 544dc2f2..19215305 100644 --- a/test/app_profiler/request_parameters_test.rb +++ b/test/app_profiler/request_parameters_test.rb @@ -19,8 +19,8 @@ class RequestParametersTest < TestCase test "#valid? returns false when interval is less than allowed" do AppProfiler.logger.expects(:info).never - AppProfiler::Parameters::MIN_INTERVALS.each do |mode, interval| - interval -= 1 + AppProfiler::StackprofBackend::AVAILABLE_MODES.each do |mode| + interval = AppProfiler::Parameters::MIN_INTERVALS[mode.to_s] - 1 params = request_params(headers: { AppProfiler.request_profile_header => "mode=#{mode};interval=#{interval}", }) @@ -50,7 +50,8 @@ class RequestParametersTest < TestCase end test "#to_h return correct hash when request parameters are ok" do - AppProfiler::Parameters::DEFAULT_INTERVALS.each do |mode, interval| + AppProfiler::StackprofBackend::AVAILABLE_MODES.each do |mode| + interval = AppProfiler::Parameters::DEFAULT_INTERVALS[mode.to_s] params = request_params(headers: { AppProfiler.request_profile_header => "mode=#{mode};interval=#{interval};context=test;ignore_gc=1", "HTTP_X_REQUEST_ID" => "123", diff --git a/test/app_profiler/run_test.rb b/test/app_profiler/run_test.rb index b0d1a04f..a7a713a4 100644 --- a/test/app_profiler/run_test.rb +++ b/test/app_profiler/run_test.rb @@ -4,16 +4,16 @@ module AppProfiler class RunTest < TestCase - test ".run delegates to Profiler.run" do - Profiler.expects(:run) + test ".run delegates to profiler.run" do + AppProfiler.profiler.expects(:run) AppProfiler.run do sleep 0.1 end end - test ".start delegates to Profiler.start" do - Profiler.expects(:start) + test ".start delegates to profiler.start" do + AppProfiler.profiler.expects(:start) AppProfiler.start end @@ -23,7 +23,21 @@ class RunTest < TestCase sleep 0.1 profile = AppProfiler.stop - assert_instance_of(Profile, profile) + assert_instance_of(StackprofProfile, profile) + end + + test ".run sets the backend then returns to the previous value" do + orig_backend = AppProfiler.backend + skip("Vernier not supported") unless defined?(AppProfiler::VernierBackend::NAME) + + assert_equal(AppProfiler.backend, AppProfiler::StackprofBackend) + refute(AppProfiler.running?) + AppProfiler.run(backend: AppProfiler::VernierBackend) do + assert_equal(AppProfiler::VernierBackend, AppProfiler.backend) + end + assert_equal(AppProfiler.backend, AppProfiler::StackprofBackend) + ensure + AppProfiler.backend = orig_backend end end end diff --git a/test/app_profiler/storage/google_cloud_storage_test.rb b/test/app_profiler/storage/google_cloud_storage_test.rb index 073035cf..76192ae6 100644 --- a/test/app_profiler/storage/google_cloud_storage_test.rb +++ b/test/app_profiler/storage/google_cloud_storage_test.rb @@ -54,7 +54,7 @@ def teardown end test ".process_queue is a no-op when nothing to upload" do - Profile.any_instance.expects(:upload).never + StackprofProfile.any_instance.expects(:upload).never GoogleCloudStorage.send(:process_queue) end @@ -87,7 +87,7 @@ def teardown AppProfiler.upload_queue_max_length.times do GoogleCloudStorage.enqueue_upload(profile_from_stackprof) end - dropped_profile = Profile.from_stackprof(profile_from_stackprof) + dropped_profile = AbstractProfile.from_stackprof(profile_from_stackprof) AppProfiler.logger.expects(:info).with { |value| value =~ /upload queue is full/ } @called = false @@ -124,7 +124,7 @@ def with_stubbed_process_queue_thread end def profile_from_stackprof - Profile.from_stackprof(stackprof_profile(metadata: { id: "bar" })) + AbstractProfile.from_stackprof(stackprof_profile(metadata: { id: "bar" })) end def json_test_file diff --git a/test/app_profiler/viewer/remote/firefox_viewer_test.rb b/test/app_profiler/viewer/remote/firefox_viewer_test.rb new file mode 100644 index 00000000..baf8b1e7 --- /dev/null +++ b/test/app_profiler/viewer/remote/firefox_viewer_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "test_helper" + +module AppProfiler + module Viewer + class FirefoxRemoteViewerTest < TestCase + test ".view initializes and calls #view" do + FirefoxRemoteViewer.any_instance.expects(:view) + + profile = VernierProfile.new(vernier_profile) + FirefoxRemoteViewer.view(profile) + end + + test "#view logs middleware URL" do + profile = VernierProfile.new(vernier_profile) + + viewer = FirefoxRemoteViewer.new(profile) + id = FirefoxRemoteViewer::Middleware.id(profile.file) + + AppProfiler.logger.expects(:info).with( + "[Profiler] Profile available at /app_profiler/#{id}\n" + ) + + viewer.view + end + + test "#view with response redirects to URL" do + response = [200, {}, ["OK"]] + profile = VernierProfile.new(vernier_profile) + + viewer = FirefoxRemoteViewer.new(profile) + id = FirefoxRemoteViewer::Middleware.id(profile.file) + + viewer.view(response: response) + + assert_equal(303, response[0]) + assert_equal("/app_profiler/firefox/viewer/#{id}", response[1]["Location"]) + end + end + end +end diff --git a/test/app_profiler/viewer/remote/middleware/firefox_middleware_test.rb b/test/app_profiler/viewer/remote/middleware/firefox_middleware_test.rb new file mode 100644 index 00000000..d31a3c1c --- /dev/null +++ b/test/app_profiler/viewer/remote/middleware/firefox_middleware_test.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "test_helper" + +module AppProfiler + module Viewer + class FirefoxRemoteViewer + class MiddlewareTest < TestCase + setup do + @app = Middleware.new( + proc { [200, { "Content-Type" => "text/plain" }, ["Hello world!"]] } + ) + end + + test ".id" do + profile = VernierProfile.new(vernier_profile) + profile_id = profile.file.basename.to_s + + assert_equal(profile_id, Middleware.id(profile.file)) + end + + test "#call index" do + profiles = Array.new(3) { VernierProfile.new(vernier_profile).tap(&:file) } + + code, content_type, html = @app.call({ "PATH_INFO" => "/app_profiler" }) + html = html.first + + assert_equal(200, code) + assert_equal({ "Content-Type" => "text/html" }, content_type) + assert_match(%r(