rails-profiler 0.31.0 → 0.31.2
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/CHANGELOG.md +139 -0
- data/app/assets/builds/profiler-toolbar.js +13 -6
- data/app/assets/builds/profiler.js +53 -33
- data/app/controllers/profiler/api/ajax_controller.rb +6 -0
- data/app/controllers/profiler/api/env_vars_controller.rb +10 -0
- data/lib/profiler/allocation_counter.rb +21 -0
- data/lib/profiler/cluster/slave_proxy.rb +6 -0
- data/lib/profiler/collectors/base_collector.rb +31 -0
- data/lib/profiler/collectors/cache_collector.rb +5 -0
- data/lib/profiler/collectors/database_collector.rb +5 -0
- data/lib/profiler/collectors/exception_collector.rb +5 -0
- data/lib/profiler/collectors/flamegraph_collector.rb +5 -0
- data/lib/profiler/collectors/http_collector.rb +6 -1
- data/lib/profiler/collectors/log_collector.rb +5 -0
- data/lib/profiler/collectors/request_collector.rb +14 -11
- data/lib/profiler/collectors/view_collector.rb +5 -0
- data/lib/profiler/configuration.rb +33 -7
- data/lib/profiler/console_profiler.rb +3 -18
- data/lib/profiler/env_override_store.rb +54 -43
- data/lib/profiler/job_profiler.rb +3 -16
- data/lib/profiler/mcp/body_formatter.rb +4 -4
- data/lib/profiler/mcp/file_cache.rb +19 -7
- data/lib/profiler/mcp/resources/recent_requests.rb +2 -0
- data/lib/profiler/mcp/tools/delete_env_var.rb +3 -1
- data/lib/profiler/mcp/tools/get_profile_detail.rb +13 -1
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
- data/lib/profiler/mcp/tools/list_env_vars.rb +3 -0
- data/lib/profiler/mcp/tools/reset_all_env_vars.rb +3 -0
- data/lib/profiler/mcp/tools/reset_env_var.rb +3 -0
- data/lib/profiler/mcp/tools/set_env_var.rb +2 -0
- data/lib/profiler/middleware/capturing_body.rb +112 -0
- data/lib/profiler/middleware/profiler_middleware.rb +261 -53
- data/lib/profiler/middleware/streamed_profile.rb +135 -0
- data/lib/profiler/middleware/toolbar_injector.rb +3 -10
- data/lib/profiler/models/profile.rb +69 -5
- data/lib/profiler/redaction.rb +10 -0
- data/lib/profiler/storage/base_store.rb +5 -1
- data/lib/profiler/storage/blob_store.rb +28 -4
- data/lib/profiler/storage/file_store.rb +21 -15
- data/lib/profiler/storage/memory_store.rb +6 -0
- data/lib/profiler/storage/private_files.rb +135 -0
- data/lib/profiler/storage/redis_store.rb +6 -0
- data/lib/profiler/storage/sqlite_store.rb +26 -8
- data/lib/profiler/storage/token.rb +24 -0
- data/lib/profiler/test_profiler.rb +3 -18
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +12 -0
- metadata +10 -5
|
@@ -5,17 +5,21 @@ require "securerandom"
|
|
|
5
5
|
require "json"
|
|
6
6
|
require "zlib"
|
|
7
7
|
require_relative "../redaction"
|
|
8
|
+
require_relative "../allocation_counter"
|
|
8
9
|
|
|
9
10
|
module Profiler
|
|
10
11
|
module Models
|
|
11
12
|
class Profile
|
|
12
13
|
attr_reader :path
|
|
13
|
-
attr_accessor :token, :method, :status, :duration, :
|
|
14
|
+
attr_accessor :token, :method, :status, :duration, :allocated_objects,
|
|
14
15
|
:started_at, :finished_at, :params, :headers,
|
|
15
16
|
:response_headers, :collectors_data, :collectors_metadata,
|
|
16
17
|
:parent_token, :is_ajax, :profile_type,
|
|
17
18
|
:request_body, :request_body_encoding,
|
|
18
19
|
:response_body, :response_body_encoding,
|
|
20
|
+
:request_body_size, :request_body_truncated, :request_body_size_is_minimum,
|
|
21
|
+
:response_body_size, :response_body_truncated, :response_body_size_is_minimum,
|
|
22
|
+
:collectors_released_after_seconds,
|
|
19
23
|
:gem_version
|
|
20
24
|
|
|
21
25
|
def initialize(request = nil)
|
|
@@ -35,13 +39,32 @@ module Profiler
|
|
|
35
39
|
end
|
|
36
40
|
end
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
# The sizes are those of the whole bodies, when a body was cut at max_captured_body_bytes;
|
|
43
|
+
# *_size_is_minimum when the whole size is not known (no Content-Length, a stream that
|
|
44
|
+
# stopped), only that it is at least that.
|
|
45
|
+
def set_bodies(request_body:, response_body:, req_content_type:, resp_content_type:,
|
|
46
|
+
request_body_size: nil, response_body_size: nil,
|
|
47
|
+
request_body_size_is_minimum: false, response_body_size_is_minimum: false)
|
|
39
48
|
req = process_body(request_body, req_content_type)
|
|
40
49
|
resp = process_body(response_body, resp_content_type)
|
|
41
50
|
@request_body = req[:body]
|
|
42
51
|
@request_body_encoding = req[:encoding]
|
|
43
52
|
@response_body = resp[:body]
|
|
44
53
|
@response_body_encoding = resp[:encoding]
|
|
54
|
+
@request_body_size, @request_body_truncated = body_size(request_body, request_body_size)
|
|
55
|
+
@response_body_size, @response_body_truncated = body_size(response_body, response_body_size)
|
|
56
|
+
@request_body_size_is_minimum = request_body_size_is_minimum ? true : false
|
|
57
|
+
@response_body_size_is_minimum = response_body_size_is_minimum ? true : false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Deprecated: the number of allocated objects times 40, the figure earlier versions
|
|
61
|
+
# reported as bytes. Still written in to_h, for the readers that only know it.
|
|
62
|
+
def memory
|
|
63
|
+
@allocated_objects&.*(AllocationCounter::LEGACY_BYTES_PER_OBJECT)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def memory=(value)
|
|
67
|
+
@allocated_objects = value && value / AllocationCounter::LEGACY_BYTES_PER_OBJECT
|
|
45
68
|
end
|
|
46
69
|
|
|
47
70
|
def finish(status, response_headers = {})
|
|
@@ -67,8 +90,20 @@ module Profiler
|
|
|
67
90
|
end
|
|
68
91
|
|
|
69
92
|
def add_collector_metadata(collector)
|
|
93
|
+
@collectors_metadata << collector_metadata(collector)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A collector collected again, after the profile's tabs were listed (a streamed response
|
|
97
|
+
# that failed part way): its tab keeps its place, with what it now has.
|
|
98
|
+
def refresh_collector_metadata(collector)
|
|
99
|
+
entry = collector_metadata(collector)
|
|
100
|
+
index = @collectors_metadata.index { |tab| tab[:key] == entry[:key] }
|
|
101
|
+
index ? @collectors_metadata[index] = entry : @collectors_metadata << entry
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def collector_metadata(collector)
|
|
70
105
|
config = collector.tab_config
|
|
71
|
-
|
|
106
|
+
{
|
|
72
107
|
key: config[:key],
|
|
73
108
|
label: config[:label],
|
|
74
109
|
icon: config[:icon],
|
|
@@ -79,6 +114,7 @@ module Profiler
|
|
|
79
114
|
has_data: collector.has_data?
|
|
80
115
|
}
|
|
81
116
|
end
|
|
117
|
+
private :collector_metadata
|
|
82
118
|
|
|
83
119
|
def to_h
|
|
84
120
|
req_body, req_enc = decode_body(@request_body, @request_body_encoding)
|
|
@@ -92,7 +128,8 @@ module Profiler
|
|
|
92
128
|
method: @method,
|
|
93
129
|
status: @status,
|
|
94
130
|
duration: @duration,
|
|
95
|
-
|
|
131
|
+
allocated_objects: @allocated_objects,
|
|
132
|
+
memory: memory,
|
|
96
133
|
started_at: @started_at&.iso8601,
|
|
97
134
|
finished_at: @finished_at&.iso8601,
|
|
98
135
|
params: @params,
|
|
@@ -102,6 +139,13 @@ module Profiler
|
|
|
102
139
|
request_body_encoding: req_enc,
|
|
103
140
|
response_body: resp_body,
|
|
104
141
|
response_body_encoding: resp_enc,
|
|
142
|
+
request_body_size: @request_body_size,
|
|
143
|
+
request_body_truncated: @request_body_truncated,
|
|
144
|
+
request_body_size_is_minimum: @request_body_size_is_minimum,
|
|
145
|
+
response_body_size: @response_body_size,
|
|
146
|
+
response_body_truncated: @response_body_truncated,
|
|
147
|
+
response_body_size_is_minimum: @response_body_size_is_minimum,
|
|
148
|
+
collectors_released_after_seconds: @collectors_released_after_seconds,
|
|
105
149
|
collectors_data: @collectors_data,
|
|
106
150
|
tabs: @collectors_metadata,
|
|
107
151
|
parent_token: @parent_token,
|
|
@@ -125,7 +169,12 @@ module Profiler
|
|
|
125
169
|
profile.method = data[:method]
|
|
126
170
|
profile.status = data[:status]
|
|
127
171
|
profile.duration = data[:duration]
|
|
128
|
-
profile
|
|
172
|
+
# A profile saved before allocated_objects only carries memory, the count times 40.
|
|
173
|
+
if data.key?(:allocated_objects)
|
|
174
|
+
profile.allocated_objects = data[:allocated_objects]
|
|
175
|
+
else
|
|
176
|
+
profile.memory = data[:memory]
|
|
177
|
+
end
|
|
129
178
|
profile.started_at = data[:started_at] ? Time.parse(data[:started_at]) : nil
|
|
130
179
|
profile.finished_at = data[:finished_at] ? Time.parse(data[:finished_at]) : nil
|
|
131
180
|
profile.params = data[:params]
|
|
@@ -135,6 +184,15 @@ module Profiler
|
|
|
135
184
|
profile.request_body_encoding = data[:request_body_encoding] || "text"
|
|
136
185
|
profile.response_body = data[:response_body]
|
|
137
186
|
profile.response_body_encoding = data[:response_body_encoding] || "text"
|
|
187
|
+
profile.request_body_size = data[:request_body_size]
|
|
188
|
+
# Left nil when the store did not keep them (SqliteStore rebuilds a profile from its
|
|
189
|
+
# columns): the request collector's data still has them.
|
|
190
|
+
profile.request_body_truncated = data[:request_body_truncated]
|
|
191
|
+
profile.request_body_size_is_minimum = data[:request_body_size_is_minimum]
|
|
192
|
+
profile.response_body_size = data[:response_body_size]
|
|
193
|
+
profile.response_body_truncated = data[:response_body_truncated]
|
|
194
|
+
profile.response_body_size_is_minimum = data[:response_body_size_is_minimum]
|
|
195
|
+
profile.collectors_released_after_seconds = data[:collectors_released_after_seconds]
|
|
138
196
|
profile.parent_token = data[:parent_token]
|
|
139
197
|
profile.is_ajax = data[:is_ajax] || false
|
|
140
198
|
profile.profile_type = data[:profile_type] || "http"
|
|
@@ -181,6 +239,12 @@ module Profiler
|
|
|
181
239
|
end
|
|
182
240
|
end
|
|
183
241
|
|
|
242
|
+
def body_size(captured, total)
|
|
243
|
+
captured_size = captured.to_s.bytesize
|
|
244
|
+
total ||= captured_size
|
|
245
|
+
[total, total > captured_size]
|
|
246
|
+
end
|
|
247
|
+
|
|
184
248
|
def compress_body?(text)
|
|
185
249
|
Profiler.configuration.compress_bodies &&
|
|
186
250
|
text.bytesize > Profiler.configuration.compress_body_threshold
|
data/lib/profiler/redaction.rb
CHANGED
|
@@ -88,6 +88,16 @@ module Profiler
|
|
|
88
88
|
text.length > max ? "#{text[0, max]}#{ellipsis}" : text
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# The first +max+ bytes of +text+, a body kept only in part. A credential cut in two by the
|
|
92
|
+
# limit would escape the masking by value, which runs later on what is kept: the bytes
|
|
93
|
+
# that could start one are left out as well. Text no longer than +max+ is returned whole.
|
|
94
|
+
def cut_bytes(text, max)
|
|
95
|
+
return text if text.bytesize <= max
|
|
96
|
+
|
|
97
|
+
cut = text.byteslice(0, max)
|
|
98
|
+
cut.byteslice(0, cut.bytesize - held_back_bytes(cut))
|
|
99
|
+
end
|
|
100
|
+
|
|
91
101
|
# How many trailing bytes of +text+, a stream read in pieces, could be
|
|
92
102
|
# the start of a credential and have to wait for the next piece: the
|
|
93
103
|
# longest end of the text that is also the beginning of a credential,
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "token"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
4
6
|
module Storage
|
|
5
7
|
class BaseStore
|
|
6
8
|
# Public interface: persists the profile then records the save, for the pages showing it
|
|
7
9
|
# to see it changed (Profiler::SSE).
|
|
8
|
-
# Subclasses implement #do_save, not #save.
|
|
10
|
+
# Subclasses implement #do_save, not #save. The token must be one the gem issued
|
|
11
|
+
# (Storage::Token); every other public method answers "not found" for any other token.
|
|
9
12
|
def save(token, profile)
|
|
13
|
+
Token.validate!(token)
|
|
10
14
|
result = do_save(token, profile)
|
|
11
15
|
broadcast_event(token)
|
|
12
16
|
result
|
|
@@ -2,22 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "json"
|
|
5
|
+
require_relative "private_files"
|
|
6
|
+
require_relative "token"
|
|
5
7
|
|
|
6
8
|
module Profiler
|
|
7
9
|
module Storage
|
|
10
|
+
# Large collector payloads of the SQLite store, one directory per profile token and one JSON
|
|
11
|
+
# file per collector. A token or a name that is not one the gem writes is never a path: reads
|
|
12
|
+
# find nothing and writes are refused.
|
|
8
13
|
class BlobStore
|
|
14
|
+
NAME_FORMAT = /\A[a-z0-9_]+\z/
|
|
15
|
+
|
|
9
16
|
def initialize(path)
|
|
10
|
-
@path = path
|
|
11
|
-
|
|
17
|
+
@path = path.to_s
|
|
18
|
+
PrivateFiles.mkdir(@path)
|
|
12
19
|
end
|
|
13
20
|
|
|
14
21
|
def write(token, collector_name, data)
|
|
22
|
+
Token.validate!(token)
|
|
23
|
+
raise ArgumentError, "invalid blob name: #{collector_name.inspect[0, 80]}" unless valid_name?(collector_name)
|
|
24
|
+
|
|
15
25
|
dir = token_dir(token)
|
|
16
|
-
|
|
17
|
-
|
|
26
|
+
PrivateFiles.mkdir(dir)
|
|
27
|
+
PrivateFiles.write(File.join(dir, "#{collector_name}.json"), JSON.generate(data))
|
|
18
28
|
end
|
|
19
29
|
|
|
20
30
|
def read(token, collector_name)
|
|
31
|
+
return nil unless valid?(token, collector_name)
|
|
32
|
+
|
|
21
33
|
file = File.join(token_dir(token), "#{collector_name}.json")
|
|
22
34
|
return nil unless File.exist?(file)
|
|
23
35
|
|
|
@@ -28,16 +40,28 @@ module Profiler
|
|
|
28
40
|
end
|
|
29
41
|
|
|
30
42
|
def delete(token)
|
|
43
|
+
return unless Token.valid?(token)
|
|
44
|
+
|
|
31
45
|
dir = token_dir(token)
|
|
32
46
|
FileUtils.rm_rf(dir) if File.directory?(dir)
|
|
33
47
|
end
|
|
34
48
|
|
|
35
49
|
def exists?(token, collector_name)
|
|
50
|
+
return false unless valid?(token, collector_name)
|
|
51
|
+
|
|
36
52
|
File.exist?(File.join(token_dir(token), "#{collector_name}.json"))
|
|
37
53
|
end
|
|
38
54
|
|
|
39
55
|
private
|
|
40
56
|
|
|
57
|
+
def valid?(token, collector_name)
|
|
58
|
+
Token.valid?(token) && valid_name?(collector_name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def valid_name?(collector_name)
|
|
62
|
+
collector_name.is_a?(String) && NAME_FORMAT.match?(collector_name)
|
|
63
|
+
end
|
|
64
|
+
|
|
41
65
|
def token_dir(token)
|
|
42
66
|
File.join(@path, token)
|
|
43
67
|
end
|
|
@@ -3,28 +3,38 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "json"
|
|
5
5
|
require_relative "base_store"
|
|
6
|
+
require_relative "private_files"
|
|
7
|
+
require_relative "token"
|
|
6
8
|
require_relative "../models/profile"
|
|
7
9
|
|
|
8
10
|
module Profiler
|
|
9
11
|
module Storage
|
|
10
|
-
# File-based profile storage backend. Persists each profile as a JSON file
|
|
11
|
-
# under tmp_path
|
|
12
|
+
# File-based profile storage backend. Persists each profile as a JSON file named after its
|
|
13
|
+
# token, under tmp_path/profiles by default or the directory given as options[:path], and
|
|
14
|
+
# evicts the oldest files when total size exceeds max_size. Only the files named after a
|
|
15
|
+
# token are taken for profiles, so a path shared with other files (tmp_path itself, where the
|
|
16
|
+
# env overrides live) is safe.
|
|
12
17
|
class FileStore < BaseStore
|
|
13
18
|
def initialize(options = {})
|
|
14
19
|
super()
|
|
15
|
-
@path = options[:path] || default_path
|
|
16
20
|
@max_size = options[:max_size] || (100 * 1024 * 1024) # 100 MB
|
|
17
|
-
|
|
21
|
+
if options[:path]
|
|
22
|
+
@path = options[:path].to_s
|
|
23
|
+
PrivateFiles.mkdir(@path)
|
|
24
|
+
else
|
|
25
|
+
@path = PrivateFiles.tmp_dir("profiles").to_s
|
|
26
|
+
end
|
|
18
27
|
end
|
|
19
28
|
|
|
20
29
|
def do_save(token, profile)
|
|
21
|
-
|
|
22
|
-
File.write(file_path, profile.to_json)
|
|
30
|
+
PrivateFiles.write(profile_file_path(token), profile.to_json)
|
|
23
31
|
cleanup_if_needed
|
|
24
32
|
token
|
|
25
33
|
end
|
|
26
34
|
|
|
27
35
|
def load(token)
|
|
36
|
+
return nil unless Token.valid?(token)
|
|
37
|
+
|
|
28
38
|
file_path = profile_file_path(token)
|
|
29
39
|
return nil unless File.exist?(file_path)
|
|
30
40
|
|
|
@@ -55,6 +65,8 @@ module Profiler
|
|
|
55
65
|
end
|
|
56
66
|
|
|
57
67
|
def find_by_parent(parent_token)
|
|
68
|
+
return [] unless Token.valid?(parent_token)
|
|
69
|
+
|
|
58
70
|
profile_files
|
|
59
71
|
.map { |f| load(File.basename(f, ".json")) }
|
|
60
72
|
.compact
|
|
@@ -63,6 +75,8 @@ module Profiler
|
|
|
63
75
|
end
|
|
64
76
|
|
|
65
77
|
def delete(token)
|
|
78
|
+
return unless Token.valid?(token)
|
|
79
|
+
|
|
66
80
|
FileUtils.rm_f(profile_file_path(token))
|
|
67
81
|
end
|
|
68
82
|
|
|
@@ -85,20 +99,12 @@ module Profiler
|
|
|
85
99
|
|
|
86
100
|
private
|
|
87
101
|
|
|
88
|
-
def default_path
|
|
89
|
-
Profiler.configuration.tmp_path
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def ensure_directory_exists
|
|
93
|
-
FileUtils.mkdir_p(@path) unless File.directory?(@path)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
102
|
def profile_file_path(token)
|
|
97
103
|
File.join(@path, "#{token}.json")
|
|
98
104
|
end
|
|
99
105
|
|
|
100
106
|
def profile_files
|
|
101
|
-
Dir.glob(File.join(@path, "*.json"))
|
|
107
|
+
Dir.glob(File.join(@path, "*.json")).select { |f| Token.valid?(File.basename(f, ".json")) }
|
|
102
108
|
end
|
|
103
109
|
|
|
104
110
|
def cleanup_if_needed # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
@@ -19,6 +19,8 @@ module Profiler
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def load(token)
|
|
22
|
+
return nil unless Token.valid?(token)
|
|
23
|
+
|
|
22
24
|
data = @profiles[token]
|
|
23
25
|
return nil unless data
|
|
24
26
|
|
|
@@ -43,6 +45,8 @@ module Profiler
|
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
def find_by_parent(parent_token)
|
|
48
|
+
return [] unless Token.valid?(parent_token)
|
|
49
|
+
|
|
46
50
|
@profiles.values
|
|
47
51
|
.map { |data| deserialize_profile(data) }
|
|
48
52
|
.select { |profile| profile.parent_token == parent_token }
|
|
@@ -50,6 +54,8 @@ module Profiler
|
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
def delete(token)
|
|
57
|
+
return unless Token.valid?(token)
|
|
58
|
+
|
|
53
59
|
@profiles.delete(token)
|
|
54
60
|
end
|
|
55
61
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module Storage
|
|
8
|
+
# Creates the profiler's directories and files readable by the process user only (0700 and
|
|
9
|
+
# 0600), whatever the umask: profiles hold cookies, tokens and environment values. A directory
|
|
10
|
+
# that already exists keeps its mode. config.restrict_storage_permissions = false leaves the
|
|
11
|
+
# modes to the umask, for a web process and workers running under two users.
|
|
12
|
+
module PrivateFiles
|
|
13
|
+
DIR_MODE = 0o700
|
|
14
|
+
FILE_MODE = 0o600
|
|
15
|
+
# Where the platform has it, and while the modes are restricted: a symbolic link placed where
|
|
16
|
+
# the profiler expects its own file is not followed (ELOOP) instead of being written through.
|
|
17
|
+
NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
|
|
18
|
+
|
|
19
|
+
@warned = {}
|
|
20
|
+
@warn_mutex = Mutex.new
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def restricted?
|
|
25
|
+
Profiler.configuration.restrict_storage_permissions ? true : false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Creates the missing parents with the umask's mode, so that the application's own
|
|
29
|
+
# directories (tmp/ for instance) are left as they would be, and the directory itself 0700.
|
|
30
|
+
def mkdir(path)
|
|
31
|
+
path = path.to_s
|
|
32
|
+
return if File.directory?(path)
|
|
33
|
+
return FileUtils.mkdir_p(path) unless restricted?
|
|
34
|
+
|
|
35
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
36
|
+
begin
|
|
37
|
+
Dir.mkdir(path, DIR_MODE)
|
|
38
|
+
File.chmod(DIR_MODE, path)
|
|
39
|
+
rescue Errno::EEXIST
|
|
40
|
+
raise unless File.directory?(path)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A directory under tmp_path, created with tmp_path itself as #mkdir creates a directory.
|
|
45
|
+
def tmp_dir(*parts)
|
|
46
|
+
dir = Profiler.configuration.tmp_path
|
|
47
|
+
mkdir(dir)
|
|
48
|
+
warn_if_shared(dir)
|
|
49
|
+
parts.each do |part|
|
|
50
|
+
dir = dir.join(part)
|
|
51
|
+
mkdir(dir)
|
|
52
|
+
end
|
|
53
|
+
dir
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Writes to a temporary file next to path then renames it over path: a reader never sees a
|
|
57
|
+
# half-written file, and the file has its mode from the start, even when it replaces an
|
|
58
|
+
# older one with a wider mode.
|
|
59
|
+
def write(path, data)
|
|
60
|
+
path = path.to_s
|
|
61
|
+
tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.#{SecureRandom.hex(4)}.tmp")
|
|
62
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, new_file_mode) do |file|
|
|
63
|
+
file.chmod(FILE_MODE) if restricted?
|
|
64
|
+
file.write(data)
|
|
65
|
+
end
|
|
66
|
+
File.rename(tmp, path)
|
|
67
|
+
tmp = nil
|
|
68
|
+
path
|
|
69
|
+
ensure
|
|
70
|
+
FileUtils.rm_f(tmp) if tmp
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Opens path for reading and writing, creating it 0600 when missing.
|
|
74
|
+
def open(path, &block)
|
|
75
|
+
File.open(path.to_s, File::RDWR | File::CREAT | nofollow, new_file_mode, &block)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Creates an empty file 0600 when missing, or brings an existing one back to 0600.
|
|
79
|
+
def touch(path)
|
|
80
|
+
path = path.to_s
|
|
81
|
+
refuse_link(path)
|
|
82
|
+
File.open(path, File::WRONLY | File::CREAT | nofollow, new_file_mode) { |file| file.chmod(FILE_MODE) if restricted? }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Brings an existing file back to 0600; a missing one is left missing.
|
|
86
|
+
def restrict(path)
|
|
87
|
+
path = path.to_s
|
|
88
|
+
refuse_link(path)
|
|
89
|
+
File.chmod(FILE_MODE, path) if restricted? && File.exist?(path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A symbolic link where the profiler keeps a file of its own would make it change the mode of
|
|
93
|
+
# (and write to) whatever the link points at. Refused while the permissions are restricted.
|
|
94
|
+
def refuse_link(path)
|
|
95
|
+
return unless restricted? && File.symlink?(path)
|
|
96
|
+
|
|
97
|
+
raise Profiler::Error, "Refusing to use #{path}: it is a symbolic link, where the profiler expects " \
|
|
98
|
+
"a file of its own. Remove it, or set config.restrict_storage_permissions = false, " \
|
|
99
|
+
"which follows such links and creates the files with the modes of the umask."
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Says once per directory when an existing tmp_path belongs to another user or can be written
|
|
103
|
+
# to by others (tmp/rails-profiler under a shared /tmp, outside Rails): they could place files
|
|
104
|
+
# or links there before the profiler does.
|
|
105
|
+
def warn_if_shared(dir)
|
|
106
|
+
return unless restricted?
|
|
107
|
+
|
|
108
|
+
stat = File.stat(dir.to_s)
|
|
109
|
+
return if stat.uid == Process.euid && (stat.mode & 0o022).zero?
|
|
110
|
+
|
|
111
|
+
key = dir.to_s
|
|
112
|
+
first = @warn_mutex.synchronize { @warned[key] ? false : (@warned[key] = true) }
|
|
113
|
+
return unless first
|
|
114
|
+
|
|
115
|
+
warn "[Profiler] tmp_path #{key} belongs to another user or is writable by group or others " \
|
|
116
|
+
"(mode #{format("%o", stat.mode & 0o777)}): other local users could place files there. Set " \
|
|
117
|
+
"config.tmp_path to a directory of your own, or run chmod 700 on it."
|
|
118
|
+
rescue SystemCallError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def reset_warnings!
|
|
123
|
+
@warn_mutex.synchronize { @warned = {} }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def nofollow
|
|
127
|
+
restricted? ? NOFOLLOW : 0
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def new_file_mode
|
|
131
|
+
restricted? ? FILE_MODE : 0o666
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -29,6 +29,8 @@ module Profiler
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def load(token)
|
|
32
|
+
return nil unless Token.valid?(token)
|
|
33
|
+
|
|
32
34
|
key = profile_key(token)
|
|
33
35
|
json_data = @redis.get(key)
|
|
34
36
|
return nil unless json_data
|
|
@@ -50,6 +52,8 @@ module Profiler
|
|
|
50
52
|
end
|
|
51
53
|
|
|
52
54
|
def find_by_parent(parent_token)
|
|
55
|
+
return [] unless Token.valid?(parent_token)
|
|
56
|
+
|
|
53
57
|
# Get all tokens from the sorted set
|
|
54
58
|
tokens = @redis.zrange(list_key, 0, -1)
|
|
55
59
|
|
|
@@ -61,6 +65,8 @@ module Profiler
|
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
def delete(token)
|
|
68
|
+
return unless Token.valid?(token)
|
|
69
|
+
|
|
64
70
|
@redis.del(profile_key(token))
|
|
65
71
|
@redis.zrem(list_key, token)
|
|
66
72
|
end
|
|
@@ -4,6 +4,8 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require_relative "base_store"
|
|
6
6
|
require_relative "blob_store"
|
|
7
|
+
require_relative "private_files"
|
|
8
|
+
require_relative "token"
|
|
7
9
|
require_relative "../models/profile"
|
|
8
10
|
|
|
9
11
|
module Profiler
|
|
@@ -12,12 +14,12 @@ module Profiler
|
|
|
12
14
|
def initialize(options = {})
|
|
13
15
|
require "sqlite3"
|
|
14
16
|
|
|
15
|
-
db_path = options[:database] || default_db_path
|
|
16
|
-
blob_path = options[:blob_path] ||
|
|
17
|
+
db_path = (options[:database] || default_db_path).to_s
|
|
18
|
+
blob_path = options[:blob_path] || PrivateFiles.tmp_dir("blobs")
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
prepare_database_file(db_path, default: options[:database].nil?)
|
|
19
21
|
|
|
20
|
-
@db = SQLite3::Database.new(db_path
|
|
22
|
+
@db = SQLite3::Database.new(db_path)
|
|
21
23
|
@db.results_as_hash = true
|
|
22
24
|
@db.busy_timeout = 5000
|
|
23
25
|
@db.execute("PRAGMA journal_mode=WAL")
|
|
@@ -83,6 +85,8 @@ module Profiler
|
|
|
83
85
|
end
|
|
84
86
|
|
|
85
87
|
def load(token)
|
|
88
|
+
return nil unless Token.valid?(token)
|
|
89
|
+
|
|
86
90
|
row = @db.get_first_row(
|
|
87
91
|
"SELECT * FROM profiler_profiles WHERE token = :token", token: token
|
|
88
92
|
)
|
|
@@ -103,6 +107,8 @@ module Profiler
|
|
|
103
107
|
end
|
|
104
108
|
|
|
105
109
|
def find_by_parent(parent_token)
|
|
110
|
+
return [] unless Token.valid?(parent_token)
|
|
111
|
+
|
|
106
112
|
rows = @db.execute(
|
|
107
113
|
"SELECT * FROM profiler_profiles WHERE parent_token = :parent_token ORDER BY started_at ASC",
|
|
108
114
|
parent_token: parent_token
|
|
@@ -111,6 +117,8 @@ module Profiler
|
|
|
111
117
|
end
|
|
112
118
|
|
|
113
119
|
def delete(token)
|
|
120
|
+
return unless Token.valid?(token)
|
|
121
|
+
|
|
114
122
|
@db.execute("DELETE FROM profiler_profiles WHERE token = :token", token: token)
|
|
115
123
|
@blob_store.delete(token)
|
|
116
124
|
end
|
|
@@ -264,13 +272,23 @@ module Profiler
|
|
|
264
272
|
default
|
|
265
273
|
end
|
|
266
274
|
|
|
267
|
-
|
|
268
|
-
|
|
275
|
+
# SQLite gives the -wal and -shm files the mode of the database file: create it 0600 before
|
|
276
|
+
# SQLite does, and bring an older one, and its companions, back to 0600. An in-memory or URI
|
|
277
|
+
# database is left to SQLite.
|
|
278
|
+
def prepare_database_file(db_path, default:)
|
|
279
|
+
return if db_path == ":memory:" || db_path.start_with?("file:")
|
|
280
|
+
|
|
281
|
+
default ? PrivateFiles.tmp_dir : PrivateFiles.mkdir(File.dirname(db_path))
|
|
282
|
+
# A link in place of one of them is refused: SQLite would follow it.
|
|
283
|
+
%w[-wal -shm].each { |suffix| PrivateFiles.refuse_link("#{db_path}#{suffix}") }
|
|
284
|
+
PrivateFiles.touch(db_path)
|
|
285
|
+
%w[-wal -shm].each { |suffix| PrivateFiles.restrict("#{db_path}#{suffix}") }
|
|
269
286
|
end
|
|
270
287
|
|
|
271
|
-
def
|
|
272
|
-
|
|
288
|
+
def default_db_path
|
|
289
|
+
Profiler.configuration.tmp_path.join("profiler.db")
|
|
273
290
|
end
|
|
291
|
+
|
|
274
292
|
end
|
|
275
293
|
end
|
|
276
294
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module Storage
|
|
5
|
+
# The format of a profile token, as Models::Profile issues it (SecureRandom.hex(16)) for every
|
|
6
|
+
# kind of profile. The backends check a token against it before they turn it into a file name,
|
|
7
|
+
# a directory or a key: a token is client input for the controllers, the cluster proxy and the
|
|
8
|
+
# MCP tools, which all call the storage directly.
|
|
9
|
+
module Token
|
|
10
|
+
FORMAT = /\A[0-9a-f]{32}\z/
|
|
11
|
+
|
|
12
|
+
def self.valid?(token)
|
|
13
|
+
token.is_a?(String) && FORMAT.match?(token)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# For the writers: the gem only writes under the tokens it issued, so another one is a bug.
|
|
17
|
+
def self.validate!(token)
|
|
18
|
+
raise ArgumentError, "invalid profile token: #{token.inspect[0, 80]}" unless valid?(token)
|
|
19
|
+
|
|
20
|
+
token
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -56,7 +56,7 @@ module Profiler
|
|
|
56
56
|
|
|
57
57
|
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
allocations_before = AllocationCounter.current if Profiler.configuration.track_memory
|
|
60
60
|
|
|
61
61
|
test_status = "passed"
|
|
62
62
|
error_message = nil
|
|
@@ -103,7 +103,7 @@ module Profiler
|
|
|
103
103
|
Profiler::CurrentContext.token = previous_token
|
|
104
104
|
|
|
105
105
|
if Profiler.configuration.track_memory
|
|
106
|
-
profile.
|
|
106
|
+
profile.allocated_objects = AllocationCounter.current - allocations_before
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
test_collector.update_status(test_status, error_message)
|
|
@@ -118,26 +118,11 @@ module Profiler
|
|
|
118
118
|
end
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
-
Profiler.
|
|
121
|
+
Profiler.save_profile(profile, from: "TestProfiler")
|
|
122
122
|
end
|
|
123
123
|
ensure
|
|
124
124
|
# After collect, and also when collect or the storage failed.
|
|
125
125
|
Collectors::Lifecycle.release_all(collectors)
|
|
126
126
|
end
|
|
127
|
-
|
|
128
|
-
private
|
|
129
|
-
|
|
130
|
-
def current_memory
|
|
131
|
-
return 0 unless defined?(GC.stat)
|
|
132
|
-
|
|
133
|
-
stats = GC.stat
|
|
134
|
-
if stats.key?(:total_allocated_size)
|
|
135
|
-
stats[:total_allocated_size]
|
|
136
|
-
elsif stats.key?(:total_allocated_objects)
|
|
137
|
-
stats[:total_allocated_objects] * 40
|
|
138
|
-
else
|
|
139
|
-
0
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
127
|
end
|
|
143
128
|
end
|
data/lib/profiler/version.rb
CHANGED