rails-profiler 0.31.2 → 0.31.3

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: e53e69cbdde6c7e775444a738e0069cef86994f505f77d86673a15e25adc768d
4
- data.tar.gz: c134819ed6bff52aa1d5ad7309b752e1ed6c6a6e1803510d4387462f1607b73a
3
+ metadata.gz: '09422d8df091081147998c5e266018f8156874c3f2590b4269dfc1bab5b2efcf'
4
+ data.tar.gz: 3f0ac06620cf6d1f0297528cdce39e80962b736e835d127538968a2aff8b748f
5
5
  SHA512:
6
- metadata.gz: 1a277a3d46f4967dc8d875957e95b4d2df118c5a7a96dfcdac83e4a932c4aa4fbd73e1498ef5250bf714e96b5842d99814b617e7f530befcef2eac07984e571c
7
- data.tar.gz: 978b1b85623419ca8fd8afe3c9550f24be03dbd5e1a8ef02f8ded99270cff218f2e2d68622b66b9edc7def30a46cb574c8b06bdb7b3cdb6f3502b91a919d7941
6
+ metadata.gz: 7d74679898828ec69cea3718945c08e8c8ce4aec93d1ccb8942de28617a888ff2dfc2fbde4e4c01892f9a05a8723f713907a87b378b3be5dd11d5cb7cec7500b
7
+ data.tar.gz: 918333cbc7efbb6c61217adb4c52ced40be60a62b621fb828e942878598defd92d69bd36672bf515bcd5b6f9e4ed82869268fe704f64b674c309e2f61846fe6a
data/CHANGELOG.md CHANGED
@@ -19,6 +19,69 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.31.3] - 2026-10-05
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Fixed
27
+
28
+ - **Collectors:** a profile no longer records the SQL queries, views, cache operations, timeline
29
+ events, exceptions and mails of other requests. The collectors subscribed to the process-wide
30
+ `ActiveSupport::Notifications` bus without looking at the thread, so on a multi-threaded server
31
+ (Puma, Sidekiq) a profile could show the queries of a concurrent request, another user's
32
+ included. Each collector now records the events of its own request: its thread, the threads it
33
+ starts with `Thread.new`, the thread `ActionController::Live` runs the action in, the fibers
34
+ it creates (Ruby 3.2+, the layout of `render stream: true` under Falcon), the server thread
35
+ iterating a streamed body, and the queries of `load_async`, which Rails reports on the
36
+ request's thread. The process holds one subscriber per event instead of one per profiled request,
37
+ so an SQL event costs the same however many requests are profiled at once (6 us with 8 requests
38
+ profiled, 260 us before, measured). A job performed inline still shows in both profiles; one
39
+ performed by ActiveJob's `:async` adapter is profiled apart from the request that enqueued it,
40
+ even while that request still runs. A test profile no longer shows the queries the application
41
+ server runs for a system test in its own thread: they are in the profile of that HTTP request.
42
+ - **Instrumentation:** a thread created by a pool while a request ran (a concurrent-ruby worker
43
+ behind `Concurrent::Promises`, ActiveJob's `:async` adapter or `ActionController::Live`, or the
44
+ thread Puma starts for a request marked as IO bound) no longer keeps that request's HTTP and
45
+ timeline collectors for good, which put the outgoing HTTP calls of other requests' tasks in its
46
+ profile. A task posted to a concurrent-ruby executor now runs in the context of the request that
47
+ posted it, and a pool thread inherits nothing. On Puma with 8 threads, 40 concurrent requests
48
+ each running a query and an HTTP call in a `Concurrent::Promises.future`: 0 queries and 0 HTTP
49
+ calls of another request in a profile, none lost, against 560 foreign queries, 35 misattributed
50
+ and 36 lost HTTP calls before (measured, `script/bench/puma_attribution.rb`).
51
+ - **Function profiler:** without the `stackprof` gem, which the gem does not depend on, the
52
+ function profiler stays off instead of falling back to a `TracePoint` enabled on every thread of
53
+ the process, which called `GC.stat` four times per method and tripled the cost of a request, for
54
+ a tab that is not shown. To trace again, set `Profiler.function_profiling_tracepoint_fallback =
55
+ true`; the `TracePoint`, like the one of the `full` mode chosen from the dashboard, is now
56
+ enabled on the request's thread only. The Timeline tab now says that stackprof is missing and
57
+ how to add it, instead of a button to enable function profiling that changed nothing, and no
58
+ longer says that sampling is on by default whatever the Gemfile.
59
+ - **Database:** the caller of a query is captured the first time a statement runs in the request
60
+ (values left out, as the N+1 detection groups them) and for every slow query, instead of for
61
+ every query, at about 0.1 ms each. The N+1 groups of the Database tab and of the MCP
62
+ `n1-patterns` resource keep their location. The backtrace now starts at the application's code
63
+ that ran the query, cleaned by `Rails.backtrace_cleaner` as in the Exception tab, or without
64
+ Rails, past the frames of gems and of Ruby; it often started inside Active Support, with the
65
+ application's first frame too far down for the three frames `n1-patterns` shows.
66
+ `config.sql_backtrace = :all` captures every query again, `:none` none.
67
+ - **Routes and Env tabs:** a profile no longer stores the whole route table and `ENV`, rebuilt and
68
+ sorted on every request (the route table alone cost 7.7 ms per request with 210 routes, and 29 KB
69
+ per profile, measured). It keeps the route its request matched, the one of the request's verb
70
+ (a `PUT` showed the `PATCH` route of the same action). The Routes tab, the toolbar and the MCP
71
+ `get_profile` tool list the route table of the process that serves them, rebuilt when the
72
+ routes are reloaded in development; the Env tab of an HTTP profile shows the current `ENV` of that
73
+ process, not the `ENV` as it was during the request, masked as before. The profiles of jobs,
74
+ console expressions and tests, which run in another process, keep that process's `ENV`, masked,
75
+ as before. Profiles saved by an earlier version keep
76
+ showing their own table and variables. With both changes, the profiler adds about 10 to 13 ms to
77
+ the reference page of `script/bench/request_overhead.rb` instead of 21 to 22 ms with stackprof
78
+ and 26 ms without it in 0.31.2 (Ruby 3.3, measured); the README gives this measured cost instead
79
+ of "< 5ms per request".
80
+ - **Upgrading:** to get the previous behaviours back, set `config.sql_backtrace = :all` and
81
+ `Profiler.function_profiling_tracepoint_fallback = true` in `config/initializers/profiler.rb`.
82
+ The Routes and Env tabs cannot show the table and the variables of the request's time any more:
83
+ they show the process's current ones.
84
+
22
85
  ## [0.31.2] - 2026-10-05
23
86
 
24
87
  <!-- stamped -->
@@ -7413,7 +7413,9 @@
7413
7413
  const [totalCount, setTotalCount] = d2(0);
7414
7414
  const [fnSortKey, setFnSortKey] = d2("total_duration");
7415
7415
  const [fnSortDir, setFnSortDir] = d2("desc");
7416
- const [fnEnabled, setFnEnabled] = d2(functionProfileData?.enabled ?? false);
7416
+ const [fnEnabled, setFnEnabled] = d2(
7417
+ functionProfileData?.reason === "stackprof_missing" ? true : functionProfileData?.enabled ?? false
7418
+ );
7417
7419
  const [fnMaxFrames, setFnMaxFrames] = d2(functionProfileData?.max_frames ?? 2e3);
7418
7420
  const [fnMode, setFnMode] = d2(functionProfileData?.mode === "lite" ? "lite" : "full");
7419
7421
  const [fnClock, setFnClock] = d2(functionProfileData?.clock ?? "wall");
@@ -7722,6 +7724,7 @@
7722
7724
  const [hoveredFnName, setHoveredFnName] = d2(null);
7723
7725
  const fnRendererRef = A2(null);
7724
7726
  const hasData = enabled && data?.enabled && (data.functions?.length ?? 0) > 0;
7727
+ const stackprofMissing = data?.reason === "stackprof_missing" && mode === "lite";
7725
7728
  const dataMode = data?.mode ?? "full";
7726
7729
  const dataClock = data?.clock ?? "wall";
7727
7730
  const isSampling = dataMode === "lite";
@@ -7814,7 +7817,7 @@
7814
7817
  class: `profiler-fn-profiling__toggle${enabled ? " profiler-fn-profiling__toggle--active" : ""}`,
7815
7818
  onClick: onToggle,
7816
7819
  disabled: toggling,
7817
- children: toggling ? "\u2026" : enabled ? "Enabled \u2014 click to disable" : "Disabled \u2014 click to enable"
7820
+ children: toggling ? "\u2026" : enabled ? "Enabled (click to disable)" : "Disabled (click to enable)"
7818
7821
  }
7819
7822
  )
7820
7823
  ] })
@@ -7823,12 +7826,25 @@
7823
7826
  "Enable function profiling to see where your app spends time.",
7824
7827
  " ",
7825
7828
  /* @__PURE__ */ u3("strong", { children: "Lite" }),
7826
- ": statistical sampling via stackprof \u2014 very low overhead (<1%), enabled by default.",
7829
+ ": statistical sampling with the stackprof gem, to add to the application's Gemfile; very low overhead (<1%), on by default once stackprof is installed.",
7827
7830
  " ",
7828
7831
  /* @__PURE__ */ u3("strong", { children: "Full" }),
7829
7832
  ": exhaustive TracePoint tracing with memory bytes \u2014 significant overhead."
7830
7833
  ] }),
7831
- enabled && !hasData && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: "Function profiling is active. Data will appear on the next request." }),
7834
+ enabled && stackprofMissing && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: [
7835
+ "No data: sampling needs the ",
7836
+ /* @__PURE__ */ u3("code", { children: "stackprof" }),
7837
+ " gem, which is not installed. Add",
7838
+ " ",
7839
+ /* @__PURE__ */ u3("code", { children: 'gem "stackprof"' }),
7840
+ " to the application's Gemfile. To trace every method call instead, at a much higher cost, choose ",
7841
+ /* @__PURE__ */ u3("strong", { children: "Full" }),
7842
+ ", or set",
7843
+ " ",
7844
+ /* @__PURE__ */ u3("code", { children: "Profiler.function_profiling_tracepoint_fallback = true" }),
7845
+ " in an initializer."
7846
+ ] }),
7847
+ enabled && !hasData && !stackprofMissing && /* @__PURE__ */ u3("p", { class: "profiler-fn-profiling__hint", children: "Function profiling is active. Data will appear on the next request." }),
7832
7848
  hasData && /* @__PURE__ */ u3(k, { children: [
7833
7849
  data.frame_cap_reached && /* @__PURE__ */ u3("div", { class: "profiler-fn-profiling__cap-warning", children: [
7834
7850
  "\u26A0 Frame cap reached (",
@@ -29,6 +29,8 @@ module Profiler
29
29
 
30
30
  # Recalculate AJAX collector data (since AJAX requests happen after page load)
31
31
  recalculate_ajax_data(profile)
32
+ # The route table and ENV are the process's, not stored in the profile
33
+ Profiler::ProcessSnapshot.hydrate(profile)
32
34
 
33
35
  render json: profile.to_h.merge(
34
36
  child_jobs: build_child_jobs(profile),
@@ -16,6 +16,8 @@ module Profiler
16
16
 
17
17
  # Recalculate AJAX collector data (since AJAX requests happen after page load)
18
18
  recalculate_ajax_data(profile)
19
+ # The route table and ENV are the process's, not stored in the profile
20
+ Profiler::ProcessSnapshot.hydrate(profile)
19
21
 
20
22
  render json: {
21
23
  profile: profile.to_h.merge(child_jobs: build_child_jobs(profile)),
@@ -22,6 +22,8 @@ module Profiler
22
22
 
23
23
  # Recalculate AJAX collector data (since AJAX requests happen after page load)
24
24
  recalculate_ajax_data(@profile)
25
+ # The route table and ENV are the process's, not stored in the profile
26
+ Profiler::ProcessSnapshot.hydrate(@profile)
25
27
 
26
28
  @profile_data = @profile.to_h.merge(
27
29
  child_jobs: build_child_jobs(@profile),
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../redaction"
4
4
  require_relative "lifecycle"
5
+ require_relative "scoped_notifications"
5
6
 
6
7
  module Profiler
7
8
  module Collectors
@@ -143,10 +144,14 @@ module Profiler
143
144
  @claimed_thread_slots = nil
144
145
  end
145
146
 
146
- def unsubscribe_notifications(subscriptions)
147
- return unless defined?(ActiveSupport::Notifications)
147
+ # Subscribes to +event+ for the request being profiled only: the block never sees what
148
+ # another request's thread emits (see ScopedNotifications). Returns the handle to release.
149
+ def subscribe_notification(event, &block)
150
+ ScopedNotifications.subscribe(event, &block)
151
+ end
148
152
 
149
- subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
153
+ def unsubscribe_notifications(subscriptions)
154
+ subscriptions.each { |handle| ScopedNotifications.unsubscribe(handle) }
150
155
  subscriptions.clear
151
156
  end
152
157
  end
@@ -35,7 +35,7 @@ module Profiler
35
35
  def subscribe
36
36
  return unless defined?(ActiveSupport::Notifications)
37
37
 
38
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_read.active_support") do |name, started, finished, unique_id, payload|
38
+ @subscriptions << subscribe_notification("cache_read.active_support") do |name, started, finished, unique_id, payload|
39
39
  duration = ((finished - started) * 1000).round(2)
40
40
  @cache_reads << {
41
41
  key: payload[:key],
@@ -44,7 +44,7 @@ module Profiler
44
44
  }
45
45
  end
46
46
 
47
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_write.active_support") do |name, started, finished, unique_id, payload|
47
+ @subscriptions << subscribe_notification("cache_write.active_support") do |name, started, finished, unique_id, payload|
48
48
  duration = ((finished - started) * 1000).round(2)
49
49
  @cache_writes << {
50
50
  key: payload[:key],
@@ -52,7 +52,7 @@ module Profiler
52
52
  }
53
53
  end
54
54
 
55
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("cache_delete.active_support") do |name, started, finished, unique_id, payload|
55
+ @subscriptions << subscribe_notification("cache_delete.active_support") do |name, started, finished, unique_id, payload|
56
56
  duration = ((finished - started) * 1000).round(2)
57
57
  @cache_deletes << {
58
58
  key: payload[:key],
@@ -6,10 +6,24 @@ require_relative "../models/sql_query"
6
6
  module Profiler
7
7
  module Collectors
8
8
  class DatabaseCollector < BaseCollector
9
+ BACKTRACE_DEPTH = 10
10
+ MAX_SCANNED_FRAMES = 100
11
+ FRAMES_PAST_APPLICATION = 15
12
+
13
+
14
+ # Frames between the application's code and the subscriber: the profiler itself, the
15
+ # notifications bus and Active Record.
16
+ INTERNAL_FRAMES = [
17
+ File.expand_path("..", __dir__) + "/",
18
+ "/active_support/notifications",
19
+ "/active_record/"
20
+ ].freeze
21
+
9
22
  def initialize(profile)
10
23
  super
11
24
  @queries = []
12
- @subscription = nil
25
+ @subscriptions = []
26
+ @seen_statements = {}
13
27
  end
14
28
 
15
29
  def icon
@@ -34,7 +48,10 @@ module Profiler
34
48
  def subscribe
35
49
  return unless defined?(ActiveSupport::Notifications)
36
50
 
37
- @subscription = ActiveSupport::Notifications.monotonic_subscribe("sql.active_record") do |name, started, finished, unique_id, payload|
51
+ @backtrace_mode = Profiler.configuration.sql_backtrace&.to_sym
52
+ @slow_query_threshold = Profiler.configuration.slow_query_threshold
53
+
54
+ @subscriptions << subscribe_notification("sql.active_record") do |name, started, finished, unique_id, payload|
38
55
  duration = ((finished - started) * 1000).round(2) # milliseconds
39
56
 
40
57
  # Skip schema queries and internal Rails queries
@@ -47,7 +64,7 @@ module Profiler
47
64
  binds: extract_binds(payload[:binds]),
48
65
  name: payload[:name],
49
66
  connection: payload[:connection],
50
- backtrace: extract_backtrace
67
+ backtrace: backtrace?(payload[:sql], duration) ? extract_backtrace : []
51
68
  )
52
69
 
53
70
  @queries << query
@@ -74,10 +91,7 @@ module Profiler
74
91
  end
75
92
 
76
93
  def unsubscribe
77
- return unless @subscription
78
-
79
- ActiveSupport::Notifications.unsubscribe(@subscription)
80
- @subscription = nil
94
+ unsubscribe_notifications(@subscriptions)
81
95
  end
82
96
 
83
97
  def toolbar_summary
@@ -118,10 +132,114 @@ module Profiler
118
132
  binds.map { Profiler::Redaction::MASK }
119
133
  end
120
134
 
135
+ # Capturing the caller costs tens of microseconds. The first run of a statement locates
136
+ # it, an N+1 loop included (its repeats are the same statement with other values); a slow
137
+ # query is always located.
138
+ def backtrace?(sql, duration)
139
+ case @backtrace_mode
140
+ when :all then true
141
+ when :none then false
142
+ else
143
+ key = statement_key(sql)
144
+ first = !@seen_statements.key?(key)
145
+ @seen_statements[key] = true if first
146
+ first || duration > @slow_query_threshold
147
+ end
148
+ end
149
+
150
+ # The statement with its values left out, exactly as the N+1 detection of the Database tab
151
+ # (DatabaseTab.tsx) and of the MCP (Resources::N1Patterns) groups queries: the first query
152
+ # of each group is then always one whose caller was captured, values inlined in the SQL
153
+ # (MySQL) included.
154
+ def statement_key(sql)
155
+ sql.to_s
156
+ .gsub(/\$\d+/, "?")
157
+ .gsub(/\b\d+\b/, "?")
158
+ .gsub(/'[^']*'/, "?")
159
+ .gsub(/"[^"]*"/, "?")
160
+ .strip
161
+ end
162
+
163
+ # The code that ran the query: the frames Rails.backtrace_cleaner keeps, as the exception
164
+ # tab shows them, or without Rails, the frames outside gems and Ruby itself. Whatever stands
165
+ # in between (the profiler, Active Support, Active Record, the driver, other gems) is left
166
+ # out, so that the first frames are the application's.
121
167
  def extract_backtrace
122
- caller_locations(5, 10)
123
- .reject { |loc| loc.path.include?("active_record") }
124
- .map { |loc| "#{loc.path}:#{loc.lineno}:in `#{loc.label}`" }
168
+ cleaner = rails_backtrace_cleaner
169
+ cleaned = []
170
+ plain = []
171
+ scanned = 0
172
+ last_kept = nil
173
+ while scanned < MAX_SCANNED_FRAMES && cleaned.size < BACKTRACE_DEPTH
174
+ chunk = caller_locations(1 + scanned, 25)
175
+ break if chunk.nil? || chunk.empty?
176
+
177
+ chunk.each do |loc|
178
+ scanned += 1
179
+ path = loc.path.to_s
180
+ next if INTERNAL_FRAMES.any? { |internal| path.include?(internal) }
181
+ # Gems and Ruby are never the application's code, and Rails' cleaner silences them
182
+ # too: a string test spares it most frames.
183
+ next if library_frame?(path)
184
+
185
+ line = "#{path}:#{loc.lineno}:in `#{loc.label}`"
186
+ if cleaner && (kept = clean_frame(cleaner, line))
187
+ cleaned << kept
188
+ last_kept = scanned
189
+ end
190
+ plain << line if plain.size < BACKTRACE_DEPTH
191
+ break if cleaned.size == BACKTRACE_DEPTH
192
+ end
193
+ # Without Rails, the first frames outside gems are enough; with it, the application's
194
+ # frames come together: a few frames past the last one kept, the rest is the framework.
195
+ break if cleaner.nil? && plain.size >= BACKTRACE_DEPTH
196
+ break if last_kept && scanned - last_kept > FRAMES_PAST_APPLICATION
197
+ end
198
+
199
+ frames = cleaned.empty? ? plain : cleaned
200
+ frames.empty? ? fallback_backtrace : frames
201
+ end
202
+
203
+ def rails_backtrace_cleaner
204
+ Rails.backtrace_cleaner if defined?(Rails) && Rails.respond_to?(:backtrace_cleaner)
205
+ rescue StandardError
206
+ nil
207
+ end
208
+
209
+ # The frame as the cleaner shows it, or nil when it silences it.
210
+ def clean_frame(cleaner, line)
211
+ cleaner.respond_to?(:clean_frame) ? cleaner.clean_frame(line) : cleaner.clean([line]).first
212
+ rescue StandardError
213
+ nil
214
+ end
215
+
216
+ def fallback_backtrace
217
+ (caller_locations(1, BACKTRACE_DEPTH + 20) || []).filter_map do |loc|
218
+ path = loc.path.to_s
219
+ "#{path}:#{loc.lineno}:in `#{loc.label}`" unless INTERNAL_FRAMES.any? { |internal| path.include?(internal) }
220
+ end.first(BACKTRACE_DEPTH)
221
+ end
222
+
223
+ # Ruby itself and the directories gems are installed in; an application that lives under a
224
+ # directory called gems is not one.
225
+ def library_frame?(path)
226
+ path.start_with?("<internal:") || library_prefixes.any? { |dir| path.start_with?(dir) }
227
+ end
228
+
229
+ def library_prefixes
230
+ @library_prefixes ||= begin
231
+ dirs = [RbConfig::CONFIG["rubylibdir"], RbConfig::CONFIG["rubyarchdir"], RbConfig::CONFIG["bindir"]]
232
+ dirs.concat(Gem.path) if defined?(Gem)
233
+ dirs << bundle_path
234
+ dirs.compact.reject(&:empty?).map { |dir| File.join(File.expand_path(dir), "") }.uniq.freeze
235
+ end
236
+ end
237
+
238
+ # Bundler raises without a Gemfile.
239
+ def bundle_path
240
+ Bundler.bundle_path.to_s if defined?(Bundler) && Bundler.respond_to?(:bundle_path)
241
+ rescue StandardError
242
+ nil
125
243
  end
126
244
  end
127
245
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base_collector"
4
+ require_relative "../process_snapshot"
4
5
 
5
6
  module Profiler
6
7
  module Collectors
@@ -24,14 +25,21 @@ module Profiler
24
25
  }
25
26
  end
26
27
 
28
+ # ENV is the process's, not the request's: an HTTP profile keeps none of it, and the Env tab
29
+ # shows the variables of the process that displays the profile, which is the one that served
30
+ # the request (ProcessSnapshot). A job, a console expression or a test runs in another
31
+ # process (Sidekiq, the console, rspec): its profile keeps that process's ENV, masked.
27
32
  def collect
28
- variables = Profiler::Redaction.env_snapshot
29
- store_data({ variables: variables, total: variables.size })
33
+ if @profile.profile_type.to_s == "http"
34
+ store_data({ scope: "process" })
35
+ else
36
+ variables = Profiler::Redaction.env_snapshot
37
+ store_data({ variables: variables, total: variables.size })
38
+ end
30
39
  end
31
40
 
32
41
  def toolbar_summary
33
- data = panel_content
34
- { text: "#{data[:total] || 0} vars", color: "gray" }
42
+ { text: "#{ENV.size} vars", color: "gray" }
35
43
  end
36
44
  end
37
45
  end
@@ -31,11 +31,12 @@ module Profiler
31
31
  def subscribe
32
32
  @exception_data = nil
33
33
 
34
- @subscriber = ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
35
- event = ActiveSupport::Notifications::Event.new(*args)
36
- ex = event.payload[:exception_object]
37
- capture_exception(ex) if ex && @exception_data.nil?
38
- end
34
+ @subscriptions = [
35
+ subscribe_notification("process_action.action_controller") do |_name, _started, _finished, _id, payload|
36
+ ex = payload[:exception_object]
37
+ capture_exception(ex) if ex && @exception_data.nil?
38
+ end
39
+ ]
39
40
  end
40
41
 
41
42
  def capture(ex)
@@ -54,10 +55,7 @@ module Profiler
54
55
  end
55
56
 
56
57
  def unsubscribe
57
- return unless @subscriber
58
-
59
- ActiveSupport::Notifications.unsubscribe(@subscriber)
60
- @subscriber = nil
58
+ unsubscribe_notifications(@subscriptions) if @subscriptions
61
59
  end
62
60
 
63
61
  def has_data?
@@ -38,7 +38,7 @@ module Profiler
38
38
  claim_thread_slot(:profiler_flamegraph_collector, self)
39
39
 
40
40
  # Controller action
41
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process_action.action_controller") do |_name, started, finished, _unique_id, payload|
41
+ @subscriptions << subscribe_notification("process_action.action_controller") do |_name, started, finished, _unique_id, payload|
42
42
  add_event Models::TimelineEvent.new(
43
43
  name: "#{payload[:controller]}##{payload[:action]}",
44
44
  started_at: started,
@@ -56,7 +56,7 @@ module Profiler
56
56
  end
57
57
 
58
58
  # Template rendering
59
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("render_template.action_view") do |_name, started, finished, _unique_id, payload|
59
+ @subscriptions << subscribe_notification("render_template.action_view") do |_name, started, finished, _unique_id, payload|
60
60
  identifier = short_identifier(payload[:identifier])
61
61
  add_event Models::TimelineEvent.new(
62
62
  name: "Render: #{identifier}",
@@ -68,7 +68,7 @@ module Profiler
68
68
  end
69
69
 
70
70
  # Partial rendering
71
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("render_partial.action_view") do |_name, started, finished, _unique_id, payload|
71
+ @subscriptions << subscribe_notification("render_partial.action_view") do |_name, started, finished, _unique_id, payload|
72
72
  identifier = short_identifier(payload[:identifier])
73
73
  add_event Models::TimelineEvent.new(
74
74
  name: "Partial: #{identifier}",
@@ -80,7 +80,7 @@ module Profiler
80
80
  end
81
81
 
82
82
  # SQL queries
83
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("sql.active_record") do |_name, started, finished, _unique_id, payload|
83
+ @subscriptions << subscribe_notification("sql.active_record") do |_name, started, finished, _unique_id, payload|
84
84
  next if payload[:name] == "SCHEMA"
85
85
  next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT)/i
86
86
 
@@ -96,7 +96,7 @@ module Profiler
96
96
 
97
97
  # Cache operations
98
98
  %w[cache_read.active_support cache_write.active_support cache_delete.active_support].each do |event_name|
99
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe(event_name) do |name, started, finished, _unique_id, payload|
99
+ @subscriptions << subscribe_notification(event_name) do |name, started, finished, _unique_id, payload|
100
100
  op = name.split(".").first.sub("cache_", "")
101
101
  key = payload[:key].to_s
102
102
  add_event Models::TimelineEvent.new(
@@ -5,7 +5,7 @@ require_relative "base_collector"
5
5
  begin
6
6
  require "stackprof"
7
7
  rescue LoadError
8
- # stackprof not available — lite mode will fall back to TracePoint without memory tracking
8
+ # stackprof not available: lite mode stays off, unless Profiler.function_profiling_tracepoint_fallback
9
9
  end
10
10
 
11
11
  module Profiler
@@ -55,6 +55,20 @@ module Profiler
55
55
 
56
56
  mode = Profiler.function_profiling_mode
57
57
  clock = Profiler.function_profiling_clock
58
+
59
+ # Sampling needs stackprof, which the gem does not depend on. Tracing every method call
60
+ # instead costs several times the rest of the profiler: only when asked for.
61
+ if stackprof_missing?(mode)
62
+ store_data({
63
+ enabled: false,
64
+ reason: "stackprof_missing",
65
+ max_frames: Profiler.function_profiling_max_frames,
66
+ mode: mode,
67
+ clock: clock
68
+ })
69
+ return
70
+ end
71
+
58
72
  @subscribed = true
59
73
  claim_thread_slot(:fn_profiler_mode, mode)
60
74
  claim_thread_slot(:fn_profiler_clock, clock)
@@ -119,6 +133,7 @@ module Profiler
119
133
 
120
134
  def toolbar_summary
121
135
  return { text: "off", color: "gray" } unless Profiler.function_profiling_enabled
136
+ return { text: "off", color: "gray" } if stackprof_missing?(Profiler.function_profiling_mode)
122
137
  case Profiler.function_profiling_mode
123
138
  when "lite" then { text: "sampling on", color: "blue" }
124
139
  else { text: "fn profiling on", color: "purple" }
@@ -127,6 +142,10 @@ module Profiler
127
142
 
128
143
  private
129
144
 
145
+ def stackprof_missing?(mode)
146
+ mode == "lite" && !defined?(StackProf) && !Profiler.function_profiling_tracepoint_fallback
147
+ end
148
+
130
149
  # ── StackProf (lite mode) ──────────────────────────────────────────────────
131
150
 
132
151
  def store_sampler_busy(mode, clock)
@@ -297,10 +316,9 @@ module Profiler
297
316
  claim_thread_slot(:fn_profiler_depth, Hash.new(0))
298
317
 
299
318
  @trace = TracePoint.new(:call, :return) do |tp|
300
- next unless tp.path&.start_with?(app_root)
301
-
302
319
  stack = Thread.current[:fn_profiler_stack]
303
320
  next unless stack
321
+ next unless tp.path&.start_with?(app_root)
304
322
 
305
323
  case tp.event
306
324
  when :call
@@ -339,7 +357,13 @@ module Profiler
339
357
  end
340
358
  end
341
359
 
342
- @trace.enable
360
+ # On the request's thread only: a TracePoint enabled for the whole process slows down
361
+ # every other thread, other requests included.
362
+ begin
363
+ @trace.enable(target_thread: Thread.current)
364
+ rescue ArgumentError
365
+ @trace.enable # a Ruby without target_thread: the hook returns at once on other threads
366
+ end
343
367
  end
344
368
 
345
369
  def collect_tracepoint(mode)
@@ -40,15 +40,10 @@ module Profiler
40
40
  return unless defined?(ActiveSupport::Notifications)
41
41
  return unless Profiler.configuration.track_mailers
42
42
 
43
- # Capture the subscriber thread so notifications from other threads (e.g. async
44
- # job threads delivering mail enqueued via deliver_later) are ignored by this collector.
45
- @subscriber_thread = Thread.current
46
-
47
43
  # Use a stack so multiple deliver_later calls in the same request are all tracked.
48
44
  claim_thread_slot(:profiler_pending_processes, [])
49
45
 
50
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process.action_mailer") do |_name, started, finished, _id, payload|
51
- next unless Thread.current.equal?(@subscriber_thread)
46
+ @subscriptions << subscribe_notification("process.action_mailer") do |_name, started, finished, _id, payload|
52
47
  next if rails_preview_request?
53
48
 
54
49
  mailer_class = payload[:mailer].to_s
@@ -64,8 +59,7 @@ module Profiler
64
59
  }
65
60
  end
66
61
 
67
- @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("deliver.action_mailer") do |_name, started, finished, _id, payload|
68
- next unless Thread.current.equal?(@subscriber_thread)
62
+ @subscriptions << subscribe_notification("deliver.action_mailer") do |_name, started, finished, _id, payload|
69
63
  next unless payload[:perform_deliveries]
70
64
  next if rails_preview_request?
71
65