rails-profiler 0.34.3 → 0.34.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 066e4af89b9741273fe1740d6dfbcecaa32df311835699642cafad8e703ca4ec
4
- data.tar.gz: 05f65764c8e80a2b61ceea1f164dd85fa1d3d920b71452c3abdd35044698107b
3
+ metadata.gz: 0b498ec1274af5787fe96b70e64613b009298b578a7b084d65d450ec86a431bc
4
+ data.tar.gz: b575d9afef090eee5539cfca2ffd536887d90b4c08a3b5099504a8e31ca69eab
5
5
  SHA512:
6
- metadata.gz: 7eeb6e60ace7bbfccb32d3a5eeb0d42243a5bd39d551d08aeaefe05ae8cb79a76b91aee3e1e2a5e2edcaba11d1cd99a89d9860c788841915b16a62483bc457bb
7
- data.tar.gz: ae5f198e1f98ca9f964b25a1fba4d419d4c2544e5c651d7921f984626be85d64f4afc494501602e4a19e306ff6cacc8d16f9e5c6f2895a45778af1f444c7e3e3
6
+ metadata.gz: f7da413fad8fd79420dddc04242b1ce30c05aac1106073219b7c0dcda26dada8c59a1b8e0d6a6c3c403d92ee6cd519271285d5f99fe6670b0e970179f79be119
7
+ data.tar.gz: d36516568229d7fa228dad51b70a30e8ca2753cc07fa114279f322200d32bd9a4f5e02891ff0ea96b0c904dc1594d528f677129b840fe0acadd2f106f29ea9cc
data/CHANGELOG.md CHANGED
@@ -19,6 +19,40 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.34.5] - 2026-10-16
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Fixed
27
+
28
+ - **Request capture:** the parameters of a request are now read once the application has returned, so
29
+ route parameters are present and `param_encoding` is applied, and they are converted to UTF-8. They
30
+ used to be read in the outermost middleware, before the router ran.
31
+ - **Request capture:** a text body keeps its accents. It is now read in the `charset` of its
32
+ `Content-Type`, UTF-8 when it declares none, and invalid bytes are replaced, instead of every
33
+ accent being turned into a replacement character.
34
+ - **Request capture:** a request whose `Content-Type` declares a charset Ruby cannot convert from
35
+ (`charset=utf-7`, `charset=internal`) no longer loses its profile; the body is read as UTF-8 with
36
+ invalid bytes replaced.
37
+
38
+ ## [0.34.4] - 2026-10-16
39
+
40
+ <!-- stamped -->
41
+
42
+ ### Fixed
43
+
44
+ - **Sidekiq:** A job run through the ActiveJob adapter is no longer profiled twice. It reaches the
45
+ Sidekiq middleware wrapped (`job["wrapped"]`) and is already profiled by the ActiveJob
46
+ `around_perform` callback, so the middleware now leaves it alone when ActiveJob is instrumented
47
+ and the same work is recorded once.
48
+ - **Logs:** The log buffer behind `CaptureLogger` is now guarded by a `Mutex`, so lines recorded
49
+ from several threads at once (a streamed body iterated by another thread, work handed the buffer)
50
+ keep the byte count and the stored lines consistent.
51
+ - **Flame graph and views:** Nested durations are counted once. The total of the flame graph and
52
+ the total of the views and partials now cover each moment a single time, so a template that
53
+ renders partials, or a controller action that runs queries, is no longer added to what it
54
+ contains.
55
+
22
56
  ## [0.34.3] - 2026-10-15
23
57
 
24
58
  <!-- stamped -->
@@ -124,6 +124,24 @@ module Profiler
124
124
 
125
125
  protected
126
126
 
127
+ # The time covered by +intervals+ ([started, finished] pairs of Time or Float seconds), in
128
+ # milliseconds, each moment counted once: a template that renders partials, or a controller
129
+ # action that runs queries, is not added to what it contains.
130
+ def covered_ms(intervals)
131
+ covered = 0.0
132
+ current_end = nil
133
+ intervals.sort_by(&:first).each do |started, finished|
134
+ if current_end.nil? || started >= current_end
135
+ covered += finished - started
136
+ current_end = finished
137
+ elsif finished > current_end
138
+ covered += finished - current_end
139
+ current_end = finished
140
+ end
141
+ end
142
+ (covered * 1000).round(2)
143
+ end
144
+
127
145
  def store_data(data)
128
146
  @data = data
129
147
  @profile.add_collector_data(name, data)
@@ -139,7 +139,7 @@ module Profiler
139
139
 
140
140
  store_data({
141
141
  total_events: @events.size,
142
- total_duration: @events.empty? ? 0 : @events.map(&:duration).sum.round(2),
142
+ total_duration: @events.empty? ? 0 : covered_ms(@events.map { |e| [e.started_at, e.finished_at] }),
143
143
  root_events: root_events.map(&:to_h)
144
144
  })
145
145
  end
@@ -20,13 +20,22 @@ module Profiler
20
20
  @bytes = 0
21
21
  @dropped = 0
22
22
  @truncated = false
23
+ @mutex = Mutex.new
23
24
  end
24
25
 
25
26
  def truncated?
26
27
  @truncated
27
28
  end
28
29
 
30
+ # A streamed body iterated by another thread, or work handed the buffer, can record while
31
+ # the request's own thread does: the byte count and the lines are updated together.
29
32
  def record(entry)
33
+ @mutex.synchronize { record_unlocked(entry) }
34
+ end
35
+
36
+ private
37
+
38
+ def record_unlocked(entry)
30
39
  message = entry[:message]
31
40
  if @limit
32
41
  room = @limit - @bytes
@@ -9,6 +9,7 @@ module Profiler
9
9
  super
10
10
  @views = []
11
11
  @partials = []
12
+ @intervals = []
12
13
  @subscriptions = []
13
14
  end
14
15
 
@@ -36,6 +37,7 @@ module Profiler
36
37
 
37
38
  @subscriptions << subscribe_notification("render_template.action_view") do |name, started, finished, unique_id, payload|
38
39
  duration = ((finished - started) * 1000).round(2)
40
+ @intervals << [started, finished]
39
41
  @views << {
40
42
  identifier: payload[:identifier],
41
43
  layout: payload[:layout],
@@ -45,6 +47,7 @@ module Profiler
45
47
 
46
48
  @subscriptions << subscribe_notification("render_partial.action_view") do |name, started, finished, unique_id, payload|
47
49
  duration = ((finished - started) * 1000).round(2)
50
+ @intervals << [started, finished]
48
51
  @partials << {
49
52
  identifier: payload[:identifier],
50
53
  duration: duration
@@ -60,7 +63,7 @@ module Profiler
60
63
  partials: @partials,
61
64
  total_views: @views.size,
62
65
  total_partials: @partials.size,
63
- total_duration: (@views + @partials).sum { |v| v[:duration] }.round(2)
66
+ total_duration: covered_ms(@intervals)
64
67
  }
65
68
 
66
69
  store_data(data)
@@ -76,7 +79,7 @@ module Profiler
76
79
  end
77
80
 
78
81
  def toolbar_summary
79
- total_duration = (@views + @partials).sum { |v| v[:duration] }.round(2)
82
+ total_duration = covered_ms(@intervals)
80
83
 
81
84
  {
82
85
  text: "#{@views.size} views, #{@partials.size} partials (#{total_duration}ms)",
@@ -12,6 +12,11 @@ module Profiler
12
12
  class SidekiqMiddleware
13
13
  def call(worker, job, queue, &block)
14
14
  Profiler.env_override_store.apply!
15
+ # A job run through the ActiveJob adapter reaches Sidekiq wrapped (job["wrapped"] names the
16
+ # real class) and is profiled by ActiveJobInstrumentation's around_perform: profiling the
17
+ # wrapper too would record the same work twice.
18
+ return block.call if job["wrapped"] && active_job_profiled?
19
+
15
20
  Profiler::JobProfiler.profile(
16
21
  job_class: job["class"],
17
22
  job_id: job["jid"],
@@ -22,6 +27,13 @@ module Profiler
22
27
  &block
23
28
  )
24
29
  end
30
+
31
+ private
32
+
33
+ def active_job_profiled?
34
+ defined?(ActiveJob::Base) && defined?(Profiler::Instrumentation::ActiveJobInstrumentation) &&
35
+ ActiveJob::Base.include?(Profiler::Instrumentation::ActiveJobInstrumentation)
36
+ end
25
37
  end
26
38
  end
27
39
  end
@@ -28,7 +28,7 @@ module Profiler
28
28
  kept = nil
29
29
  begin
30
30
  begin
31
- profile = Models::Profile.new(build_request(env))
31
+ profile = Models::Profile.new(build_request(env), params: false)
32
32
  profile.gem_version = Profiler::VERSION
33
33
  Profiler::CurrentContext.token = profile.token
34
34
 
@@ -57,10 +57,12 @@ module Profiler
57
57
  begin
58
58
  response = @app.call(env)
59
59
  rescue Exception => e # rubocop:disable Lint/RescueException
60
+ capture_params(env, profile) if failed_request?(e)
60
61
  record_failed_request(env, profile, collectors, request_body, allocations_before, e) if failed_request?(e)
61
62
  raise
62
63
  end
63
64
 
65
+ capture_params(env, profile)
64
66
  response, kept = complete_profile(env, profile, collectors, request_body, allocations_before, response)
65
67
  response
66
68
  ensure
@@ -74,6 +76,14 @@ module Profiler
74
76
 
75
77
  private
76
78
 
79
+ # Read after the application has run, so after the routing: the route parameters are there,
80
+ # and the params carry the encoding the route declared, as the controller saw them.
81
+ def capture_params(env, profile)
82
+ profile.capture_params(build_request(env))
83
+ rescue => e
84
+ Profiler.log_error("ProfilerMiddleware: could not read the request params", e)
85
+ end
86
+
77
87
  def complete_profile(env, profile, collectors, request_body, allocations_before, response)
78
88
  status, headers, body = response
79
89
 
@@ -22,7 +22,9 @@ module Profiler
22
22
  :collectors_released_after_seconds, :params_truncated,
23
23
  :gem_version
24
24
 
25
- def initialize(request = nil)
25
+ # +params+: false leaves the params to capture_params, for a caller that builds the profile
26
+ # before the router has run.
27
+ def initialize(request = nil, params: true)
26
28
  @token = SecureRandom.hex(16)
27
29
  @started_at = Time.now
28
30
  @collectors_data = {}
@@ -34,12 +36,19 @@ module Profiler
34
36
  if request
35
37
  @path = request.path
36
38
  @method = request.request_method
37
- @params, @params_truncated = cap_params(sanitize_params(request.params),
38
- Profiler.configuration.max_captured_body_bytes)
39
+ capture_params(request) if params
39
40
  @headers = extract_headers(request.env)
40
41
  end
41
42
  end
42
43
 
44
+ # The request's params as the controller sees them. Read once the router has run: Rails
45
+ # adds the route parameters (:id...) and applies the routes' param_encoding only then, so
46
+ # a read before it keeps neither.
47
+ def capture_params(request)
48
+ @params, @params_truncated = cap_params(sanitize_params(utf8_params(request.params)),
49
+ Profiler.configuration.max_captured_body_bytes)
50
+ end
51
+
43
52
  # The sizes are those of the whole bodies, when a body was cut at max_captured_body_bytes;
44
53
  # *_size_is_minimum when the whole size is not known (no Content-Length, a stream that
45
54
  # stopped), only that it is at least that.
@@ -252,7 +261,7 @@ module Profiler
252
261
  # compressed format (zip, png, gzip...) does not hold the secret as it is.
253
262
  { body: Base64.strict_encode64(Redaction.hide_credentials(raw.b)), encoding: "base64" }
254
263
  else
255
- text = Redaction.filter_body(raw, content_type).encode("UTF-8", invalid: :replace, undef: :replace)
264
+ text = utf8_text(Redaction.filter_body(raw, content_type), content_type)
256
265
  if compress_body?(text)
257
266
  # The fastest level: paid on the request, a quarter larger than the default level.
258
267
  { body: Base64.strict_encode64(Zlib::Deflate.deflate(text, Zlib::BEST_SPEED)), encoding: "gzip+base64" }
@@ -262,6 +271,39 @@ module Profiler
262
271
  end
263
272
  end
264
273
 
274
+ # The text as UTF-8: a body read from the wire is binary, and is taken for the charset its
275
+ # Content-Type declares (UTF-8 when it declares none, or one Ruby does not know). Bytes
276
+ # that do not belong to it are replaced, never raised on.
277
+ def utf8_text(text, content_type)
278
+ return text.encode("UTF-8", invalid: :replace, undef: :replace) unless text.encoding == Encoding::BINARY
279
+
280
+ charset = content_type.to_s[/charset\s*=\s*"?([^\s";]+)/i, 1]
281
+ source = begin
282
+ charset ? Encoding.find(charset) : Encoding::UTF_8
283
+ rescue ArgumentError
284
+ Encoding::UTF_8
285
+ end
286
+ # "internal" is a name Ruby knows but resolves to nil, "utf-7" is a dummy encoding with no converter.
287
+ source ||= Encoding::UTF_8
288
+ begin
289
+ text.dup.force_encoding(source).encode("UTF-8", invalid: :replace, undef: :replace)
290
+ rescue EncodingError
291
+ text.dup.force_encoding(Encoding::UTF_8).encode("UTF-8", invalid: :replace, undef: :replace)
292
+ end
293
+ end
294
+
295
+ # The params with their strings in UTF-8: a route's param_encoding gives some another
296
+ # encoding, which the profile must not carry into its JSON.
297
+ def utf8_params(value)
298
+ case value
299
+ when Hash then value.to_h { |k, v| [utf8_params(k), utf8_params(v)] }
300
+ when Array then value.map { |v| utf8_params(v) }
301
+ when String
302
+ value.encoding == Encoding::UTF_8 ? value : value.encode("UTF-8", invalid: :replace, undef: :replace)
303
+ else value
304
+ end
305
+ end
306
+
265
307
  def body_size(captured, total)
266
308
  captured_size = captured.to_s.bytesize
267
309
  total ||= captured_size
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.34.3"
4
+ VERSION = "0.34.5"
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.34.3
4
+ version: 0.34.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy