Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
02e3896
Add vernier backend support
dalehamel Jan 22, 2024
5fbaa1d
Fixup
dalehamel Jan 22, 2024
a1d39b0
Apply suggestions from code review
dalehamel Jan 23, 2024
e55a148
Move backend updating code into main AppProfiler module
dalehamel Jan 23, 2024
914334c
Refactor access to setting backend
dalehamel Jan 24, 2024
9aeae92
Fix vernier load error
dalehamel Jan 24, 2024
3b3c746
Raise when viewing vernier
dalehamel Jan 24, 2024
76558a9
Readme fix
dalehamel Jan 24, 2024
9ea3d0f
Remove commented code
dalehamel Jan 24, 2024
a1c938c
Pull with_backend code into AppProfiler.run
dalehamel Jan 25, 2024
29e278b
Lock around AppProfiler.run
dalehamel Jan 25, 2024
d7940d3
Fix default viewer
dalehamel Jan 30, 2024
7d1a737
Update README.md
dalehamel Feb 7, 2024
4cef38a
Update lib/app_profiler.rb
dalehamel Feb 7, 2024
857a79f
Update .github/workflows/ci.yml
dalehamel Feb 7, 2024
04d4152
Update README.md
dalehamel Feb 7, 2024
26a3932
Rename with_backend to backend, avoid variable shadowing with explici…
dalehamel Feb 7, 2024
ababe06
Move vernier to be autoloaded, remove rescue blocks around loading
dalehamel Feb 7, 2024
b3e7a15
Move run lock acquisition into concrete backends, put lock on base ei…
dalehamel Feb 7, 2024
c1f685d
Move VernierProfile to using hash instead of Results object
dalehamel Feb 8, 2024
8895987
Deprecate Profile in favour of AbstractProfile
dalehamel Feb 8, 2024
c9f2da4
Readme update
dalehamel Feb 8, 2024
ae0c49d
Add support for firefox profile viewer
dalehamel Jan 22, 2024
358806c
Make firefox-profiler the viewer for vernier profiles
dalehamel Jan 24, 2024
6cc32fb
Update readme to specify viewer
dalehamel Jan 24, 2024
12f45f2
Only insert viewer middlewares in dev or test
dalehamel Jan 25, 2024
e725182
Revert "Fix default viewer"
dalehamel Jan 30, 2024
84b2146
Bring back local speedscope viewer, rename middleware-based viewers t…
dalehamel Feb 9, 2024
a067ddd
Use AppProfiler.speedscope_viewer to set viewer for stackprof, deprec…
dalehamel Feb 9, 2024
dd8fe90
Readme update
dalehamel Feb 9, 2024
c94424c
Bug fixes
dalehamel Feb 9, 2024
9f8e8fa
Remove dead code
dalehamel Feb 9, 2024
832402e
WIP precompile task for firefox
dalehamel Jan 30, 2024
8b8dd4b
Add helper to prune package.json contents
dalehamel Feb 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ jobs:
- "2.7"
- "3.0"
- "3.1"
- "3.2"
steps:
- uses: actions/checkout@v3
- name: Set up Ruby ${{ matrix.ruby }}
Expand All @@ -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: |
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -197,6 +198,7 @@ DEPENDENCIES
rubocop
rubocop-performance
rubocop-shopify
vernier (~> 0.4.0)

BUNDLED WITH
2.4.18
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

```
Expand Down
2 changes: 2 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: app-profiler
type: ruby

up:
- ruby: 3.1.2
- ruby: 3.2.1
- bundler

commands:
Expand Down
91 changes: 82 additions & 9 deletions lib/app_profiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions lib/app_profiler/backend.rb
Original file line number Diff line number Diff line change
@@ -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
93 changes: 93 additions & 0 deletions lib/app_profiler/backend/stackprof.rb
Original file line number Diff line number Diff line change
@@ -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
Loading