rails-profiler 0.31.4 → 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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -0
  3. data/app/assets/builds/profiler.js +9 -1
  4. data/app/controllers/profiler/profiles_controller.rb +2 -2
  5. data/lib/profiler/cluster/master_client.rb +7 -15
  6. data/lib/profiler/cluster/security.rb +3 -3
  7. data/lib/profiler/cluster/slave_proxy.rb +7 -4
  8. data/lib/profiler/collectors/lifecycle.rb +2 -2
  9. data/lib/profiler/collectors/log_collector.rb +54 -5
  10. data/lib/profiler/collectors/mailer_collector.rb +8 -4
  11. data/lib/profiler/collectors/request_collector.rb +6 -1
  12. data/lib/profiler/collectors/scoped_notifications.rb +1 -1
  13. data/lib/profiler/configuration.rb +15 -4
  14. data/lib/profiler/console_profiler.rb +1 -1
  15. data/lib/profiler/engine.rb +2 -1
  16. data/lib/profiler/env_override_store.rb +4 -4
  17. data/lib/profiler/explain_runner.rb +5 -1
  18. data/lib/profiler/instrumentation/net_http_instrumentation.rb +138 -40
  19. data/lib/profiler/job_profiler.rb +1 -1
  20. data/lib/profiler/local_request.rb +2 -6
  21. data/lib/profiler/mcp/file_cache.rb +2 -1
  22. data/lib/profiler/mcp/server.rb +1 -1
  23. data/lib/profiler/mcp/tools/get_profile_http.rb +6 -1
  24. data/lib/profiler/mcp/tools/query_console_profiles.rb +5 -1
  25. data/lib/profiler/mcp/tools/query_jobs.rb +5 -1
  26. data/lib/profiler/mcp/tools/query_mailers.rb +5 -1
  27. data/lib/profiler/mcp/tools/query_profiles.rb +5 -1
  28. data/lib/profiler/mcp/tools/query_test_profiles.rb +5 -1
  29. data/lib/profiler/mcp/tools/run_tests.rb +4 -1
  30. data/lib/profiler/middleware/profiler_middleware.rb +22 -19
  31. data/lib/profiler/middleware/streamed_profile.rb +1 -1
  32. data/lib/profiler/middleware/toolbar_injector.rb +21 -2
  33. data/lib/profiler/models/profile.rb +60 -2
  34. data/lib/profiler/process_snapshot.rb +8 -3
  35. data/lib/profiler/railtie.rb +1 -1
  36. data/lib/profiler/redaction.rb +1 -3
  37. data/lib/profiler/sse/redis_event_bus.rb +2 -1
  38. data/lib/profiler/storage/base_store.rb +4 -2
  39. data/lib/profiler/storage/blob_store.rb +1 -1
  40. data/lib/profiler/storage/file_store.rb +8 -3
  41. data/lib/profiler/storage/private_files.rb +3 -3
  42. data/lib/profiler/storage/redis_store.rb +2 -2
  43. data/lib/profiler/storage/sqlite_store.rb +3 -3
  44. data/lib/profiler/storage/unavailable.rb +1 -2
  45. data/lib/profiler/test_helpers/reporter.rb +1 -1
  46. data/lib/profiler/test_profiler.rb +1 -1
  47. data/lib/profiler/test_runner/runner.rb +3 -7
  48. data/lib/profiler/version.rb +1 -1
  49. data/lib/profiler.rb +126 -3
  50. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ca8756c9efed6c4619db0cbc1226f104deb5f7287f972b137f9a8bf291a40c4
4
- data.tar.gz: dcb61c33b3262c081493beb1ee783923b3c7b3ac8d4cd26ef9195ea4e3ff182d
3
+ metadata.gz: 87fc21dec17ab7627d4430f49b042e53061efb33034d7309ea07aac6eb83ac5d
4
+ data.tar.gz: 3e64027bb71fa5ce3d2a23a23cf1525f0e5a55c508c019f1398c35c705f20434
5
5
  SHA512:
6
- metadata.gz: 2576d031d1b4d3e6e462392ed5ca00bc83c12eaed23b9ec07ea842bd3365e373d7eff1b4c53ae73e779366fc947ad70f01f15e8b1108c8fb36e85409986ebd22
7
- data.tar.gz: 4b4d5914c82c06e58fd1d8e14157a28b2d0a9bbc00d15f574a5a5bdb0e782c0e9957042f25c38ba2d6cbc93c159b0b4d2f921667d552e397f37fed896bcc2245
6
+ metadata.gz: 596fda2dad5ffa7e2a7c98a2b749c03901df7748d26776203a30d88a86b842e5956d92453b3847a3cf856a7afee5c4511f02ce83070a6dd26d0ef4d1e6784b5b
7
+ data.tar.gz: b70a833918e15474ac410c182763f13e03e0352e39cd7a0ed066c68a47f2f86762e7ef01fcd5246bfe47d391d86c03556929f66dbb84f0098f0bc43c43e9b415
data/CHANGELOG.md CHANGED
@@ -19,6 +19,79 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.31.5] - 2026-10-05
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Fixed
27
+
28
+ - **Logging:** Send the profiler's own errors and warnings to the application's log instead of
29
+ `$stderr`. A profile that could not be saved, a collector that failed, a store that could not be
30
+ read, a cluster node out of reach: about thirty places wrote to `$stderr`, which is not where
31
+ `log/development.log` is, so these errors went unseen. They now go through one entry point,
32
+ `Profiler.log_error`, to `config.logger` when the application sets one (read on each message),
33
+ otherwise `Rails.logger`, otherwise `$stderr`. Every line starts with `[Profiler]` (the cluster's
34
+ lines, `[Profiler Cluster]` before, now start with `[Profiler] Cluster:`), carries the error's
35
+ class and message, masked then cut at 1,000 bytes, and has the cluster secret masked. A logger that
36
+ raises never fails a request, a job or the boot: the line then goes to `$stderr`. A slave whose
37
+ `Rails.logger` was still nil raised instead of logging. Several failures that left no trace at
38
+ all are now logged, once per place and error class for the life of the process: a store lookup or broadcast, the Redis
39
+ version read of the toolbar, the MCP body cache, the mailer collector reading a mail, the route
40
+ lookup of the Request tab, a file profile that cannot be read to rebuild the index. The request middleware saves its
41
+ profiles through `Profiler.save_profile`, like jobs, console commands and tests; a page whose
42
+ profile could not be saved gets neither the `X-Profiler-Token` header nor the toolbar, which
43
+ would point to a missing profile. The profiler's own lines no longer appear in the Logs tab of
44
+ the request during which they are written, nor count as its errors. The same holds while the store is
45
+ unavailable, for pages and for streamed responses, which no longer send `X-Profiler-Token` for a
46
+ profile that will be dropped. The storage warnings (a store that cannot be created, said once
47
+ per cause; a Redis profile that cannot be indexed; a SQLite summary that cannot be read) go to
48
+ the application's log too, and the `run_tests` MCP tool answers an unavailable store as an error
49
+ instead of a run without profiles.
50
+ - **Outbound HTTP:** Record the calls to services on the same machine. `127.0.0.1`, `localhost`
51
+ and `::1` were always left out, so in development the calls to a neighbouring service never
52
+ showed. `config.http_skip_hosts` is now the whole list, empty by default. The profiler's own
53
+ calls are never recorded, whatever the list says: a slave's registration and heartbeats, and the
54
+ master's requests to its slaves (the slave proxy, the fan-out of the profile pages and the MCP
55
+ tools that query a slave), marked where they are made, and any call to the host and port of
56
+ `config.master_url`. `NetHttpInstrumentation::SKIP_HOSTS` stays, deprecated, with its former value
57
+ (`%w[127.0.0.1 localhost ::1]`); nothing reads it any more.
58
+ - **Outbound HTTP:** Keep the bodies of outbound `Net::HTTP` calls to the first
59
+ `config.max_captured_body_bytes`, sent and received, instead of copying them whole; a compressed
60
+ answer is inflated only up to that size, give or take one 16 KB buffer. A `body_stream` is read
61
+ only when it can be put back where it was (a `StringIO`, a file), up to that size and no further
62
+ than its `Content-Length`: a pipe or a socket was read whole and then sent empty, it is now sent
63
+ unread and its body is not captured. A `body_stream` without `pos`, such as a Faraday multipart
64
+ body or a `RestClient::Payload`, is not captured either, where earlier versions replaced it with
65
+ a copy read whole. The size is the `Content-Length` or the stream's own size, otherwise a
66
+ minimum. The HTTP tab and the `get_profile_http` MCP tool say when a body was cut or not
67
+ captured; each call carries `request_body_truncated`, `response_body_truncated`,
68
+ `request_body_not_captured` and `request_size_is_minimum`. Text bodies read in part stay UTF-8.
69
+ - **Middleware:** Cap the `params` a profile keeps at `config.max_captured_body_bytes`: long values
70
+ are cut and the entries past it left out, with a `"[profiler]"` key saying so, and the profile
71
+ carries `params_truncated`. The application still gets all of its params. With the log line
72
+ capped below, a 5 MB JSON request no longer takes about 15 MB in its profile.
73
+ - **Logs:** Cap what a profile keeps of its request's log lines at the new
74
+ `config.max_captured_log_bytes` (1 MB by default). A request, or a stream held open, that logged
75
+ without end grew its thread's buffer without bound; a single line, such as the
76
+ `Parameters: ...` line of a large request, is cut at the cap. The Logs tab ends with the number
77
+ of lines left out, and the collected data carries `truncated` and `dropped`.
78
+ - **Dump:** `Profiler.dump` returns its argument when the profiler is disabled too; it returned
79
+ `nil`, so a call left in the code changed the value of the expression in production.
80
+ - **Engine:** Stop failing the boot of an application that loads `ActionController::API` (an
81
+ `api_only` application, or a single API controller): the engine called `helper` on it, which
82
+ `ActionController::API` does not have (`NoMethodError`).
83
+ - **Toolbar:** Find where the toolbar goes about four times faster on a page of many tags (0.8 ms
84
+ instead of 4 ms for a page of 20 KB and 4,000 tags, 10 ms instead of 25 ms for 320 KB of tags
85
+ with attributes): ordinary text and tags are skipped in one pass. The place is the same, and a
86
+ `</body>` in an attribute, a comment, a script or a CDATA section, an unclosed quote or a
87
+ `<plaintext>` are handled as before.
88
+ - **Upgrading:** to get the previous behaviour back, in `config/initializers/profiler.rb`:
89
+ - the profiler's messages on `$stderr`: `config.logger = Logger.new($stderr)`;
90
+ - the local hosts left out of the HTTP tab:
91
+ `config.http_skip_hosts += Profiler::Configuration::LOCAL_HTTP_HOSTS`;
92
+ - whole outbound bodies and params: `config.max_captured_body_bytes = nil` (also keeps the
93
+ incoming bodies whole); every log line: `config.max_captured_log_bytes = nil`.
94
+
22
95
  ## [0.31.4] - 2026-10-05
23
96
 
24
97
  <!-- stamped -->
@@ -4414,6 +4414,13 @@
4414
4414
  parts.push(` ${JSON.stringify(req.url)}`);
4415
4415
  return parts.join(" \\\n");
4416
4416
  }
4417
+ function bodyNotes(req) {
4418
+ const notes = [];
4419
+ if (req.request_body_not_captured) notes.push("Request body not captured: a stream that cannot be rewound is sent unread.");
4420
+ if (req.request_body_truncated) notes.push("Request body truncated: only the beginning was kept (max_captured_body_bytes).");
4421
+ if (req.response_body_truncated) notes.push("Response body truncated: only the beginning was kept (max_captured_body_bytes).");
4422
+ return notes;
4423
+ }
4417
4424
  function HttpRequestDetail({ req, index, threshold }) {
4418
4425
  const [open, setOpen] = d2(false);
4419
4426
  const isError = req.status >= 400 || req.status === 0;
@@ -4436,12 +4443,13 @@
4436
4443
  /* @__PURE__ */ u3("div", { class: "profiler-ajax-card__row", children: [
4437
4444
  /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", children: [
4438
4445
  "\u2191 ",
4439
- formatBytes(req.request_size),
4446
+ req.request_size == null ? "size unknown" : `${req.request_size_is_minimum ? "at least " : ""}${formatBytes(req.request_size)}`,
4440
4447
  " \xB7 \u2193 ",
4441
4448
  formatBytes(req.response_size)
4442
4449
  ] }),
4443
4450
  req.backtrace && req.backtrace.length > 0 && /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", style: "margin-left:12px", children: req.backtrace[0] })
4444
4451
  ] }),
4452
+ bodyNotes(req).map((note) => /* @__PURE__ */ u3("div", { class: "profiler-ajax-card__row", children: /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", children: note }) })),
4445
4453
  req.error && /* @__PURE__ */ u3("div", { class: "profiler-ajax-card__row", children: /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--error", children: req.error }) }),
4446
4454
  open && /* @__PURE__ */ u3(
4447
4455
  HttpReqRespDetail,
@@ -100,7 +100,7 @@ module Profiler
100
100
  return profile
101
101
  end
102
102
  rescue => e
103
- Rails.logger.warn("[Profiler] Cached slave #{cached_slave} failed for #{token}: #{e.message}")
103
+ Profiler.log_warn("Cluster: cached slave #{cached_slave} failed for #{token}", e)
104
104
  end
105
105
  end
106
106
  Profiler.token_cache.invalidate(token)
@@ -130,7 +130,7 @@ module Profiler
130
130
  end
131
131
  end
132
132
  rescue => e
133
- Rails.logger.warn("[Profiler] Fan-out error for slave #{slave_name} (#{token}): #{e.message}")
133
+ Profiler.log_warn("Cluster: fan-out error for slave #{slave_name} (#{token})", e)
134
134
  end
135
135
  end
136
136
  end
@@ -14,7 +14,7 @@ module Profiler
14
14
  register!
15
15
  @registered = true
16
16
  rescue => e
17
- log_warn("Could not register with master at startup: #{e.message} — will retry in heartbeat loop")
17
+ log_warn("could not register with master at startup, will retry in the heartbeat loop", e)
18
18
  end
19
19
  start_heartbeat_thread
20
20
  end
@@ -25,7 +25,7 @@ module Profiler
25
25
  config = Profiler.configuration
26
26
  uri = master_uri("register")
27
27
  body = { name: config.resolved_name, url: config.self_url }.to_json
28
- resp = Net::HTTP.post(uri, body, request_headers)
28
+ resp = Profiler.untracked_http { Net::HTTP.post(uri, body, request_headers) }
29
29
  unless resp.code.to_i.between?(200, 299)
30
30
  raise "Master returned #{resp.code}: #{resp.body.to_s.slice(0, 200)}"
31
31
  end
@@ -37,7 +37,7 @@ module Profiler
37
37
  config = Profiler.configuration
38
38
  uri = master_uri("heartbeat")
39
39
  body = { name: config.resolved_name }.to_json
40
- resp = Net::HTTP.post(uri, body, request_headers)
40
+ resp = Profiler.untracked_http { Net::HTTP.post(uri, body, request_headers) }
41
41
  raise "Heartbeat rejected #{resp.code}" unless resp.code.to_i.between?(200, 299)
42
42
  end
43
43
 
@@ -74,25 +74,17 @@ module Profiler
74
74
  end
75
75
  rescue => e
76
76
  @registered = false
77
- log_warn("Cluster communication failed: #{e.message} — will retry")
77
+ log_warn("communication with the master failed, will retry", e)
78
78
  end
79
79
  end
80
80
  end
81
81
 
82
82
  def log_info(msg)
83
- if defined?(Rails)
84
- Rails.logger.info("[Profiler Cluster] #{msg}")
85
- else
86
- $stderr.puts("[Profiler Cluster] #{msg}")
87
- end
83
+ Profiler.log_info("Cluster: #{msg}")
88
84
  end
89
85
 
90
- def log_warn(msg)
91
- if defined?(Rails)
92
- Rails.logger.warn("[Profiler Cluster] #{msg}")
93
- else
94
- $stderr.puts("[Profiler Cluster] WARN #{msg}")
95
- end
86
+ def log_warn(msg, error = nil)
87
+ Profiler.log_warn("Cluster: #{msg}", error)
96
88
  end
97
89
  end
98
90
  end
@@ -44,9 +44,9 @@ module Profiler
44
44
  return unless config.cluster_master? || config.slave?
45
45
  return unless (problem = secret_problem) && secret_required?
46
46
 
47
- logger.warn("[Profiler Cluster] #{problem}: registrations, heartbeats and proxied requests " \
48
- "are refused. Generate one with `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'` " \
49
- "and give the same value to every node.")
47
+ Profiler.log_warn("Cluster: #{problem}: registrations, heartbeats and proxied requests " \
48
+ "are refused. Generate one with `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'` " \
49
+ "and give the same value to every node.", logger: logger)
50
50
  end
51
51
 
52
52
  # Constant-time comparison of the secret a request carries with the configured one.
@@ -115,10 +115,13 @@ module Profiler
115
115
  Security.outgoing_headers.each { |name, value| req[name] = value }
116
116
  # Net::HTTP never follows a redirect; a 3xx is reported, without its body, rather than
117
117
  # handed back as if the slave had answered.
118
- resp = Net::HTTP.start(uri.hostname, uri.port,
119
- open_timeout: @open_timeout, read_timeout: @read_timeout,
120
- use_ssl: uri.scheme == "https") do |http|
121
- http.request(req)
118
+ # The profiler's own call, never recorded in the profile of the request that makes it.
119
+ resp = Profiler.untracked_http do
120
+ Net::HTTP.start(uri.hostname, uri.port,
121
+ open_timeout: @open_timeout, read_timeout: @read_timeout,
122
+ use_ssl: uri.scheme == "https") do |http|
123
+ http.request(req)
124
+ end
122
125
  end
123
126
  return {} if resp.code == "204"
124
127
  if resp.code.to_s.start_with?("3")
@@ -13,7 +13,7 @@ module Profiler
13
13
  collectors.each { |collector| collector.subscribe if collector.respond_to?(:subscribe) }
14
14
  true
15
15
  rescue => e
16
- warn "Profiler #{label}: Collector subscribe failed: #{e.message}"
16
+ Profiler.log_error("#{label}: collector subscribe failed", e)
17
17
  release_all(collectors)
18
18
  false
19
19
  end
@@ -23,7 +23,7 @@ module Profiler
23
23
  collectors&.each do |collector|
24
24
  collector.unsubscribe if collector.respond_to?(:unsubscribe)
25
25
  rescue => e
26
- warn "Profiler: Collector #{collector.class} release failed: #{e.message}"
26
+ Profiler.log_error("collector #{collector.class} release failed", e)
27
27
  end
28
28
  end
29
29
  end
@@ -8,6 +8,43 @@ module Profiler
8
8
  class LogCollector < BaseCollector
9
9
  SEVERITY_LABELS = %w[DEBUG INFO WARN ERROR FATAL UNKNOWN].freeze
10
10
 
11
+ # The lines of one request, up to max_captured_log_bytes of messages: past it, a line is cut
12
+ # to what is left, and the next ones are only counted. A request, or a stream held open,
13
+ # that logs without end no longer grows its thread's buffer without bound.
14
+ class Buffer < Array
15
+ attr_reader :bytes, :dropped
16
+
17
+ def initialize(limit)
18
+ super()
19
+ @limit = limit
20
+ @bytes = 0
21
+ @dropped = 0
22
+ @truncated = false
23
+ end
24
+
25
+ def truncated?
26
+ @truncated
27
+ end
28
+
29
+ def record(entry)
30
+ message = entry[:message]
31
+ if @limit
32
+ room = @limit - @bytes
33
+ if room <= 0
34
+ @dropped += 1
35
+ @truncated = true
36
+ return
37
+ end
38
+ if message.bytesize > room
39
+ message = Redaction.cut_bytes(message, room).scrub("")
40
+ @truncated = true
41
+ end
42
+ end
43
+ @bytes += message.bytesize
44
+ self << entry.merge(message: message)
45
+ end
46
+ end
47
+
11
48
  # Records into the thread's buffer, which only exists while a LogCollector of this thread
12
49
  # is subscribed: lines logged by other threads, or after release, are not kept. A logger
13
50
  # bound to one collector's buffer records only while that buffer is the thread's current
@@ -21,17 +58,20 @@ module Profiler
21
58
  def add(severity, message = nil, progname = nil)
22
59
  logs = Thread.current[:profiler_logs]
23
60
  return true unless logs
61
+ # The profiler's own messages (Profiler.log) are not the application's.
62
+ return true if Thread.current[:profiler_logging]
24
63
  return true if @buffer && !logs.equal?(@buffer)
25
64
 
26
65
  msg = message || progname
27
66
  msg = yield if block_given? && msg.nil?
28
67
  return true if msg.nil?
29
68
 
30
- logs << {
69
+ entry = {
31
70
  level: SEVERITY_LABELS[severity] || "UNKNOWN",
32
71
  message: msg.to_s.strip,
33
72
  timestamp: Time.now.iso8601(3)
34
73
  }
74
+ logs.respond_to?(:record) ? logs.record(entry) : logs << entry
35
75
  true
36
76
  end
37
77
 
@@ -78,7 +118,7 @@ module Profiler
78
118
  end
79
119
 
80
120
  def subscribe
81
- @logs = claim_thread_slot(:profiler_logs, [])
121
+ @logs = claim_thread_slot(:profiler_logs, Buffer.new(Profiler.configuration.max_captured_log_bytes))
82
122
 
83
123
  if defined?(Rails) && Rails.logger
84
124
  if Rails.logger.respond_to?(:broadcast_to)
@@ -90,7 +130,7 @@ module Profiler
90
130
  end
91
131
  end
92
132
  rescue => e
93
- warn "LogCollector#subscribe failed: #{e.message}"
133
+ Profiler.log_error("LogCollector: subscribe failed", e)
94
134
  end
95
135
 
96
136
  # Collect reads only what the collector gathered itself.
@@ -113,12 +153,21 @@ module Profiler
113
153
 
114
154
  errors = logs.count { |l| %w[ERROR FATAL].include?(l[:level]) }
115
155
  warnings = logs.count { |l| l[:level] == "WARN" }
156
+ dropped = logs.respond_to?(:dropped) ? logs.dropped : 0
157
+ truncated = logs.respond_to?(:truncated?) && logs.truncated?
158
+ lines = logs.to_a
159
+ if dropped.positive?
160
+ lines += [{ level: "WARN", timestamp: Time.now.iso8601(3),
161
+ message: "[Profiler] #{dropped} more log lines were not recorded (max_captured_log_bytes)" }]
162
+ end
116
163
 
117
164
  store_data({
118
- count: logs.size,
165
+ count: logs.size + dropped,
119
166
  errors: errors,
120
167
  warnings: warnings,
121
- logs: logs
168
+ logs: lines,
169
+ truncated: truncated,
170
+ dropped: dropped
122
171
  })
123
172
  end
124
173
 
@@ -213,7 +213,8 @@ module Profiler
213
213
  serialize_assign(filtered)
214
214
  end
215
215
  end
216
- rescue StandardError
216
+ rescue StandardError => e
217
+ Profiler.log_error_once(:mailer_assigns, "MailerCollector: could not read the mailer's assigns", e)
217
218
  {}
218
219
  end
219
220
 
@@ -232,7 +233,8 @@ module Profiler
232
233
  text = Profiler::Redaction.truncate(mail.body.decoded, MAX_BODY_SIZE, "")
233
234
  end
234
235
  [html.nil? || html.empty? ? nil : html, text.nil? || text.empty? ? nil : text]
235
- rescue StandardError
236
+ rescue StandardError => e
237
+ Profiler.log_error_once(:mailer_body, "MailerCollector: could not read the mail's body", e)
236
238
  [nil, nil]
237
239
  end
238
240
 
@@ -250,7 +252,8 @@ module Profiler
250
252
  return nil unless mail.is_a?(String)
251
253
 
252
254
  Mail.new(mail)
253
- rescue StandardError
255
+ rescue StandardError => e
256
+ Profiler.log_error_once(:mailer_parse, "MailerCollector: could not parse the mail", e)
254
257
  nil
255
258
  end
256
259
 
@@ -291,7 +294,8 @@ module Profiler
291
294
  "filename" => att.filename.to_s,
292
295
  "size" => att.body.decoded.bytesize
293
296
  }
294
- rescue StandardError
297
+ rescue StandardError => e
298
+ Profiler.log_error_once(:mailer_attachment, "MailerCollector: could not read an attachment", e)
295
299
  { "filename" => att.filename.to_s, "size" => 0 }
296
300
  end
297
301
  end
@@ -33,6 +33,7 @@ module Profiler
33
33
  allocated_objects: @profile.allocated_objects,
34
34
  memory: @profile.memory, # deprecated, see Profile#memory
35
35
  params: @profile.params,
36
+ params_truncated: @profile.params_truncated || false,
36
37
  headers: @profile.headers,
37
38
  response_headers: @profile.response_headers,
38
39
  request_body: @profile.request_body,
@@ -103,7 +104,11 @@ module Profiler
103
104
  route_params: Profiler::Redaction.filter_hash(route_params),
104
105
  controller_action: controller_action
105
106
  }
106
- rescue StandardError
107
+ rescue StandardError => e
108
+ # A path no route matches (a 404) has no route to show.
109
+ unless defined?(ActionController::RoutingError) && e.is_a?(ActionController::RoutingError)
110
+ Profiler.log_error_once(:request_route, "RequestCollector: could not read the matched route", e)
111
+ end
107
112
  {}
108
113
  end
109
114
 
@@ -219,7 +219,7 @@ module Profiler
219
219
  handler.call(name, started, finished, id, payload)
220
220
  rescue StandardError => e
221
221
  # Never into the application's query, nor at the expense of the other collectors.
222
- warn "Profiler: a collector failed on #{event}: #{e.class}: #{e.message}"
222
+ Profiler.log_error("collector failed on #{event}", e)
223
223
  end
224
224
  end
225
225
  end
@@ -33,6 +33,10 @@ module Profiler
33
33
 
34
34
  BACKEND_LOCK = Mutex.new
35
35
 
36
+ # The hosts earlier versions always left out of the outbound HTTP tab. Add them back with
37
+ # config.http_skip_hosts += Profiler::Configuration::LOCAL_HTTP_HOSTS.
38
+ LOCAL_HTTP_HOSTS = [/\A127\.0\.0\.1\z/, /\Alocalhost\z/i, /\A::1\z/].freeze
39
+
36
40
  attr_accessor :storage_options, :collectors,
37
41
  :skip_paths, :slow_query_threshold, :max_queries_warning, :sql_backtrace,
38
42
  :track_memory, :allocated_objects_warning_threshold,
@@ -46,12 +50,12 @@ module Profiler
46
50
  :apply_env_overrides_when_disabled, :restrict_storage_permissions,
47
51
  :test_runner_allow_undiscovered_files,
48
52
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
49
- :compress_bodies, :compress_body_threshold, :max_captured_body_bytes,
53
+ :compress_bodies, :compress_body_threshold, :max_captured_body_bytes, :max_captured_log_bytes,
50
54
  :redact_sensitive_data, :filter_parameters, :env_allowlist,
51
55
  :name, :master_url, :self_url,
52
56
  :cluster_heartbeat_interval, :cluster_offline_threshold,
53
57
  :cluster_master, :cluster_secret, :cluster_require_secret,
54
- :cluster_allow_insecure_http
58
+ :cluster_allow_insecure_http, :logger
55
59
 
56
60
  attr_reader :authorize_block, :enabled, :track_tests, :max_profiles
57
61
 
@@ -89,6 +93,8 @@ module Profiler
89
93
  @ajax_skip_paths = [/^\/_profiler/]
90
94
  @track_http = true
91
95
  @slow_http_threshold = 500 # milliseconds
96
+ # Every host whose outbound calls are left out (Strings and Regexps matched against the host).
97
+ # The profiler's own calls (cluster, slave proxy) are never recorded, whatever this says.
92
98
  @http_skip_hosts = []
93
99
  @http_backtrace_depth = 40
94
100
  @track_jobs = true
@@ -107,6 +113,8 @@ module Profiler
107
113
  # The request and response bodies kept in a profile stop at this many bytes; the
108
114
  # application still reads and sends all of them. nil keeps whole bodies.
109
115
  @max_captured_body_bytes = 256 * 1024
116
+ # What a profile keeps of the lines its request logs, in bytes. nil keeps every line.
117
+ @max_captured_log_bytes = 1024 * 1024
110
118
  @redact_sensitive_data = true
111
119
  @filter_parameters = DEFAULT_FILTER_PARAMETERS.dup
112
120
  @env_allowlist = DEFAULT_ENV_ALLOWLIST.dup
@@ -125,6 +133,9 @@ module Profiler
125
133
  @cluster_require_secret = true
126
134
  @cluster_allowed_slave_urls = []
127
135
  @cluster_allow_insecure_http = false
136
+ # Where the profiler's own errors go (Profiler.log): Rails.logger when nil, or $stderr when
137
+ # there is none.
138
+ @logger = nil
128
139
  end
129
140
 
130
141
  # Always a Pathname, whether set from a String or left to its default.
@@ -146,8 +157,8 @@ module Profiler
146
157
  end
147
158
 
148
159
  def memory_warning_threshold=(bytes)
149
- warn "[Profiler] memory_warning_threshold is deprecated, set allocated_objects_warning_threshold " \
150
- "(a number of objects) instead"
160
+ Profiler.log_warn("memory_warning_threshold is deprecated, set allocated_objects_warning_threshold " \
161
+ "(a number of objects) instead")
151
162
  @allocated_objects_warning_threshold = bytes && bytes / AllocationCounter::LEGACY_BYTES_PER_OBJECT
152
163
  end
153
164
 
@@ -76,7 +76,7 @@ module Profiler
76
76
  collector.collect if collector.respond_to?(:collect)
77
77
  profile.add_collector_metadata(collector)
78
78
  rescue => e
79
- warn "Profiler ConsoleProfiler: Collector #{collector.class} failed: #{e.message}"
79
+ Profiler.log_error("ConsoleProfiler: collector #{collector.class} failed", e)
80
80
  end
81
81
  end
82
82
 
@@ -9,8 +9,9 @@ module Profiler
9
9
  config.profiler = ActiveSupport::OrderedOptions.new
10
10
 
11
11
  initializer "profiler.helpers" do
12
+ # The hook runs for ActionController::API as well, which has no view helpers.
12
13
  ActiveSupport.on_load(:action_controller) do
13
- helper Profiler::Engine.helpers
14
+ helper Profiler::Engine.helpers if respond_to?(:helper)
14
15
  end
15
16
  end
16
17
  end
@@ -108,10 +108,10 @@ module Profiler
108
108
 
109
109
  @boot_warning_logged = true
110
110
  noun, verb = count == 1 ? %w[override was] : %w[overrides were]
111
- logger&.warn("[Profiler] #{count} persisted environment #{noun} in #{override_file_path} " \
112
- "#{verb} not applied: #{BLOCKED_REASONS.fetch(reason)}.")
111
+ Profiler.log_warn("#{count} persisted environment #{noun} in #{override_file_path} " \
112
+ "#{verb} not applied: #{BLOCKED_REASONS.fetch(reason)}.", logger: logger)
113
113
  rescue => e
114
- warn "[Profiler] EnvOverrideStore: failed to check overrides at boot: #{e.message}"
114
+ Profiler.log_error("EnvOverrideStore: failed to check overrides at boot", e)
115
115
  end
116
116
 
117
117
  # Set by the test runner in the environment of the test process, to which it already gave
@@ -158,7 +158,7 @@ module Profiler
158
158
  end
159
159
  end
160
160
  rescue => e
161
- warn "[Profiler] EnvOverrideStore: failed to apply overrides: #{e.message}"
161
+ Profiler.log_error("EnvOverrideStore: failed to apply overrides", e)
162
162
  end
163
163
 
164
164
  # Returns active overrides (excludes RESTORE entries, already being reverted)
@@ -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