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 +4 -4
- data/lib/app_profiler/middleware/upload_action.rb +3 -11
- data/lib/app_profiler/profile.rb +9 -3
- data/lib/app_profiler/railtie.rb +28 -0
- data/lib/app_profiler/request_parameters.rb +6 -0
- data/lib/app_profiler/server.rb +307 -0
- data/lib/app_profiler/version.rb +1 -1
- data/lib/app_profiler/viewer/base_viewer.rb +7 -1
- data/lib/app_profiler/viewer/speedscope_remote_viewer/base_middleware.rb +101 -0
- data/lib/app_profiler/viewer/speedscope_remote_viewer/middleware.rb +58 -0
- data/lib/app_profiler/viewer/speedscope_remote_viewer.rb +26 -0
- data/lib/app_profiler/viewer/speedscope_viewer.rb +5 -49
- data/lib/app_profiler/yarn/command.rb +76 -0
- data/lib/app_profiler/yarn/with_speedscope.rb +23 -0
- data/lib/app_profiler.rb +21 -9
- metadata +27 -21
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 340bc83668cbe8abd27780ef23456a6d98f1f8c46059f2b447a25f1e6784483e
|
4
|
+
data.tar.gz: 06fcbd4abe95e53f8e37595df7ca445bcd59f61cd9ba24af8c82578cdb6bb9c4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
data/lib/app_profiler/profile.rb
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
module AppProfiler
|
4
4
|
class Profile
|
5
|
-
INTERNAL_METADATA_KEYS =
|
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(
|
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
|
data/lib/app_profiler/railtie.rb
CHANGED
@@ -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
|
data/lib/app_profiler/version.rb
CHANGED
@@ -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
|
-
|
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
|
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
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
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,
|
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 ||=
|
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.
|
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:
|
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:
|
33
|
+
name: rack
|
34
34
|
requirement: !ruby/object:Gem::Requirement
|
35
35
|
requirements:
|
36
36
|
- - ">="
|
37
37
|
- !ruby/object:Gem::Version
|
38
|
-
version: '
|
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: '
|
45
|
+
version: '0'
|
46
46
|
- !ruby/object:Gem::Dependency
|
47
|
-
name:
|
47
|
+
name: railties
|
48
48
|
requirement: !ruby/object:Gem::Requirement
|
49
49
|
requirements:
|
50
50
|
- - ">="
|
51
51
|
- !ruby/object:Gem::Version
|
52
|
-
version: '
|
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: '
|
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:
|
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:
|
131
|
+
name: rake
|
132
132
|
requirement: !ruby/object:Gem::Requirement
|
133
133
|
requirements:
|
134
|
-
- -
|
134
|
+
- - ">="
|
135
135
|
- !ruby/object:Gem::Version
|
136
|
-
version: '0
|
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
|
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: '
|
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.
|
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.
|