rails-profiler 0.30.12 → 0.30.13

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: fc826508539b9892ab3317d52a95d77cdb162b4439a4086ae3c538239edb7eaa
4
- data.tar.gz: 05c364a0cfeca030f37e635b35b150819f81f266de13137f98d33b9a9be29a53
3
+ metadata.gz: 2613fd14e9adfbb7911645a1bd23fcd1043d4424f532e64c267601d021db7ad0
4
+ data.tar.gz: 1f474b73d65dd2aac23151a8ea4f61aba98399f7182cd7db9dda39947afb7b28
5
5
  SHA512:
6
- metadata.gz: b0439a929e4b29fb9cc74ea0806135bfe10fb619bac6664c375aac1ae2b969f5adcd51a1f2d04322878c952d79430e6dec6350f4bda37665df26fe459b63ac0f
7
- data.tar.gz: cedc00eb32c405fa556be7723fe2adde3ae7704c57e2053d6fcf8828c933deecb1b300833f7975fdd3239679c25512435c8ccf107e7d84925400f00ea661aa02
6
+ metadata.gz: 67dd8e4b429926a3fdef908c1491650c796497c177cb263cfdd94e7b14a5587a7a6ef48ea09c33b430ef5bca1ac0c921a4294d16c093127a39b1eac9db5c5cd3
7
+ data.tar.gz: 004f12ec514d606d212f49b0ac5c8c1f593437d62cf58e2a2b1242eef67fd144caa1ef165e8371947db4c9d8ae291a9bbdb83a2acf40058a8b53e5e50a78be71
data/CHANGELOG.md CHANGED
@@ -19,6 +19,48 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.30.13] - 2026-10-04
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Fixed
27
+
28
+ - **Middleware:** A request that raises below the profiler no longer runs your application a
29
+ second time, and no longer leaves the collectors installed. Until now the exception made the
30
+ profiler call the application again, duplicating its side effects (two emails sent, two
31
+ records written), and every SQL, view, cache, mailer and controller subscriber, the log sink on
32
+ `Rails.logger`, the StackProf sampler or the `TracePoint` of the function profiler stayed active
33
+ for the life of the process: memory and request time grew with each failed request until the
34
+ process was killed. Any exception between the profiler and `ShowExceptions` triggers it, for
35
+ example `ActionDispatch::RemoteIp::IpSpoofAttackError` on contradictory `Client-IP` and
36
+ `X-Forwarded-For` headers, and in tests every exception a controller raises. The exception now
37
+ goes on to the server unchanged, and the profile of the failed request is kept, with status 500
38
+ and the exception in the Exception tab.
39
+ - **Collectors:** Collectors gain `unsubscribe`, which releases what `subscribe` installed and runs
40
+ after `collect` and on every error path, for requests, jobs, console commands and tests alike.
41
+ A collector that fails to subscribe no longer leaves the others installed: the work runs once,
42
+ unprofiled. A custom collector that subscribes to something should implement `unsubscribe`, as
43
+ the README example now does.
44
+ - **Logs:** On Rails 7.0, the log collector no longer extends `Rails.logger` with a new module on
45
+ every request; one shared sink is attached once, and only records while a profile runs on the
46
+ thread.
47
+ - **Collectors:** A job performed inline during a profiled request (`perform_now`, or the
48
+ `:inline` adapter) no longer takes the request's records with it. The job's collectors now hand
49
+ back the thread-local slots they borrow, so the request keeps its logs, dumps, outbound HTTP
50
+ calls and timeline events from before and after the job, and the job's profile gets only what
51
+ the job did. Until now the job erased the request's logs and dumps recorded before it, and the
52
+ request lost its HTTP calls and timeline events from after it.
53
+ - **Middleware:** A request cut short by a timeout (`Timeout::ExitException`,
54
+ `Rack::Timeout::RequestTimeoutException`) keeps its profile too, with status 500. A request
55
+ stopped by a signal or by `exit` keeps none. An exception outside `StandardError` raised while
56
+ the collectors are being set up no longer leaves them installed.
57
+ - **Function profiler:** In sampling (`lite`) mode, a request no longer stops the StackProf
58
+ sampler that a concurrent request started, nor takes its samples: its profile says the sampler
59
+ was busy instead.
60
+ - **Dumps:** `Profiler.dump` called outside a profile that collects dumps (a request the profiler
61
+ skips, a test profile) no longer keeps the value on the thread. Nothing ever read those dumps,
62
+ and the thread held on to them, and to everything they referenced, for good.
63
+
22
64
  ## [0.30.12] - 2026-10-04
23
65
 
24
66
  <!-- stamped -->
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../redaction"
4
+ require_relative "lifecycle"
4
5
 
5
6
  module Profiler
6
7
  module Collectors
@@ -59,6 +60,13 @@ module Profiler
59
60
  # Override in subclasses to subscribe to ActiveSupport::Notifications
60
61
  end
61
62
 
63
+ # Releases whatever subscribe installed: notification subscribers, samplers, log sinks,
64
+ # thread-local slots. Called after collect, and on every path where collect never runs,
65
+ # so it has to be idempotent and safe when subscribe failed part way or never ran.
66
+ def unsubscribe
67
+ # Override in subclasses that install something in subscribe
68
+ end
69
+
62
70
  def collect
63
71
  # Override in subclasses to collect data
64
72
  end
@@ -89,6 +97,27 @@ module Profiler
89
97
  @data = data
90
98
  @profile.add_collector_data(name, data)
91
99
  end
100
+
101
+ # Thread-local slots follow a stack: subscribe takes one over and remembers what it held,
102
+ # unsubscribe hands it back. A job performed inline during a request thus records into
103
+ # its own slots, and the request gets its own back, with what it recorded before the job.
104
+ def claim_thread_slot(key, value)
105
+ @claimed_thread_slots ||= {}
106
+ @claimed_thread_slots[key] = Thread.current[key] unless @claimed_thread_slots.key?(key)
107
+ Thread.current[key] = value
108
+ end
109
+
110
+ def restore_thread_slots
111
+ @claimed_thread_slots&.each { |key, previous| Thread.current[key] = previous }
112
+ @claimed_thread_slots = nil
113
+ end
114
+
115
+ def unsubscribe_notifications(subscriptions)
116
+ return unless defined?(ActiveSupport::Notifications)
117
+
118
+ subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
119
+ subscriptions.clear
120
+ end
92
121
  end
93
122
  end
94
123
  end
@@ -62,7 +62,7 @@ module Profiler
62
62
  end
63
63
 
64
64
  def collect
65
- @subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
65
+ unsubscribe
66
66
 
67
67
  hits = @cache_reads.count { |r| r[:hit] }
68
68
  misses = @cache_reads.count { |r| !r[:hit] }
@@ -82,6 +82,10 @@ module Profiler
82
82
  store_data(data)
83
83
  end
84
84
 
85
+ def unsubscribe
86
+ unsubscribe_notifications(@subscriptions)
87
+ end
88
+
85
89
  def toolbar_summary
86
90
  hits = @cache_reads.count { |r| r[:hit] }
87
91
  total = @cache_reads.size
@@ -55,8 +55,7 @@ module Profiler
55
55
  end
56
56
 
57
57
  def collect
58
- # Unsubscribe from notifications
59
- ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
58
+ unsubscribe
60
59
 
61
60
  data = {
62
61
  total_queries: @queries.size,
@@ -69,6 +68,13 @@ module Profiler
69
68
  store_data(data)
70
69
  end
71
70
 
71
+ def unsubscribe
72
+ return unless @subscription
73
+
74
+ ActiveSupport::Notifications.unsubscribe(@subscription)
75
+ @subscription = nil
76
+ end
77
+
72
78
  def toolbar_summary
73
79
  total = @queries.size
74
80
  slow = @queries.select { |q| q.slow?(Profiler.configuration.slow_query_threshold) }.size
@@ -30,7 +30,8 @@ module Profiler
30
30
  end
31
31
 
32
32
  def collect
33
- dumps = Thread.current[:profiler_dumps] || []
33
+ dumps = @dumps || Thread.current[:profiler_dumps] || []
34
+ unsubscribe
34
35
 
35
36
  formatted_dumps = dumps.map do |dump|
36
37
  {
@@ -47,9 +48,15 @@ module Profiler
47
48
  count: formatted_dumps.size,
48
49
  dumps: formatted_dumps
49
50
  })
51
+ end
52
+
53
+ # Profiler.dump fills this slot while the profile runs.
54
+ def subscribe
55
+ @dumps = claim_thread_slot(:profiler_dumps, [])
56
+ end
50
57
 
51
- # Clear dumps for next request
52
- Thread.current[:profiler_dumps] = []
58
+ def unsubscribe
59
+ restore_thread_slots
53
60
  end
54
61
 
55
62
  def toolbar_summary
@@ -43,11 +43,18 @@ module Profiler
43
43
  end
44
44
 
45
45
  def collect
46
- ActiveSupport::Notifications.unsubscribe(@subscriber) if @subscriber
46
+ unsubscribe
47
47
 
48
48
  store_data(@exception_data || {})
49
49
  end
50
50
 
51
+ def unsubscribe
52
+ return unless @subscriber
53
+
54
+ ActiveSupport::Notifications.unsubscribe(@subscriber)
55
+ @subscriber = nil
56
+ end
57
+
51
58
  def has_data?
52
59
  !@exception_data.nil? && !@exception_data.empty?
53
60
  end
@@ -35,7 +35,7 @@ module Profiler
35
35
  def subscribe
36
36
  return unless defined?(ActiveSupport::Notifications)
37
37
 
38
- Thread.current[:profiler_flamegraph_collector] = self
38
+ claim_thread_slot(:profiler_flamegraph_collector, self)
39
39
 
40
40
  # Controller action
41
41
  @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process_action.action_controller") do |_name, started, finished, _unique_id, payload|
@@ -133,8 +133,7 @@ module Profiler
133
133
  end
134
134
 
135
135
  def collect
136
- @subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
137
- Thread.current[:profiler_flamegraph_collector] = nil
136
+ unsubscribe
138
137
 
139
138
  root_events = build_hierarchy(@events)
140
139
 
@@ -145,6 +144,11 @@ module Profiler
145
144
  })
146
145
  end
147
146
 
147
+ def unsubscribe
148
+ unsubscribe_notifications(@subscriptions)
149
+ restore_thread_slots
150
+ end
151
+
148
152
  def toolbar_summary
149
153
  {
150
154
  text: "#{@events.size} events",
@@ -55,42 +55,66 @@ module Profiler
55
55
 
56
56
  mode = Profiler.function_profiling_mode
57
57
  clock = Profiler.function_profiling_clock
58
- Thread.current[:fn_profiler_mode] = mode
59
- Thread.current[:fn_profiler_clock] = clock
58
+ @subscribed = true
59
+ claim_thread_slot(:fn_profiler_mode, mode)
60
+ claim_thread_slot(:fn_profiler_clock, clock)
60
61
 
61
62
  if mode == "lite" && defined?(StackProf)
62
63
  # Always record wall + cpu at request level for the comparison stat
63
- Thread.current[:fn_profiler_wall_start] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
- Thread.current[:fn_profiler_cpu_start] = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
64
+ claim_thread_slot(:fn_profiler_wall_start, Process.clock_gettime(Process::CLOCK_MONOTONIC))
65
+ claim_thread_slot(:fn_profiler_cpu_start, Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID))
65
66
 
66
67
  sp_mode = case clock
67
68
  when "cpu" then :cpu
68
69
  when "object" then :object
69
70
  else :wall
70
71
  end
71
- StackProf.start(mode: sp_mode, interval: 1000, raw: true)
72
+ # false when another request already runs the process-wide sampler: then it is not
73
+ # this collector's to stop on release.
74
+ @stackprof_started = StackProf.start(mode: sp_mode, interval: 1000, raw: true)
72
75
  else
73
76
  subscribe_tracepoint(mode)
74
77
  end
75
78
  end
76
79
 
77
80
  def collect
81
+ # Disabled: subscribe stored the "off" data, and there is no sampler of ours to read.
82
+ return unless @subscribed
83
+
78
84
  mode = Thread.current[:fn_profiler_mode] || Profiler.function_profiling_mode
79
85
  clock = Thread.current[:fn_profiler_clock] || Profiler.function_profiling_clock
80
- Thread.current[:fn_profiler_mode] = nil
81
- Thread.current[:fn_profiler_clock] = nil
82
86
 
83
87
  if mode == "lite" && defined?(StackProf)
88
+ # The sampler is process-wide: when another request started it, its samples are not
89
+ # this request's to take, nor is it this collector's to stop.
90
+ return store_sampler_busy(mode, clock) unless @stackprof_started
91
+
84
92
  StackProf.stop
93
+ @stackprof_started = false
85
94
  result = StackProf.results
86
95
  wall_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - (Thread.current[:fn_profiler_wall_start] || 0)) * 1000
87
96
  cpu_ms = (Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - (Thread.current[:fn_profiler_cpu_start] || 0)) * 1000
88
- Thread.current[:fn_profiler_wall_start] = nil
89
- Thread.current[:fn_profiler_cpu_start] = nil
90
97
  collect_stackprof(result, mode, clock, wall_ms, cpu_ms)
91
98
  else
92
99
  collect_tracepoint(mode)
93
100
  end
101
+ ensure
102
+ unsubscribe
103
+ end
104
+
105
+ def unsubscribe
106
+ if @stackprof_started
107
+ @stackprof_started = false
108
+ if StackProf.running?
109
+ StackProf.stop
110
+ StackProf.results # discards the samples and frees the sampler's buffers
111
+ end
112
+ end
113
+
114
+ @trace&.disable
115
+ @trace = nil
116
+
117
+ restore_thread_slots
94
118
  end
95
119
 
96
120
  def toolbar_summary
@@ -105,6 +129,12 @@ module Profiler
105
129
 
106
130
  # ── StackProf (lite mode) ──────────────────────────────────────────────────
107
131
 
132
+ def store_sampler_busy(mode, clock)
133
+ store_data({ enabled: true, mode: mode, clock: clock, sampler_busy: true, max_frames: 0,
134
+ frame_cap_reached: false, total_calls: 0, total_duration: 0,
135
+ functions: [], root_calls: [] })
136
+ end
137
+
108
138
  def collect_stackprof(result, mode, clock, wall_ms, cpu_ms)
109
139
  unless result
110
140
  store_data({ enabled: true, mode: mode, clock: clock, max_frames: 0,
@@ -261,10 +291,10 @@ module Profiler
261
291
  app_root = app_root_path
262
292
  max_frames = Profiler.function_profiling_max_frames
263
293
 
264
- Thread.current[:fn_profiler_stack] = []
265
- Thread.current[:fn_profiler_roots] = []
266
- Thread.current[:fn_profiler_count] = 0
267
- Thread.current[:fn_profiler_depth] = Hash.new(0)
294
+ claim_thread_slot(:fn_profiler_stack, [])
295
+ claim_thread_slot(:fn_profiler_roots, [])
296
+ claim_thread_slot(:fn_profiler_count, 0)
297
+ claim_thread_slot(:fn_profiler_depth, Hash.new(0))
268
298
 
269
299
  @trace = TracePoint.new(:call, :return) do |tp|
270
300
  next unless tp.path&.start_with?(app_root)
@@ -336,11 +366,6 @@ module Profiler
336
366
  end
337
367
  end
338
368
 
339
- Thread.current[:fn_profiler_stack] = nil
340
- Thread.current[:fn_profiler_roots] = nil
341
- Thread.current[:fn_profiler_count] = nil
342
- Thread.current[:fn_profiler_depth] = nil
343
-
344
369
  stats = {}
345
370
  aggregate(roots, stats)
346
371
  sorted_functions = stats.values.sort_by { |s| -s[:total_duration] }
@@ -36,11 +36,17 @@ module Profiler
36
36
  return unless Profiler.configuration.track_http
37
37
 
38
38
  Profiler::Instrumentation::NetHttpInstrumentation.install!
39
- Thread.current[:profiler_http_collector] = self
39
+ claim_thread_slot(:profiler_http_collector, self)
40
+ end
41
+
42
+ # The Net::HTTP patch installed by subscribe stays: it is installed once per process and
43
+ # records nothing when no collector holds the thread-local slot.
44
+ def unsubscribe
45
+ restore_thread_slots
40
46
  end
41
47
 
42
48
  def collect
43
- Thread.current[:profiler_http_collector] = nil
49
+ unsubscribe
44
50
 
45
51
  data = @mutex.synchronize do
46
52
  @collected = true
@@ -53,11 +53,17 @@ module Profiler
53
53
  I18n.singleton_class.prepend(Profiler::I18nLookupTracker)
54
54
  end
55
55
 
56
- Thread.current[:profiler_i18n_collector] = self
56
+ claim_thread_slot(:profiler_i18n_collector, self)
57
+ end
58
+
59
+ # The I18n patch installed by subscribe stays: it is prepended once per process and
60
+ # records nothing when no collector holds the thread-local slot.
61
+ def unsubscribe
62
+ restore_thread_slots
57
63
  end
58
64
 
59
65
  def collect
60
- Thread.current[:profiler_i18n_collector] = nil
66
+ unsubscribe
61
67
 
62
68
  missing_count = @lookups.count { |l| l[:missing] }
63
69
 
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Collectors
5
+ # Subscribes and releases the collectors of one profile, for every entry point that runs
6
+ # them (the request middleware, jobs, console commands, tests).
7
+ module Lifecycle
8
+ module_function
9
+
10
+ # Subscribes each collector in turn. When one fails, releases them all, the ones that
11
+ # never subscribed included, and returns false: the caller then runs the work unprofiled.
12
+ def subscribe_all(collectors, label)
13
+ collectors.each { |collector| collector.subscribe if collector.respond_to?(:subscribe) }
14
+ true
15
+ rescue => e
16
+ warn "Profiler #{label}: Collector subscribe failed: #{e.message}"
17
+ release_all(collectors)
18
+ false
19
+ end
20
+
21
+ # Releases every collector, whatever happened before, one failure not stopping the others.
22
+ def release_all(collectors)
23
+ collectors&.each do |collector|
24
+ collector.unsubscribe if collector.respond_to?(:unsubscribe)
25
+ rescue => e
26
+ warn "Profiler: Collector #{collector.class} release failed: #{e.message}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -8,18 +8,26 @@ module Profiler
8
8
  class LogCollector < BaseCollector
9
9
  SEVERITY_LABELS = %w[DEBUG INFO WARN ERROR FATAL UNKNOWN].freeze
10
10
 
11
+ # Records into the thread's buffer, which only exists while a LogCollector of this thread
12
+ # is subscribed: lines logged by other threads, or after release, are not kept. A logger
13
+ # bound to one collector's buffer records only while that buffer is the thread's current
14
+ # one, so a job performed inline during a request does not log twice into the job.
11
15
  class CaptureLogger < ::Logger
12
- def initialize
16
+ def initialize(buffer = nil)
13
17
  super(File::NULL)
18
+ @buffer = buffer
14
19
  end
15
20
 
16
21
  def add(severity, message = nil, progname = nil)
22
+ logs = Thread.current[:profiler_logs]
23
+ return true unless logs
24
+ return true if @buffer && !logs.equal?(@buffer)
25
+
17
26
  msg = message || progname
18
27
  msg = yield if block_given? && msg.nil?
19
28
  return true if msg.nil?
20
29
 
21
- Thread.current[:profiler_logs] ||= []
22
- Thread.current[:profiler_logs] << {
30
+ logs << {
23
31
  level: SEVERITY_LABELS[severity] || "UNKNOWN",
24
32
  message: msg.to_s.strip,
25
33
  timestamp: Time.now.iso8601(3)
@@ -55,31 +63,48 @@ module Profiler
55
63
  }
56
64
  end
57
65
 
66
+ # Rails 7.0 broadcasts by extending the logger with a module, which cannot be taken off
67
+ # again: the logger gets one shared CaptureLogger, once, instead of one per request.
68
+ LEGACY_CAPTURE_LOGGER = CaptureLogger.new
69
+ LEGACY_BROADCAST_MUTEX = Mutex.new
70
+
71
+ def self.attach_legacy_broadcast(logger)
72
+ LEGACY_BROADCAST_MUTEX.synchronize do
73
+ return if logger.instance_variable_get(:@profiler_capture_attached)
74
+
75
+ logger.extend(ActiveSupport::Logger.broadcast(LEGACY_CAPTURE_LOGGER))
76
+ logger.instance_variable_set(:@profiler_capture_attached, true)
77
+ end
78
+ end
79
+
58
80
  def subscribe
59
- Thread.current[:profiler_logs] = []
60
- @capture_logger = CaptureLogger.new
81
+ @logs = claim_thread_slot(:profiler_logs, [])
61
82
 
62
83
  if defined?(Rails) && Rails.logger
63
84
  if Rails.logger.respond_to?(:broadcast_to)
64
- Rails.logger.broadcast_to(@capture_logger)
65
- elsif defined?(ActiveSupport::Logger)
66
- Rails.logger.extend(ActiveSupport::Logger.broadcast(@capture_logger))
85
+ @capture_logger = CaptureLogger.new(@logs)
86
+ @broadcaster = Rails.logger
87
+ @broadcaster.broadcast_to(@capture_logger)
88
+ elsif defined?(ActiveSupport::Logger) && ActiveSupport::Logger.respond_to?(:broadcast)
89
+ self.class.attach_legacy_broadcast(Rails.logger)
67
90
  end
68
91
  end
69
92
  rescue => e
70
93
  warn "LogCollector#subscribe failed: #{e.message}"
71
94
  end
72
95
 
73
- def collect
74
- logs = Thread.current[:profiler_logs] || []
75
-
76
- if defined?(Rails) && Rails.logger && @capture_logger
77
- if Rails.logger.respond_to?(:stop_broadcasting_to)
78
- Rails.logger.stop_broadcasting_to(@capture_logger)
79
- end
96
+ def unsubscribe
97
+ if @capture_logger
98
+ @broadcaster.stop_broadcasting_to(@capture_logger) if @broadcaster.respond_to?(:stop_broadcasting_to)
99
+ @capture_logger = nil
100
+ @broadcaster = nil
80
101
  end
102
+ restore_thread_slots
103
+ end
81
104
 
82
- Thread.current[:profiler_logs] = []
105
+ def collect
106
+ logs = @logs || Thread.current[:profiler_logs] || []
107
+ unsubscribe
83
108
 
84
109
  errors = logs.count { |l| %w[ERROR FATAL].include?(l[:level]) }
85
110
  warnings = logs.count { |l| l[:level] == "WARN" }
@@ -45,7 +45,7 @@ module Profiler
45
45
  @subscriber_thread = Thread.current
46
46
 
47
47
  # Use a stack so multiple deliver_later calls in the same request are all tracked.
48
- Thread.current[:profiler_pending_processes] ||= []
48
+ claim_thread_slot(:profiler_pending_processes, [])
49
49
 
50
50
  @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process.action_mailer") do |_name, started, finished, _id, payload|
51
51
  next unless Thread.current.equal?(@subscriber_thread)
@@ -53,7 +53,10 @@ module Profiler
53
53
 
54
54
  mailer_class = payload[:mailer].to_s
55
55
  action = payload[:action].to_s
56
- (Thread.current[:profiler_pending_processes] ||= []) << {
56
+ pending = Thread.current[:profiler_pending_processes]
57
+ next unless pending
58
+
59
+ pending << {
57
60
  mailer_class: mailer_class,
58
61
  action: action,
59
62
  duration_ms: ((finished - started) * 1000).round(2),
@@ -67,7 +70,7 @@ module Profiler
67
70
  next if rails_preview_request?
68
71
 
69
72
  delivery_ms = ((finished - started) * 1000).round(2)
70
- process_info = (Thread.current[:profiler_pending_processes] ||= []).pop || {}
73
+ process_info = Thread.current[:profiler_pending_processes]&.pop || {}
71
74
  mail = payload[:mail]
72
75
 
73
76
  mailer_class = process_info[:mailer_class] || payload[:mailer_class].to_s
@@ -87,7 +90,7 @@ module Profiler
87
90
  # Any remaining pending process entries had no matching deliver event:
88
91
  # they were enqueued via deliver_later in the HTTP context.
89
92
  pending = Thread.current[:profiler_pending_processes] || []
90
- Thread.current[:profiler_pending_processes] = nil
93
+ unsubscribe
91
94
 
92
95
  pending.each do |info|
93
96
  @queued << {
@@ -102,8 +105,6 @@ module Profiler
102
105
  }
103
106
  end
104
107
 
105
- @subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
106
-
107
108
  detect_loops
108
109
 
109
110
  truncated = @emails.size > MAX_EMAILS
@@ -124,6 +125,11 @@ module Profiler
124
125
  )
125
126
  end
126
127
 
128
+ def unsubscribe
129
+ unsubscribe_notifications(@subscriptions)
130
+ restore_thread_slots
131
+ end
132
+
127
133
  def has_data?
128
134
  @emails.any? || @errors.any? || @queued.any?
129
135
  end
@@ -53,7 +53,7 @@ module Profiler
53
53
  end
54
54
 
55
55
  def collect
56
- @subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
56
+ unsubscribe
57
57
 
58
58
  data = {
59
59
  views: @views,
@@ -66,6 +66,10 @@ module Profiler
66
66
  store_data(data)
67
67
  end
68
68
 
69
+ def unsubscribe
70
+ unsubscribe_notifications(@subscriptions)
71
+ end
72
+
69
73
  def toolbar_summary
70
74
  total_duration = (@views + @partials).sum { |v| v[:duration] }.round(2)
71
75
 
@@ -44,7 +44,7 @@ module Profiler
44
44
 
45
45
  console_collector = Collectors::ConsoleCollector.new(profile, expression: @expression)
46
46
  collectors = [console_collector] + CONSOLE_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
47
- collectors.each { |c| c.subscribe if c.respond_to?(:subscribe) }
47
+ return block.call unless Collectors::Lifecycle.subscribe_all(collectors, "ConsoleProfiler")
48
48
 
49
49
  exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
50
50
 
@@ -82,6 +82,9 @@ module Profiler
82
82
 
83
83
  Profiler.storage.save(profile.token, profile)
84
84
  end
85
+ ensure
86
+ # After collect, and also when collect or the storage failed.
87
+ Collectors::Lifecycle.release_all(collectors)
85
88
  end
86
89
 
87
90
  private
@@ -67,7 +67,7 @@ module Profiler
67
67
  })
68
68
 
69
69
  collectors = [job_collector] + JOB_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
70
- collectors.each { |c| c.subscribe if c.respond_to?(:subscribe) }
70
+ return block.call unless Collectors::Lifecycle.subscribe_all(collectors, "JobProfiler")
71
71
 
72
72
  exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
73
73
 
@@ -109,6 +109,9 @@ module Profiler
109
109
 
110
110
  Profiler.storage.save(profile.token, profile)
111
111
  end
112
+ ensure
113
+ # After collect, and also when collect or the storage failed.
114
+ Collectors::Lifecycle.release_all(collectors)
112
115
  end
113
116
 
114
117
  private
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../models/profile"
4
4
  require_relative "../current_context"
5
+ require_relative "../collectors/lifecycle"
5
6
  require_relative "toolbar_injector"
6
7
 
7
8
  module Profiler
@@ -14,28 +15,55 @@ module Profiler
14
15
  def call(env)
15
16
  return @app.call(env) unless should_profile?(env)
16
17
 
17
- status = nil
18
- headers = nil
19
- body = nil
20
18
  collectors = nil
19
+ begin
20
+ begin
21
+ profile = Models::Profile.new(build_request(env))
22
+ profile.gem_version = Profiler::VERSION
23
+ Profiler::CurrentContext.token = profile.token
24
+
25
+ # Capture request body before app processes it
26
+ req_body_raw = read_rack_input(env)
27
+
28
+ # Store profile in env for collectors
29
+ env["profiler.profile"] = profile
30
+
31
+ collectors = create_collectors(profile)
32
+ env["profiler.collectors"] = collectors
33
+ subscribed = Collectors::Lifecycle.subscribe_all(collectors, "ProfilerMiddleware")
34
+ rescue => e
35
+ warn "Profiler error: #{e.message}\n#{e.backtrace.join("\n")}"
36
+ subscribed = false
37
+ end
21
38
 
22
- profile = Models::Profile.new(build_request(env))
23
- profile.gem_version = Profiler::VERSION
24
- Profiler::CurrentContext.token = profile.token
39
+ unless subscribed
40
+ # The application has not run yet: serve the request once, unprofiled.
41
+ release(collectors)
42
+ return @app.call(env)
43
+ end
25
44
 
26
- # Capture request body before app processes it
27
- req_body_raw = read_rack_input(env)
45
+ # Measure memory before
46
+ memory_before = current_memory if Profiler.configuration.track_memory
28
47
 
29
- # Store profile in env for collectors
30
- env["profiler.profile"] = profile
48
+ begin
49
+ response = @app.call(env)
50
+ rescue Exception => e # rubocop:disable Lint/RescueException
51
+ record_failed_request(env, profile, collectors, req_body_raw, memory_before, e) if failed_request?(e)
52
+ raise
53
+ end
31
54
 
32
- collectors = create_collectors(profile)
33
- env["profiler.collectors"] = collectors
55
+ complete_profile(env, profile, collectors, req_body_raw, memory_before, response)
56
+ ensure
57
+ # Opened before the first subscribe: whatever happens from there on, an exception
58
+ # outside StandardError included, nothing a collector installed outlives the request.
59
+ release(collectors)
60
+ end
61
+ end
34
62
 
35
- # Measure memory before
36
- memory_before = current_memory if Profiler.configuration.track_memory
63
+ private
37
64
 
38
- status, headers, body = @app.call(env)
65
+ def complete_profile(env, profile, collectors, req_body_raw, memory_before, response)
66
+ status, headers, body = response
39
67
  headers = headers.dup
40
68
 
41
69
  # Measure memory after
@@ -56,17 +84,9 @@ module Profiler
56
84
  resp_content_type: (headers["content-type"] || headers["Content-Type"]).to_s
57
85
  )
58
86
 
59
- collectors.each do |collector|
60
- begin
61
- collector.collect if collector.respond_to?(:collect)
62
- profile.add_collector_metadata(collector)
63
- rescue => e
64
- warn "Collector #{collector.class} failed: #{e.message}"
65
- end
66
- end
87
+ collect_all(profile, collectors)
67
88
 
68
89
  Profiler.storage.save(profile.token, profile)
69
- Profiler::CurrentContext.clear
70
90
 
71
91
  headers["X-Profiler-Token"] = profile.token
72
92
 
@@ -77,13 +97,50 @@ module Profiler
77
97
 
78
98
  [status, headers, body]
79
99
  rescue => e
100
+ # The application has run: its response goes out, profiled or not.
80
101
  warn "Profiler error: #{e.message}\n#{e.backtrace.join("\n")}"
81
- collectors&.each { |c| c.unsubscribe if c.respond_to?(:unsubscribe) }
82
- Profiler::CurrentContext.clear
83
- status ? [status, headers, body || []] : @app.call(env)
102
+ [status, headers, body || []]
84
103
  end
85
104
 
86
- private
105
+ # A request cut short by a timeout (Timeout::ExitException, Rack::Timeout) is profiled like
106
+ # one that raised; one stopped by a signal or an exit is not, the process is going away.
107
+ def failed_request?(error)
108
+ !error.is_a?(SignalException) && !error.is_a?(SystemExit)
109
+ end
110
+
111
+ # The profile of a request that raised is kept: status 500, which the server answers once
112
+ # the exception leaves the stack, and the exception itself. Never masks the exception.
113
+ def record_failed_request(env, profile, collectors, req_body_raw, memory_before, error)
114
+ collectors.each { |collector| collector.capture(error) if collector.respond_to?(:capture) }
115
+ profile.memory = current_memory - memory_before if Profiler.configuration.track_memory
116
+ profile.finish(500)
117
+ profile.set_bodies(
118
+ request_body: req_body_raw,
119
+ response_body: "",
120
+ req_content_type: env["CONTENT_TYPE"].to_s,
121
+ resp_content_type: ""
122
+ )
123
+ collect_all(profile, collectors)
124
+ Profiler.storage.save(profile.token, profile)
125
+ rescue => e
126
+ warn "Profiler error while recording a failed request: #{e.message}"
127
+ end
128
+
129
+ def collect_all(profile, collectors)
130
+ collectors.each do |collector|
131
+ begin
132
+ collector.collect if collector.respond_to?(:collect)
133
+ profile.add_collector_metadata(collector)
134
+ rescue => e
135
+ warn "Collector #{collector.class} failed: #{e.message}"
136
+ end
137
+ end
138
+ end
139
+
140
+ def release(collectors)
141
+ Collectors::Lifecycle.release_all(collectors)
142
+ Profiler::CurrentContext.clear
143
+ end
87
144
 
88
145
  def should_profile?(env)
89
146
  return false unless Profiler.enabled?
@@ -104,12 +161,10 @@ module Profiler
104
161
  end
105
162
  end
106
163
 
164
+ # Builds the collectors without subscribing them: Lifecycle.subscribe_all does, so that
165
+ # a failure part way leaves every collector reachable for release.
107
166
  def create_collectors(profile)
108
- Profiler.configuration.collectors.map do |collector_class|
109
- collector = collector_class.new(profile)
110
- collector.subscribe if collector.respond_to?(:subscribe)
111
- collector
112
- end
167
+ Profiler.configuration.collectors.map { |collector_class| collector_class.new(profile) }
113
168
  end
114
169
 
115
170
  def html_response?(headers)
@@ -52,7 +52,7 @@ module Profiler
52
52
  )
53
53
 
54
54
  collectors = [test_collector] + TEST_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
55
- collectors.each { |c| c.subscribe if c.respond_to?(:subscribe) }
55
+ return block.call unless Collectors::Lifecycle.subscribe_all(collectors, "TestProfiler")
56
56
 
57
57
  exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
58
58
 
@@ -120,6 +120,9 @@ module Profiler
120
120
 
121
121
  Profiler.storage.save(profile.token, profile)
122
122
  end
123
+ ensure
124
+ # After collect, and also when collect or the storage failed.
125
+ Collectors::Lifecycle.release_all(collectors)
123
126
  end
124
127
 
125
128
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.30.12"
4
+ VERSION = "0.30.13"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -77,16 +77,18 @@ module Profiler
77
77
  def dump(value, label = nil)
78
78
  return unless enabled?
79
79
 
80
+ # The slot exists only while a DumpCollector profiles this thread: outside of one, nobody
81
+ # would ever read the dump, and the thread would keep it for good.
82
+ dumps = Thread.current[:profiler_dumps]
83
+ return value unless dumps
84
+
80
85
  # Get caller location
81
86
  caller_location = caller_locations(1, 1).first
82
87
  file = caller_location.path
83
88
  line = caller_location.lineno
84
89
 
85
- # Initialize dumps array if needed
86
- Thread.current[:profiler_dumps] ||= []
87
-
88
90
  # Store the dump
89
- Thread.current[:profiler_dumps] << {
91
+ dumps << {
90
92
  value: value,
91
93
  label: label,
92
94
  file: file,
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.30.12
4
+ version: 0.30.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -152,6 +152,7 @@ files:
152
152
  - lib/profiler/collectors/http_collector.rb
153
153
  - lib/profiler/collectors/i18n_collector.rb
154
154
  - lib/profiler/collectors/job_collector.rb
155
+ - lib/profiler/collectors/lifecycle.rb
155
156
  - lib/profiler/collectors/log_collector.rb
156
157
  - lib/profiler/collectors/mailer_collector.rb
157
158
  - lib/profiler/collectors/request_collector.rb