app_profiler 0.2.5 → 0.2.6

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36a0afed63f70a16ab782039e981583c16e80f846eb6caf6cefce219d7934d0d
4
- data.tar.gz: eda36bd2a579b25757db0e872c3f2aa1cbdda60ddf486b4bd3ad90f34f74829c
3
+ metadata.gz: b5be705606d138c4e042f4d87820e97413be62f3eeeb92cbb34d5e02aac600ea
4
+ data.tar.gz: d15e16f16c53f939cb874ab841b60528f4b46b5359e755ba5ce6acfe01265b3e
5
5
  SHA512:
6
- metadata.gz: 8ef999eb872722bca7b910b88df8a091c756b4d9f0e95da4ac770aacfb0b5454a160ca37b8c13852e25e5fd8aecf698f6bc06f6669104cbbb02c5af033b66a80
7
- data.tar.gz: 2896ca3138e8b631445b628bcd6206e77bcd94fbef57c00560b0a63f8677b3103555eef97aabbb936722406d5667ec6c8885c9224fe85cd82d2619f45fdb9051
6
+ metadata.gz: ccbe4b8fb2b5a4a1882fb7e33e03f35b7f91afa2a8eeb836e705bae104131e05b1d48d6c7d118cd1c69665e9736c2d9d621a1b05626ba112d63299615de609b1
7
+ data.tar.gz: ff0cb2c2cced5f0edd575d9b6ddb2d6cf83021225d528c4f7e194ed527360bab09a511864e6e5ed047e2e84ddbab737a7060c39b9931ac8fbc976a4489aa128e
@@ -3,9 +3,6 @@
3
3
  require "active_support/deprecation/constant_accessor"
4
4
 
5
5
  module AppProfiler
6
- autoload :StackprofProfile, "app_profiler/profile/stackprof"
7
- autoload :VernierProfile, "app_profiler/profile/vernier"
8
-
9
6
  class BaseProfile
10
7
  INTERNAL_METADATA_KEYS = [:id, :context]
11
8
  private_constant :INTERNAL_METADATA_KEYS
@@ -111,7 +108,4 @@ module AppProfiler
111
108
  AppProfiler.profile_root.join(filename)
112
109
  end
113
110
  end
114
-
115
- include ActiveSupport::Deprecation::DeprecatedConstantAccessor
116
- deprecate_constant "Profile", "AppProfiler::BaseProfile", deprecator: ActiveSupport::Deprecation.new
117
111
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AppProfiler
4
+ module Exec # :nodoc:
5
+ protected
6
+
7
+ def valid_commands
8
+ raise NotImplementedError
9
+ end
10
+
11
+ def ensure_command_valid(command)
12
+ unless valid_command?(command)
13
+ raise ArgumentError, "Illegal command: #{command.join(" ")}."
14
+ end
15
+ end
16
+
17
+ def valid_command?(command)
18
+ valid_commands.any? do |valid_command|
19
+ next unless valid_command.size == command.size
20
+
21
+ valid_command.zip(command).all? do |valid_part, part|
22
+ part.match?(valid_part)
23
+ end
24
+ end
25
+ end
26
+
27
+ def exec(*command, silent: false, environment: {})
28
+ ensure_command_valid(command)
29
+
30
+ if silent
31
+ system(environment, *command, out: File::NULL).tap { |return_code| yield unless return_code }
32
+ else
33
+ system(environment, *command).tap { |return_code| yield unless return_code }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -4,7 +4,6 @@ require "rack"
4
4
  require "app_profiler/middleware/base_action"
5
5
  require "app_profiler/middleware/upload_action"
6
6
  require "app_profiler/middleware/view_action"
7
- require "app_profiler/sampler/config"
8
7
 
9
8
  module AppProfiler
10
9
  class Middleware
@@ -11,7 +11,12 @@ module AppProfiler
11
11
  AppProfiler.logger = app.config.app_profiler.logger || Rails.logger
12
12
  AppProfiler.root = app.config.app_profiler.root || Rails.root
13
13
  AppProfiler.storage = app.config.app_profiler.storage || Storage::FileStorage
14
- AppProfiler.viewer = app.config.app_profiler.viewer || Viewer::SpeedscopeViewer
14
+ if app.config.app_profiler.stackprof_viewer
15
+ AppProfiler.stackprof_viewer = app.config.app_profiler.stackprof_viewer
16
+ end
17
+ if app.config.app_profiler.vernier_viewer
18
+ AppProfiler.vernier_viewer = app.config.app_profiler.vernier_viewer
19
+ end
15
20
  AppProfiler.storage.bucket_name = app.config.app_profiler.storage_bucket_name || "profiles"
16
21
  AppProfiler.storage.credentials = app.config.app_profiler.storage_credentials || {}
17
22
  AppProfiler.middleware = app.config.app_profiler.middleware || Middleware
@@ -40,7 +45,7 @@ module AppProfiler
40
45
  AppProfiler.profile_enqueue_success = app.config.app_profiler.profile_enqueue_success
41
46
  AppProfiler.profile_enqueue_failure = app.config.app_profiler.profile_enqueue_failure
42
47
  AppProfiler.after_process_queue = app.config.app_profiler.after_process_queue
43
- AppProfiler.backend = app.config.app_profiler.profiler_backend || :stackprof
48
+ AppProfiler.backend = app.config.app_profiler.profiler_backend || :stackprof unless AppProfiler.running?
44
49
  AppProfiler.forward_metadata_on_upload = app.config.app_profiler.forward_metadata_on_upload || false
45
50
  AppProfiler.profile_sampler_enabled = app.config.app_profiler.profile_sampler_enabled || false
46
51
  AppProfiler.profile_sampler_config = app.config.app_profiler.profile_sampler_config ||
@@ -49,8 +54,8 @@ module AppProfiler
49
54
 
50
55
  initializer "app_profiler.add_middleware" do |app|
51
56
  unless AppProfiler.middleware.disabled
52
- if AppProfiler.viewer == Viewer::SpeedscopeRemoteViewer
53
- app.middleware.insert_before(0, Viewer::SpeedscopeRemoteViewer::Middleware)
57
+ if (Rails.env.development? || Rails.env.test?) && AppProfiler.stackprof_viewer.remote?
58
+ app.middleware.insert_before(0, AppProfiler.viewer::Middleware)
54
59
  end
55
60
  app.middleware.insert_before(0, AppProfiler.middleware)
56
61
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "app_profiler/sampler/stackprof_config"
4
4
  require "app_profiler/sampler/vernier_config"
5
+
5
6
  module AppProfiler
6
7
  module Sampler
7
8
  class Config
@@ -27,7 +28,7 @@ module AppProfiler
27
28
 
28
29
  raise ArgumentError, "mode probabilities must sum to 1" unless backends_probability.values.sum == 1.0
29
30
 
30
- ActiveSupport::Deprecation.new.warn("passing paths is deprecated, use targets instead") if paths
31
+ AppProfiler.deprecator.warn("passing paths is deprecated, use targets instead") if paths
31
32
 
32
33
  @sample_rate = sample_rate
33
34
  @targets = paths || targets
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "app_profiler/sampler/config"
4
+
4
5
  module AppProfiler
5
6
  module Sampler
6
7
  @excluded_cache = {}
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AppProfiler
4
4
  class StackprofProfile < BaseProfile
5
- FILE_EXTENSION = ".json"
5
+ FILE_EXTENSION = ".stackprof.json"
6
6
 
7
7
  def mode
8
8
  @data[:mode]
@@ -17,7 +17,7 @@ module AppProfiler
17
17
  end
18
18
 
19
19
  def view(params = {})
20
- AppProfiler.viewer.view(self, **params)
20
+ AppProfiler.stackprof_viewer.view(self, **params)
21
21
  end
22
22
  end
23
23
  end
@@ -37,7 +37,7 @@ module AppProfiler
37
37
 
38
38
  def enqueue_upload(profile)
39
39
  mutex.synchronize do
40
- process_queue_thread unless @process_queue_thread&.alive?
40
+ start_process_queue_thread
41
41
 
42
42
  @queue ||= init_queue
43
43
  begin
@@ -50,12 +50,6 @@ module AppProfiler
50
50
  end
51
51
  end
52
52
 
53
- def reset_queue # for testing
54
- init_queue
55
- @process_queue_thread&.kill
56
- @process_queue_thread = nil
57
- end
58
-
59
53
  private
60
54
 
61
55
  def mutex
@@ -66,8 +60,10 @@ module AppProfiler
66
60
  @queue = SizedQueue.new(AppProfiler.upload_queue_max_length)
67
61
  end
68
62
 
69
- def process_queue_thread
70
- @process_queue_thread ||= Thread.new do
63
+ def start_process_queue_thread
64
+ return if @process_queue_thread&.alive?
65
+
66
+ @process_queue_thread = Thread.new do
71
67
  loop do
72
68
  process_queue
73
69
  sleep(AppProfiler.upload_queue_interval_secs)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AppProfiler
4
4
  class VernierProfile < BaseProfile
5
- FILE_EXTENSION = ".gecko.json"
5
+ FILE_EXTENSION = ".vernier.json"
6
6
 
7
7
  def mode
8
8
  @data[:meta][:mode]
@@ -17,7 +17,7 @@ module AppProfiler
17
17
  end
18
18
 
19
19
  def view(params = {})
20
- raise NotImplementedError
20
+ AppProfiler.vernier_viewer.view(self, **params)
21
21
  end
22
22
  end
23
23
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppProfiler
4
- VERSION = "0.2.5"
4
+ VERSION = "0.2.6"
5
5
  end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ gem "rails-html-sanitizer", ">= 1.6.0"
4
+ require "rails-html-sanitizer"
5
+
6
+ module AppProfiler
7
+ module Viewer
8
+ class BaseMiddleware
9
+ class Sanitizer < Rails::HTML::Sanitizer.best_supported_vendor.safe_list_sanitizer
10
+ self.allowed_tags = Set.new([
11
+ "strong",
12
+ "em",
13
+ "b",
14
+ "i",
15
+ "p",
16
+ "code",
17
+ "pre",
18
+ "tt",
19
+ "samp",
20
+ "kbd",
21
+ "var",
22
+ "sub",
23
+ "sup",
24
+ "dfn",
25
+ "cite",
26
+ "big",
27
+ "small",
28
+ "address",
29
+ "hr",
30
+ "br",
31
+ "div",
32
+ "span",
33
+ "h1",
34
+ "h2",
35
+ "h3",
36
+ "h4",
37
+ "h5",
38
+ "h6",
39
+ "ul",
40
+ "ol",
41
+ "li",
42
+ "dl",
43
+ "dt",
44
+ "dd",
45
+ "abbr",
46
+ "acronym",
47
+ "a",
48
+ "img",
49
+ "blockquote",
50
+ "del",
51
+ "ins",
52
+ "script",
53
+ ])
54
+ end
55
+
56
+ private_constant(:Sanitizer)
57
+
58
+ class << self
59
+ def id(file)
60
+ file.basename.to_s
61
+ end
62
+ end
63
+
64
+ def initialize(app)
65
+ @app = app
66
+ end
67
+
68
+ def call(env)
69
+ request = Rack::Request.new(env)
70
+
71
+ return index(env) if %r(\A/app_profiler/?\z).match?(request.path_info)
72
+
73
+ @app.call(env)
74
+ end
75
+
76
+ protected
77
+
78
+ def id(file)
79
+ self.class.id(file)
80
+ end
81
+
82
+ def profile_files
83
+ AppProfiler.profile_root.glob("**/*.json")
84
+ end
85
+
86
+ def render(html)
87
+ [
88
+ 200,
89
+ { "Content-Type" => "text/html" },
90
+ [
91
+ +<<~HTML,
92
+ <!doctype html>
93
+ <html>
94
+ <head>
95
+ <title>App Profiler</title>
96
+ </head>
97
+ <body>
98
+ #{sanitizer.sanitize(html)}
99
+ </body>
100
+ </html>
101
+ HTML
102
+ ],
103
+ ]
104
+ end
105
+
106
+ def sanitizer
107
+ @sanitizer ||= Sanitizer.new
108
+ end
109
+
110
+ def viewer(_env, path)
111
+ raise NotImplementedError
112
+ end
113
+
114
+ def show(env, id)
115
+ raise NotImplementedError
116
+ end
117
+
118
+ def index(_env)
119
+ render(
120
+ (+"").tap do |content|
121
+ content << "<h1>Profiles</h1>"
122
+ profile_files.each do |file|
123
+ viewer = if file.to_s.end_with?(AppProfiler::VernierProfile::FILE_EXTENSION)
124
+ AppProfiler::Viewer::FirefoxRemoteViewer::NAME
125
+ else
126
+ AppProfiler::Viewer::SpeedscopeRemoteViewer::NAME
127
+ end
128
+ content << <<~HTML
129
+ <p>
130
+ <a href="/app_profiler/#{viewer}/viewer/#{id(file)}">
131
+ #{id(file)}
132
+ </a>
133
+ </p>
134
+ HTML
135
+ end
136
+ end,
137
+ )
138
+ end
139
+ end
140
+ end
141
+ end
@@ -7,10 +7,10 @@ module AppProfiler
7
7
  def view(profile, params = {})
8
8
  new(profile).view(**params)
9
9
  end
10
- end
11
10
 
12
- def view(_params = {})
13
- raise NotImplementedError
11
+ def remote?
12
+ false
13
+ end
14
14
  end
15
15
  end
16
16
  end
@@ -0,0 +1,66 @@
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 < AppProfiler::Viewer::BaseMiddleware
10
+ include Yarn::WithFirefoxProfiler
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
+ # Firefox profiler *really* doesn't like for /from-url/ to be at any other mount point
22
+ # so with this enabled, we take over both /app_profiler and /from-url in the app in development.
23
+ return from(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/from-url/(.*)\z)
24
+ return viewer(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/firefox/viewer/(.*)\z)
25
+ return show(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/firefox/(.*)\z)
26
+
27
+ super
28
+ end
29
+
30
+ protected
31
+
32
+ attr_reader(:firefox_profiler)
33
+
34
+ def viewer(env, path)
35
+ setup_yarn unless yarn_setup
36
+
37
+ if path.end_with?(AppProfiler::VernierProfile::FILE_EXTENSION)
38
+ proto = env["rack.url_scheme"]
39
+ host = env["HTTP_HOST"]
40
+ source = "#{proto}://#{host}/app_profiler/firefox/#{path}"
41
+ target = "/from-url/#{CGI.escape(source)}"
42
+
43
+ [302, { "Location" => target }, [""]]
44
+ else
45
+ env[Rack::PATH_INFO] = path.delete_prefix("/app_profiler")
46
+ firefox_profiler.call(env)
47
+ end
48
+ end
49
+
50
+ def from(env, path)
51
+ setup_yarn unless yarn_setup
52
+ index = File.read(File.join(AppProfiler.root, "node_modules/firefox-profiler/dist/index.html"))
53
+ [200, { "Content-Type" => "text/html" }, [index]]
54
+ end
55
+
56
+ def show(_env, name)
57
+ profile = profile_files.find do |file|
58
+ id(file) == name
59
+ end || raise(ArgumentError)
60
+
61
+ [200, { "Content-Type" => "application/json" }, [profile.read]]
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "app_profiler/viewer/firefox_remote_viewer/middleware"
4
+
5
+ module AppProfiler
6
+ module Viewer
7
+ class FirefoxRemoteViewer < BaseViewer
8
+ NAME = "firefox"
9
+
10
+ class << self
11
+ def remote?
12
+ true
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,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "app_profiler/exec"
4
+
5
+ module AppProfiler
6
+ module Viewer
7
+ class FirefoxViewer < BaseViewer
8
+ include Exec
9
+
10
+ CHILD_PIDS = []
11
+
12
+ at_exit { Process.wait if CHILD_PIDS.any? }
13
+
14
+ trap("INT") do
15
+ CHILD_PIDS.each { |pid| Process.kill("INT", pid) }
16
+ sleep(0.5)
17
+ end
18
+
19
+ class ProfileViewerError < StandardError; end
20
+
21
+ VALID_COMMANDS = [
22
+ ["which", "profile-viewer"],
23
+ ["gem", "install", "profile-viewer"],
24
+ ["profile-viewer", /.*\.json/],
25
+ ]
26
+ private_constant(:VALID_COMMANDS)
27
+
28
+ class << self
29
+ def view(profile, params = {})
30
+ new(profile).view(**params)
31
+ end
32
+ end
33
+
34
+ def valid_commands
35
+ VALID_COMMANDS
36
+ end
37
+
38
+ def initialize(profile)
39
+ super()
40
+ @profile = profile
41
+ end
42
+
43
+ def view(_params = {})
44
+ profile_viewer(@profile.file.to_s)
45
+ end
46
+
47
+ private
48
+
49
+ def setup_profile_viewer
50
+ exec("which", "profile-viewer", silent: true) do
51
+ gem_install("profile_viewer")
52
+ end
53
+ @profile_viewer_initialized = true
54
+ end
55
+
56
+ def profile_viewer_setup
57
+ @profile_viewer_initialized || false
58
+ end
59
+
60
+ def gem_install(gem)
61
+ exec("gem", "install", gem) do
62
+ raise ProfileViewerError, "Failed to run gem install #{gem}."
63
+ end
64
+ end
65
+
66
+ def profile_viewer(path)
67
+ setup_profile_viewer unless profile_viewer_setup
68
+
69
+ CHILD_PIDS << fork do
70
+ Bundler.with_unbundled_env do
71
+ exec("profile-viewer", path) do
72
+ raise ProfileViewerError, "Failed to run profile-viewer."
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -16,14 +16,23 @@ module AppProfiler
16
16
  )
17
17
  end
18
18
 
19
+ def call(env)
20
+ request = Rack::Request.new(env)
21
+
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
+
19
28
  protected
20
29
 
21
30
  attr_reader(:speedscope)
22
31
 
23
32
  def viewer(env, path)
24
33
  setup_yarn unless yarn_setup
25
- env[Rack::PATH_INFO] = path.delete_prefix("/app_profiler")
26
34
 
35
+ env[Rack::PATH_INFO] = path.delete_prefix("/app_profiler/speedscope")
27
36
  speedscope.call(env)
28
37
  end
29
38
 
@@ -32,25 +41,7 @@ module AppProfiler
32
41
  id(file) == name
33
42
  end || raise(ArgumentError)
34
43
 
35
- render(
36
- <<~HTML,
37
- <script type="text/javascript">
38
- var graph = #{profile.read};
39
- var json = JSON.stringify(graph);
40
- var blob = new Blob([json], { type: 'text/plain' });
41
- var objUrl = encodeURIComponent(URL.createObjectURL(blob));
42
- var iframe = document.createElement('iframe');
43
-
44
- document.body.style.margin = '0px';
45
- document.body.appendChild(iframe);
46
-
47
- iframe.style.width = '100vw';
48
- iframe.style.height = '100vh';
49
- iframe.style.border = 'none';
50
- iframe.setAttribute('src', '/app_profiler/viewer/index.html#profileURL=' + objUrl + '&title=' + 'Flamegraph for #{name}');
51
- </script>
52
- HTML
53
- )
44
+ [200, { "Content-Type" => "application/json" }, [profile.read]]
54
45
  end
55
46
  end
56
47
  end
@@ -1,14 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "app_profiler/viewer/speedscope_remote_viewer/base_middleware"
3
+ require "active_support/deprecation/constant_accessor"
4
4
  require "app_profiler/viewer/speedscope_remote_viewer/middleware"
5
5
 
6
6
  module AppProfiler
7
7
  module Viewer
8
8
  class SpeedscopeRemoteViewer < BaseViewer
9
+ include ActiveSupport::Deprecation::DeprecatedConstantAccessor
10
+ deprecate_constant(
11
+ "BaseMiddleware",
12
+ "AppProfiler::Viewer::BaseMiddleware",
13
+ deprecator: AppProfiler.deprecator,
14
+ )
15
+ NAME = "speedscope"
16
+
9
17
  class << self
10
- def view(profile, params = {})
11
- new(profile).view(**params)
18
+ def remote?
19
+ true
12
20
  end
13
21
  end
14
22
 
@@ -21,7 +29,7 @@ module AppProfiler
21
29
  id = Middleware.id(@profile.file)
22
30
 
23
31
  if response && response[0].to_i < 500
24
- response[1]["Location"] = "/app_profiler/#{id}"
32
+ response[1]["Location"] = "/app_profiler/#{NAME}/viewer/#{id}"
25
33
  response[0] = 303
26
34
  else
27
35
  AppProfiler.logger.info("[Profiler] Profile available at /app_profiler/#{id}\n")
@@ -8,12 +8,6 @@ module AppProfiler
8
8
  class SpeedscopeViewer < BaseViewer
9
9
  include Yarn::WithSpeedscope
10
10
 
11
- class << self
12
- def view(profile, params = {})
13
- new(profile).view(**params)
14
- end
15
- end
16
-
17
11
  def initialize(profile)
18
12
  super()
19
13
  @profile = profile
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "app_profiler/exec"
4
+
3
5
  module AppProfiler
4
6
  module Yarn
5
7
  module Command
8
+ include Exec
9
+
6
10
  class YarnError < StandardError; end
7
11
 
8
12
  VALID_COMMANDS = [
@@ -10,10 +14,16 @@ module AppProfiler
10
14
  ["yarn", "init", "--yes"],
11
15
  ["yarn", "add", "speedscope", "--dev", "--ignore-workspace-root-check"],
12
16
  ["yarn", "run", "speedscope", /.*\.json/],
17
+ ["yarn", "add", "--dev", %r{.*/firefox-profiler}],
18
+ ["yarn", "--cwd", %r{.*/firefox-profiler}],
19
+ ["yarn", "--cwd", %r{.*/firefox-profiler}, "build-prod"],
13
20
  ]
14
21
 
15
22
  private_constant(:VALID_COMMANDS)
16
- mattr_accessor(:yarn_setup, default: false)
23
+
24
+ def valid_commands
25
+ VALID_COMMANDS
26
+ end
17
27
 
18
28
  def yarn(command, *options)
19
29
  setup_yarn unless yarn_setup
@@ -29,22 +39,16 @@ module AppProfiler
29
39
  yarn("init", "--yes") unless package_json_exists?
30
40
  end
31
41
 
32
- private
33
-
34
- def ensure_command_valid(command)
35
- unless valid_command?(command)
36
- raise YarnError, "Illegal command: #{command.join(" ")}."
37
- end
42
+ def yarn_setup
43
+ @yarn_initialized || false
38
44
  end
39
45
 
40
- def valid_command?(command)
41
- VALID_COMMANDS.any? do |valid_command|
42
- valid_command.zip(command).all? do |valid_part, part|
43
- part.match?(valid_part)
44
- end
45
- end
46
+ def yarn_setup=(state)
47
+ @yarn_initialized = state
46
48
  end
47
49
 
50
+ private
51
+
48
52
  def ensure_yarn_installed
49
53
  exec("which", "yarn", silent: true) do
50
54
  raise(
@@ -55,22 +59,12 @@ module AppProfiler
55
59
  MSG
56
60
  )
57
61
  end
58
- self.yarn_setup = true
62
+ @yarn_initialized = true
59
63
  end
60
64
 
61
65
  def package_json_exists?
62
66
  AppProfiler.root.join("package.json").exist?
63
67
  end
64
-
65
- def exec(*command, silent: false)
66
- ensure_command_valid(command)
67
-
68
- if silent
69
- system(*command, out: File::NULL).tap { |return_code| yield unless return_code }
70
- else
71
- system(*command).tap { |return_code| yield unless return_code }
72
- end
73
- end
74
68
  end
75
69
  end
76
70
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AppProfiler
4
+ module Yarn
5
+ module WithFirefoxProfiler
6
+ include Command
7
+
8
+ PACKAGE = "https://github.com/tenderlove/profiler#v0.0.2"
9
+ VALID_COMMANDS = [
10
+ *VALID_COMMANDS,
11
+ ["git", "clone", "https://github.com/tenderlove/profiler", "firefox-profiler", "--branch=v0.0.2"],
12
+ ]
13
+ private_constant(:PACKAGE, :VALID_COMMANDS)
14
+
15
+ def setup_yarn
16
+ super
17
+ return if firefox_profiler_added?
18
+
19
+ fetch_firefox_profiler
20
+ end
21
+
22
+ def valid_commands
23
+ VALID_COMMANDS
24
+ end
25
+
26
+ private
27
+
28
+ def firefox_profiler_added?
29
+ AppProfiler.root.join("node_modules/firefox-profiler/dist").exist?
30
+ end
31
+
32
+ def fetch_firefox_profiler
33
+ repo, branch = PACKAGE.to_s.split("#")
34
+
35
+ dir = "./tmp"
36
+ FileUtils.mkdir_p(dir)
37
+ Dir.chdir(dir) do
38
+ clone_args = ["git", "clone", repo, "firefox-profiler"]
39
+ clone_args.push("--branch=#{branch}") unless branch.nil? || branch&.empty?
40
+ exec(*clone_args)
41
+ package_contents = File.read("firefox-profiler/package.json")
42
+ package_json = JSON.parse(package_contents)
43
+ package_json["name"] ||= "firefox-profiler"
44
+ package_json["version"] ||= "0.0.1"
45
+ File.write("firefox-profiler/package.json", package_json.to_json)
46
+ end
47
+ yarn("--cwd", "#{dir}/firefox-profiler")
48
+
49
+ patch_firefox_profiler(dir)
50
+ yarn("--cwd", "#{dir}/firefox-profiler", "build-prod")
51
+ patch_file(
52
+ "#{dir}/firefox-profiler/dist/index.html",
53
+ 'href="locales/en-US/app.ftl"',
54
+ 'href="/app_profiler/firefox/viewer/locales/en-US/app.ftl"',
55
+ )
56
+
57
+ yarn("add", "--dev", "#{dir}/firefox-profiler")
58
+ end
59
+
60
+ def patch_firefox_profiler(dir)
61
+ # Patch the publicPath so that the app can be "mounted" at the right location
62
+ patch_file(
63
+ "#{dir}/firefox-profiler/webpack.config.js",
64
+ "publicPath: '/'",
65
+ "publicPath: '/app_profiler/firefox/viewer/'",
66
+ )
67
+ patch_file(
68
+ "#{dir}/firefox-profiler/src/app-logic/l10n.js",
69
+ "fetch(`/locales/",
70
+ "fetch(`/app_profiler/firefox/viewer/locales/",
71
+ )
72
+ end
73
+
74
+ def patch_file(file, find, replace)
75
+ contents = File.read(file)
76
+ new_contents = contents.gsub(find, replace)
77
+ File.write(file, new_contents)
78
+ end
79
+ end
80
+ end
81
+ end
data/lib/app_profiler.rb CHANGED
@@ -30,16 +30,21 @@ module AppProfiler
30
30
  module Viewer
31
31
  autoload :BaseViewer, "app_profiler/viewer/base_viewer"
32
32
  autoload :SpeedscopeViewer, "app_profiler/viewer/speedscope_viewer"
33
+ autoload :FirefoxViewer, "app_profiler/viewer/firefox_viewer"
34
+ autoload :BaseMiddleware, "app_profiler/viewer/base_middleware"
33
35
  autoload :SpeedscopeRemoteViewer, "app_profiler/viewer/speedscope_remote_viewer"
36
+ autoload :FirefoxRemoteViewer, "app_profiler/viewer/firefox_remote_viewer"
34
37
  end
35
38
 
36
- require "app_profiler/middleware"
37
- require "app_profiler/parameters"
38
- require "app_profiler/request_parameters"
39
- require "app_profiler/profile"
40
- require "app_profiler/backend"
41
- require "app_profiler/server"
42
- require "app_profiler/sampler"
39
+ autoload(:Middleware, "app_profiler/middleware")
40
+ autoload(:Parameters, "app_profiler/parameters")
41
+ autoload(:RequestParameters, "app_profiler/request_parameters")
42
+ autoload(:BaseProfile, "app_profiler/base_profile")
43
+ autoload :StackprofProfile, "app_profiler/stackprof_profile"
44
+ autoload :VernierProfile, "app_profiler/vernier_profile"
45
+ autoload(:Backend, "app_profiler/backend")
46
+ autoload(:Server, "app_profiler/server")
47
+ autoload(:Sampler, "app_profiler/sampler")
43
48
 
44
49
  mattr_accessor :logger, default: Logger.new($stdout)
45
50
  mattr_accessor :root
@@ -50,11 +55,10 @@ module AppProfiler
50
55
  mattr_reader :profile_header, default: "X-Profile"
51
56
  mattr_accessor :profile_async_header, default: "X-Profile-Async"
52
57
  mattr_accessor :context, default: nil
53
- mattr_reader :profile_url_formatter,
54
- default: DefaultProfileFormatter
55
-
58
+ mattr_reader :profile_url_formatter, default: DefaultProfileFormatter
56
59
  mattr_accessor :storage, default: Storage::FileStorage
57
- mattr_accessor :viewer, default: Viewer::SpeedscopeViewer
60
+ mattr_writer :stackprof_viewer, default: nil
61
+ mattr_writer :vernier_viewer, default: nil
58
62
  mattr_accessor :middleware, default: Middleware
59
63
  mattr_accessor :server, default: Server
60
64
  mattr_accessor :upload_queue_max_length, default: 10
@@ -68,55 +72,63 @@ module AppProfiler
68
72
  mattr_accessor :profile_sampler_config
69
73
 
70
74
  class << self
75
+ def deprecator # :nodoc:
76
+ @deprecator ||= ActiveSupport::Deprecation.new("in future releases", "app_profiler")
77
+ end
78
+
71
79
  def run(*args, backend: nil, **kwargs, &block)
72
- orig_backend = self.backend
73
- begin
74
- self.backend = backend if backend
75
- profiler.run(*args, **kwargs, &block)
76
- rescue BackendError => e
77
- logger.error(
78
- "[AppProfiler.run] exception #{e} configuring backend #{backend}: #{e.message}",
79
- )
80
- yield
80
+ if backend
81
+ original_backend = self.backend
82
+ self.backend = backend
81
83
  end
84
+ profiler.run(*args, **kwargs, &block)
85
+ rescue BackendError => e
86
+ logger.error(
87
+ "[AppProfiler.run] exception #{e} configuring backend #{backend}: #{e.message}",
88
+ )
89
+ yield
82
90
  ensure
83
- AppProfiler.backend = orig_backend
91
+ self.backend = original_backend if backend
84
92
  end
85
93
 
86
- def start(*args)
87
- profiler.start(*args)
94
+ def start(*args, backend: nil, **kwargs)
95
+ self.backend = backend if backend
96
+ profiler.start(*args, **kwargs)
88
97
  end
89
98
 
90
99
  def stop
91
100
  profiler.stop
92
- profiler.results
101
+ profiler.results.tap { clear }
93
102
  end
94
103
 
95
104
  def running?
96
- @backend&.running?
105
+ @profiler&.running?
97
106
  end
98
107
 
99
108
  def profiler
100
- backend
101
- @backend ||= @profiler_backend.new
109
+ @profiler ||= profiler_backend.new
102
110
  end
103
111
 
104
112
  def backend=(new_backend)
105
- return if new_backend == backend
106
-
107
- new_profiler_backend = backend_for(new_backend)
113
+ return if (new_profiler_backend = backend_for(new_backend)) == profiler_backend
108
114
 
109
115
  if running?
110
116
  raise BackendError,
111
117
  "cannot change backend to #{new_backend} while #{backend} backend is running"
112
118
  end
113
119
 
114
- return if @profiler_backend == new_profiler_backend
115
-
116
120
  clear
117
121
  @profiler_backend = new_profiler_backend
118
122
  end
119
123
 
124
+ def stackprof_viewer
125
+ @@stackprof_viewer ||= Viewer::SpeedscopeViewer # rubocop:disable Style/ClassVars
126
+ end
127
+
128
+ def vernier_viewer
129
+ @@vernier_viewer ||= Viewer::FirefoxViewer # rubocop:disable Style/ClassVars
130
+ end
131
+
120
132
  def profile_sampler_enabled=(value)
121
133
  if value.is_a?(Proc)
122
134
  raise ArgumentError,
@@ -141,22 +153,21 @@ module AppProfiler
141
153
 
142
154
  def backend_for(backend_name)
143
155
  if vernier_supported? &&
144
- backend_name == AppProfiler::Backend::VernierBackend.name
156
+ backend_name&.to_sym == AppProfiler::Backend::VernierBackend.name
145
157
  AppProfiler::Backend::VernierBackend
146
- elsif backend_name == AppProfiler::Backend::StackprofBackend.name
158
+ elsif backend_name&.to_sym == AppProfiler::Backend::StackprofBackend.name
147
159
  AppProfiler::Backend::StackprofBackend
148
160
  else
149
- raise BackendError, "unknown backend #{backend_name}"
161
+ raise BackendError, "unknown backend #{backend_name.inspect}"
150
162
  end
151
163
  end
152
164
 
153
165
  def backend
154
- @profiler_backend ||= Backend::StackprofBackend
155
- @profiler_backend.name
166
+ profiler_backend.name
156
167
  end
157
168
 
158
169
  def vernier_supported?
159
- defined?(AppProfiler::Backend::VernierBackend.name)
170
+ RUBY_VERSION >= "3.2.1"
160
171
  end
161
172
 
162
173
  def profile_header=(profile_header)
@@ -207,11 +218,26 @@ module AppProfiler
207
218
  AppProfiler.profile_url_formatter.call(upload)
208
219
  end
209
220
 
221
+ def viewer
222
+ deprecator.warn("AppProfiler.viewer is deprecated, please use stackprof_viewer instead.")
223
+ stackprof_viewer
224
+ end
225
+
226
+ def viewer=(viewer)
227
+ deprecator.warn("AppProfiler.viewer= is deprecated, please use stackprof_viewer= instead.")
228
+ self.stackprof_viewer = viewer
229
+ end
230
+
210
231
  private
211
232
 
233
+ def profiler_backend
234
+ @profiler_backend ||= Backend::StackprofBackend
235
+ end
236
+
212
237
  def clear
213
- @backend.stop if @backend&.running?
214
- @backend = nil
238
+ profiler.stop if running?
239
+ @profiler = nil
240
+ @profiler_backend = nil
215
241
  end
216
242
  end
217
243
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: app_profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gannon McGibbon
@@ -10,10 +10,9 @@ authors:
10
10
  - Jon Simpson
11
11
  - Kevin Jalbert
12
12
  - Scott Francis
13
- autorequire:
14
13
  bindir: bin
15
14
  cert_chain: []
16
- date: 2024-10-15 00:00:00.000000000 Z
15
+ date: 2025-01-22 00:00:00.000000000 Z
17
16
  dependencies:
18
17
  - !ruby/object:Gem::Dependency
19
18
  name: activesupport
@@ -127,7 +126,6 @@ dependencies:
127
126
  - - ">="
128
127
  - !ruby/object:Gem::Version
129
128
  version: '0'
130
- description:
131
129
  email:
132
130
  - gems@shopify.com
133
131
  executables: []
@@ -139,14 +137,13 @@ files:
139
137
  - lib/app_profiler/backend/base_backend.rb
140
138
  - lib/app_profiler/backend/stackprof_backend.rb
141
139
  - lib/app_profiler/backend/vernier_backend.rb
140
+ - lib/app_profiler/base_profile.rb
141
+ - lib/app_profiler/exec.rb
142
142
  - lib/app_profiler/middleware.rb
143
143
  - lib/app_profiler/middleware/base_action.rb
144
144
  - lib/app_profiler/middleware/upload_action.rb
145
145
  - lib/app_profiler/middleware/view_action.rb
146
146
  - lib/app_profiler/parameters.rb
147
- - lib/app_profiler/profile.rb
148
- - lib/app_profiler/profile/stackprof.rb
149
- - lib/app_profiler/profile/vernier.rb
150
147
  - lib/app_profiler/railtie.rb
151
148
  - lib/app_profiler/request_parameters.rb
152
149
  - lib/app_profiler/sampler.rb
@@ -154,22 +151,27 @@ files:
154
151
  - lib/app_profiler/sampler/stackprof_config.rb
155
152
  - lib/app_profiler/sampler/vernier_config.rb
156
153
  - lib/app_profiler/server.rb
154
+ - lib/app_profiler/stackprof_profile.rb
157
155
  - lib/app_profiler/storage/base_storage.rb
158
156
  - lib/app_profiler/storage/file_storage.rb
159
157
  - lib/app_profiler/storage/google_cloud_storage.rb
158
+ - lib/app_profiler/vernier_profile.rb
160
159
  - lib/app_profiler/version.rb
160
+ - lib/app_profiler/viewer/base_middleware.rb
161
161
  - lib/app_profiler/viewer/base_viewer.rb
162
+ - lib/app_profiler/viewer/firefox_remote_viewer.rb
163
+ - lib/app_profiler/viewer/firefox_remote_viewer/middleware.rb
164
+ - lib/app_profiler/viewer/firefox_viewer.rb
162
165
  - lib/app_profiler/viewer/speedscope_remote_viewer.rb
163
- - lib/app_profiler/viewer/speedscope_remote_viewer/base_middleware.rb
164
166
  - lib/app_profiler/viewer/speedscope_remote_viewer/middleware.rb
165
167
  - lib/app_profiler/viewer/speedscope_viewer.rb
166
168
  - lib/app_profiler/yarn/command.rb
169
+ - lib/app_profiler/yarn/with_firefox_profiler.rb
167
170
  - lib/app_profiler/yarn/with_speedscope.rb
168
171
  homepage: https://github.com/Shopify/app_profiler
169
172
  licenses: []
170
173
  metadata:
171
174
  allowed_push_host: https://rubygems.org
172
- post_install_message:
173
175
  rdoc_options: []
174
176
  require_paths:
175
177
  - lib
@@ -184,8 +186,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
184
186
  - !ruby/object:Gem::Version
185
187
  version: '0'
186
188
  requirements: []
187
- rubygems_version: 3.5.21
188
- signing_key:
189
+ rubygems_version: 3.6.2
189
190
  specification_version: 4
190
191
  summary: Collect performance profiles for your Rails application.
191
192
  test_files: []
@@ -1,142 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- gem "rails-html-sanitizer", ">= 1.6.0"
4
- require "rails-html-sanitizer"
5
-
6
- module AppProfiler
7
- module Viewer
8
- class SpeedscopeRemoteViewer < BaseViewer
9
- class BaseMiddleware
10
- class Sanitizer < Rails::HTML::Sanitizer.best_supported_vendor.safe_list_sanitizer
11
- self.allowed_tags = Set.new([
12
- "strong",
13
- "em",
14
- "b",
15
- "i",
16
- "p",
17
- "code",
18
- "pre",
19
- "tt",
20
- "samp",
21
- "kbd",
22
- "var",
23
- "sub",
24
- "sup",
25
- "dfn",
26
- "cite",
27
- "big",
28
- "small",
29
- "address",
30
- "hr",
31
- "br",
32
- "div",
33
- "span",
34
- "h1",
35
- "h2",
36
- "h3",
37
- "h4",
38
- "h5",
39
- "h6",
40
- "ul",
41
- "ol",
42
- "li",
43
- "dl",
44
- "dt",
45
- "dd",
46
- "abbr",
47
- "acronym",
48
- "a",
49
- "img",
50
- "blockquote",
51
- "del",
52
- "ins",
53
- "script",
54
- ])
55
- end
56
-
57
- private_constant(:Sanitizer)
58
-
59
- class << self
60
- def id(file)
61
- file.basename.to_s.delete_suffix(".json")
62
- end
63
- end
64
-
65
- def initialize(app)
66
- @app = app
67
- end
68
-
69
- def call(env)
70
- request = Rack::Request.new(env)
71
-
72
- return index(env) if request.path_info =~ %r(\A/app_profiler/?\z)
73
- return viewer(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/viewer/(.*)\z)
74
- return show(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/(.*)\z)
75
-
76
- @app.call(env)
77
- end
78
-
79
- protected
80
-
81
- def id(file)
82
- self.class.id(file)
83
- end
84
-
85
- def profile_files
86
- AppProfiler.profile_root.glob("**/*.json")
87
- end
88
-
89
- def render(html)
90
- [
91
- 200,
92
- { "Content-Type" => "text/html" },
93
- [
94
- +<<~HTML,
95
- <!doctype html>
96
- <html>
97
- <head>
98
- <title>App Profiler</title>
99
- </head>
100
- <body>
101
- #{sanitizer.sanitize(html)}
102
- </body>
103
- </html>
104
- HTML
105
- ],
106
- ]
107
- end
108
-
109
- def sanitizer
110
- @sanitizer ||= Sanitizer.new
111
- end
112
-
113
- def viewer(_env, path)
114
- raise NotImplementedError
115
- end
116
-
117
- def index(_env)
118
- render(
119
- (+"").tap do |content|
120
- content << "<h1>Profiles</h1>"
121
- profile_files.each do |file|
122
- content << <<~HTML
123
- <p>
124
- <a href="/app_profiler/#{id(file)}">
125
- #{id(file)}
126
- </a>
127
- </p>
128
- HTML
129
- end
130
- end,
131
- )
132
- end
133
-
134
- def show(env, id)
135
- raise NotImplementedError
136
- end
137
- end
138
-
139
- private_constant(:BaseMiddleware)
140
- end
141
- end
142
- end