app_profiler 0.0.8 → 0.1.1

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: 4342f9415d422d77b172d358f968f9bc6c0ae670364e2ed351c7d1daf7cfcd3f
4
- data.tar.gz: 4868b701483cc23fef7dad0432437afe61a65ff4ff5bcd752eec37636f4eeddb
3
+ metadata.gz: 340bc83668cbe8abd27780ef23456a6d98f1f8c46059f2b447a25f1e6784483e
4
+ data.tar.gz: 06fcbd4abe95e53f8e37595df7ca445bcd59f61cd9ba24af8c82578cdb6bb9c4
5
5
  SHA512:
6
- metadata.gz: ab3d7f1677cc9f3a645ca23cf5fc2b3125cd498cbaace7640e31aad8b45e8f9c23bda135bf470b105e723cafeb1c0456149618c3268cc9ea5674fe2427b00fe0
7
- data.tar.gz: 12189f7de4e869d34939d9c3cdbeeb94d257de0f31c232f2deff2011a879fadc8aaccfde44f9780eec6bd863375f9a098f3c571ed53bc65c9e9825f83be6d96a
6
+ metadata.gz: 04cf486ef95efd05af19fd241f05b45c0a942c1e701295181fdef898f59cb24ace85c86e047ea63359b5c5ddfb2f8a233afbc3958004a823e0ffcf0daf692830
7
+ data.tar.gz: bab5ddd1f1dcfb4d06e23b575341a7d7a783e04191b62a74dd553cf78d5726187500931e34c49ebe5e019d60602ceec074d4071d024008c05c8bfe5e5ddb7d52
@@ -21,28 +21,20 @@ module AppProfiler
21
21
  def append_headers(response, upload:, autoredirect:)
22
22
  return unless upload
23
23
 
24
- response[1][profile_header] = profile_url(upload)
24
+ response[1][profile_header] = AppProfiler.profile_url(upload)
25
25
  response[1][profile_data_header] = profile_data_url(upload)
26
26
 
27
27
  return unless autoredirect
28
28
 
29
29
  # Automatically redirect to profile if autoredirect is true.
30
30
  if response[0].to_i < 500
31
- response[1]["Location"] = profile_url(upload)
31
+ response[1]["Location"] = AppProfiler.profile_url(upload)
32
32
  response[0] = 303
33
33
  end
34
34
  end
35
35
 
36
- def profile_url(upload)
37
- if AppProfiler.profile_url_formatter.nil?
38
- "#{AppProfiler.speedscope_host}#profileURL=#{upload.url}"
39
- else
40
- AppProfiler.profile_url_formatter.call(upload)
41
- end
42
- end
43
-
44
36
  def profile_data_url(upload)
45
- upload.url
37
+ upload.url.to_s
46
38
  end
47
39
 
48
40
  def profile_header
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AppProfiler
4
4
  class Profile
5
- INTERNAL_METADATA_KEYS = %i(id context)
5
+ INTERNAL_METADATA_KEYS = [:id, :context]
6
6
  private_constant :INTERNAL_METADATA_KEYS
7
7
  class UnsafeFilename < StandardError; end
8
8
 
@@ -40,7 +40,13 @@ module AppProfiler
40
40
  def upload
41
41
  AppProfiler.storage.upload(self).tap do |upload|
42
42
  if upload && defined?(upload.url)
43
- AppProfiler.logger.info("[Profiler] uploaded profile profile_url=#{upload.url}")
43
+ AppProfiler.logger.info(
44
+ <<~INFO.squish
45
+ [Profiler] data uploaded:
46
+ profile_url=#{upload.url}
47
+ profile_viewer_url=#{AppProfiler.profile_url(upload)}
48
+ INFO
49
+ )
44
50
  end
45
51
  end
46
52
  rescue => error
@@ -71,7 +77,7 @@ module AppProfiler
71
77
  Socket.gethostname,
72
78
  ].compact.join("-") << ".json"
73
79
 
74
- raise UnsafeFilename if /[^0-9A-Za-z.\-\_]/.match(filename)
80
+ raise UnsafeFilename if /[^0-9A-Za-z.\-\_]/.match?(filename)
75
81
 
76
82
  AppProfiler.profile_root.join(filename)
77
83
  end
@@ -16,6 +16,12 @@ module AppProfiler
16
16
  AppProfiler.middleware = app.config.app_profiler.middleware || Middleware
17
17
  AppProfiler.middleware.action = app.config.app_profiler.middleware_action || default_middleware_action
18
18
  AppProfiler.middleware.disabled = app.config.app_profiler.middleware_disabled || false
19
+ AppProfiler.server.enabled = app.config.app_profiler.server_enabled || false
20
+ AppProfiler.server.transport = app.config.app_profiler.server_transport || default_appprofiler_transport
21
+ AppProfiler.server.port = app.config.app_profiler.server_port || 0
22
+ AppProfiler.server.duration = app.config.app_profiler.server_duration || 30
23
+ AppProfiler.server.cors = app.config.app_profiler.server_cors || true
24
+ AppProfiler.server.cors_host = app.config.app_profiler.server_cors_host || "*"
19
25
  AppProfiler.autoredirect = app.config.app_profiler.autoredirect || false
20
26
  AppProfiler.speedscope_host = app.config.app_profiler.speedscope_host || ENV.fetch(
21
27
  "APP_PROFILER_SPEEDSCOPE_URL", "https://speedscope.app"
@@ -30,10 +36,22 @@ module AppProfiler
30
36
 
31
37
  initializer "app_profiler.add_middleware" do |app|
32
38
  unless AppProfiler.middleware.disabled
39
+ if AppProfiler.viewer == Viewer::SpeedscopeRemoteViewer
40
+ app.middleware.insert_before(0, Viewer::SpeedscopeRemoteViewer::Middleware)
41
+ end
33
42
  app.middleware.insert_before(0, AppProfiler.middleware)
34
43
  end
35
44
  end
36
45
 
46
+ initializer "app_profiler.enable_server" do
47
+ if AppProfiler.server.enabled
48
+ AppProfiler::Server.start
49
+ ActiveSupport::ForkTracker.after_fork do
50
+ AppProfiler::Server.start
51
+ end
52
+ end
53
+ end
54
+
37
55
  private
38
56
 
39
57
  def default_middleware_action
@@ -43,5 +61,15 @@ module AppProfiler
43
61
  Middleware::UploadAction
44
62
  end
45
63
  end
64
+
65
+ def default_appprofiler_transport
66
+ if Rails.env.development?
67
+ # default to TCP server in development so that if wanted users are able to target
68
+ # the server with speedscope
69
+ AppProfiler::Server::TRANSPORT_TCP
70
+ else
71
+ AppProfiler::Server::TRANSPORT_UNIX
72
+ end
73
+ end
46
74
  end
47
75
  end
@@ -37,6 +37,7 @@ module AppProfiler
37
37
  {
38
38
  mode: mode.to_sym,
39
39
  interval: interval.to_i,
40
+ ignore_gc: !!ignore_gc,
40
41
  metadata: {
41
42
  id: request_id,
42
43
  context: context,
@@ -50,6 +51,10 @@ module AppProfiler
50
51
  query_param("profile") || profile_header_param("mode")
51
52
  end
52
53
 
54
+ def ignore_gc
55
+ query_param("ignore_gc") || profile_header_param("ignore_gc")
56
+ end
57
+
53
58
  def interval
54
59
  query_param("interval") || profile_header_param("interval") || DEFAULT_INTERVALS[mode]
55
60
  end
@@ -76,6 +81,7 @@ module AppProfiler
76
81
 
77
82
  def header(name)
78
83
  return unless @request.has_header?(name)
84
+
79
85
  @request.get_header(name)
80
86
  end
81
87
 
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "rack"
5
+ require "tempfile"
6
+
7
+ # This module provides a means to start a golang-inspired profile server
8
+ # it is implemented using stdlib and Rack to avoid additional dependencies
9
+
10
+ module AppProfiler
11
+ module Server
12
+ HTTP_OK = 200
13
+ HTTP_BAD_REQUEST = 400
14
+ HTTP_NOT_FOUND = 404
15
+ HTTP_NOT_ALLOWED = 405
16
+ HTTP_CONFLICT = 409
17
+
18
+ TRANSPORT_UNIX = "unix"
19
+ TRANSPORT_TCP = "tcp"
20
+
21
+ DEFAULTS = {
22
+ enabled: false,
23
+ transport: TRANSPORT_UNIX,
24
+ cors: true,
25
+ cors_host: "*",
26
+ port: 0,
27
+ duration: 30,
28
+ }
29
+
30
+ mattr_accessor :enabled, default: DEFAULTS[:enabled]
31
+ mattr_accessor :transport, default: DEFAULTS[:transport]
32
+ mattr_accessor :cors, default: DEFAULTS[:cors]
33
+ mattr_accessor :cors_host, default: DEFAULTS[:cors_host]
34
+ mattr_accessor :port, default: DEFAULTS[:port]
35
+ mattr_accessor :duration, default: DEFAULTS[:duration]
36
+
37
+ class ProfileApplication
38
+ class InvalidProfileArgsError < StandardError; end
39
+
40
+ def initialize
41
+ @semaphore = Thread::Mutex.new
42
+ @profile_running = false
43
+ end
44
+
45
+ def call(env)
46
+ handle(Rack::Request.new(env)).finish
47
+ end
48
+
49
+ private
50
+
51
+ def handle(request)
52
+ response = Rack::Response.new
53
+ if request.request_method != "GET"
54
+ response.status = HTTP_NOT_ALLOWED
55
+ response.write("Only GET requests are supported")
56
+ return response
57
+ end
58
+ case request.path
59
+ when "/profile"
60
+ begin
61
+ stackprof_args, duration = validate_profile_params(request.params)
62
+ rescue InvalidProfileArgsError => e
63
+ response.status = HTTP_BAD_REQUEST
64
+ response.write("Invalid argument #{e.message}")
65
+ return response
66
+ end
67
+
68
+ if start_running
69
+ start_time = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
70
+ AppProfiler.start(**stackprof_args)
71
+ sleep(duration)
72
+ profile = AppProfiler.stop
73
+ stop_running
74
+ response.status = HTTP_OK
75
+ response.set_header("Content-Type", "application/json")
76
+ if AppProfiler::Server.cors
77
+ response.set_header("Access-Control-Allow-Origin", AppProfiler::Server.cors_host)
78
+ end
79
+ profile_hash = profile.to_h
80
+ profile_hash["start_time_nsecs"] = start_time # NOTE: this is not part of the stackprof profile spec
81
+ response.write(JSON.dump(profile_hash))
82
+ else
83
+ response.status = HTTP_CONFLICT
84
+ response.write("A profile is already running")
85
+ end
86
+ else
87
+ response.status = HTTP_NOT_FOUND
88
+ response.write("Unsupported endpoint #{request.path}")
89
+ end
90
+ response
91
+ end
92
+
93
+ def validate_profile_params(params)
94
+ params = params.symbolize_keys
95
+ stackprof_args = {}
96
+ begin
97
+ duration = Float(params.key?(:duration) ? params[:duration] : AppProfiler::Server.duration)
98
+ rescue ArgumentError
99
+ raise InvalidProfileArgsError, "invalid duration #{params[:duration]}"
100
+ end
101
+ if params.key?(:mode)
102
+ if ["cpu", "wall", "object"].include?(params[:mode])
103
+ stackprof_args[:mode] = params[:mode].to_sym
104
+ else
105
+ raise InvalidProfileArgsError, "invalid mode #{params[:mode]}"
106
+ end
107
+ end
108
+ if params.key?(:interval)
109
+ stackprof_args[:interval] = params[:interval].to_i
110
+ raise InvalidProfileArgsError, "invalid interval #{params[:interval]}" if stackprof_args[:interval] <= 0
111
+ end
112
+ [stackprof_args, duration]
113
+ end
114
+
115
+ # Prevent multiple concurrent profiles by synchronizing between threads
116
+ def start_running
117
+ @semaphore.synchronize do
118
+ return false if @profile_running
119
+
120
+ @profile_running = true
121
+ end
122
+ end
123
+
124
+ def stop_running
125
+ @semaphore.synchronize { @profile_running = false }
126
+ end
127
+ end
128
+
129
+ # This is a minimal, non-compliant "HTTP" server.
130
+ # It will create an extremely minimal rack environment hash and hand it off
131
+ # to our application to process
132
+ class ProfileServer
133
+ PROFILER_TEMPFILE_PATH = "/tmp/app_profiler" # for tempfile that indicates port in filename or unix sockets
134
+
135
+ class Transport
136
+ attr_reader :socket
137
+
138
+ def client
139
+ raise(NotImplementedError)
140
+ end
141
+
142
+ def stop
143
+ raise(NotImplementedError)
144
+ end
145
+ end
146
+
147
+ class UNIX < Transport
148
+ def initialize
149
+ super
150
+ FileUtils.mkdir_p(PROFILER_TEMPFILE_PATH)
151
+ @socket_file = File.join(PROFILER_TEMPFILE_PATH, "app-profiler-#{Process.pid}.sock")
152
+ File.unlink(@socket_file) if File.exist?(@socket_file) && File.socket?(@socket_file)
153
+ @socket = UNIXServer.new(@socket_file)
154
+ end
155
+
156
+ def client
157
+ UNIXSocket.new(@socket_file)
158
+ end
159
+
160
+ def stop
161
+ File.unlink(@socket_file) if File.exist?(@socket_file) && File.socket?(@socket_file)
162
+ @socket.close
163
+ end
164
+ end
165
+
166
+ class TCP < Transport
167
+ SERVER_ADDRESS = "127.0.0.1" # it is ONLY safe to run this bound to localhost
168
+
169
+ def initialize(port = 0)
170
+ super()
171
+ FileUtils.mkdir_p(PROFILER_TEMPFILE_PATH)
172
+ @socket = TCPServer.new(SERVER_ADDRESS, port)
173
+ @port = @socket.addr[1]
174
+ @port_file = Tempfile.new("profileserver-#{Process.pid}-port-#{@port}-", PROFILER_TEMPFILE_PATH)
175
+ end
176
+
177
+ def client
178
+ TCPSocket.new(SERVER_ADDRESS, @port)
179
+ end
180
+
181
+ def stop
182
+ @port_file.unlink
183
+ @socket.close
184
+ end
185
+ end
186
+
187
+ def initialize(transport)
188
+ case transport
189
+ when TRANSPORT_UNIX
190
+ @transport = ProfileServer::UNIX.new
191
+ when TRANSPORT_TCP
192
+ @transport = ProfileServer::TCP.new(AppProfiler::Server.port)
193
+ else
194
+ raise "invalid transport #{transport}"
195
+ end
196
+
197
+ @listen_thread = nil
198
+
199
+ AppProfiler.logger.info(
200
+ "[AppProfiler::Server] listening on addr=#{@transport.socket.addr}"
201
+ )
202
+ @pid = Process.pid
203
+ at_exit { stop }
204
+ end
205
+
206
+ def client
207
+ @transport.client
208
+ end
209
+
210
+ def serve
211
+ return unless @listen_thread.nil?
212
+
213
+ app = ProfileApplication.new
214
+
215
+ @listen_thread = Thread.new do
216
+ loop do
217
+ Thread.new(@transport.socket.accept) do |session|
218
+ request = session.gets
219
+ if request.nil?
220
+ session.close
221
+ next
222
+ end
223
+ method, path, http_version = request.split(" ")
224
+ path_info, query_string = path.split("?")
225
+ env = { # an extremely minimal rack env hash, just enough to get the job done
226
+ "HTTP_VERSION" => http_version,
227
+ "REQUEST_METHOD" => method,
228
+ "PATH_INFO" => path_info,
229
+ "QUERY_STRING" => query_string,
230
+ "rack.input" => "",
231
+ }
232
+ status, headers, body = app.call(env)
233
+
234
+ begin
235
+ session.print("#{http_version} #{status}\r\n")
236
+ headers.each do |header, value|
237
+ session.print("#{header}: #{value}\r\n")
238
+ end
239
+ session.print("\r\n")
240
+ body.each do |part|
241
+ session.print(part)
242
+ end
243
+ rescue => e
244
+ AppProfiler.logger.error(
245
+ "[AppProfiler::Server] exception #{e} responding to request #{request}: #{e.message}"
246
+ )
247
+ ensure
248
+ session.close
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
254
+
255
+ def stop
256
+ return unless @pid == Process.pid
257
+
258
+ @listen_thread.kill
259
+ @transport.stop
260
+ end
261
+ end
262
+
263
+ private_constant :ProfileApplication, :ProfileServer
264
+
265
+ class << self
266
+ def reset
267
+ profile_servers.clear
268
+ DEFAULTS.each do |config, value|
269
+ class_variable_set(:"@@#{config}", value) # rubocop:disable Style/ClassVars
270
+ end
271
+ end
272
+
273
+ def start
274
+ return if profile_server
275
+
276
+ profile_servers[Process.pid] = ProfileServer.new(AppProfiler::Server.transport)
277
+ profile_server.serve
278
+ profile_server
279
+ end
280
+
281
+ def client
282
+ return unless profile_server
283
+
284
+ profile_server.client
285
+ end
286
+
287
+ def stop
288
+ return unless profile_server
289
+
290
+ profile_server.stop
291
+ profile_servers.delete(Process.pid)
292
+ end
293
+
294
+ private
295
+
296
+ def profile_server
297
+ profile_servers[Process.pid]
298
+ end
299
+
300
+ # It is possible there will be a server pre-fork, this is to distinguish
301
+ # the child server from its parent via PID
302
+ def profile_servers
303
+ @profile_servers ||= {}
304
+ end
305
+ end
306
+ end
307
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppProfiler
4
- VERSION = "0.0.8"
4
+ VERSION = "0.1.1"
5
5
  end
@@ -3,7 +3,13 @@
3
3
  module AppProfiler
4
4
  module Viewer
5
5
  class BaseViewer
6
- def self.view(_profile)
6
+ class << self
7
+ def view(profile)
8
+ new(profile).view
9
+ end
10
+ end
11
+
12
+ def view(_profile)
7
13
  raise NotImplementedError
8
14
  end
9
15
  end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails-html-sanitizer"
4
+
5
+ module AppProfiler
6
+ module Viewer
7
+ class SpeedscopeRemoteViewer < BaseViewer
8
+ class BaseMiddleware
9
+ class Sanitizer < Rails::Html::SafeListSanitizer
10
+ self.allowed_tags = Set.new([
11
+ "strong", "em", "b", "i", "p", "code", "pre", "tt", "samp", "kbd", "var", "sub",
12
+ "sup", "dfn", "cite", "big", "small", "address", "hr", "br", "div", "span", "h1",
13
+ "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "dl", "dt", "dd", "abbr", "acronym",
14
+ "a", "img", "blockquote", "del", "ins", "script",
15
+ ])
16
+ end
17
+
18
+ private_constant(:Sanitizer)
19
+
20
+ def self.id(file)
21
+ file.basename.to_s.delete_suffix(".json")
22
+ end
23
+
24
+ def initialize(app)
25
+ @app = app
26
+ end
27
+
28
+ def call(env)
29
+ request = Rack::Request.new(env)
30
+
31
+ return index(env) if request.path_info =~ %r(\A/app_profiler/?\z)
32
+ return viewer(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/viewer/(.*)\z)
33
+ return show(env, Regexp.last_match(1)) if request.path_info =~ %r(\A/app_profiler/(.*)\z)
34
+
35
+ @app.call(env)
36
+ end
37
+
38
+ protected
39
+
40
+ def id(file)
41
+ self.class.id(file)
42
+ end
43
+
44
+ def profile_files
45
+ AppProfiler.profile_root.glob("**/*.json")
46
+ end
47
+
48
+ def render(html)
49
+ [
50
+ 200,
51
+ { "Content-Type" => "text/html" },
52
+ [
53
+ +<<~HTML,
54
+ <!doctype html>
55
+ <html>
56
+ <head>
57
+ <title>App Profiler</title>
58
+ </head>
59
+ <body>
60
+ #{sanitizer.sanitize(html)}
61
+ </body>
62
+ </html>
63
+ HTML
64
+ ],
65
+ ]
66
+ end
67
+
68
+ def sanitizer
69
+ @sanitizer ||= Sanitizer.new
70
+ end
71
+
72
+ def viewer(_env, path)
73
+ raise NotImplementedError
74
+ end
75
+
76
+ def index(_env)
77
+ render(
78
+ (+"").tap do |content|
79
+ content << "<h1>Profiles</h1>"
80
+ profile_files.each do |file|
81
+ content << <<~HTML
82
+ <p>
83
+ <a href="/app_profiler/#{id(file)}">
84
+ #{id(file)}
85
+ </a>
86
+ </p>
87
+ HTML
88
+ end
89
+ end
90
+ )
91
+ end
92
+
93
+ def show(env, id)
94
+ raise NotImplementedError
95
+ end
96
+ end
97
+
98
+ private_constant(:BaseMiddleware)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,58 @@
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
+ protected
20
+
21
+ attr_reader(:speedscope)
22
+
23
+ def viewer(env, path)
24
+ setup_yarn unless yarn_setup
25
+ env[Rack::PATH_INFO] = path.delete_prefix("/app_profiler")
26
+
27
+ speedscope.call(env)
28
+ end
29
+
30
+ def show(_env, name)
31
+ profile = profile_files.find do |file|
32
+ id(file) == name
33
+ end || raise(ArgumentError)
34
+
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
+ )
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "app_profiler/viewer/speedscope_remote_viewer/base_middleware"
4
+ require "app_profiler/viewer/speedscope_remote_viewer/middleware"
5
+
6
+ module AppProfiler
7
+ module Viewer
8
+ class SpeedscopeRemoteViewer < BaseViewer
9
+ class << self
10
+ def view(profile)
11
+ new(profile).view
12
+ end
13
+ end
14
+
15
+ def initialize(profile)
16
+ super()
17
+ @profile = profile
18
+ end
19
+
20
+ def view
21
+ id = Middleware.id(@profile.file)
22
+ AppProfiler.logger.info("[Profiler] Profile available at /app_profiler/#{id}\n")
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "app_profiler/yarn/command"
4
+ require "app_profiler/yarn/with_speedscope"
5
+
3
6
  module AppProfiler
4
7
  module Viewer
5
8
  class SpeedscopeViewer < BaseViewer
6
- mattr_accessor :yarn_setup, default: false
7
-
8
- class YarnError < StandardError; end
9
+ include Yarn::WithSpeedscope
9
10
 
10
11
  class << self
11
12
  def view(profile)
@@ -19,52 +20,7 @@ module AppProfiler
19
20
  end
20
21
 
21
22
  def view
22
- yarn("run speedscope \"#{@profile.file}\"")
23
- end
24
-
25
- private
26
-
27
- def yarn(command)
28
- setup_yarn unless yarn_setup
29
- exec("yarn #{command}") do
30
- raise YarnError, "Failed to run #{command}."
31
- end
32
- end
33
-
34
- def setup_yarn
35
- ensure_yarn_installed
36
- yarn("init --yes") unless package_json_exists?
37
- # We currently only support this gem in the root Gemfile.
38
- # See https://github.com/Shopify/app_profiler/issues/15
39
- # for more information
40
- yarn("add --dev --ignore-workspace-root-check speedscope") unless speedscope_added?
41
- end
42
-
43
- def ensure_yarn_installed
44
- exec("which yarn > /dev/null") do
45
- raise(
46
- YarnError,
47
- <<~MSG.squish
48
- `yarn` command not found.
49
- Please install `yarn` or make it available in PATH.
50
- MSG
51
- )
52
- end
53
- self.yarn_setup = true
54
- end
55
-
56
- def package_json_exists?
57
- AppProfiler.root.join("package.json").exist?
58
- end
59
-
60
- def speedscope_added?
61
- AppProfiler.root.join("node_modules/speedscope").exist?
62
- end
63
-
64
- def exec(command)
65
- system(command).tap do |return_code|
66
- yield unless return_code
67
- end
23
+ yarn("run", "speedscope", @profile.file.to_s)
68
24
  end
69
25
  end
70
26
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AppProfiler
4
+ module Yarn
5
+ module Command
6
+ class YarnError < StandardError; end
7
+
8
+ VALID_COMMANDS = [
9
+ ["which", "yarn"],
10
+ ["yarn", "init", "--yes"],
11
+ ["yarn", "add", "speedscope", "--dev", "--ignore-workspace-root-check"],
12
+ ["yarn", "run", "speedscope", /.*\.json/],
13
+ ]
14
+
15
+ private_constant(:VALID_COMMANDS)
16
+ mattr_accessor(:yarn_setup, default: false)
17
+
18
+ def yarn(command, *options)
19
+ setup_yarn unless yarn_setup
20
+
21
+ exec("yarn", command, *options) do
22
+ raise YarnError, "Failed to run #{command}."
23
+ end
24
+ end
25
+
26
+ def setup_yarn
27
+ ensure_yarn_installed
28
+
29
+ yarn("init", "--yes") unless package_json_exists?
30
+ end
31
+
32
+ private
33
+
34
+ def ensure_command_valid(command)
35
+ unless valid_command?(command)
36
+ raise YarnError, "Illegal command: #{command.join(" ")}."
37
+ end
38
+ end
39
+
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
+ end
47
+
48
+ def ensure_yarn_installed
49
+ exec("which", "yarn", silent: true) do
50
+ raise(
51
+ YarnError,
52
+ <<~MSG.squish
53
+ `yarn` command not found.
54
+ Please install `yarn` or make it available in PATH.
55
+ MSG
56
+ )
57
+ end
58
+ self.yarn_setup = true
59
+ end
60
+
61
+ def package_json_exists?
62
+ AppProfiler.root.join("package.json").exist?
63
+ 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
+ end
75
+ end
76
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AppProfiler
4
+ module Yarn
5
+ module WithSpeedscope
6
+ include Command
7
+
8
+ def setup_yarn
9
+ super
10
+ # We currently only support this gem in the root Gemfile.
11
+ # See https://github.com/Shopify/app_profiler/issues/15
12
+ # for more information
13
+ yarn("add", "speedscope", "--dev", "--ignore-workspace-root-check") unless speedscope_added?
14
+ end
15
+
16
+ private
17
+
18
+ def speedscope_added?
19
+ AppProfiler.root.join("node_modules/speedscope").exist?
20
+ end
21
+ end
22
+ end
23
+ end
data/lib/app_profiler.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support"
3
+ require "active_support/core_ext/class"
4
+ require "active_support/core_ext/module"
5
+ require "logger"
4
6
  require "app_profiler/version"
5
7
  require "app_profiler/railtie" if defined?(Rails::Railtie)
6
8
 
@@ -8,6 +10,10 @@ module AppProfiler
8
10
  class ConfigurationError < StandardError
9
11
  end
10
12
 
13
+ DefaultProfileFormatter = proc do |upload|
14
+ "#{AppProfiler.speedscope_host}#profileURL=#{upload.url}"
15
+ end
16
+
11
17
  module Storage
12
18
  autoload :BaseStorage, "app_profiler/storage/base_storage"
13
19
  autoload :FileStorage, "app_profiler/storage/file_storage"
@@ -17,12 +23,14 @@ module AppProfiler
17
23
  module Viewer
18
24
  autoload :BaseViewer, "app_profiler/viewer/base_viewer"
19
25
  autoload :SpeedscopeViewer, "app_profiler/viewer/speedscope_viewer"
26
+ autoload :SpeedscopeRemoteViewer, "app_profiler/viewer/speedscope_remote_viewer"
20
27
  end
21
28
 
22
- autoload :Middleware, "app_profiler/middleware"
23
- autoload :RequestParameters, "app_profiler/request_parameters"
24
- autoload :Profiler, "app_profiler/profiler"
25
- autoload :Profile, "app_profiler/profile"
29
+ require "app_profiler/middleware"
30
+ require "app_profiler/request_parameters"
31
+ require "app_profiler/profiler"
32
+ require "app_profiler/profile"
33
+ require "app_profiler/server"
26
34
 
27
35
  mattr_accessor :logger, default: Logger.new($stdout)
28
36
  mattr_accessor :root
@@ -32,11 +40,13 @@ module AppProfiler
32
40
  mattr_accessor :autoredirect, default: false
33
41
  mattr_reader :profile_header, default: "X-Profile"
34
42
  mattr_accessor :context, default: nil
35
- mattr_reader :profile_url_formatter, default: nil
43
+ mattr_reader :profile_url_formatter,
44
+ default: DefaultProfileFormatter
36
45
 
37
46
  mattr_accessor :storage, default: Storage::FileStorage
38
47
  mattr_accessor :viewer, default: Viewer::SpeedscopeViewer
39
48
  mattr_accessor :middleware, default: Middleware
49
+ mattr_accessor :server, default: Server
40
50
 
41
51
  class << self
42
52
  def run(*args, &block)
@@ -59,9 +69,7 @@ module AppProfiler
59
69
  end
60
70
 
61
71
  def request_profile_header
62
- @@request_profile_header ||= begin # rubocop:disable Style/ClassVars
63
- profile_header.upcase.gsub("-", "_").prepend("HTTP_")
64
- end
72
+ @@request_profile_header ||= profile_header.upcase.tr("-", "_").prepend("HTTP_") # rubocop:disable Style/ClassVars
65
73
  end
66
74
 
67
75
  def profile_data_header
@@ -71,5 +79,9 @@ module AppProfiler
71
79
  def profile_url_formatter=(block)
72
80
  @@profile_url_formatter = block # rubocop:disable Style/ClassVars
73
81
  end
82
+
83
+ def profile_url(upload)
84
+ AppProfiler.profile_url_formatter.call(upload)
85
+ end
74
86
  end
75
87
  end
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.0.8
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gannon McGibbon
@@ -13,7 +13,7 @@ authors:
13
13
  autorequire:
14
14
  bindir: bin
15
15
  cert_chain: []
16
- date: 2021-06-09 00:00:00.000000000 Z
16
+ date: 2022-06-16 00:00:00.000000000 Z
17
17
  dependencies:
18
18
  - !ruby/object:Gem::Dependency
19
19
  name: activesupport
@@ -30,33 +30,33 @@ dependencies:
30
30
  - !ruby/object:Gem::Version
31
31
  version: '5.2'
32
32
  - !ruby/object:Gem::Dependency
33
- name: railties
33
+ name: rack
34
34
  requirement: !ruby/object:Gem::Requirement
35
35
  requirements:
36
36
  - - ">="
37
37
  - !ruby/object:Gem::Version
38
- version: '5.2'
38
+ version: '0'
39
39
  type: :runtime
40
40
  prerelease: false
41
41
  version_requirements: !ruby/object:Gem::Requirement
42
42
  requirements:
43
43
  - - ">="
44
44
  - !ruby/object:Gem::Version
45
- version: '5.2'
45
+ version: '0'
46
46
  - !ruby/object:Gem::Dependency
47
- name: rack
47
+ name: railties
48
48
  requirement: !ruby/object:Gem::Requirement
49
49
  requirements:
50
50
  - - ">="
51
51
  - !ruby/object:Gem::Version
52
- version: '0'
52
+ version: '5.2'
53
53
  type: :runtime
54
54
  prerelease: false
55
55
  version_requirements: !ruby/object:Gem::Requirement
56
56
  requirements:
57
57
  - - ">="
58
58
  - !ruby/object:Gem::Version
59
- version: '0'
59
+ version: '5.2'
60
60
  - !ruby/object:Gem::Dependency
61
61
  name: stackprof
62
62
  requirement: !ruby/object:Gem::Requirement
@@ -86,7 +86,7 @@ dependencies:
86
86
  - !ruby/object:Gem::Version
87
87
  version: '0'
88
88
  - !ruby/object:Gem::Dependency
89
- name: rake
89
+ name: minitest
90
90
  requirement: !ruby/object:Gem::Requirement
91
91
  requirements:
92
92
  - - ">="
@@ -100,19 +100,19 @@ dependencies:
100
100
  - !ruby/object:Gem::Version
101
101
  version: '0'
102
102
  - !ruby/object:Gem::Dependency
103
- name: minitest
103
+ name: minitest-stub-const
104
104
  requirement: !ruby/object:Gem::Requirement
105
105
  requirements:
106
- - - ">="
106
+ - - '='
107
107
  - !ruby/object:Gem::Version
108
- version: '0'
108
+ version: '0.6'
109
109
  type: :development
110
110
  prerelease: false
111
111
  version_requirements: !ruby/object:Gem::Requirement
112
112
  requirements:
113
- - - ">="
113
+ - - '='
114
114
  - !ruby/object:Gem::Version
115
- version: '0'
115
+ version: '0.6'
116
116
  - !ruby/object:Gem::Dependency
117
117
  name: mocha
118
118
  requirement: !ruby/object:Gem::Requirement
@@ -128,19 +128,19 @@ dependencies:
128
128
  - !ruby/object:Gem::Version
129
129
  version: '0'
130
130
  - !ruby/object:Gem::Dependency
131
- name: minitest-stub-const
131
+ name: rake
132
132
  requirement: !ruby/object:Gem::Requirement
133
133
  requirements:
134
- - - '='
134
+ - - ">="
135
135
  - !ruby/object:Gem::Version
136
- version: '0.6'
136
+ version: '0'
137
137
  type: :development
138
138
  prerelease: false
139
139
  version_requirements: !ruby/object:Gem::Requirement
140
140
  requirements:
141
- - - '='
141
+ - - ">="
142
142
  - !ruby/object:Gem::Version
143
- version: '0.6'
143
+ version: '0'
144
144
  description:
145
145
  email:
146
146
  - gems@shopify.com
@@ -157,12 +157,18 @@ files:
157
157
  - lib/app_profiler/profiler.rb
158
158
  - lib/app_profiler/railtie.rb
159
159
  - lib/app_profiler/request_parameters.rb
160
+ - lib/app_profiler/server.rb
160
161
  - lib/app_profiler/storage/base_storage.rb
161
162
  - lib/app_profiler/storage/file_storage.rb
162
163
  - lib/app_profiler/storage/google_cloud_storage.rb
163
164
  - lib/app_profiler/version.rb
164
165
  - lib/app_profiler/viewer/base_viewer.rb
166
+ - lib/app_profiler/viewer/speedscope_remote_viewer.rb
167
+ - lib/app_profiler/viewer/speedscope_remote_viewer/base_middleware.rb
168
+ - lib/app_profiler/viewer/speedscope_remote_viewer/middleware.rb
165
169
  - lib/app_profiler/viewer/speedscope_viewer.rb
170
+ - lib/app_profiler/yarn/command.rb
171
+ - lib/app_profiler/yarn/with_speedscope.rb
166
172
  homepage: https://github.com/Shopify/app_profiler
167
173
  licenses: []
168
174
  metadata:
@@ -175,14 +181,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
175
181
  requirements:
176
182
  - - ">="
177
183
  - !ruby/object:Gem::Version
178
- version: '0'
184
+ version: '2.7'
179
185
  required_rubygems_version: !ruby/object:Gem::Requirement
180
186
  requirements:
181
187
  - - ">="
182
188
  - !ruby/object:Gem::Version
183
189
  version: '0'
184
190
  requirements: []
185
- rubygems_version: 3.2.17
191
+ rubygems_version: 3.3.3
186
192
  signing_key:
187
193
  specification_version: 4
188
194
  summary: Collect performance profiles for your Rails application.