app_profiler 0.0.8 → 0.1.1

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: 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.