rails-profiler 0.33.1 → 0.33.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d632e07d28796ba7902cd20f9466b4c529c77ab76b5627c2b946e6b059b23c1f
4
- data.tar.gz: 7c91607d3ae24180f6708938a8dfa118ad297b11a6b9749cf155adeef5b81336
3
+ metadata.gz: 8e1caccb1662a8f09108682f5933e943d41a0b36ed7d60930c60eb8c385b5159
4
+ data.tar.gz: c10d767853cc07caa36484f9693a4c4adeef415e0874d29ca817d8d7d1b17730
5
5
  SHA512:
6
- metadata.gz: a7781894a3b105e730d47d63c0e7a2a15b28a58a51a38fc4662fb193c0665c1cdb8fd7072cadc7926f38a96e6a68a350ae19f16ef2560a119c89355fdbe3f4a6
7
- data.tar.gz: e626c7d2d344ad58473fdecbbbaf3bfae340faf17eb1dd25ca7f88325ffe87c9a9837fefcdc9b1dea3cdc3541ce46a4156d1d4b17acd3f4b049defd2c08fcd03
6
+ metadata.gz: 8aacfc0469f52d9f6a11a58356eebb853ff716f2729ca47833b9b0ca99197f767a0dad5904f8c9e7d8a8501869dd712a0c5e6e246fa8ea0e55976b546589d1ac
7
+ data.tar.gz: 3ce33e8db95c0ae4028a68e11d941bb72072b5a6edcf539cfc0fb918c2248d675c4df92e28788da0d59d942546458384b22ec5ac974bb58404be1d0049130627
data/CHANGELOG.md CHANGED
@@ -19,6 +19,47 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.33.2] - 2026-10-14
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Changed
27
+
28
+ - **Configuration:** A String in `config.http_skip_hosts` is now a host name, compared whole and
29
+ ignoring case, instead of a pattern: `"example.com"` no longer leaves out `notexample.com`,
30
+ `exampleXcom` or `example.com.attacker.net`. A String that starts with a dot is a domain whose
31
+ subdomains are left out (`".internal"` leaves out `api.internal`). A Regexp is matched as
32
+ before. An entry that is not a host name, a wildcard String (`"*.internal"`), an empty one, or
33
+ anything that is neither a String nor a Regexp (`nil`), is left out when the list is set, with
34
+ a warning in the log that says what to write, and the other entries are kept. It does not
35
+ raise: the gem is loaded in production too, where a mistake in this list must not stop the
36
+ application from booting, even with the profiler disabled.
37
+ - **Dumps:** A dump is stored as its formatted text only: the raw `value` field, which no tab
38
+ read and the API did not document, is no longer part of the dump data.
39
+
40
+ ### Fixed
41
+
42
+ - **Configuration:** A String in `config.skip_paths` no longer fails every request with a
43
+ `TypeError`: it skips the path equal to it, and a Regexp is matched as before. A single
44
+ pattern, a String or a Regexp, set without a list is read as a list of one.
45
+ - **Outbound HTTP:** A String in `config.http_skip_hosts` that is not a valid Regexp, such as
46
+ `"*.internal"` added with `<<`, no longer fails every `Net::HTTP` call of a profiled request
47
+ with a `RegexpError`.
48
+ - **Database:** A query that binds bytes, a `blob` or `bytea` column such as an image, no longer
49
+ loses the profile of its request when it is saved: the bind shows as text when its bytes are
50
+ valid UTF-8, and as `[binary data, N bytes]` otherwise.
51
+ - **Logs:** A line logged with bytes that are not valid UTF-8 no longer raises an
52
+ `Encoding::CompatibilityError` in the application's own logger call (Rails 7.1 and later), and
53
+ a binary line no longer loses the profile when it is saved: the line is kept as UTF-8, what
54
+ cannot be read replaced with U+FFFD, and a line in another encoding is converted.
55
+ - **Dumps:** `Profiler.dump` of an object that refers to itself, directly or through an instance
56
+ variable, no longer fails the request with a `SystemStackError` (a 500 with the file store), and
57
+ an Array or a Hash holding itself no longer loses the profile. An object whose own `inspect`
58
+ recurses without end shows `[Error formatting value: stack level too deep]`.
59
+ - **Middleware:** A page whose body is made of fragments of different encodings, a binary one
60
+ among UTF-8 ones, no longer fails with an `Encoding::CompatibilityError` once profiled: its
61
+ bytes are joined as the application gave them, and it gets its toolbar.
62
+
22
63
  ## [0.33.1] - 2026-10-14
23
64
 
24
65
  <!-- stamped -->
@@ -9,6 +9,8 @@ module Profiler
9
9
  BACKTRACE_DEPTH = 10
10
10
  MAX_SCANNED_FRAMES = 100
11
11
  FRAMES_PAST_APPLICATION = 15
12
+ # What a bind holding bytes that are not text shows instead (see #displayable_bind).
13
+ BINARY_BIND = "[binary data, %d bytes]"
12
14
 
13
15
 
14
16
  # Frames between the application's code and the subscriber: the profiler itself, the
@@ -122,9 +124,9 @@ module Profiler
122
124
  binds.map do |bind|
123
125
  if bind.respond_to?(:value)
124
126
  name = bind.name if bind.respond_to?(:name)
125
- Profiler::Redaction.filter_named(name, bind.value)
127
+ displayable_bind(Profiler::Redaction.filter_named(name, bind.value))
126
128
  else
127
- bind
129
+ displayable_bind(bind)
128
130
  end
129
131
  end
130
132
  rescue StandardError
@@ -132,6 +134,18 @@ module Profiler
132
134
  binds.map { Profiler::Redaction::MASK }
133
135
  end
134
136
 
137
+ # A blob or bytea value is bytes, not text: kept as it is, it fails the JSON of the whole
138
+ # profile when it is saved. Bytes that are valid UTF-8 are shown as text, others as their
139
+ # size. Text in another encoding stays as it is: JSON converts it.
140
+ def displayable_bind(value)
141
+ value = value.to_s if defined?(ActiveModel::Type::Binary::Data) && value.is_a?(ActiveModel::Type::Binary::Data)
142
+ return value unless value.is_a?(String)
143
+ return value if value.valid_encoding? && value.encoding != Encoding::BINARY
144
+
145
+ utf8 = value.dup.force_encoding(Encoding::UTF_8)
146
+ utf8.valid_encoding? ? utf8 : format(BINARY_BIND, value.bytesize)
147
+ end
148
+
135
149
  # Capturing the caller costs tens of microseconds. The first run of a statement locates
136
150
  # it, an N+1 loop included (its repeats are the same statement with other values); a slow
137
151
  # query is always located.
@@ -33,9 +33,10 @@ module Profiler
33
33
  dumps = @dumps || Thread.current[:profiler_dumps] || []
34
34
  unsubscribe
35
35
 
36
+ # Only the formatted text is kept: the object itself can refer to itself, or hold what
37
+ # JSON cannot write, and would make the profile fail when it is saved.
36
38
  formatted_dumps = dumps.map do |dump|
37
39
  {
38
- value: dump[:value],
39
40
  formatted: format_value(dump[:value]),
40
41
  file: dump[:file],
41
42
  line: dump[:line],
@@ -97,7 +98,8 @@ module Profiler
97
98
  formatted += ">"
98
99
  formatted
99
100
  end
100
- rescue => e
101
+ # An inspect of the application's that recurses without end is said, like any other error.
102
+ rescue StandardError, SystemStackError => e
101
103
  "[Error formatting value: #{e.message}]"
102
104
  end
103
105
  end
@@ -68,7 +68,7 @@ module Profiler
68
68
 
69
69
  entry = {
70
70
  level: SEVERITY_LABELS[severity] || "UNKNOWN",
71
- message: msg.to_s.strip,
71
+ message: self.class.readable(msg.to_s).strip,
72
72
  timestamp: Time.now.iso8601(3)
73
73
  }
74
74
  logs.respond_to?(:record) ? logs.record(entry) : logs << entry
@@ -78,6 +78,20 @@ module Profiler
78
78
  def <<(msg)
79
79
  add(0, msg.to_s)
80
80
  end
81
+
82
+ # The line as valid UTF-8, a copy when it has to change: bytes that are not text in its
83
+ # encoding would raise in strip, inside the application's own logger call, or fail the
84
+ # JSON of the profile when it is saved. Binary bytes are read as UTF-8; a line in another
85
+ # encoding is converted. What cannot be read becomes U+FFFD.
86
+ def self.readable(text)
87
+ return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
88
+
89
+ if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(text.encoding)
90
+ text.dup.force_encoding(Encoding::UTF_8).scrub
91
+ else
92
+ text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
93
+ end
94
+ end
81
95
  end
82
96
 
83
97
  def icon
@@ -38,13 +38,13 @@ module Profiler
38
38
  LOCAL_HTTP_HOSTS = [/\A127\.0\.0\.1\z/, /\Alocalhost\z/i, /\A::1\z/].freeze
39
39
 
40
40
  attr_accessor :storage_options, :collectors,
41
- :skip_paths, :slow_query_threshold, :max_queries_warning, :sql_backtrace,
41
+ :slow_query_threshold, :max_queries_warning, :sql_backtrace,
42
42
  :track_memory, :allocated_objects_warning_threshold,
43
43
  :mcp_enabled, :mcp_transport, :mcp_port,
44
44
  :authorization_mode, :extension_cors_enabled,
45
45
  :cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
46
46
  :track_ajax, :ajax_skip_paths,
47
- :track_http, :slow_http_threshold, :http_skip_hosts, :http_backtrace_depth,
47
+ :track_http, :slow_http_threshold, :http_backtrace_depth,
48
48
  :track_jobs,
49
49
  :track_console,
50
50
  :apply_env_overrides_when_disabled, :restrict_storage_permissions,
@@ -148,7 +148,23 @@ module Profiler
148
148
  @tmp_path = value.nil? ? nil : Pathname.new(value)
149
149
  end
150
150
 
151
- attr_reader :cluster_allowed_slave_urls, :mcp_allowed_hosts
151
+ attr_reader :cluster_allowed_slave_urls, :mcp_allowed_hosts, :skip_paths, :http_skip_hosts
152
+
153
+ # Paths that are not profiled: a Regexp matched against the path, or a String equal to it.
154
+ # A single pattern is a list of one.
155
+ def skip_paths=(value)
156
+ @skip_paths = Array(value)
157
+ end
158
+
159
+ # Hosts whose outbound calls are left out: a String is a host name, matched whole and
160
+ # case-insensitively, or, when it starts with a dot (".internal"), a domain whose subdomains
161
+ # are left out; a Regexp is matched against the host name. Any other entry (a wildcard
162
+ # "*.internal", an empty String, nil) is left out with a warning in the log, the others kept:
163
+ # the gem is loaded in production too, where a mistake here must not stop the application
164
+ # from booting.
165
+ def http_skip_hosts=(value)
166
+ @http_skip_hosts = Array(value).select { |entry| valid_http_skip_host?(entry) }
167
+ end
152
168
 
153
169
  # Host names the MCP HTTP endpoint accepts beyond the loopback ones and config.hosts: a String
154
170
  # (a host name, any port, or a host:port) or a Regexp anchored with \A and \z, matched
@@ -323,6 +339,22 @@ module Profiler
323
339
  end
324
340
  end
325
341
 
342
+ def valid_http_skip_host?(entry)
343
+ problem =
344
+ case entry
345
+ when Regexp then nil
346
+ when String
347
+ if entry.strip.empty? || entry.match?(/[*\s]/)
348
+ "is not a host name; list each host, a domain as \".internal\" for its subdomains, or use a Regexp"
349
+ end
350
+ else "must be a String or a Regexp"
351
+ end
352
+ return true unless problem
353
+
354
+ Profiler.log_warn("config.http_skip_hosts: #{entry.inspect} #{problem}; ignored")
355
+ false
356
+ end
357
+
326
358
  def validate_mcp_allowed_host_pattern(pattern)
327
359
  unless Profiler::Cluster::Security.anchored_pattern?(pattern)
328
360
  raise ArgumentError, "config.mcp_allowed_hosts: #{pattern.inspect} must start with \\A and end with \\z, " \
@@ -122,12 +122,29 @@ module Profiler
122
122
  # it are the profiler's, whichever code makes them.
123
123
  def self.skip_host?(host, port = nil)
124
124
  config = Profiler.configuration
125
- return true if config.http_skip_hosts.any? { |pattern| host.match?(pattern) }
125
+ return true if config.http_skip_hosts.any? { |pattern| skip_host_pattern?(pattern, host) }
126
126
 
127
127
  master = master_address(config.master_url)
128
128
  !master.nil? && master[0].casecmp?(host) && (port.nil? || master[1] == port)
129
129
  end
130
130
 
131
+ # A String is never read as a Regexp: "example.com" would match notexample.com, and
132
+ # "*.internal" would raise on every call. It is the host name, or, starting with a dot, a
133
+ # domain whose subdomains match. A list changed in place (<<) is not validated: anything
134
+ # else matches nothing.
135
+ def self.skip_host_pattern?(pattern, host)
136
+ case pattern
137
+ when Regexp then pattern.match?(host)
138
+ when String
139
+ if pattern.start_with?(".")
140
+ host.length > pattern.length && host.downcase.end_with?(pattern.downcase)
141
+ else
142
+ pattern.casecmp?(host)
143
+ end
144
+ else false
145
+ end
146
+ end
147
+
131
148
  def self.master_address(url)
132
149
  return nil if url.nil? || url.to_s.empty?
133
150
 
@@ -278,7 +278,8 @@ module Profiler
278
278
 
279
279
  # Skip profiler's own paths and static assets
280
280
  path = env["PATH_INFO"]
281
- Profiler.configuration.skip_paths.none? { |pattern| path =~ pattern }
281
+ # === matches a Regexp against the path and compares a String with it.
282
+ Profiler.configuration.skip_paths.none? { |pattern| pattern === path }
282
283
  end
283
284
 
284
285
  def build_request(env)
@@ -371,13 +372,7 @@ module Profiler
371
372
  def read_buffered_body(body)
372
373
  content =
373
374
  begin
374
- if body.respond_to?(:to_ary)
375
- body.to_ary.join
376
- else
377
- parts = +""
378
- body.each { |part| parts << part }
379
- parts
380
- end
375
+ join_parts(body.respond_to?(:to_ary) ? body.to_ary : body)
381
376
  rescue Exception # rubocop:disable Lint/RescueException
382
377
  begin
383
378
  close_body(body)
@@ -390,6 +385,20 @@ module Profiler
390
385
  content
391
386
  end
392
387
 
388
+ # The fragments' bytes, joined as they are: fragments of different encodings (a binary one
389
+ # among UTF-8 ones) cannot be joined as text, and converting them would change the page.
390
+ # The result takes the encoding of the first fragment, as CapturingBody does.
391
+ def join_parts(parts)
392
+ content = String.new(encoding: Encoding::BINARY)
393
+ encoding = nil
394
+ parts.each do |part|
395
+ part = part.to_s
396
+ encoding ||= part.encoding
397
+ content << part.b
398
+ end
399
+ content.force_encoding(encoding || Encoding::UTF_8)
400
+ end
401
+
393
402
  def close_body(body)
394
403
  body.close if body.respond_to?(:close) && !(body.respond_to?(:closed?) && body.closed?)
395
404
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.33.1"
4
+ VERSION = "0.33.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.33.1
4
+ version: 0.33.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy