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
@@ -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
@@ -29,13 +29,19 @@ module Profiler
29
29
 
30
30
  # Options whose default depends on the Rails environment: the railtie sets them only when
31
31
  # the application has not assigned them itself (see #default).
32
- RAILS_DEFAULTED = %i[enabled storage track_tests].freeze
32
+ RAILS_DEFAULTED = %i[enabled storage track_tests max_profiles].freeze
33
+
34
+ BACKEND_LOCK = Mutex.new
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
33
39
 
34
40
  attr_accessor :storage_options, :collectors,
35
41
  :skip_paths, :slow_query_threshold, :max_queries_warning, :sql_backtrace,
36
42
  :track_memory, :allocated_objects_warning_threshold,
37
43
  :mcp_enabled, :mcp_transport, :mcp_port,
38
- :authorization_mode, :max_profiles, :extension_cors_enabled,
44
+ :authorization_mode, :extension_cors_enabled,
39
45
  :cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
40
46
  :track_ajax, :ajax_skip_paths,
41
47
  :track_http, :slow_http_threshold, :http_skip_hosts, :http_backtrace_depth,
@@ -44,14 +50,14 @@ module Profiler
44
50
  :apply_env_overrides_when_disabled, :restrict_storage_permissions,
45
51
  :test_runner_allow_undiscovered_files,
46
52
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
47
- :compress_bodies, :compress_body_threshold, :max_captured_body_bytes,
53
+ :compress_bodies, :compress_body_threshold, :max_captured_body_bytes, :max_captured_log_bytes,
48
54
  :redact_sensitive_data, :filter_parameters, :env_allowlist,
49
55
  :name, :master_url, :self_url,
50
56
  :cluster_heartbeat_interval, :cluster_offline_threshold,
51
57
  :cluster_master, :cluster_secret, :cluster_require_secret,
52
- :cluster_allow_insecure_http
58
+ :cluster_allow_insecure_http, :logger
53
59
 
54
- attr_reader :authorize_block, :enabled, :track_tests
60
+ attr_reader :authorize_block, :enabled, :track_tests, :max_profiles
55
61
 
56
62
  def initialize
57
63
  @assigned = []
@@ -87,6 +93,8 @@ module Profiler
87
93
  @ajax_skip_paths = [/^\/_profiler/]
88
94
  @track_http = true
89
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.
90
98
  @http_skip_hosts = []
91
99
  @http_backtrace_depth = 40
92
100
  @track_jobs = true
@@ -105,6 +113,8 @@ module Profiler
105
113
  # The request and response bodies kept in a profile stop at this many bytes; the
106
114
  # application still reads and sends all of them. nil keeps whole bodies.
107
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
108
118
  @redact_sensitive_data = true
109
119
  @filter_parameters = DEFAULT_FILTER_PARAMETERS.dup
110
120
  @env_allowlist = DEFAULT_ENV_ALLOWLIST.dup
@@ -123,6 +133,9 @@ module Profiler
123
133
  @cluster_require_secret = true
124
134
  @cluster_allowed_slave_urls = []
125
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
126
139
  end
127
140
 
128
141
  # Always a Pathname, whether set from a String or left to its default.
@@ -144,8 +157,8 @@ module Profiler
144
157
  end
145
158
 
146
159
  def memory_warning_threshold=(bytes)
147
- warn "[Profiler] memory_warning_threshold is deprecated, set allocated_objects_warning_threshold " \
148
- "(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")
149
162
  @allocated_objects_warning_threshold = bytes && bytes / AllocationCounter::LEGACY_BYTES_PER_OBJECT
150
163
  end
151
164
 
@@ -181,6 +194,13 @@ module Profiler
181
194
  @track_tests = value
182
195
  end
183
196
 
197
+ # How many profiles a store keeps, the oldest evicted first; nil for no cap on the count (the
198
+ # memory store still keeps 100). The railtie leaves it nil in the test environment.
199
+ def max_profiles=(value)
200
+ @assigned |= [:max_profiles]
201
+ @max_profiles = value
202
+ end
203
+
184
204
  # Sets one of RAILS_DEFAULTED to value unless the application assigned it, in
185
205
  # config/application.rb for instance, which runs before the railtie's initializers.
186
206
  def default(option, value)
@@ -246,8 +266,10 @@ module Profiler
246
266
  @storage_backend = nil
247
267
  end
248
268
 
269
+ # Under a lock of its own, for a caller that asks the configuration directly; Profiler.storage
270
+ # takes it inside its own, always in that order.
249
271
  def storage_backend
250
- @storage_backend ||= build_storage_backend
272
+ @storage_backend || BACKEND_LOCK.synchronize { @storage_backend ||= build_storage_backend }
251
273
  end
252
274
 
253
275
  private
@@ -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
@@ -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!
@@ -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
 
@@ -5,7 +5,7 @@ module Profiler
5
5
  module Resources
6
6
  class FailingTests
7
7
  def self.call
8
- profiles = Profiler.storage.list(limit: 500)
8
+ profiles = Profiler.storage.list(limit: 500, type: "test")
9
9
  failing = profiles.select do |p|
10
10
  next false unless p.profile_type == "test"
11
11
  test_data = p.collector_data("test") || {}
@@ -5,8 +5,8 @@ module Profiler
5
5
  module Resources
6
6
  class RecentConsole
7
7
  def self.call
8
- profiles = Profiler.storage.list(limit: 200)
9
- consoles = profiles.select { |p| p.profile_type == "console" }.first(50)
8
+ profiles = Profiler.storage.list(limit: 50, type: "console")
9
+ consoles = profiles.select { |p| p.profile_type == "console" }
10
10
 
11
11
  data = consoles.map do |profile|
12
12
  console_data = profile.collector_data("console") || {}
@@ -5,8 +5,8 @@ module Profiler
5
5
  module Resources
6
6
  class RecentJobs
7
7
  def self.call
8
- profiles = Profiler.storage.list(limit: 200)
9
- jobs = profiles.select { |p| p.profile_type == "job" }.first(50)
8
+ profiles = Profiler.storage.list(limit: 50, type: "job")
9
+ jobs = profiles.select { |p| p.profile_type == "job" }
10
10
 
11
11
  data = jobs.map do |profile|
12
12
  job_data = profile.collector_data("job") || {}