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