rails-profiler 0.31.1 → 0.31.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.
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../collectors/lifecycle"
4
+ require_relative "../current_context"
5
+
6
+ module Profiler
7
+ module Middleware
8
+ # The profile of a streamed response between the moment the application returns and the
9
+ # moment the server closes the body. The collectors that read nothing from the request's
10
+ # thread (Collectors::BaseCollector#collect_from_any_thread?) stay subscribed meanwhile, so
11
+ # that the queries, views and logs of the stream are recorded.
12
+ #
13
+ # While the server iterates the body (enter, leave), its fiber carries the profile's token
14
+ # and the thread-local slots of the collectors kept, so that the logs, outbound HTTP calls
15
+ # and measures of the stream are recorded, and a filter by request can tell its events.
16
+ #
17
+ # Rack requires the server to close the body, but nothing here relies on it alone: a
18
+ # stream still open when the execution context that started it (its fiber) starts another
19
+ # profiled request was left behind, and its profile is finished then; one subscribed for
20
+ # longer than MAX_SUBSCRIBED_SECONDS has its collectors released by the next profiled
21
+ # request, from any thread, which the profile says. The body itself is closed only by the
22
+ # server. The context is the fiber, not the thread: a fiber-based server (Falcon) runs
23
+ # many requests at once on one thread, each in its own fiber, and Thread#[] slots are
24
+ # fiber-local too; a threaded server runs each request in its thread's root fiber.
25
+ class StreamedProfile
26
+ MAX_SUBSCRIBED_SECONDS = 300
27
+
28
+ @pending = {}
29
+ @lock = Mutex.new
30
+
31
+ class << self
32
+ def register(streamed)
33
+ @lock.synchronize { @pending[streamed.object_id] = streamed }
34
+ end
35
+
36
+ def unregister(streamed)
37
+ @lock.synchronize { @pending.delete(streamed.object_id) }
38
+ end
39
+
40
+ # Run when a profiled request starts.
41
+ def sweep(context = Fiber.current)
42
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
+ left, old = @lock.synchronize do
44
+ [@pending.values.select { |s| s.context.equal?(context) },
45
+ @pending.values.select { |s| !s.context.equal?(context) && now - s.started_at > MAX_SUBSCRIBED_SECONDS }]
46
+ end
47
+ left.each(&:abandon)
48
+ old.each do |streamed|
49
+ unregister(streamed)
50
+ streamed.release_collectors(after_seconds: MAX_SUBSCRIBED_SECONDS)
51
+ end
52
+ end
53
+
54
+ # Releases every stream still subscribed (specs, and a process shutting down).
55
+ def release_all_pending
56
+ @lock.synchronize { @pending.values.tap { @pending.clear } }.each(&:release_collectors)
57
+ end
58
+ end
59
+
60
+ attr_reader :profile, :context, :started_at
61
+ attr_accessor :body
62
+
63
+ # on_finish runs once, with what the body captured, when it is closed or abandoned.
64
+ def initialize(profile, collectors, &on_finish)
65
+ @profile = profile
66
+ @collectors = collectors
67
+ @on_finish = on_finish
68
+ @context = Fiber.current
69
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
+ @mutex = Mutex.new
71
+ @released = false
72
+ @finished = false
73
+ end
74
+
75
+ # Collects the collectors kept for the stream and releases them, once, from any thread.
76
+ # +after_seconds+: released before the body was closed, after that long; the profile says
77
+ # so, as what came after is missing from it.
78
+ def release_collectors(after_seconds: nil)
79
+ @mutex.synchronize do
80
+ return if @released
81
+
82
+ @released = true
83
+ @profile.collectors_released_after_seconds = after_seconds if after_seconds
84
+ @collectors.each do |collector|
85
+ collector.collect if collector.respond_to?(:collect)
86
+ @profile.refresh_collector_metadata(collector)
87
+ rescue => e
88
+ warn "Collector #{collector.class} failed: #{e.message}"
89
+ end
90
+ Collectors::Lifecycle.release_all(@collectors)
91
+ end
92
+ end
93
+
94
+ def finish(captured, size, complete, error)
95
+ @mutex.synchronize do
96
+ return if @finished
97
+
98
+ @finished = true
99
+ end
100
+ self.class.unregister(self)
101
+ @on_finish.call(captured, size, complete, error)
102
+ ensure
103
+ # Whatever on_finish did, nothing stays subscribed.
104
+ release_collectors
105
+ end
106
+
107
+ # The server moved on without closing the body: its profile is finished with what it
108
+ # captured so far. The body stays the server's to close.
109
+ def abandon
110
+ body ? body.abandon : finish("", 0, false, nil)
111
+ end
112
+
113
+ # Called by the body around its iteration, on the fiber that iterates it. Lends nothing
114
+ # once the collectors are released.
115
+ def enter
116
+ token = CurrentContext.token
117
+ CurrentContext.token = @profile.token
118
+ lent = @mutex.synchronize do
119
+ next [] if @released
120
+
121
+ @collectors.filter_map do |collector|
122
+ [collector, collector.lend_thread_slots] if collector.respond_to?(:lend_thread_slots)
123
+ end
124
+ end
125
+ [token, lent]
126
+ end
127
+
128
+ def leave(state)
129
+ token, lent = state
130
+ lent.reverse_each { |collector, previous| collector.return_thread_slots(previous) }
131
+ CurrentContext.token = token
132
+ end
133
+ end
134
+ end
135
+ end
@@ -129,17 +129,10 @@ module Profiler
129
129
  end
130
130
  end
131
131
 
132
+ # The middleware hands over the page it has already read: an Array of parts, or a String.
133
+ # Never a body to iterate, which would drain a stream and close it.
132
134
  def extract_content(body)
133
- if body.respond_to?(:body)
134
- body.body
135
- elsif body.respond_to?(:each)
136
- parts = []
137
- body.each { |part| parts << part }
138
- body.close if body.respond_to?(:close)
139
- parts.join
140
- else
141
- body.to_s
142
- end
135
+ body.is_a?(String) ? body : Array(body).join
143
136
  end
144
137
 
145
138
  def toolbar_html
@@ -5,17 +5,21 @@ require "securerandom"
5
5
  require "json"
6
6
  require "zlib"
7
7
  require_relative "../redaction"
8
+ require_relative "../allocation_counter"
8
9
 
9
10
  module Profiler
10
11
  module Models
11
12
  class Profile
12
13
  attr_reader :path
13
- attr_accessor :token, :method, :status, :duration, :memory,
14
+ attr_accessor :token, :method, :status, :duration, :allocated_objects,
14
15
  :started_at, :finished_at, :params, :headers,
15
16
  :response_headers, :collectors_data, :collectors_metadata,
16
17
  :parent_token, :is_ajax, :profile_type,
17
18
  :request_body, :request_body_encoding,
18
19
  :response_body, :response_body_encoding,
20
+ :request_body_size, :request_body_truncated, :request_body_size_is_minimum,
21
+ :response_body_size, :response_body_truncated, :response_body_size_is_minimum,
22
+ :collectors_released_after_seconds,
19
23
  :gem_version
20
24
 
21
25
  def initialize(request = nil)
@@ -35,13 +39,32 @@ module Profiler
35
39
  end
36
40
  end
37
41
 
38
- def set_bodies(request_body:, response_body:, req_content_type:, resp_content_type:)
42
+ # The sizes are those of the whole bodies, when a body was cut at max_captured_body_bytes;
43
+ # *_size_is_minimum when the whole size is not known (no Content-Length, a stream that
44
+ # stopped), only that it is at least that.
45
+ def set_bodies(request_body:, response_body:, req_content_type:, resp_content_type:,
46
+ request_body_size: nil, response_body_size: nil,
47
+ request_body_size_is_minimum: false, response_body_size_is_minimum: false)
39
48
  req = process_body(request_body, req_content_type)
40
49
  resp = process_body(response_body, resp_content_type)
41
50
  @request_body = req[:body]
42
51
  @request_body_encoding = req[:encoding]
43
52
  @response_body = resp[:body]
44
53
  @response_body_encoding = resp[:encoding]
54
+ @request_body_size, @request_body_truncated = body_size(request_body, request_body_size)
55
+ @response_body_size, @response_body_truncated = body_size(response_body, response_body_size)
56
+ @request_body_size_is_minimum = request_body_size_is_minimum ? true : false
57
+ @response_body_size_is_minimum = response_body_size_is_minimum ? true : false
58
+ end
59
+
60
+ # Deprecated: the number of allocated objects times 40, the figure earlier versions
61
+ # reported as bytes. Still written in to_h, for the readers that only know it.
62
+ def memory
63
+ @allocated_objects&.*(AllocationCounter::LEGACY_BYTES_PER_OBJECT)
64
+ end
65
+
66
+ def memory=(value)
67
+ @allocated_objects = value && value / AllocationCounter::LEGACY_BYTES_PER_OBJECT
45
68
  end
46
69
 
47
70
  def finish(status, response_headers = {})
@@ -67,8 +90,20 @@ module Profiler
67
90
  end
68
91
 
69
92
  def add_collector_metadata(collector)
93
+ @collectors_metadata << collector_metadata(collector)
94
+ end
95
+
96
+ # A collector collected again, after the profile's tabs were listed (a streamed response
97
+ # that failed part way): its tab keeps its place, with what it now has.
98
+ def refresh_collector_metadata(collector)
99
+ entry = collector_metadata(collector)
100
+ index = @collectors_metadata.index { |tab| tab[:key] == entry[:key] }
101
+ index ? @collectors_metadata[index] = entry : @collectors_metadata << entry
102
+ end
103
+
104
+ def collector_metadata(collector)
70
105
  config = collector.tab_config
71
- @collectors_metadata << {
106
+ {
72
107
  key: config[:key],
73
108
  label: config[:label],
74
109
  icon: config[:icon],
@@ -79,6 +114,7 @@ module Profiler
79
114
  has_data: collector.has_data?
80
115
  }
81
116
  end
117
+ private :collector_metadata
82
118
 
83
119
  def to_h
84
120
  req_body, req_enc = decode_body(@request_body, @request_body_encoding)
@@ -92,7 +128,8 @@ module Profiler
92
128
  method: @method,
93
129
  status: @status,
94
130
  duration: @duration,
95
- memory: @memory,
131
+ allocated_objects: @allocated_objects,
132
+ memory: memory,
96
133
  started_at: @started_at&.iso8601,
97
134
  finished_at: @finished_at&.iso8601,
98
135
  params: @params,
@@ -102,6 +139,13 @@ module Profiler
102
139
  request_body_encoding: req_enc,
103
140
  response_body: resp_body,
104
141
  response_body_encoding: resp_enc,
142
+ request_body_size: @request_body_size,
143
+ request_body_truncated: @request_body_truncated,
144
+ request_body_size_is_minimum: @request_body_size_is_minimum,
145
+ response_body_size: @response_body_size,
146
+ response_body_truncated: @response_body_truncated,
147
+ response_body_size_is_minimum: @response_body_size_is_minimum,
148
+ collectors_released_after_seconds: @collectors_released_after_seconds,
105
149
  collectors_data: @collectors_data,
106
150
  tabs: @collectors_metadata,
107
151
  parent_token: @parent_token,
@@ -125,7 +169,12 @@ module Profiler
125
169
  profile.method = data[:method]
126
170
  profile.status = data[:status]
127
171
  profile.duration = data[:duration]
128
- profile.memory = data[:memory]
172
+ # A profile saved before allocated_objects only carries memory, the count times 40.
173
+ if data.key?(:allocated_objects)
174
+ profile.allocated_objects = data[:allocated_objects]
175
+ else
176
+ profile.memory = data[:memory]
177
+ end
129
178
  profile.started_at = data[:started_at] ? Time.parse(data[:started_at]) : nil
130
179
  profile.finished_at = data[:finished_at] ? Time.parse(data[:finished_at]) : nil
131
180
  profile.params = data[:params]
@@ -135,6 +184,15 @@ module Profiler
135
184
  profile.request_body_encoding = data[:request_body_encoding] || "text"
136
185
  profile.response_body = data[:response_body]
137
186
  profile.response_body_encoding = data[:response_body_encoding] || "text"
187
+ profile.request_body_size = data[:request_body_size]
188
+ # Left nil when the store did not keep them (SqliteStore rebuilds a profile from its
189
+ # columns): the request collector's data still has them.
190
+ profile.request_body_truncated = data[:request_body_truncated]
191
+ profile.request_body_size_is_minimum = data[:request_body_size_is_minimum]
192
+ profile.response_body_size = data[:response_body_size]
193
+ profile.response_body_truncated = data[:response_body_truncated]
194
+ profile.response_body_size_is_minimum = data[:response_body_size_is_minimum]
195
+ profile.collectors_released_after_seconds = data[:collectors_released_after_seconds]
138
196
  profile.parent_token = data[:parent_token]
139
197
  profile.is_ajax = data[:is_ajax] || false
140
198
  profile.profile_type = data[:profile_type] || "http"
@@ -181,6 +239,12 @@ module Profiler
181
239
  end
182
240
  end
183
241
 
242
+ def body_size(captured, total)
243
+ captured_size = captured.to_s.bytesize
244
+ total ||= captured_size
245
+ [total, total > captured_size]
246
+ end
247
+
184
248
  def compress_body?(text)
185
249
  Profiler.configuration.compress_bodies &&
186
250
  text.bytesize > Profiler.configuration.compress_body_threshold
@@ -88,6 +88,16 @@ module Profiler
88
88
  text.length > max ? "#{text[0, max]}#{ellipsis}" : text
89
89
  end
90
90
 
91
+ # The first +max+ bytes of +text+, a body kept only in part. A credential cut in two by the
92
+ # limit would escape the masking by value, which runs later on what is kept: the bytes
93
+ # that could start one are left out as well. Text no longer than +max+ is returned whole.
94
+ def cut_bytes(text, max)
95
+ return text if text.bytesize <= max
96
+
97
+ cut = text.byteslice(0, max)
98
+ cut.byteslice(0, cut.bytesize - held_back_bytes(cut))
99
+ end
100
+
91
101
  # How many trailing bytes of +text+, a stream read in pieces, could be
92
102
  # the start of a credential and have to wait for the next piece: the
93
103
  # longest end of the text that is also the beginning of a credential,
@@ -56,7 +56,7 @@ module Profiler
56
56
 
57
57
  exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
58
58
 
59
- memory_before = current_memory if Profiler.configuration.track_memory
59
+ allocations_before = AllocationCounter.current if Profiler.configuration.track_memory
60
60
 
61
61
  test_status = "passed"
62
62
  error_message = nil
@@ -103,7 +103,7 @@ module Profiler
103
103
  Profiler::CurrentContext.token = previous_token
104
104
 
105
105
  if Profiler.configuration.track_memory
106
- profile.memory = current_memory - memory_before
106
+ profile.allocated_objects = AllocationCounter.current - allocations_before
107
107
  end
108
108
 
109
109
  test_collector.update_status(test_status, error_message)
@@ -124,20 +124,5 @@ module Profiler
124
124
  # After collect, and also when collect or the storage failed.
125
125
  Collectors::Lifecycle.release_all(collectors)
126
126
  end
127
-
128
- private
129
-
130
- def current_memory
131
- return 0 unless defined?(GC.stat)
132
-
133
- stats = GC.stat
134
- if stats.key?(:total_allocated_size)
135
- stats[:total_allocated_size]
136
- elsif stats.key?(:total_allocated_objects)
137
- stats[:total_allocated_objects] * 40
138
- else
139
- 0
140
- end
141
- end
142
127
  end
143
128
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.31.1"
4
+ VERSION = "0.31.2"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "profiler/boot_env"
5
5
  require_relative "profiler/version"
6
6
  require_relative "profiler/configuration"
7
7
  require_relative "profiler/redaction"
8
+ require_relative "profiler/allocation_counter"
8
9
 
9
10
  module Profiler
10
11
  class Error < StandardError; 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.31.1
4
+ version: 0.31.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -133,6 +133,7 @@ files:
133
133
  - config/routes.rb
134
134
  - exe/profiler-mcp
135
135
  - lib/profiler.rb
136
+ - lib/profiler/allocation_counter.rb
136
137
  - lib/profiler/boot_env.rb
137
138
  - lib/profiler/cluster/master_client.rb
138
139
  - lib/profiler/cluster/security.rb
@@ -206,8 +207,10 @@ files:
206
207
  - lib/profiler/mcp/tools/reset_env_var.rb
207
208
  - lib/profiler/mcp/tools/run_tests.rb
208
209
  - lib/profiler/mcp/tools/set_env_var.rb
210
+ - lib/profiler/middleware/capturing_body.rb
209
211
  - lib/profiler/middleware/cors_middleware.rb
210
212
  - lib/profiler/middleware/profiler_middleware.rb
213
+ - lib/profiler/middleware/streamed_profile.rb
211
214
  - lib/profiler/middleware/toolbar_injector.rb
212
215
  - lib/profiler/models/profile.rb
213
216
  - lib/profiler/models/sql_query.rb
@@ -235,13 +238,13 @@ files:
235
238
  - lib/profiler/test_runner/run_store.rb
236
239
  - lib/profiler/test_runner/runner.rb
237
240
  - lib/profiler/version.rb
238
- homepage: https://git.duplessy.eu/sebastien/rails-profiler-gem
241
+ homepage: https://github.com/Pepsit36/rails-profiler
239
242
  licenses:
240
243
  - MIT
241
244
  metadata:
242
- homepage_uri: https://git.duplessy.eu/sebastien/rails-profiler-gem
243
- source_code_uri: https://git.duplessy.eu/sebastien/rails-profiler-gem
244
- changelog_uri: https://git.duplessy.eu/sebastien/rails-profiler-gem/-/blob/master/CHANGELOG.md
245
+ homepage_uri: https://github.com/Pepsit36/rails-profiler
246
+ source_code_uri: https://github.com/Pepsit36/rails-profiler
247
+ changelog_uri: https://github.com/Pepsit36/rails-profiler/blob/master/CHANGELOG.md
245
248
  post_install_message:
246
249
  rdoc_options: []
247
250
  require_paths: