rails-profiler 0.30.12 → 0.30.14

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: e09eb31d9bcb170273cd7c9df1f6e149d772d38cd52fd0f6d2930644054ccc8d
4
+ data.tar.gz: 5b8996d059b59b4a69e702ff238060dd83e08614fa416ae09ed8ed077d0d550f
5
5
  SHA512:
6
- metadata.gz: b0439a929e4b29fb9cc74ea0806135bfe10fb619bac6664c375aac1ae2b969f5adcd51a1f2d04322878c952d79430e6dec6350f4bda37665df26fe459b63ac0f
7
- data.tar.gz: cedc00eb32c405fa556be7723fe2adde3ae7704c57e2053d6fcf8828c933deecb1b300833f7975fdd3239679c25512435c8ccf107e7d84925400f00ea661aa02
6
+ metadata.gz: 5042a2f2c93acb5fd91e27907880d01b51aecaa7316a34886b827ec73584d9e3dcf356d07079f368066511fe447b06ecbeba1520e5a245befd82eb10f45b2d46
7
+ data.tar.gz: 8ca5df627c525ad56bddfcfa01771f0e8d23f2c3b6ade75d71dde0ea9db85a2d06ca2b2807f834f6195aba3423197d674986033427edae7d612ada1b7398b028
data/CHANGELOG.md CHANGED
@@ -19,6 +19,74 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.30.14] - 2026-10-05
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Fixed
27
+
28
+ - **Railtie:** Honor `config.enabled` set in `config/initializers/profiler.rb`. The profiler
29
+ decided before the application's initializers had run, so `enabled = false` there still put its
30
+ middlewares in the stack, configured Sidekiq, included its ActiveJob instrumentation and, in
31
+ the test environment, loaded the test profiler, and `enabled = true` in production got none of
32
+ them. Those decisions are now taken once the application's initializers have run. An active
33
+ profiler keeps its place in the middleware stack, and its Sidekiq middlewares and ActiveJob
34
+ callbacks stay ahead of the ones the application installs. The profiler is now the outermost
35
+ layer around a job: it runs before every other Sidekiq middleware, including those of gems
36
+ loaded before it and those the application prepends (such as `Sidekiq::CurrentAttributes`),
37
+ and before the callbacks Rails adds to ActiveJob (logging, instrumentation, and on Rails 7.0
38
+ time zone and locale), so the duration of a profiled job now includes them.
39
+ - **Railtie:** Keep the `enabled`, `storage` and `track_tests` values set with
40
+ `Profiler.configure` in `config/application.rb`, which the Rails defaults overwrote at boot, and
41
+ apply `config.profiler` (`config.profiler.enabled = false` in `config/application.rb`), which was
42
+ accepted and never read. A `config.profiler` key that is not a profiler option logs a warning.
43
+ `config/initializers/profiler.rb` still has the last word. Check these places when upgrading:
44
+ an `enabled` set with `Profiler.configure` in `config/application.rb` or in a
45
+ `config/environments` file, or with `config.profiler.enabled`, now takes effect, in production
46
+ too, where it was overwritten or ignored before.
47
+
48
+ ## [0.30.13] - 2026-10-04
49
+
50
+ <!-- stamped -->
51
+
52
+ ### Fixed
53
+
54
+ - **Middleware:** A request that raises below the profiler no longer runs your application a
55
+ second time, and no longer leaves the collectors installed. Until now the exception made the
56
+ profiler call the application again, duplicating its side effects (two emails sent, two
57
+ records written), and every SQL, view, cache, mailer and controller subscriber, the log sink on
58
+ `Rails.logger`, the StackProf sampler or the `TracePoint` of the function profiler stayed active
59
+ for the life of the process: memory and request time grew with each failed request until the
60
+ process was killed. Any exception between the profiler and `ShowExceptions` triggers it, for
61
+ example `ActionDispatch::RemoteIp::IpSpoofAttackError` on contradictory `Client-IP` and
62
+ `X-Forwarded-For` headers, and in tests every exception a controller raises. The exception now
63
+ goes on to the server unchanged, and the profile of the failed request is kept, with status 500
64
+ and the exception in the Exception tab.
65
+ - **Collectors:** Collectors gain `unsubscribe`, which releases what `subscribe` installed and runs
66
+ after `collect` and on every error path, for requests, jobs, console commands and tests alike.
67
+ A collector that fails to subscribe no longer leaves the others installed: the work runs once,
68
+ unprofiled. A custom collector that subscribes to something should implement `unsubscribe`, as
69
+ the README example now does.
70
+ - **Logs:** On Rails 7.0, the log collector no longer extends `Rails.logger` with a new module on
71
+ every request; one shared sink is attached once, and only records while a profile runs on the
72
+ thread.
73
+ - **Collectors:** A job performed inline during a profiled request (`perform_now`, or the
74
+ `:inline` adapter) no longer takes the request's records with it. The job's collectors now hand
75
+ back the thread-local slots they borrow, so the request keeps its logs, dumps, outbound HTTP
76
+ calls and timeline events from before and after the job, and the job's profile gets only what
77
+ the job did. Until now the job erased the request's logs and dumps recorded before it, and the
78
+ request lost its HTTP calls and timeline events from after it.
79
+ - **Middleware:** A request cut short by a timeout (`Timeout::ExitException`,
80
+ `Rack::Timeout::RequestTimeoutException`) keeps its profile too, with status 500. A request
81
+ stopped by a signal or by `exit` keeps none. An exception outside `StandardError` raised while
82
+ the collectors are being set up no longer leaves them installed.
83
+ - **Function profiler:** In sampling (`lite`) mode, a request no longer stops the StackProf
84
+ sampler that a concurrent request started, nor takes its samples: its profile says the sampler
85
+ was busy instead.
86
+ - **Dumps:** `Profiler.dump` called outside a profile that collects dumps (a request the profiler
87
+ skips, a test profile) no longer keeps the value on the thread. Nothing ever read those dumps,
88
+ and the thread held on to them, and to everything they referenced, for good.
89
+
22
90
  ## [0.30.12] - 2026-10-04
23
91
 
24
92
  <!-- 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
 
@@ -25,7 +25,11 @@ module Profiler
25
25
  BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_WITHOUT GEM_HOME GEM_PATH BOOTSNAP_CACHE_DIR
26
26
  ].freeze
27
27
 
28
- attr_accessor :enabled, :storage_options, :collectors,
28
+ # Options whose default depends on the Rails environment: the railtie sets them only when
29
+ # the application has not assigned them itself (see #default).
30
+ RAILS_DEFAULTED = %i[enabled storage track_tests].freeze
31
+
32
+ attr_accessor :storage_options, :collectors,
29
33
  :skip_paths, :slow_query_threshold, :max_queries_warning,
30
34
  :track_memory, :memory_warning_threshold,
31
35
  :mcp_enabled, :mcp_transport, :mcp_port,
@@ -36,7 +40,7 @@ module Profiler
36
40
  :track_jobs,
37
41
  :track_console,
38
42
  :apply_env_overrides_when_disabled,
39
- :track_tests, :test_runner_allow_undiscovered_files,
43
+ :test_runner_allow_undiscovered_files,
40
44
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
41
45
  :compress_bodies, :compress_body_threshold,
42
46
  :redact_sensitive_data, :filter_parameters, :env_allowlist,
@@ -47,9 +51,10 @@ module Profiler
47
51
 
48
52
  attr_writer :tmp_path
49
53
 
50
- attr_reader :authorize_block
54
+ attr_reader :authorize_block, :enabled, :track_tests
51
55
 
52
56
  def initialize
57
+ @assigned = []
53
58
  @enabled = false
54
59
  @storage = :memory
55
60
  @storage_options = {}
@@ -112,6 +117,26 @@ module Profiler
112
117
  @tmp_path || default_tmp_path
113
118
  end
114
119
 
120
+ def enabled=(value)
121
+ @assigned |= [:enabled]
122
+ @enabled = value
123
+ end
124
+
125
+ def track_tests=(value)
126
+ @assigned |= [:track_tests]
127
+ @track_tests = value
128
+ end
129
+
130
+ # Sets one of RAILS_DEFAULTED to value unless the application assigned it, in
131
+ # config/application.rb for instance, which runs before the railtie's initializers.
132
+ def default(option, value)
133
+ raise ArgumentError, "#{option} has no Rails default" unless RAILS_DEFAULTED.include?(option)
134
+ return if @assigned.include?(option)
135
+
136
+ public_send("#{option}=", value)
137
+ @assigned.delete(option)
138
+ end
139
+
115
140
  def slave?
116
141
  !master_url.nil? && !master_url.empty?
117
142
  end
@@ -162,6 +187,7 @@ module Profiler
162
187
  end
163
188
 
164
189
  def storage=(value)
190
+ @assigned |= [:storage]
165
191
  @storage = value
166
192
  @storage_backend = nil
167
193
  end
@@ -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
@@ -8,11 +8,12 @@ module Profiler
8
8
  included do
9
9
  attr_accessor :profiler_parent_token
10
10
 
11
- before_enqueue do |job|
11
+ # Ahead of the application's callbacks, which may already be there when this is included.
12
+ before_enqueue(prepend: true) do |job|
12
13
  job.profiler_parent_token = Profiler::CurrentContext.token
13
14
  end
14
15
 
15
- around_perform do |job, block|
16
+ around_perform(prepend: true) do |job, block|
16
17
  Profiler::JobProfiler.profile(
17
18
  job_class: job.class.name,
18
19
  job_id: job.job_id,
@@ -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)
@@ -6,27 +6,38 @@ module Profiler
6
6
  class Railtie < Rails::Railtie
7
7
  config.profiler = ActiveSupport::OrderedOptions.new
8
8
 
9
+ # Runs before the application's config/initializers, so that they override what it sets.
10
+ # The Rails defaults go only where config/application.rb, which runs earlier still, has not
11
+ # assigned the option, and config.profiler, also set there, is applied on top.
9
12
  initializer "profiler.set_configs" do |app|
10
- # Set default configuration for Rails environment
13
+ development_or_test = Rails.env.development? || Rails.env.test?
11
14
  Profiler.configure do |config|
12
- config.enabled = Rails.env.development? || Rails.env.test?
13
- config.storage = (Rails.env.development? || Rails.env.test?) ? :file : :memory
14
- config.tmp_path = Rails.root.join("tmp", "rails-profiler")
15
- config.track_tests = Rails.env.test?
15
+ config.default(:enabled, development_or_test)
16
+ config.default(:storage, development_or_test ? :file : :memory)
17
+ config.default(:track_tests, Rails.env.test?)
18
+
19
+ app.config.profiler.each do |key, value|
20
+ if config.respond_to?("#{key}=")
21
+ config.public_send("#{key}=", value)
22
+ else
23
+ Rails.logger&.warn("[Profiler] config.profiler.#{key} is not a profiler option, ignored")
24
+ end
25
+ end
16
26
  end
17
27
  end
18
28
 
29
+ # Always inserted here, before the application's initializers have decided `enabled`, so
30
+ # that the profiler keeps its place in the stack relative to the middlewares they insert;
31
+ # profiler.remove_disabled_middleware takes it out again once they have run.
19
32
  initializer "profiler.insert_middleware", before: :build_middleware_stack do |app|
20
- if Profiler.configuration.enabled
21
- require_relative "middleware/profiler_middleware"
33
+ require_relative "middleware/profiler_middleware"
22
34
 
23
- # Always inserted: it sets the framing headers of every profiler response, and its
24
- # CORS headers only when extension_cors_enabled is set.
25
- require_relative "middleware/cors_middleware"
26
- app.middleware.insert_before 0, Profiler::Middleware::CorsMiddleware
35
+ # It sets the framing headers of every profiler response, and its CORS headers only
36
+ # when extension_cors_enabled is set.
37
+ require_relative "middleware/cors_middleware"
38
+ app.middleware.insert_before 0, Profiler::Middleware::CorsMiddleware
27
39
 
28
- app.middleware.insert_before 0, Profiler::Middleware::ProfilerMiddleware
29
- end
40
+ app.middleware.insert_before 0, Profiler::Middleware::ProfilerMiddleware
30
41
  end
31
42
 
32
43
  initializer "profiler.load_collectors" do
@@ -52,6 +63,17 @@ module Profiler
52
63
  end
53
64
  end
54
65
 
66
+ # Everything below runs after the application's config/initializers, where `enabled` is
67
+ # decided, and before the middleware stack is built. An initializer declared without
68
+ # `after:` runs after the one declared before it, so these stay declared last, in order.
69
+ initializer "profiler.remove_disabled_middleware", after: :load_config_initializers do |app|
70
+ next if Profiler.configuration.enabled
71
+
72
+ # Rails applies every delete after the other middleware operations, whatever their order.
73
+ app.middleware.delete Profiler::Middleware::ProfilerMiddleware
74
+ app.middleware.delete Profiler::Middleware::CorsMiddleware
75
+ end
76
+
55
77
  initializer "profiler.setup_test_profiler" do
56
78
  next unless Profiler.configuration.enabled && Profiler.configuration.track_tests
57
79
 
@@ -68,16 +90,19 @@ module Profiler
68
90
 
69
91
  require_relative "job_profiler"
70
92
 
93
+ # This runs after the application's initializers: prepend, not add, keeps the profiler
94
+ # ahead of the middlewares and callbacks they install, so that a job's profile covers them.
95
+ # Sidekiq::Middleware::Chain#prepend is there from Sidekiq 3.3 (2014) to 8.1 at least.
71
96
  if defined?(Sidekiq)
72
97
  require_relative "instrumentation/sidekiq_middleware"
73
98
  Sidekiq.configure_server do |config|
74
99
  config.server_middleware do |chain|
75
- chain.add Profiler::Instrumentation::SidekiqMiddleware
100
+ chain.prepend Profiler::Instrumentation::SidekiqMiddleware
76
101
  end
77
102
  end
78
103
  Sidekiq.configure_client do |config|
79
104
  config.client_middleware do |chain|
80
- chain.add Profiler::Instrumentation::SidekiqClientMiddleware
105
+ chain.prepend Profiler::Instrumentation::SidekiqClientMiddleware
81
106
  end
82
107
  end
83
108
  end
@@ -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.14"
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,14 +1,14 @@
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.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-04 00:00:00.000000000 Z
11
+ date: 2026-10-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -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