Skip to content

Commit fe924ec

Browse files
committed
Add support for firefox profile viewer
1 parent 9fd1ee1 commit fe924ec

22 files changed

Lines changed: 559 additions & 222 deletions

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,11 +282,16 @@ report = AppProfiler.run(mode: :cpu) do
282282
# ...
283283
end
284284

285-
report.view # opens the profile locally in speedscope.
285+
report.view # opens the profile locally in speedscope or firefox profiler, as appropriate
286286
```
287287

288288
Profile files can be found locally in your rails app at `tmp/app_profiler/*.json`.
289289

290+
**Note** In development, if using the SpeedscopeRemoteViewer for stackprof
291+
or if using Vernier, a route for `/app_profiler` will be added to the application.
292+
If using Vernier, a route for `/from-url` is also added. These will be handled
293+
in middlewares, before any application routing logic. There is a small chance
294+
that these could shadow existing routes in the application.
290295

291296
## Storage backends
292297

@@ -328,6 +333,8 @@ Rails.application.config.app_profiler.backend = AppProfiler::StackprofBackend #
328333

329334
By default, the stackprof backend will be used.
330335

336+
In local development, changing the backend will change whether the profile is viewed in speedscope or firefox-profiler.
337+
331338
## Running tests
332339

333340
```

lib/app_profiler.rb

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ module Storage
2828

2929
module Viewer
3030
autoload :BaseViewer, "app_profiler/viewer/base_viewer"
31-
autoload :SpeedscopeViewer, "app_profiler/viewer/speedscope_viewer"
32-
autoload :SpeedscopeRemoteViewer, "app_profiler/viewer/speedscope_remote_viewer"
31+
autoload :SpeedscopeViewer, "app_profiler/viewer/speedscope"
32+
autoload :SpeedscopeRemoteViewer, "app_profiler/viewer/remote/speedscope"
33+
autoload :FirefoxRemoteViewer, "app_profiler/viewer/remote/firefox"
3334
end
3435

3536
require "app_profiler/middleware"
@@ -51,8 +52,10 @@ module Viewer
5152
mattr_reader :profile_url_formatter,
5253
default: DefaultProfileFormatter
5354

55+
mattr_accessor :gecko_viewer_package, default: "https://github.com/tenderlove/profiler#v0.0.2"
5456
mattr_accessor :storage, default: Storage::FileStorage
55-
mattr_accessor :viewer, default: Viewer::SpeedscopeViewer
57+
mattr_accessor :viewer, default: Viewer::SpeedscopeViewer # DEPRECATED
58+
mattr_accessor :speedscope_viewer, default: Viewer::SpeedscopeViewer
5659
mattr_accessor :middleware, default: Middleware
5760
mattr_accessor :server, default: Server
5861
mattr_accessor :upload_queue_max_length, default: 10
@@ -186,6 +189,17 @@ def clear
186189
@backend.stop if @backend&.running?
187190
@backend = nil
188191
end
192+
193+
# DEPRECATIONS
194+
def viewer
195+
ActiveSupport::Deprecation.warn("viewer is deprecated, use speedscope_viewer instead")
196+
@viewer
197+
end
198+
199+
def viewer=(viewer)
200+
ActiveSupport::Deprecation.warn("viewer= is deprecated, use speedscope_viewer= instead")
201+
@viewer = viewer
202+
end
189203
end
190204

191205
require "app_profiler/railtie" if defined?(Rails::Railtie)

lib/app_profiler/profile/stackprof.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def format
1313
end
1414

1515
def view(params = {})
16-
AppProfiler.viewer.view(self, **params)
16+
AppProfiler.speedscope_viewer.view(self, **params)
1717
end
1818
end
1919
end

lib/app_profiler/profile/vernier.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def format
1313
end
1414

1515
def view(params = {})
16-
raise NotImplementedError
16+
Viewer::FirefoxRemoteViewer.view(self, **params)
1717
end
1818
end
1919
end

lib/app_profiler/railtie.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ class Railtie < Rails::Railtie
1111
AppProfiler.logger = app.config.app_profiler.logger || Rails.logger
1212
AppProfiler.root = app.config.app_profiler.root || Rails.root
1313
AppProfiler.storage = app.config.app_profiler.storage || Storage::FileStorage
14-
AppProfiler.viewer = app.config.app_profiler.viewer || Viewer::SpeedscopeViewer
14+
AppProfiler.viewer = app.config.app_profiler.viewer || Viewer::SpeedscopeRemoteViewer
15+
AppProfiler.speedscope_viewer = app.config.app_profiler.speedscope_viewer || AppProfiler.viewer
1516
AppProfiler.storage.bucket_name = app.config.app_profiler.storage_bucket_name || "profiles"
1617
AppProfiler.storage.credentials = app.config.app_profiler.storage_credentials || {}
1718
AppProfiler.middleware = app.config.app_profiler.middleware || Middleware
@@ -41,12 +42,16 @@ class Railtie < Rails::Railtie
4142
AppProfiler.profile_enqueue_failure = app.config.app_profiler.profile_enqueue_failure
4243
AppProfiler.after_process_queue = app.config.app_profiler.after_process_queue
4344
AppProfiler.backend = app.config.app_profiler.profiler_backend || :stackprof
45+
AppProfiler.gecko_viewer_package = app.config.app_profiler.gecko_viewer_package || "https://github.com/firefox-devtools/profiler"
4446
end
4547

4648
initializer "app_profiler.add_middleware" do |app|
4749
unless AppProfiler.middleware.disabled
48-
if AppProfiler.viewer == Viewer::SpeedscopeRemoteViewer
49-
app.middleware.insert_before(0, Viewer::SpeedscopeRemoteViewer::Middleware)
50+
if Rails.env.development? || Rails.env.test?
51+
if AppProfiler.speedscope_viewer == Viewer::SpeedscopeRemoteViewer
52+
app.middleware.insert_before(0, Viewer::SpeedscopeRemoteViewer::Middleware)
53+
end
54+
app.middleware.insert_before(0, Viewer::FirefoxRemoteViewer::Middleware)
5055
end
5156
app.middleware.insert_before(0, AppProfiler.middleware)
5257
end

lib/app_profiler/viewer/base_viewer.rb

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# frozen_string_literal: true
22

3+
gem "rails-html-sanitizer", ">= 1.6.0"
4+
require "rails-html-sanitizer"
5+
36
module AppProfiler
47
module Viewer
58
class BaseViewer
@@ -12,6 +15,101 @@ def view(profile, params = {})
1215
def view(_params = {})
1316
raise NotImplementedError
1417
end
18+
19+
class BaseMiddleware
20+
class Sanitizer < Rails::HTML::Sanitizer.best_supported_vendor.safe_list_sanitizer
21+
self.allowed_tags = Set.new([
22+
"strong", "em", "b", "i", "p", "code", "pre", "tt", "samp", "kbd", "var", "sub",
23+
"sup", "dfn", "cite", "big", "small", "address", "hr", "br", "div", "span", "h1",
24+
"h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "dl", "dt", "dd", "abbr", "acronym",
25+
"a", "img", "blockquote", "del", "ins", "script",
26+
])
27+
end
28+
29+
private_constant(:Sanitizer)
30+
31+
def self.id(file)
32+
file.basename.to_s
33+
end
34+
35+
def initialize(app)
36+
@app = app
37+
end
38+
39+
def call(env)
40+
request = Rack::Request.new(env)
41+
42+
return index(env) if %r(\A/app_profiler/?\z).match?(request.path_info)
43+
44+
@app.call(env)
45+
end
46+
47+
protected
48+
49+
def id(file)
50+
self.class.id(file)
51+
end
52+
53+
def profile_files
54+
AppProfiler.profile_root.glob("**/*.json")
55+
end
56+
57+
def render(html)
58+
[
59+
200,
60+
{ "Content-Type" => "text/html" },
61+
[
62+
+<<~HTML,
63+
<!doctype html>
64+
<html>
65+
<head>
66+
<title>App Profiler</title>
67+
</head>
68+
<body>
69+
#{sanitizer.sanitize(html)}
70+
</body>
71+
</html>
72+
HTML
73+
],
74+
]
75+
end
76+
77+
def sanitizer
78+
@sanitizer ||= Sanitizer.new
79+
end
80+
81+
def viewer(_env, path)
82+
raise NotImplementedError
83+
end
84+
85+
def show(env, id)
86+
raise NotImplementedError
87+
end
88+
89+
def index(_env)
90+
render(
91+
(+"").tap do |content|
92+
content << "<h1>Profiles</h1>"
93+
profile_files.each do |file|
94+
viewer = if file.to_s.end_with?(AppProfiler::VernierProfile::FILE_EXTENSION)
95+
AppProfiler::Viewer::FirefoxRemoteViewer::NAME
96+
else
97+
AppProfiler::Viewer::SpeedscopeRemoteViewer::NAME
98+
end
99+
content << <<~HTML
100+
<p>
101+
<a href="/app_profiler/#{viewer}/viewer/#{id(file)}">
102+
#{id(file)}
103+
</a>
104+
</p>
105+
HTML
106+
end
107+
end
108+
)
109+
end
110+
end
111+
112+
private_constant(:BaseMiddleware)
15113
end
16114
end
17115
end
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# frozen_string_literal: true
2+
3+
require "app_profiler/yarn/command"
4+
require "app_profiler/yarn/with_firefox_profiler"
5+
6+
module AppProfiler
7+
module Viewer
8+
class FirefoxRemoteViewer < BaseViewer
9+
class Middleware < BaseMiddleware
10+
include Yarn::WithFirefoxProfile
11+
12+
def initialize(app)
13+
super
14+
@firefox_profiler = Rack::File.new(
15+
File.join(AppProfiler.root, "node_modules/firefox-profiler/dist")
16+
)
17+
end
18+
19+
def call(env)
20+
request = Rack::Request.new(env)
21+
@app.call(env) if request.path_info.end_with?(AppProfiler::StackprofProfile::FILE_EXTENSION)
22+
# Firefox profiler *really* doesn't like for /from-url/ to be at any other mount point
23+
# so with this enabled, we take over both /app_profiler and /from-url in the app in development.
24+
return from(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/from-url(.*)\z)
25+
return viewer(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/firefox/viewer/(.*)\z)
26+
return show(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/firefox/(.*)\z)
27+
28+
super
29+
end
30+
31+
protected
32+
33+
attr_reader(:firefox_profiler)
34+
35+
def viewer(env, path)
36+
setup_yarn unless yarn_setup
37+
38+
if path.end_with?(AppProfiler::VernierProfile::FILE_EXTENSION)
39+
proto = env["rack.url_scheme"]
40+
host = env["HTTP_HOST"]
41+
source = "#{proto}://#{host}/app_profiler/firefox/#{path}"
42+
43+
target = "/from-url/#{CGI.escape(source)}"
44+
45+
[302, { "Location" => target }, []]
46+
else
47+
env[Rack::PATH_INFO] = path.delete_prefix("/app_profiler")
48+
firefox_profiler.call(env)
49+
end
50+
end
51+
52+
def from(env, path)
53+
setup_yarn unless yarn_setup
54+
index = File.read(File.join(AppProfiler.root, "node_modules/firefox-profiler/dist/index.html"))
55+
[200, { "Content-Type" => "text/html" }, [index]]
56+
end
57+
58+
def show(_env, name)
59+
profile = profile_files.find do |file|
60+
id(file) == name
61+
end || raise(ArgumentError)
62+
63+
[200, { "Content-Type" => "application/json" }, [profile.read]]
64+
end
65+
end
66+
end
67+
end
68+
end
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
require "app_profiler/yarn/command"
4+
require "app_profiler/yarn/with_speedscope"
5+
6+
module AppProfiler
7+
module Viewer
8+
class SpeedscopeRemoteViewer < BaseViewer
9+
class Middleware < BaseMiddleware
10+
include Yarn::WithSpeedscope
11+
12+
def initialize(app)
13+
super
14+
@speedscope = Rack::File.new(
15+
File.join(AppProfiler.root, "node_modules/speedscope/dist/release")
16+
)
17+
end
18+
19+
def call(env)
20+
request = Rack::Request.new(env)
21+
@app.call(env) if request.path_info.end_with?(AppProfiler::VernierProfile::FILE_EXTENSION)
22+
return viewer(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/speedscope/viewer/(.*)\z)
23+
return show(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/speedscope/(.*)\z)
24+
25+
super
26+
end
27+
28+
protected
29+
30+
attr_reader(:speedscope)
31+
32+
def viewer(env, path)
33+
setup_yarn unless yarn_setup
34+
35+
if path.end_with?(AppProfiler::StackprofProfile::FILE_EXTENSION)
36+
source = "/app_profiler/speedscope/#{path}"
37+
target = "/app_profiler/speedscope/viewer/index.html#profileURL=#{CGI.escape(source)}"
38+
39+
[302, { "Location" => target }, []]
40+
else
41+
env[Rack::PATH_INFO] = path.delete_prefix("/app_profiler/speedscope")
42+
speedscope.call(env)
43+
end
44+
end
45+
46+
def show(_env, name)
47+
profile = profile_files.find do |file|
48+
id(file) == name
49+
end || raise(ArgumentError)
50+
51+
[200, { "Content-Type" => "application/json" }, [profile.read]]
52+
end
53+
end
54+
end
55+
end
56+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
require "app_profiler/viewer/middleware/firefox"
4+
5+
module AppProfiler
6+
module Viewer
7+
class FirefoxRemoteViewer < BaseViewer
8+
NAME = "firefox"
9+
10+
class << self
11+
def view(profile, params = {})
12+
new(profile).view(**params)
13+
end
14+
end
15+
16+
def initialize(profile)
17+
super()
18+
@profile = profile
19+
end
20+
21+
def view(response: nil, autoredirect: nil, async: false)
22+
id = Middleware.id(@profile.file)
23+
24+
if response && response[0].to_i < 500
25+
response[1]["Location"] = "/app_profiler/#{NAME}/viewer/#{id}"
26+
response[0] = 303
27+
else
28+
AppProfiler.logger.info("[Profiler] Profile available at /app_profiler/#{id}\n")
29+
end
30+
end
31+
end
32+
end
33+
end

0 commit comments

Comments
 (0)