app_profiler 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d37c7a09664237b5de220c0eeb2e06e5d71e962c475d8b5d48379908f1fcde6
4
- data.tar.gz: de727300e3a86062ba2924aee795553098bd5f0fa1b03bf9b422d17caa99b90b
3
+ metadata.gz: b5be705606d138c4e042f4d87820e97413be62f3eeeb92cbb34d5e02aac600ea
4
+ data.tar.gz: d15e16f16c53f939cb874ab841b60528f4b46b5359e755ba5ce6acfe01265b3e
5
5
  SHA512:
6
- metadata.gz: 67bc3d4a7da8dd12063cae5d82c8cb8643133b36cbe42456dd903b1182b4b2c6687073cddcdcb33b84cc76a27ce7d8a2ea3fc135f6f462591bc90e02fd87cb62
7
- data.tar.gz: 7efb402266de2269237d9d170d94d7689146a94e1a721488f462638ca18a0d8c4335e3a3c68452791a554067214429b9ce7903dfb5cb8fea293607db8bb07974
6
+ metadata.gz: ccbe4b8fb2b5a4a1882fb7e33e03f35b7f91afa2a8eeb836e705bae104131e05b1d48d6c7d118cd1c69665e9736c2d9d621a1b05626ba112d63299615de609b1
7
+ data.tar.gz: ff0cb2c2cced5f0edd575d9b6ddb2d6cf83021225d528c4f7e194ed527360bab09a511864e6e5ed047e2e84ddbab737a7060c39b9931ac8fbc976a4489aa128e
@@ -40,7 +40,7 @@ module AppProfiler
40
40
  end
41
41
 
42
42
  def release_run_lock
43
- self.class.run_lock.unlock
43
+ self.class.run_lock.unlock if self.class.run_lock.locked?
44
44
  rescue ThreadError
45
45
  AppProfiler.logger.warn("[AppProfiler] run lock not released as it was never acquired")
46
46
  end
@@ -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
@@ -81,7 +78,7 @@ module AppProfiler
81
78
  end
82
79
 
83
80
  def metadata
84
- @data[:metadata]
81
+ raise NotImplementedError
85
82
  end
86
83
 
87
84
  def mode
@@ -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,15 +2,16 @@
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
8
- attr_reader :sample_rate, :targets, :cpu_interval, :backends_probability
9
+ attr_reader :sample_rate, :targets, :exclude_targets, :cpu_interval, :backends_probability
9
10
 
10
11
  SAMPLE_RATE = 0.001 # 0.1%
11
12
  TARGETS = ["/"]
12
13
  BACKEND_PROBABILITES = { stackprof: 1.0, vernier: 0.0 }
13
- @backends = {}
14
+ EMPTY_ARRAY = []
14
15
 
15
16
  def initialize(sample_rate: SAMPLE_RATE,
16
17
  targets: TARGETS,
@@ -18,7 +19,8 @@ module AppProfiler
18
19
  backends_config: {
19
20
  stackprof: StackprofConfig.new,
20
21
  },
21
- paths: nil)
22
+ paths: nil,
23
+ exclude_targets: EMPTY_ARRAY)
22
24
 
23
25
  if sample_rate < 0.0 || sample_rate > 1.0
24
26
  raise ArgumentError, "sample_rate must be between 0 and 1"
@@ -26,12 +28,13 @@ module AppProfiler
26
28
 
27
29
  raise ArgumentError, "mode probabilities must sum to 1" unless backends_probability.values.sum == 1.0
28
30
 
29
- 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
30
32
 
31
33
  @sample_rate = sample_rate
32
34
  @targets = paths || targets
33
35
  @backends_config = backends_config
34
36
  @backends_probability = backends_probability
37
+ @exclude_targets = exclude_targets
35
38
  end
36
39
 
37
40
  def get_backend_config(backend_name)
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "app_profiler/sampler/config"
4
+
4
5
  module AppProfiler
5
6
  module Sampler
7
+ @excluded_cache = {}
6
8
  class << self
7
9
  def profile_params(request, config)
8
10
  profile_params_for(request.path, config)
@@ -18,6 +20,12 @@ module AppProfiler
18
20
 
19
21
  def sample?(config, target)
20
22
  return false if Kernel.rand > config.sample_rate
23
+ return false if @excluded_cache[target]
24
+
25
+ if config.exclude_targets.any? { |t| target.match?(t) }
26
+ @excluded_cache[target] = true
27
+ return false
28
+ end
21
29
 
22
30
  return false unless config.targets.any? { |t| target.match?(t) }
23
31
 
@@ -2,18 +2,22 @@
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]
9
9
  end
10
10
 
11
+ def metadata
12
+ @data[:metadata]
13
+ end
14
+
11
15
  def format
12
16
  FILE_EXTENSION
13
17
  end
14
18
 
15
19
  def view(params = {})
16
- AppProfiler.viewer.view(self, **params)
20
+ AppProfiler.stackprof_viewer.view(self, **params)
17
21
  end
18
22
  end
19
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,18 +2,22 @@
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]
9
9
  end
10
10
 
11
+ def metadata
12
+ @data[:meta]
13
+ end
14
+
11
15
  def format
12
16
  FILE_EXTENSION
13
17
  end
14
18
 
15
19
  def view(params = {})
16
- raise NotImplementedError
20
+ AppProfiler.vernier_viewer.view(self, **params)
17
21
  end
18
22
  end
19
23
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppProfiler
4
- VERSION = "0.2.4"
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.4
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-09-06 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.18
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