rails-profiler 0.31.3 → 0.31.5

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/app/assets/builds/profiler.js +9 -1
  4. data/app/controllers/profiler/api/console_controller.rb +1 -11
  5. data/app/controllers/profiler/api/jobs_controller.rb +1 -11
  6. data/app/controllers/profiler/api/profiles_controller.rb +18 -28
  7. data/app/controllers/profiler/api/tests_controller.rb +1 -11
  8. data/app/controllers/profiler/api/toolbar_controller.rb +0 -20
  9. data/app/controllers/profiler/application_controller.rb +55 -15
  10. data/app/controllers/profiler/profiles_controller.rb +2 -18
  11. data/lib/profiler/ajax_data.rb +40 -0
  12. data/lib/profiler/cluster/master_client.rb +7 -15
  13. data/lib/profiler/cluster/security.rb +3 -3
  14. data/lib/profiler/cluster/slave_proxy.rb +14 -9
  15. data/lib/profiler/collectors/lifecycle.rb +2 -2
  16. data/lib/profiler/collectors/log_collector.rb +54 -5
  17. data/lib/profiler/collectors/mailer_collector.rb +8 -4
  18. data/lib/profiler/collectors/request_collector.rb +6 -1
  19. data/lib/profiler/collectors/scoped_notifications.rb +1 -1
  20. data/lib/profiler/configuration.rb +30 -8
  21. data/lib/profiler/console_profiler.rb +1 -1
  22. data/lib/profiler/engine.rb +2 -1
  23. data/lib/profiler/env_override_store.rb +4 -4
  24. data/lib/profiler/explain_runner.rb +5 -1
  25. data/lib/profiler/instrumentation/net_http_instrumentation.rb +138 -40
  26. data/lib/profiler/job_profiler.rb +1 -1
  27. data/lib/profiler/local_request.rb +2 -6
  28. data/lib/profiler/mcp/file_cache.rb +2 -1
  29. data/lib/profiler/mcp/resources/failing_tests.rb +1 -1
  30. data/lib/profiler/mcp/resources/recent_console.rb +2 -2
  31. data/lib/profiler/mcp/resources/recent_jobs.rb +2 -2
  32. data/lib/profiler/mcp/resources/slow_tests.rb +1 -1
  33. data/lib/profiler/mcp/server.rb +9 -3
  34. data/lib/profiler/mcp/tools/get_profile_ajax.rb +2 -0
  35. data/lib/profiler/mcp/tools/get_profile_http.rb +6 -1
  36. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +1 -1
  37. data/lib/profiler/mcp/tools/query_console_profiles.rb +6 -2
  38. data/lib/profiler/mcp/tools/query_jobs.rb +6 -2
  39. data/lib/profiler/mcp/tools/query_mailers.rb +5 -1
  40. data/lib/profiler/mcp/tools/query_profiles.rb +6 -2
  41. data/lib/profiler/mcp/tools/query_test_profiles.rb +6 -2
  42. data/lib/profiler/mcp/tools/run_tests.rb +5 -2
  43. data/lib/profiler/middleware/profiler_middleware.rb +22 -16
  44. data/lib/profiler/middleware/streamed_profile.rb +1 -1
  45. data/lib/profiler/middleware/toolbar_injector.rb +21 -2
  46. data/lib/profiler/models/profile.rb +60 -2
  47. data/lib/profiler/process_snapshot.rb +8 -3
  48. data/lib/profiler/railtie.rb +4 -1
  49. data/lib/profiler/redaction.rb +1 -3
  50. data/lib/profiler/sse/redis_event_bus.rb +2 -1
  51. data/lib/profiler/storage/base_store.rb +4 -2
  52. data/lib/profiler/storage/blob_store.rb +1 -1
  53. data/lib/profiler/storage/file_store.rb +337 -58
  54. data/lib/profiler/storage/memory_store.rb +60 -31
  55. data/lib/profiler/storage/private_files.rb +16 -3
  56. data/lib/profiler/storage/redis_store.rb +252 -33
  57. data/lib/profiler/storage/sqlite_store.rb +59 -11
  58. data/lib/profiler/storage/summary.rb +50 -0
  59. data/lib/profiler/storage/unavailable.rb +57 -0
  60. data/lib/profiler/test_helpers/reporter.rb +2 -2
  61. data/lib/profiler/test_profiler.rb +1 -1
  62. data/lib/profiler/test_runner/runner.rb +3 -7
  63. data/lib/profiler/version.rb +1 -1
  64. data/lib/profiler.rb +138 -4
  65. metadata +4 -1
@@ -5,7 +5,7 @@ module Profiler
5
5
  module Resources
6
6
  class SlowTests
7
7
  def self.call
8
- profiles = Profiler.storage.list(limit: 500)
8
+ profiles = Profiler.storage.list(limit: 500, type: "test")
9
9
  tests = profiles.select { |p| p.profile_type == "test" }
10
10
  .sort_by { |p| -p.duration }
11
11
  .first(10)
@@ -35,8 +35,12 @@ module Profiler
35
35
  handler = @resource_handlers[uri]
36
36
  next [{ uri: uri, mimeType: "application/json", text: "Resource not found: #{uri}" }] unless handler
37
37
 
38
- result = handler.call
39
- [{ uri: result[:uri], mimeType: result[:mimeType], text: result[:text] }]
38
+ begin
39
+ result = handler.call
40
+ [{ uri: result[:uri], mimeType: result[:mimeType], text: result[:text] }]
41
+ rescue Profiler::Storage::Unavailable::Error => e
42
+ [{ uri: uri, mimeType: "text/plain", text: e.message }]
43
+ end
40
44
  end
41
45
  end
42
46
 
@@ -53,7 +57,7 @@ module Profiler
53
57
  when :stdio
54
58
  ::MCP::Server::Transports::StdioTransport.new(@server).open
55
59
  when :http
56
- $stderr.puts "MCP HTTP transport active — endpoint: /_profiler/mcp"
60
+ Profiler.log_info("MCP: HTTP transport active, endpoint /_profiler/mcp")
57
61
  else
58
62
  raise Error, "Unknown transport: #{transport}"
59
63
  end
@@ -393,6 +397,8 @@ module Profiler
393
397
  ::MCP::Tool.define(name: name, description: description, input_schema: input_schema) do |server_context: nil, **args|
394
398
  result = handler.call(args.transform_keys(&:to_s))
395
399
  result.is_a?(::MCP::Tool::Response) ? result : ::MCP::Tool::Response.new(result)
400
+ rescue Profiler::Storage::Unavailable::Error => e
401
+ ::MCP::Tool::Response.new([{ type: "text", text: e.message }], error: true)
396
402
  end
397
403
  end
398
404
 
@@ -22,6 +22,8 @@ module Profiler
22
22
  return [{ type: "text", text: "Profile not found: #{token}" }]
23
23
  end
24
24
 
25
+ # The sub-requests are saved after the page: computed from its children, as the page does.
26
+ Profiler::AjaxData.attach(profile, storage.find_by_parent(profile.token))
25
27
  ajax_data = profile.collector_data("ajax")
26
28
  unless ajax_data && ajax_data["total_requests"].to_i > 0
27
29
  return [{ type: "text", text: "No AJAX requests found in this profile" }]
@@ -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?
@@ -14,7 +14,7 @@ module Profiler
14
14
 
15
15
  storage = MCP::SlaveSupport.resolve_storage(params)
16
16
  profile = if token == "latest"
17
- profiles = storage.list(limit: 200)
17
+ profiles = storage.list(limit: 1, type: "test")
18
18
  profiles.find { |p| p.profile_type == "test" }
19
19
  else
20
20
  storage.load(token)
@@ -12,7 +12,7 @@ module Profiler
12
12
  limit = params["limit"]&.to_i || 20
13
13
  fetch_size = [limit * 5, 500].min
14
14
  storage = MCP::SlaveSupport.resolve_storage(params)
15
- profiles = storage.list(limit: fetch_size)
15
+ profiles = storage.list(limit: fetch_size, type: "console")
16
16
 
17
17
  consoles = profiles.select { |p| p.profile_type == "console" }
18
18
 
@@ -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
 
@@ -12,7 +12,7 @@ module Profiler
12
12
  limit = params["limit"]&.to_i || 20
13
13
  fetch_size = [limit * 5, 500].min
14
14
  storage = MCP::SlaveSupport.resolve_storage(params)
15
- profiles = storage.list(limit: fetch_size)
15
+ profiles = storage.list(limit: fetch_size, type: "job")
16
16
 
17
17
  jobs = profiles.select { |p| p.profile_type == "job" }
18
18
 
@@ -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"]
@@ -12,7 +12,7 @@ module Profiler
12
12
  storage = MCP::SlaveSupport.resolve_storage(params)
13
13
  limit = params["limit"]&.to_i || 20
14
14
  fetch_size = [limit * 5, 500].min
15
- profiles = storage.list(limit: fetch_size)
15
+ profiles = storage.list(limit: fetch_size, type: params["profile_type"])
16
16
 
17
17
  profiles = profiles.select { |p| p.path&.include?(params["path"]) } if params["path"]
18
18
  profiles = profiles.select { |p| p.method == params["method"]&.upcase } if params["method"]
@@ -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
 
@@ -12,7 +12,7 @@ module Profiler
12
12
  limit = params["limit"]&.to_i || 20
13
13
  fetch_size = [limit * 5, 500].min
14
14
  storage = MCP::SlaveSupport.resolve_storage(params)
15
- profiles = storage.list(limit: fetch_size)
15
+ profiles = storage.list(limit: fetch_size, type: "test")
16
16
 
17
17
  tests = profiles.select { |p| p.profile_type == "test" }
18
18
 
@@ -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
 
@@ -114,10 +114,13 @@ module Profiler
114
114
  end
115
115
 
116
116
  def self.collect_run_profiles(since)
117
- Profiler.storage.list(limit: 500).select do |p|
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,7 +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
- Profiler.storage.save(profile.token, profile)
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")
108
110
 
109
111
  set_header(headers, TOKEN_HEADER, profile.token)
110
112
 
@@ -119,7 +121,7 @@ module Profiler
119
121
  [status, headers, body]
120
122
  rescue => e
121
123
  # The application has run: its response goes out, profiled or not.
122
- warn "Profiler error: #{e.message}\n#{e.backtrace.join("\n")}"
124
+ Profiler.log_error("ProfilerMiddleware: could not complete the profile", e, backtrace: true)
123
125
  [status, headers, body || []]
124
126
  end
125
127
 
@@ -137,7 +139,6 @@ module Profiler
137
139
  collect_all(profile, now)
138
140
  # Their tabs keep their place; what they hold is filled in when they are collected.
139
141
  kept.each { |collector| profile.add_collector_metadata(collector) }
140
- set_header(headers, TOKEN_HEADER, profile.token)
141
142
 
142
143
  # A Rack 3 streaming body (call without each) writes to the socket itself: nothing to relay.
143
144
  unless body.respond_to?(:each)
@@ -145,13 +146,18 @@ module Profiler
145
146
  collector.collect if collector.respond_to?(:collect)
146
147
  profile.refresh_collector_metadata(collector)
147
148
  rescue => e
148
- warn "Collector #{collector.class} failed: #{e.message}"
149
+ Profiler.log_error("ProfilerMiddleware: collector #{collector.class} failed", e)
149
150
  end
150
151
  record_response(env, profile, request_body, status, profiled_headers, "", nil, false)
151
- 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")
152
154
  return [[status, headers, body], nil]
153
155
  end
154
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
+
155
161
  streamed = StreamedProfile.new(profile, kept) do |captured, size, complete, error|
156
162
  finish_streamed(env, streamed, collectors, request_body, allocations_before, status, profiled_headers,
157
163
  captured, size, complete, error)
@@ -163,7 +169,7 @@ module Profiler
163
169
  StreamedProfile.register(streamed)
164
170
  [[status, headers, streamed.body], kept]
165
171
  rescue => e
166
- warn "Profiler error: #{e.message}\n#{e.backtrace.join("\n")}"
172
+ Profiler.log_error("ProfilerMiddleware: could not profile a streamed response", e, backtrace: true)
167
173
  [[status, headers, body], nil]
168
174
  end
169
175
 
@@ -186,12 +192,12 @@ module Profiler
186
192
  collector.collect
187
193
  profile.refresh_collector_metadata(collector)
188
194
  rescue => e
189
- warn "Collector #{collector.class} failed: #{e.message}"
195
+ Profiler.log_error("ProfilerMiddleware: collector #{collector.class} failed", e)
190
196
  end
191
197
 
192
- Profiler.storage.save(profile.token, profile)
198
+ Profiler.save_profile(profile, from: "ProfilerMiddleware")
193
199
  rescue => e
194
- 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)
195
201
  end
196
202
 
197
203
  def collect_from_any_thread?(collector)
@@ -238,9 +244,9 @@ module Profiler
238
244
  resp_content_type: ""
239
245
  )
240
246
  collect_all(profile, collectors)
241
- Profiler.storage.save(profile.token, profile)
247
+ Profiler.save_profile(profile, from: "ProfilerMiddleware")
242
248
  rescue => e
243
- warn "Profiler error while recording a failed request: #{e.message}"
249
+ Profiler.log_error("ProfilerMiddleware: could not record a failed request", e)
244
250
  end
245
251
 
246
252
  def collect_all(profile, collectors)
@@ -249,7 +255,7 @@ module Profiler
249
255
  collector.collect if collector.respond_to?(:collect)
250
256
  profile.add_collector_metadata(collector)
251
257
  rescue => e
252
- warn "Collector #{collector.class} failed: #{e.message}"
258
+ Profiler.log_error("ProfilerMiddleware: collector #{collector.class} failed", e)
253
259
  end
254
260
  end
255
261
  end
@@ -259,7 +265,7 @@ module Profiler
259
265
  kept&.each do |collector|
260
266
  collector.release_thread_slots if collector.respond_to?(:release_thread_slots)
261
267
  rescue => e
262
- warn "Profiler: Collector #{collector.class} release failed: #{e.message}"
268
+ Profiler.log_error("ProfilerMiddleware: collector #{collector.class} release failed", e)
263
269
  end
264
270
  Profiler::CurrentContext.clear
265
271
  end
@@ -376,7 +382,7 @@ module Profiler
376
382
  begin
377
383
  close_body(body)
378
384
  rescue => e
379
- warn "Profiler: closing a body that failed also failed: #{e.message}"
385
+ Profiler.log_error("ProfilerMiddleware: closing a body that failed also failed", e)
380
386
  end
381
387
  raise
382
388
  end
@@ -410,7 +416,7 @@ module Profiler
410
416
  content = Redaction.cut_bytes(content, limit) if limit
411
417
  { content: content, size: size, size_is_minimum: size_is_minimum || false }
412
418
  rescue => e
413
- warn "Profiler: could not read the request body: #{e.message}"
419
+ Profiler.log_error("ProfilerMiddleware: could not read the request body", e)
414
420
  { content: "", size: nil }
415
421
  end
416
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
@@ -19,7 +19,7 @@ module Profiler
19
19
  :response_body, :response_body_encoding,
20
20
  :request_body_size, :request_body_truncated, :request_body_size_is_minimum,
21
21
  :response_body_size, :response_body_truncated, :response_body_size_is_minimum,
22
- :collectors_released_after_seconds,
22
+ :collectors_released_after_seconds, :params_truncated,
23
23
  :gem_version
24
24
 
25
25
  def initialize(request = nil)
@@ -34,7 +34,8 @@ module Profiler
34
34
  if request
35
35
  @path = request.path
36
36
  @method = request.request_method
37
- @params = sanitize_params(request.params)
37
+ @params, @params_truncated = cap_params(sanitize_params(request.params),
38
+ Profiler.configuration.max_captured_body_bytes)
38
39
  @headers = extract_headers(request.env)
39
40
  end
40
41
  end
@@ -133,6 +134,7 @@ module Profiler
133
134
  started_at: @started_at&.iso8601,
134
135
  finished_at: @finished_at&.iso8601,
135
136
  params: @params,
137
+ params_truncated: @params_truncated,
136
138
  headers: @headers,
137
139
  response_headers: @response_headers,
138
140
  request_body: req_body,
@@ -178,6 +180,7 @@ module Profiler
178
180
  profile.started_at = data[:started_at] ? Time.parse(data[:started_at]) : nil
179
181
  profile.finished_at = data[:finished_at] ? Time.parse(data[:finished_at]) : nil
180
182
  profile.params = data[:params]
183
+ profile.params_truncated = data[:params_truncated]
181
184
  profile.headers = data[:headers]
182
185
  profile.response_headers = data[:response_headers]
183
186
  profile.request_body = data[:request_body]
@@ -266,6 +269,61 @@ module Profiler
266
269
 
267
270
  LEGACY_FILTERED_PARAMS = %w[password password_confirmation token secret].freeze
268
271
 
272
+ # The key added to params cut at max_captured_body_bytes, so that the cut shows wherever
273
+ # the params are read.
274
+ PARAMS_TRUNCATED_KEY = "[profiler]"
275
+
276
+ # A copy of +params+ whose JSON stays within about +limit+ bytes: long strings are cut, and
277
+ # the entries past the limit left out. The application's own params are never touched.
278
+ # Returns the params and whether they were cut.
279
+ def cap_params(params, limit)
280
+ return [params, false] if limit.nil? || !params.is_a?(Hash)
281
+
282
+ budget = [limit]
283
+ cut = [false]
284
+ capped = cap_value(params, budget, cut)
285
+ return [capped, false] unless cut[0]
286
+
287
+ capped[PARAMS_TRUNCATED_KEY] = "params truncated at #{limit} bytes (max_captured_body_bytes)"
288
+ [capped, true]
289
+ end
290
+
291
+ def cap_value(value, budget, cut)
292
+ case value
293
+ when Hash
294
+ value.each_with_object({}) do |(key, item), out|
295
+ cost = key.to_s.bytesize + 4
296
+ if cost >= budget[0]
297
+ cut[0] = true
298
+ break out
299
+ end
300
+ budget[0] -= cost
301
+ out[key] = cap_value(item, budget, cut)
302
+ end
303
+ when Array
304
+ value.each_with_object([]) do |item, out|
305
+ if budget[0] <= 1
306
+ cut[0] = true
307
+ break out
308
+ end
309
+ budget[0] -= 1
310
+ out << cap_value(item, budget, cut)
311
+ end
312
+ when String
313
+ cost = value.bytesize + 2
314
+ if cost > budget[0]
315
+ cut[0] = true
316
+ value = Redaction.cut_bytes(value, [budget[0] - 2, 0].max).scrub("")
317
+ cost = value.bytesize + 2
318
+ end
319
+ budget[0] -= cost
320
+ value
321
+ else
322
+ budget[0] -= value.to_s.bytesize
323
+ value
324
+ end
325
+ end
326
+
269
327
  def sanitize_params(params)
270
328
  return {} unless params
271
329
 
@@ -36,7 +36,11 @@ module Profiler
36
36
  candidates.find { |route| route[:verb].split("|").include?(verb) } ||
37
37
  candidates.find { |route| route[:verb] == "ANY" } ||
38
38
  candidates.first
39
- rescue StandardError
39
+ rescue StandardError => e
40
+ # A path no route matches (a 404) has no route to show.
41
+ unless defined?(ActionController::RoutingError) && e.is_a?(ActionController::RoutingError)
42
+ Profiler.log_error_once(:snapshot_route, "could not find the route of a profile", e)
43
+ end
40
44
  nil
41
45
  end
42
46
 
@@ -61,7 +65,7 @@ module Profiler
61
65
  end
62
66
  profile
63
67
  rescue StandardError => e
64
- warn "Profiler: could not add the routes and ENV to profile #{profile.token}: #{e.message}"
68
+ Profiler.log_error("could not add the routes and ENV to profile #{profile.token}", e)
65
69
  profile
66
70
  end
67
71
 
@@ -84,7 +88,8 @@ module Profiler
84
88
  @routes = build_snapshot(fingerprint, list) unless @routes && @routes[0] == fingerprint
85
89
  @routes
86
90
  end
87
- rescue StandardError
91
+ rescue StandardError => e
92
+ Profiler.log_error_once(:snapshot_routes, "could not read the application's routes", e)
88
93
  [nil, [].freeze, {}.freeze]
89
94
  end
90
95
 
@@ -15,12 +15,15 @@ module Profiler
15
15
  config.default(:enabled, development_or_test)
16
16
  config.default(:storage, development_or_test ? :file : :memory)
17
17
  config.default(:track_tests, Rails.env.test?)
18
+ # One test profile per example: no cap on the count in the test environment, so that the
19
+ # report at the end of a suite sees all of them.
20
+ config.default(:max_profiles, Rails.env.test? ? nil : 100)
18
21
 
19
22
  app.config.profiler.each do |key, value|
20
23
  if config.respond_to?("#{key}=")
21
24
  config.public_send("#{key}=", value)
22
25
  else
23
- Rails.logger&.warn("[Profiler] config.profiler.#{key} is not a profiler option, ignored")
26
+ Profiler.log_warn("config.profiler.#{key} is not a profiler option, ignored")
24
27
  end
25
28
  end
26
29
  end
@@ -518,9 +518,7 @@ module Profiler
518
518
  return if reported.include?(error.class)
519
519
 
520
520
  reported << error.class
521
- message = "[Profiler] Redaction: #{error.class} while #{during}; the value was masked instead."
522
- logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
523
- logger ? logger.warn(message) : Kernel.warn(message)
521
+ Profiler.log_warn("Redaction: #{error.class} while #{during}; the value was masked instead.")
524
522
  rescue StandardError
525
523
  nil
526
524
  end
@@ -29,7 +29,8 @@ module Profiler
29
29
  # The version of the last save of +token+, 0 when none is known or Redis cannot be read.
30
30
  def version(token)
31
31
  redis.get(key(token)).to_i
32
- rescue StandardError
32
+ rescue StandardError => e
33
+ Profiler.log_error_once(:redis_event_bus_version, "RedisEventBus: could not read the version of a profile", e)
33
34
  0
34
35
  end
35
36
 
@@ -34,7 +34,8 @@ module Profiler
34
34
 
35
35
  def exists?(token)
36
36
  !load(token).nil?
37
- rescue
37
+ rescue StandardError => e
38
+ Profiler.log_error_once(:store_exists, "#{self.class.name.split("::").last}: could not look up a profile", e)
38
39
  false
39
40
  end
40
41
 
@@ -54,8 +55,9 @@ module Profiler
54
55
 
55
56
  def broadcast_event(token)
56
57
  Profiler::SSE.current.broadcast(token)
57
- rescue StandardError
58
+ rescue StandardError => e
58
59
  # Never let a broadcast failure prevent profile persistence
60
+ Profiler.log_error_once(:store_broadcast, "#{self.class.name.split("::").last}: could not announce a saved profile", e)
59
61
  end
60
62
  end
61
63
  end
@@ -35,7 +35,7 @@ module Profiler
35
35
 
36
36
  JSON.parse(File.read(file))
37
37
  rescue => e
38
- warn "BlobStore: failed to read #{token}/#{collector_name}: #{e.message}"
38
+ Profiler.log_error("BlobStore: could not read #{token}/#{collector_name}", e)
39
39
  nil
40
40
  end
41
41