rails-profiler 0.31.4 → 0.31.6

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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +103 -0
  3. data/app/assets/builds/profiler.js +9 -1
  4. data/app/controllers/profiler/api/console_controller.rb +1 -1
  5. data/app/controllers/profiler/api/jobs_controller.rb +1 -1
  6. data/app/controllers/profiler/api/profiles_controller.rb +1 -1
  7. data/app/controllers/profiler/api/tests_controller.rb +1 -1
  8. data/app/controllers/profiler/api/toolbar_controller.rb +1 -0
  9. data/app/controllers/profiler/application_controller.rb +2 -1
  10. data/app/controllers/profiler/profiles_controller.rb +3 -3
  11. data/lib/profiler/cluster/master_client.rb +7 -15
  12. data/lib/profiler/cluster/security.rb +3 -3
  13. data/lib/profiler/cluster/slave_proxy.rb +7 -4
  14. data/lib/profiler/collectors/lifecycle.rb +2 -2
  15. data/lib/profiler/collectors/log_collector.rb +54 -5
  16. data/lib/profiler/collectors/mailer_collector.rb +8 -4
  17. data/lib/profiler/collectors/request_collector.rb +6 -1
  18. data/lib/profiler/collectors/scoped_notifications.rb +1 -1
  19. data/lib/profiler/configuration.rb +15 -4
  20. data/lib/profiler/console_profiler.rb +1 -1
  21. data/lib/profiler/engine.rb +2 -1
  22. data/lib/profiler/env_override_store.rb +4 -4
  23. data/lib/profiler/explain_runner.rb +5 -1
  24. data/lib/profiler/instrumentation/net_http_instrumentation.rb +138 -40
  25. data/lib/profiler/job_profiler.rb +1 -1
  26. data/lib/profiler/local_request.rb +2 -6
  27. data/lib/profiler/mcp/body_formatter.rb +2 -9
  28. data/lib/profiler/mcp/file_cache.rb +2 -1
  29. data/lib/profiler/mcp/server.rb +1 -1
  30. data/lib/profiler/mcp/tools/get_profile_detail.rb +5 -1
  31. data/lib/profiler/mcp/tools/get_profile_http.rb +6 -1
  32. data/lib/profiler/mcp/tools/query_console_profiles.rb +5 -1
  33. data/lib/profiler/mcp/tools/query_jobs.rb +5 -1
  34. data/lib/profiler/mcp/tools/query_mailers.rb +5 -1
  35. data/lib/profiler/mcp/tools/query_profiles.rb +5 -1
  36. data/lib/profiler/mcp/tools/query_test_profiles.rb +5 -1
  37. data/lib/profiler/mcp/tools/run_tests.rb +4 -1
  38. data/lib/profiler/middleware/profiler_middleware.rb +22 -19
  39. data/lib/profiler/middleware/streamed_profile.rb +1 -1
  40. data/lib/profiler/middleware/toolbar_injector.rb +21 -2
  41. data/lib/profiler/models/profile.rb +84 -16
  42. data/lib/profiler/process_snapshot.rb +8 -3
  43. data/lib/profiler/railtie.rb +1 -1
  44. data/lib/profiler/redaction.rb +1 -3
  45. data/lib/profiler/sse/redis_event_bus.rb +2 -1
  46. data/lib/profiler/storage/base_store.rb +4 -2
  47. data/lib/profiler/storage/blob_store.rb +1 -1
  48. data/lib/profiler/storage/file_store.rb +8 -3
  49. data/lib/profiler/storage/private_files.rb +3 -3
  50. data/lib/profiler/storage/redis_store.rb +2 -2
  51. data/lib/profiler/storage/sqlite_store.rb +3 -3
  52. data/lib/profiler/storage/summary.rb +8 -4
  53. data/lib/profiler/storage/unavailable.rb +1 -2
  54. data/lib/profiler/test_helpers/reporter.rb +1 -1
  55. data/lib/profiler/test_profiler.rb +1 -1
  56. data/lib/profiler/test_runner/runner.rb +3 -7
  57. data/lib/profiler/version.rb +1 -1
  58. data/lib/profiler.rb +126 -3
  59. metadata +1 -1
@@ -61,7 +61,11 @@ module Profiler
61
61
  result = if format == "json"
62
62
  # PostgreSQL / MySQL return JSON in rows[0]["QUERY PLAN"] or rows[0]["EXPLAIN"]
63
63
  raw = rows.first&.values&.first.to_s
64
- JSON.parse(raw) rescue raw
64
+ begin
65
+ JSON.parse(raw)
66
+ rescue JSON::ParserError
67
+ raw
68
+ end
65
69
  else
66
70
  rows.map { |r| r.values.join("\t") }.join("\n")
67
71
  end
@@ -6,6 +6,7 @@ require "zlib"
6
6
  require "stringio"
7
7
  require "securerandom"
8
8
  require_relative "../redaction"
9
+ require_relative "../configuration"
9
10
 
10
11
  module Profiler
11
12
  module Instrumentation
@@ -17,36 +18,19 @@ module Profiler
17
18
  # Re-entrancy guard: Net::HTTP#request calls itself recursively when
18
19
  # the connection isn't started yet. Only record the outermost call.
19
20
  return super if Thread.current[:profiler_http_recording]
21
+ # The profiler's own calls (Profiler.untracked_http).
22
+ return super if Thread.current[:profiler_http_untracked]
20
23
 
21
24
  host = address.to_s
22
- return super if NetHttpInstrumentation.skip_host?(host)
25
+ return super if NetHttpInstrumentation.skip_host?(host, port)
23
26
 
24
27
  url = Redaction.filter_url(build_url(host, port, req.path, use_ssl?))
25
- req_body = req.body.to_s
26
- # Fallback: body may be passed as the 2nd argument and only applied
27
- # to req inside super via req.set_body_internal(body)
28
- req_body = body.to_s if req_body.empty? && body
29
- # Fallback: libraries like RestClient set body_stream instead of body
30
- # and pass nil as the 2nd argument to Net::HTTP#request.
31
- # Read the stream content, then restore it so the actual request works.
32
- # RestClient::Payload doesn't implement #rewind, so we fall back to
33
- # replacing body_stream with a fresh StringIO.
34
- if req_body.empty? && req.body_stream
35
- begin
36
- stream = req.body_stream
37
- req_body = stream.read.to_s
38
- if stream.respond_to?(:rewind)
39
- stream.rewind
40
- else
41
- req.body_stream = StringIO.new(req_body)
42
- end
43
- rescue
44
- req_body = ""
45
- end
46
- end
28
+ limit = Profiler.configuration.max_captured_body_bytes
29
+ captured = NetHttpInstrumentation.capture_request_body(req, body, limit)
47
30
  req_headers = Redaction.filter_headers(req.to_hash.transform_values { |v| v.join(", ") })
48
31
  req_content_type = req["content-type"].to_s
49
- processed_req = req_body.empty? ? { body: nil, encoding: "text" } : NetHttpInstrumentation.process_body(req_body, req_content_type)
32
+ req_body = captured[:content]
33
+ processed_req = req_body.nil? || req_body.empty? ? { body: nil, encoding: "text" } : NetHttpInstrumentation.process_body(req_body, req_content_type)
50
34
 
51
35
  request_id = SecureRandom.hex(8)
52
36
  started_at = Time.now.iso8601(3)
@@ -63,7 +47,10 @@ module Profiler
63
47
  request_headers: req_headers,
64
48
  request_body: processed_req[:body],
65
49
  request_body_encoding: processed_req[:encoding],
66
- request_size: req_body.bytesize,
50
+ request_size: captured[:size],
51
+ request_size_is_minimum: captured[:size_is_minimum],
52
+ request_body_truncated: captured[:truncated],
53
+ request_body_not_captured: captured[:not_captured],
67
54
  backtrace: NetHttpInstrumentation.extract_backtrace
68
55
  )
69
56
 
@@ -74,7 +61,9 @@ module Profiler
74
61
  duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2)
75
62
  resp_body_raw = response.body.to_s
76
63
  resp_content_encoding = response["content-encoding"].to_s.strip.downcase
77
- resp_body = NetHttpInstrumentation.decompress_body(resp_body_raw, resp_content_encoding)
64
+ resp_body = NetHttpInstrumentation.decompress_body(resp_body_raw, resp_content_encoding, limit)
65
+ resp_truncated = limit ? resp_body.bytesize > limit : false
66
+ resp_body = Redaction.cut_bytes(resp_body, limit) if resp_truncated
78
67
  resp_content_type = response["content-type"].to_s
79
68
  processed_resp = NetHttpInstrumentation.process_body(resp_body, resp_content_type)
80
69
 
@@ -87,7 +76,8 @@ module Profiler
87
76
  response_headers: Redaction.filter_headers(response.to_hash.transform_values { |v| v.join(", ") }),
88
77
  response_body: processed_resp[:body],
89
78
  response_body_encoding: processed_resp[:encoding],
90
- response_size: resp_body_raw.bytesize
79
+ response_size: resp_body_raw.bytesize,
80
+ response_body_truncated: resp_truncated
91
81
  )
92
82
 
93
83
  fg = Thread.current[:profiler_flamegraph_collector]
@@ -113,7 +103,11 @@ module Profiler
113
103
  end
114
104
  end
115
105
 
106
+ # Deprecated, as it was: the hosts earlier versions always left out, kept for code that read
107
+ # them. They are left out only when listed in config.http_skip_hosts now
108
+ # (Profiler::Configuration::LOCAL_HTTP_HOSTS).
116
109
  SKIP_HOSTS = %w[127.0.0.1 localhost ::1].freeze
110
+ deprecate_constant :SKIP_HOSTS
117
111
 
118
112
  TEXT_CONTENT_TYPES = /\A(text\/|application\/(json|xml|xhtml|javascript|x-www-form-urlencoded)|image\/svg)/i
119
113
  BINARY_CONTENT_TYPES = /\A(image\/|application\/pdf|application\/octet-stream|application\/zip|audio\/|video\/)/i
@@ -124,26 +118,126 @@ module Profiler
124
118
  @installed = true
125
119
  end
126
120
 
127
- def self.skip_host?(host)
128
- SKIP_HOSTS.include?(host) ||
129
- Profiler.configuration.http_skip_hosts.any? { |p| host.match?(p) }
121
+ # The hosts of http_skip_hosts, and the cluster's master (host and port): a slave's calls to
122
+ # it are the profiler's, whichever code makes them.
123
+ def self.skip_host?(host, port = nil)
124
+ config = Profiler.configuration
125
+ return true if config.http_skip_hosts.any? { |pattern| host.match?(pattern) }
126
+
127
+ master = master_address(config.master_url)
128
+ !master.nil? && master[0].casecmp?(host) && (port.nil? || master[1] == port)
130
129
  end
131
130
 
132
- def self.decompress_body(body, content_encoding)
133
- return body if content_encoding.empty? || body.nil? || body.empty?
131
+ def self.master_address(url)
132
+ return nil if url.nil? || url.to_s.empty?
134
133
 
135
- case content_encoding
136
- when "gzip", "x-gzip"
137
- Zlib::GzipReader.new(StringIO.new(body)).read
138
- when "deflate"
139
- Zlib::Inflate.inflate(body)
140
- else
141
- body
134
+ uri = URI(url.to_s)
135
+ uri.host ? [uri.host.delete_prefix("[").delete_suffix("]"), uri.port] : nil
136
+ rescue URI::Error
137
+ nil
138
+ end
139
+
140
+ # What a profile keeps of the body Net::HTTP is about to send: at most +limit+ bytes, and
141
+ # the whole size when known (size_is_minimum when only a lower bound is). A body_stream is
142
+ # read only when it can be put back where it was (a StringIO, a file), and no further than
143
+ # its Content-Length; a pipe, a socket or an object without pos (a multipart payload) is
144
+ # never touched, as reading it would leave nothing to send.
145
+ def self.capture_request_body(req, body, limit)
146
+ content = req.body
147
+ content = body if (content.nil? || content.empty?) && body
148
+ unless content.nil? || content.to_s.empty?
149
+ content = content.to_s
150
+ truncated = limit ? content.bytesize > limit : false
151
+ return { content: truncated ? Redaction.cut_bytes(content, limit) : content,
152
+ size: content.bytesize, size_is_minimum: false, truncated: truncated, not_captured: false }
142
153
  end
143
- rescue StandardError
154
+
155
+ stream = req.body_stream
156
+ return { content: nil, size: 0, size_is_minimum: false, truncated: false, not_captured: false } unless stream
157
+
158
+ declared = req["content-length"].to_s
159
+ declared = declared.match?(/\A\d+\z/) ? declared.to_i : nil
160
+ unless rewindable?(stream)
161
+ return { content: nil, size: declared, size_is_minimum: false, truncated: false, not_captured: true }
162
+ end
163
+
164
+ position = stream.pos
165
+ total = declared || remaining_size(stream, position)
166
+ want = [limit && limit + 1, total].compact.min
167
+ begin
168
+ read = (want ? stream.read(want) : stream.read).to_s
169
+ ensure
170
+ stream.pos = position
171
+ end
172
+ # Bytes as they were sent: labelled UTF-8, the encoding of text on the wire, for the text
173
+ # filter; a binary type is kept as bytes whatever the label.
174
+ read = read.dup.force_encoding(Encoding::UTF_8)
175
+ cut = limit ? read.bytesize > limit : false
176
+ content = cut ? Redaction.cut_bytes(read, limit) : read
177
+ size = total || read.bytesize
178
+ { content: content, size: size, size_is_minimum: total.nil? && cut,
179
+ truncated: content.bytesize < size, not_captured: false }
180
+ rescue IOError, SystemCallError => e
181
+ Profiler.log_error_once(:net_http_body_stream, "NetHttpInstrumentation: could not read a request body_stream", e)
182
+ { content: nil, size: declared, size_is_minimum: false, truncated: false, not_captured: true }
183
+ end
184
+
185
+ # What is left to read of a stream that knows its size (a StringIO, a file), else nil.
186
+ def self.remaining_size(stream, position)
187
+ size = stream.respond_to?(:size) ? stream.size : nil
188
+ size.is_a?(Integer) ? [size - position, 0].max : nil
189
+ rescue IOError, SystemCallError
190
+ nil
191
+ end
192
+
193
+ def self.rewindable?(stream)
194
+ return false unless stream.respond_to?(:pos) && stream.respond_to?(:pos=) && stream.respond_to?(:read)
195
+ return stream.stat.file? if stream.respond_to?(:stat)
196
+
197
+ true
198
+ rescue IOError, SystemCallError
199
+ false
200
+ end
201
+
202
+ # Inflated up to the limit, give or take one buffer of the inflater (16 KB): a small
203
+ # compressed answer can stand for a huge text, which a profile never keeps whole. Labelled
204
+ # UTF-8, as the text it is when its type is a text one.
205
+ def self.decompress_body(body, content_encoding, limit = nil)
206
+ return body if content_encoding.empty? || body.nil? || body.empty?
207
+
208
+ inflated =
209
+ case content_encoding
210
+ when "gzip", "x-gzip" then inflate_capped(body, limit, Zlib::MAX_WBITS + 16)
211
+ when "deflate" then inflate_capped(body, limit, Zlib::MAX_WBITS)
212
+ else return body
213
+ end
214
+ inflated.force_encoding(Encoding::UTF_8)
215
+ rescue Zlib::Error
144
216
  body
145
217
  end
146
218
 
219
+ # The input goes in by small slices, and the output is taken buffer by buffer: the
220
+ # inflating stops as soon as the limit is passed.
221
+ def self.inflate_capped(body, limit, window_bits)
222
+ inflater = Zlib::Inflate.new(window_bits)
223
+ return inflater.inflate(body) unless limit
224
+
225
+ out = +""
226
+ offset = 0
227
+ catch(:full) do
228
+ while offset < body.bytesize && !inflater.finished?
229
+ inflater.inflate(body.byteslice(offset, 1024)) do |chunk|
230
+ out << chunk
231
+ throw :full if out.bytesize > limit
232
+ end
233
+ offset += 1024
234
+ end
235
+ end
236
+ out
237
+ ensure
238
+ inflater&.close
239
+ end
240
+
147
241
  def self.process_body(body, content_type)
148
242
  return { body: nil, encoding: "text" } if body.nil? || body.empty?
149
243
 
@@ -153,8 +247,12 @@ module Profiler
153
247
  # Masked on the raw bytes, before the encoding hides them from any later search.
154
248
  { body: Base64.strict_encode64(Redaction.hide_credentials(body.b)), encoding: "base64" }
155
249
  else
250
+ # Bytes are read as UTF-8, as a text body on the wire is; what is not valid UTF-8 shows
251
+ # as "?".
252
+ body = body.dup.force_encoding(Encoding::UTF_8) if body.encoding == Encoding::BINARY
156
253
  text = Redaction.filter_body(body, content_type)
157
254
  .encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
255
+ text = text.scrub("?") unless text.valid_encoding?
158
256
  { body: text, encoding: "text" }
159
257
  end
160
258
  end
@@ -105,7 +105,7 @@ module Profiler
105
105
  collector.collect if collector.respond_to?(:collect)
106
106
  profile.add_collector_metadata(collector)
107
107
  rescue => e
108
- warn "Profiler JobProfiler: Collector #{collector.class} failed: #{e.message}"
108
+ Profiler.log_error("JobProfiler: collector #{collector.class} failed", e)
109
109
  end
110
110
  end
111
111
 
@@ -57,18 +57,14 @@ module Profiler
57
57
  @warned = true
58
58
  end
59
59
 
60
- message = "[Profiler] Request refused or not profiled by authorization_mode :allow_local: #{reason}. " \
60
+ message = "Request refused or not profiled by authorization_mode :allow_local: #{reason}. " \
61
61
  "The profiler only serves and captures requests made from this machine. " \
62
62
  "If the application runs in Docker or behind a remote proxy, either set " \
63
63
  "config.authorization_mode = :allow_authorized with a config.authorize_with block " \
64
64
  "that admits your network (see the Access control section of the README), or set " \
65
65
  "config.authorization_mode = :allow_all, which offers no protection at all. " \
66
66
  "This message is logged once per process."
67
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
68
- Rails.logger.warn(message)
69
- else
70
- warn(message)
71
- end
67
+ Profiler.log_warn(message)
72
68
  end
73
69
 
74
70
  def reset_warning!
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
- require "zlib"
3
+ require_relative "../models/profile"
5
4
 
6
5
  module Profiler
7
6
  module MCP
@@ -19,13 +18,7 @@ module Profiler
19
18
  return " *(binary, base64 encoded)*" if encoding == "base64"
20
19
  return nil if body.nil? || body.empty?
21
20
 
22
- if encoding == "gzip+base64"
23
- begin
24
- body = Zlib::Inflate.inflate(Base64.strict_decode64(body))
25
- rescue Zlib::Error, ArgumentError
26
- # fall through with original body
27
- end
28
- end
21
+ body, = Models::Profile.decode_body(body, encoding)
29
22
 
30
23
  if params["save_bodies"]
31
24
  path = FileCache.save(token, name, body)
@@ -26,7 +26,8 @@ module Profiler
26
26
 
27
27
  dir = Storage::PrivateFiles.tmp_dir(DIR_NAME, token)
28
28
  Storage::PrivateFiles.write(dir.join(name), content)
29
- rescue Errno::EACCES, Errno::EROFS
29
+ rescue Errno::EACCES, Errno::EROFS => e
30
+ Profiler.log_error_once(:mcp_file_cache, "MCP: could not cache a body under tmp_path", e)
30
31
  nil
31
32
  end
32
33
 
@@ -57,7 +57,7 @@ module Profiler
57
57
  when :stdio
58
58
  ::MCP::Server::Transports::StdioTransport.new(@server).open
59
59
  when :http
60
- $stderr.puts "MCP HTTP transport active — endpoint: /_profiler/mcp"
60
+ Profiler.log_info("MCP: HTTP transport active, endpoint /_profiler/mcp")
61
61
  else
62
62
  raise Error, "Unknown transport: #{transport}"
63
63
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../slave_support"
4
4
  require_relative "../../process_snapshot"
5
+ require_relative "../../models/profile"
5
6
 
6
7
  require "shellwords"
7
8
  require "cgi"
@@ -666,7 +667,10 @@ module Profiler
666
667
  def self.generate_curl(profile, req_data)
667
668
  headers = req_data&.dig("headers") || {}
668
669
  params = req_data&.dig("params") || {}
669
- req_body = req_data&.dig("request_body")
670
+ # As captured: a binary body cannot be typed on a command line.
671
+ req_body, req_encoding = Models::Profile.decode_body(req_data&.dig("request_body"),
672
+ req_data&.dig("request_body_encoding"))
673
+ req_body = nil unless req_encoding.nil? || req_encoding == "text"
670
674
 
671
675
  parts = ["curl -X #{profile.method}"]
672
676
 
@@ -85,8 +85,13 @@ module Profiler
85
85
  lines << "- **URL:** #{req['url']}"
86
86
  lines << "- **Status:** #{req['status'] == 0 ? 'connection error' : req['status']}"
87
87
  lines << "- **Duration:** #{req['duration'].round(2)} ms"
88
- lines << "- **Request Size:** #{req['request_size']} bytes"
88
+ lines << "- **Request Size:** #{req['request_size'].nil? ? 'unknown' : "#{'at least ' if req['request_size_is_minimum']}#{req['request_size']} bytes"}"
89
89
  lines << "- **Response Size:** #{req['response_size']} bytes"
90
+ if req["request_body_not_captured"]
91
+ lines << "- _Request body not captured: a stream that cannot be rewound is sent unread._"
92
+ end
93
+ lines << "- _Request body truncated: only the first bytes were kept (max_captured_body_bytes)._" if req["request_body_truncated"]
94
+ lines << "- _Response body truncated: only the first bytes were kept (max_captured_body_bytes)._" if req["response_body_truncated"]
90
95
  lines << "- **Error:** #{req['error']}" if req["error"]
91
96
 
92
97
  if req["request_headers"] && !req["request_headers"].empty?
@@ -40,7 +40,11 @@ module Profiler
40
40
  end
41
41
 
42
42
  if params["cursor"]
43
- cutoff = Time.parse(params["cursor"]) rescue nil
43
+ cutoff = begin
44
+ Time.parse(params["cursor"])
45
+ rescue ArgumentError, TypeError
46
+ nil # a cursor that is not a time is ignored, as before
47
+ end
44
48
  consoles = consoles.select { |p| p.started_at < cutoff } if cutoff
45
49
  end
46
50
 
@@ -31,7 +31,11 @@ module Profiler
31
31
  end
32
32
 
33
33
  if params["cursor"]
34
- cutoff = Time.parse(params["cursor"]) rescue nil
34
+ cutoff = begin
35
+ Time.parse(params["cursor"])
36
+ rescue ArgumentError, TypeError
37
+ nil # a cursor that is not a time is ignored, as before
38
+ end
35
39
  jobs = jobs.select { |p| p.started_at < cutoff } if cutoff
36
40
  end
37
41
 
@@ -49,7 +49,11 @@ module Profiler
49
49
  end
50
50
 
51
51
  if params["cursor"]
52
- cutoff = Time.parse(params["cursor"]) rescue nil
52
+ cutoff = begin
53
+ Time.parse(params["cursor"])
54
+ rescue ArgumentError, TypeError
55
+ nil # a cursor that is not a time is ignored, as before
56
+ end
53
57
  if cutoff
54
58
  emails = emails.select do |e|
55
59
  started = e["profile_started_at"]
@@ -23,7 +23,11 @@ module Profiler
23
23
  profiles = profiles.select { |p| p.profile_type == params["profile_type"] } if params["profile_type"]
24
24
 
25
25
  if params["cursor"]
26
- cutoff = Time.parse(params["cursor"]) rescue nil
26
+ cutoff = begin
27
+ Time.parse(params["cursor"])
28
+ rescue ArgumentError, TypeError
29
+ nil # a cursor that is not a time is ignored, as before
30
+ end
27
31
  profiles = profiles.select { |p| p.started_at < cutoff } if cutoff
28
32
  end
29
33
 
@@ -37,7 +37,11 @@ module Profiler
37
37
  end
38
38
 
39
39
  if params["cursor"]
40
- cutoff = Time.parse(params["cursor"]) rescue nil
40
+ cutoff = begin
41
+ Time.parse(params["cursor"])
42
+ rescue ArgumentError, TypeError
43
+ nil # a cursor that is not a time is ignored, as before
44
+ end
41
45
  tests = tests.select { |p| p.started_at < cutoff } if cutoff
42
46
  end
43
47
 
@@ -117,7 +117,10 @@ module Profiler
117
117
  Profiler.storage.list(limit: 500, type: "test").select do |p|
118
118
  p.profile_type == "test" && p.started_at && p.started_at >= since
119
119
  end.map(&:token)
120
- rescue
120
+ rescue Profiler::Storage::Unavailable::Error
121
+ raise # answered by the MCP server as an error
122
+ rescue StandardError => e
123
+ Profiler.log_error("MCP run_tests: could not list the profiles of the run", e)
121
124
  []
122
125
  end
123
126
 
@@ -42,7 +42,7 @@ module Profiler
42
42
  env["profiler.collectors"] = collectors
43
43
  subscribed = Collectors::Lifecycle.subscribe_all(collectors, "ProfilerMiddleware")
44
44
  rescue => e
45
- warn "Profiler error: #{e.message}\n#{e.backtrace.join("\n")}"
45
+ Profiler.log_error("ProfilerMiddleware: could not start profiling the request", e, backtrace: true)
46
46
  subscribed = false
47
47
  end
48
48
 
@@ -104,10 +104,9 @@ module Profiler
104
104
  record_response(env, profile, request_body, status, headers, content, content.bytesize, true)
105
105
  collect_all(profile, collectors)
106
106
 
107
- storage = Profiler.storage
108
- storage.save(profile.token, profile)
109
- # Not saved: no token to send, no toolbar to inject for it.
110
- return [status, headers, body] if storage.is_a?(Profiler::Storage::Unavailable)
107
+ # A profile that was not saved (a store unavailable, a save that failed) gets no token and
108
+ # no toolbar: both would point to a profile that is not there.
109
+ return [status, headers, body] unless Profiler.save_profile(profile, from: "ProfilerMiddleware")
111
110
 
112
111
  set_header(headers, TOKEN_HEADER, profile.token)
113
112
 
@@ -122,7 +121,7 @@ module Profiler
122
121
  [status, headers, body]
123
122
  rescue => e
124
123
  # The application has run: its response goes out, profiled or not.
125
- warn "Profiler error: #{e.message}\n#{e.backtrace.join("\n")}"
124
+ Profiler.log_error("ProfilerMiddleware: could not complete the profile", e, backtrace: true)
126
125
  [status, headers, body || []]
127
126
  end
128
127
 
@@ -140,7 +139,6 @@ module Profiler
140
139
  collect_all(profile, now)
141
140
  # Their tabs keep their place; what they hold is filled in when they are collected.
142
141
  kept.each { |collector| profile.add_collector_metadata(collector) }
143
- set_header(headers, TOKEN_HEADER, profile.token)
144
142
 
145
143
  # A Rack 3 streaming body (call without each) writes to the socket itself: nothing to relay.
146
144
  unless body.respond_to?(:each)
@@ -148,13 +146,18 @@ module Profiler
148
146
  collector.collect if collector.respond_to?(:collect)
149
147
  profile.refresh_collector_metadata(collector)
150
148
  rescue => e
151
- warn "Collector #{collector.class} failed: #{e.message}"
149
+ Profiler.log_error("ProfilerMiddleware: collector #{collector.class} failed", e)
152
150
  end
153
151
  record_response(env, profile, request_body, status, profiled_headers, "", nil, false)
154
- Profiler.storage.save(profile.token, profile)
152
+ # Saved before the headers leave: a profile that is not there gets no token.
153
+ set_header(headers, TOKEN_HEADER, profile.token) if Profiler.save_profile(profile, from: "ProfilerMiddleware")
155
154
  return [[status, headers, body], nil]
156
155
  end
157
156
 
157
+ # Saved when the server closes the body, after the headers have left: the token is sent
158
+ # unless the store is already known to be unavailable, where it would be dropped.
159
+ set_header(headers, TOKEN_HEADER, profile.token) unless Profiler.storage.is_a?(Profiler::Storage::Unavailable)
160
+
158
161
  streamed = StreamedProfile.new(profile, kept) do |captured, size, complete, error|
159
162
  finish_streamed(env, streamed, collectors, request_body, allocations_before, status, profiled_headers,
160
163
  captured, size, complete, error)
@@ -166,7 +169,7 @@ module Profiler
166
169
  StreamedProfile.register(streamed)
167
170
  [[status, headers, streamed.body], kept]
168
171
  rescue => e
169
- warn "Profiler error: #{e.message}\n#{e.backtrace.join("\n")}"
172
+ Profiler.log_error("ProfilerMiddleware: could not profile a streamed response", e, backtrace: true)
170
173
  [[status, headers, body], nil]
171
174
  end
172
175
 
@@ -189,12 +192,12 @@ module Profiler
189
192
  collector.collect
190
193
  profile.refresh_collector_metadata(collector)
191
194
  rescue => e
192
- warn "Collector #{collector.class} failed: #{e.message}"
195
+ Profiler.log_error("ProfilerMiddleware: collector #{collector.class} failed", e)
193
196
  end
194
197
 
195
- Profiler.storage.save(profile.token, profile)
198
+ Profiler.save_profile(profile, from: "ProfilerMiddleware")
196
199
  rescue => e
197
- warn "Profiler error while finishing a streamed response: #{e.message}"
200
+ Profiler.log_error("ProfilerMiddleware: could not finish the profile of a streamed response", e)
198
201
  end
199
202
 
200
203
  def collect_from_any_thread?(collector)
@@ -241,9 +244,9 @@ module Profiler
241
244
  resp_content_type: ""
242
245
  )
243
246
  collect_all(profile, collectors)
244
- Profiler.storage.save(profile.token, profile)
247
+ Profiler.save_profile(profile, from: "ProfilerMiddleware")
245
248
  rescue => e
246
- warn "Profiler error while recording a failed request: #{e.message}"
249
+ Profiler.log_error("ProfilerMiddleware: could not record a failed request", e)
247
250
  end
248
251
 
249
252
  def collect_all(profile, collectors)
@@ -252,7 +255,7 @@ module Profiler
252
255
  collector.collect if collector.respond_to?(:collect)
253
256
  profile.add_collector_metadata(collector)
254
257
  rescue => e
255
- warn "Collector #{collector.class} failed: #{e.message}"
258
+ Profiler.log_error("ProfilerMiddleware: collector #{collector.class} failed", e)
256
259
  end
257
260
  end
258
261
  end
@@ -262,7 +265,7 @@ module Profiler
262
265
  kept&.each do |collector|
263
266
  collector.release_thread_slots if collector.respond_to?(:release_thread_slots)
264
267
  rescue => e
265
- warn "Profiler: Collector #{collector.class} release failed: #{e.message}"
268
+ Profiler.log_error("ProfilerMiddleware: collector #{collector.class} release failed", e)
266
269
  end
267
270
  Profiler::CurrentContext.clear
268
271
  end
@@ -379,7 +382,7 @@ module Profiler
379
382
  begin
380
383
  close_body(body)
381
384
  rescue => e
382
- warn "Profiler: closing a body that failed also failed: #{e.message}"
385
+ Profiler.log_error("ProfilerMiddleware: closing a body that failed also failed", e)
383
386
  end
384
387
  raise
385
388
  end
@@ -413,7 +416,7 @@ module Profiler
413
416
  content = Redaction.cut_bytes(content, limit) if limit
414
417
  { content: content, size: size, size_is_minimum: size_is_minimum || false }
415
418
  rescue => e
416
- warn "Profiler: could not read the request body: #{e.message}"
419
+ Profiler.log_error("ProfilerMiddleware: could not read the request body", e)
417
420
  { content: "", size: nil }
418
421
  end
419
422
  end
@@ -85,7 +85,7 @@ module Profiler
85
85
  collector.collect if collector.respond_to?(:collect)
86
86
  @profile.refresh_collector_metadata(collector)
87
87
  rescue => e
88
- warn "Collector #{collector.class} failed: #{e.message}"
88
+ Profiler.log_error("ProfilerMiddleware: collector #{collector.class} failed", e)
89
89
  end
90
90
  Collectors::Lifecycle.release_all(@collectors)
91
91
  end
@@ -26,6 +26,17 @@ module Profiler
26
26
  TAG_REST = %r{(?:#{WS}++|/|#{ATTRIBUTE})*+>}
27
27
  # A whole start or end tag, read after its <: the slash and the name are captured.
28
28
  TAG = %r{(/)?([a-z][^\t\n\f\r\ />]*+)#{TAG_REST}}i
29
+ # Text and complete tags that cannot hold the page's end, skipped in one match: every tag
30
+ # but </body>, a raw-text element, <plaintext>, a comment, a declaration or a tag left
31
+ # open, which the scan below reads one by one. A tag here is read with TAG_REST, as there.
32
+ NAME_END = "(?=[\\t\\n\\f\\r\\ />])"
33
+ PLAIN_MARKUP = %r{
34
+ (?:
35
+ [^<]++
36
+ | <(?:/(?!body#{NAME_END})|(?!(?:#{(RAW_TEXT_ELEMENTS.keys + [PLAINTEXT]).join("|")})#{NAME_END}))
37
+ [a-z][^\t\n\f\r\ />]*+#{TAG_REST}
38
+ )*+
39
+ }xi
29
40
 
30
41
  def initialize(body, token, nonce = nil)
31
42
  @body = body
@@ -58,7 +69,10 @@ module Profiler
58
69
  scanner = StringScanner.new(bytes)
59
70
  position = nil
60
71
 
61
- while scanner.skip_until(/</)
72
+ loop do
73
+ skip_plain_markup(scanner)
74
+ break unless scanner.skip_until(/</)
75
+
62
76
  start = scanner.pos - 1
63
77
 
64
78
  if scanner.skip(TAG)
@@ -97,6 +111,11 @@ module Profiler
97
111
  position
98
112
  end
99
113
 
114
+ # A page is mostly ordinary text and tags: one match instead of one scan step per tag.
115
+ def skip_plain_markup(scanner)
116
+ scanner.skip(PLAIN_MARKUP)
117
+ end
118
+
100
119
  # In a script, <!-- followed by <script enters a state where </script> does not close it.
101
120
  # Only the script itself is searched, so that a page of many scripts is read in linear
102
121
  # time.
@@ -124,7 +143,7 @@ module Profiler
124
143
  path = File.join(__dir__, "..", "..", "..", "app", "assets", "javascript", "profiler-ajax-interceptor.js")
125
144
  File.read(path)
126
145
  rescue => e
127
- warn "Failed to load AJAX interceptor script: #{e.message}"
146
+ Profiler.log_error("ToolbarInjector: could not load the AJAX interceptor script", e)
128
147
  ""
129
148
  end
130
149
  end