rails-profiler 0.30.11 → 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: e7bb3bed351c863507d670d29552cb6751bb023300728a48e9baf9c718a53659
4
- data.tar.gz: 8af241a6ca0278f3ada6a4d0d88599e93b952f67057ba6b826ea36935976ba67
3
+ metadata.gz: 2613fd14e9adfbb7911645a1bd23fcd1043d4424f532e64c267601d021db7ad0
4
+ data.tar.gz: 1f474b73d65dd2aac23151a8ea4f61aba98399f7182cd7db9dda39947afb7b28
5
5
  SHA512:
6
- metadata.gz: 04d1ce9d5e1c0a2527ddb60280aa82e89e04ccc32a202a4f79e09b4a5fa87319b8f6d6c95a93f212a61cb4bc7213b92d15bb914567669a2f079eec502c075822
7
- data.tar.gz: 91db3673cbcd3debdcf9fc1e02690601c4a70aaf10f4a17a070eed4b7f9359ae6261b5a9256ab43b9a13108aec0fa61677dddc9b735f72bf8103261f7e8b24f5
6
+ metadata.gz: 67dd8e4b429926a3fdef908c1491650c796497c177cb263cfdd94e7b14a5587a7a6ef48ea09c33b430ef5bca1ac0c921a4294d16c093127a39b1eac9db5c5cd3
7
+ data.tar.gz: 004f12ec514d606d212f49b0ac5c8c1f593437d62cf58e2a2b1242eef67fd144caa1ef165e8371947db4c9d8ae291a9bbdb83a2acf40058a8b53e5e50a78be71
data/CHANGELOG.md CHANGED
@@ -19,6 +19,81 @@ 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
+
64
+ ## [0.30.12] - 2026-10-04
65
+
66
+ <!-- stamped -->
67
+
68
+ ### Security
69
+
70
+ - **Toolbar:** The toolbar endpoint (`/_profiler/api/toolbar/:token`) now answers `403` when the
71
+ profiler is disabled, like the rest of the API. It used to keep serving any profile left in
72
+ storage, request headers and environment included, after `enabled = false`. The toolbar is only
73
+ injected while the profiler is enabled, so it is not affected.
74
+ - **Profile page:** The profile embedded as JSON in the profile page is now escaped explicitly
75
+ (`<`, `>`, `&`, U+2028 and U+2029), whatever the application sets for
76
+ `ActiveSupport.escape_html_entities_in_json`. An application that turned that setting off let a
77
+ captured value (a parameter, a header, a body) close the `<script>` element and run JavaScript
78
+ in the application's origin. The token and the CSP nonce written into the application's pages
79
+ by the toolbar injector are escaped too.
80
+ - **Dashboard:** Captured bodies offered for download are no longer typed with their captured
81
+ content type: opened in a tab, a `text/html` or `image/svg+xml` body ran its scripts in the
82
+ application's origin. Previews keep their type for raster images and PDF only. Email previews
83
+ are rendered in a fully sandboxed iframe, with an opaque origin. The unused SQL highlighter,
84
+ which wrote captured SQL into the page as HTML, is removed.
85
+ - **Access control:** A disabled profiler now answers its API requests with a JSON error, like
86
+ any other refusal of the API, instead of plain text.
87
+ - **Toolbar:** The toolbar is now injected before the `</body>` that closes the page, looked for
88
+ outside comments, tags (attributes read as the browser reads them, quoted values included) and
89
+ the elements whose content is not markup (`<script>`, `<style>`, `<textarea>`, `<title>`,
90
+ `<xmp>`, `<iframe>`, `<noembed>`, `<noframes>`, `<noscript>`), instead of the first `</body>`
91
+ found. A `</body>` inside a script string of the page used to receive it, and the toolbar's own
92
+ `</script>` then turned the rest of that string into live markup. A page whose only `</body>`
93
+ sits in one of those, that leaves a comment, a tag, a quoted value or one of those elements
94
+ open, or that holds a `<plaintext>`, a double-escaped script or a CDATA section with a `>`
95
+ before its end, gets no toolbar.
96
+
22
97
  ## [0.30.11] - 2026-10-04
23
98
 
24
99
  <!-- stamped -->
@@ -4054,7 +4054,7 @@
4054
4054
  function DownloadTextButton({ text, mime }) {
4055
4055
  const [url, setUrl] = d2(null);
4056
4056
  y2(() => {
4057
- const objectUrl = URL.createObjectURL(new Blob([text], { type: mime }));
4057
+ const objectUrl = URL.createObjectURL(new Blob([text], { type: "application/octet-stream" }));
4058
4058
  setUrl(objectUrl);
4059
4059
  return () => URL.revokeObjectURL(objectUrl);
4060
4060
  }, [text, mime]);
@@ -4142,11 +4142,17 @@
4142
4142
  };
4143
4143
  return map[category] || "text/plain";
4144
4144
  }
4145
+ var INERT_PREVIEW_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "application/pdf"];
4146
+ function inertPreviewType(mime) {
4147
+ const m3 = mime.toLowerCase();
4148
+ return INERT_PREVIEW_TYPES.includes(m3) ? m3 : "application/octet-stream";
4149
+ }
4145
4150
  var PREVIEW_LIMIT = 500;
4146
4151
  var CSV_ROW_LIMIT = 10;
4147
4152
  function SmartBodyPreview({ body, encoding, headers }) {
4148
4153
  const [expanded, setExpanded] = d2(false);
4149
4154
  const [objectUrl, setObjectUrl] = d2(null);
4155
+ const [downloadUrl, setDownloadUrl] = d2(null);
4150
4156
  const category = detectContentType(headers);
4151
4157
  y2(() => {
4152
4158
  if (encoding !== "base64" || !body) return;
@@ -4154,19 +4160,27 @@
4154
4160
  const raw = atob(body);
4155
4161
  const bytes = new Uint8Array(raw.length);
4156
4162
  for (let i3 = 0; i3 < raw.length; i3++) bytes[i3] = raw.charCodeAt(i3);
4157
- const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
4163
+ const url = URL.createObjectURL(new Blob([bytes], { type: inertPreviewType(mime) }));
4164
+ const download = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
4158
4165
  setObjectUrl(url);
4159
- return () => URL.revokeObjectURL(url);
4166
+ setDownloadUrl(download);
4167
+ return () => {
4168
+ URL.revokeObjectURL(url);
4169
+ URL.revokeObjectURL(download);
4170
+ };
4160
4171
  }, [body, encoding, headers]);
4161
4172
  if (!body) return /* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: "empty" });
4162
4173
  if (encoding === "base64") {
4163
4174
  const mime = Object.entries(headers).find(([k3]) => k3.toLowerCase() === "content-type")?.[1]?.split(";")[0].trim() || "application/octet-stream";
4164
4175
  const filename = `body${mimeToExt(mime)}`;
4165
4176
  return /* @__PURE__ */ u3("div", { class: "profiler-body-binary", children: [
4166
- objectUrl && (category === "image" || category === "svg") && /* @__PURE__ */ u3("img", { src: objectUrl, alt: "response preview", style: "max-width:100%;max-height:300px;display:block;margin-bottom:8px;border-radius:4px;border:1px solid var(--profiler-border)" }),
4177
+ objectUrl && category === "image" && /* @__PURE__ */ u3("img", { src: objectUrl, alt: "response preview", style: "max-width:100%;max-height:300px;display:block;margin-bottom:8px;border-radius:4px;border:1px solid var(--profiler-border)" }),
4178
+ category === "svg" && // A data: URL rather than a blob: one: an <img> runs no script, and browsers refuse
4179
+ // to open a data: URL in a tab of its own.
4180
+ /* @__PURE__ */ u3("img", { src: `data:image/svg+xml;base64,${body}`, alt: "response preview", style: "max-width:100%;max-height:300px;display:block;margin-bottom:8px;border-radius:4px;border:1px solid var(--profiler-border)" }),
4167
4181
  objectUrl && category === "pdf" && /* @__PURE__ */ u3("iframe", { src: objectUrl, class: "profiler-body-preview-frame", title: "PDF preview" }),
4168
4182
  /* @__PURE__ */ u3("div", { style: "display:flex;gap:8px;margin-top:4px", children: [
4169
- objectUrl && /* @__PURE__ */ u3("a", { href: objectUrl, download: filename, class: "profiler-body-download-btn profiler-text--xs", children: [
4183
+ downloadUrl && /* @__PURE__ */ u3("a", { href: downloadUrl, download: filename, class: "profiler-body-download-btn profiler-text--xs", children: [
4170
4184
  "Download ",
4171
4185
  mime
4172
4186
  ] }),
@@ -8615,7 +8629,7 @@
8615
8629
  "iframe",
8616
8630
  {
8617
8631
  srcdoc: email.body_html ?? void 0,
8618
- sandbox: "allow-same-origin",
8632
+ sandbox: "",
8619
8633
  style: { width: "100%", height: "300px", border: "1px solid var(--profiler-border)", borderRadius: "var(--profiler-radius-md)", background: "#fff", display: "block" }
8620
8634
  }
8621
8635
  ),
@@ -9421,62 +9435,6 @@ Duration: ${event.duration.toFixed(2)}ms`;
9421
9435
  return "#dcdcaa";
9422
9436
  }
9423
9437
 
9424
- // app/assets/typescript/profiler/sql-formatter.ts
9425
- var SQL_KEYWORDS = [
9426
- "SELECT",
9427
- "FROM",
9428
- "WHERE",
9429
- "INSERT",
9430
- "UPDATE",
9431
- "DELETE",
9432
- "JOIN",
9433
- "LEFT",
9434
- "RIGHT",
9435
- "INNER",
9436
- "OUTER",
9437
- "ON",
9438
- "AS",
9439
- "AND",
9440
- "OR",
9441
- "NOT",
9442
- "IN",
9443
- "EXISTS",
9444
- "LIKE",
9445
- "ORDER",
9446
- "BY",
9447
- "GROUP",
9448
- "HAVING",
9449
- "LIMIT",
9450
- "OFFSET",
9451
- "DISTINCT",
9452
- "COUNT",
9453
- "SUM",
9454
- "AVG",
9455
- "MAX",
9456
- "MIN",
9457
- "CASE",
9458
- "WHEN",
9459
- "THEN",
9460
- "ELSE",
9461
- "END"
9462
- ];
9463
- function formatSQL(element) {
9464
- const sql = element.textContent || "";
9465
- const formatted = highlightSQL(sql);
9466
- element.innerHTML = formatted;
9467
- }
9468
- function highlightSQL(sql) {
9469
- let highlighted = sql;
9470
- SQL_KEYWORDS.forEach((keyword) => {
9471
- const regex = new RegExp(`\\b${keyword}\\b`, "gi");
9472
- highlighted = highlighted.replace(regex, `<span class="sql-keyword">${keyword}</span>`);
9473
- });
9474
- highlighted = highlighted.replace(/'([^']*)'/g, `<span class="sql-string">'$1'</span>`);
9475
- highlighted = highlighted.replace(/\b(\d+)\b/g, '<span class="sql-number">$1</span>');
9476
- highlighted = highlighted.replace(/--([^\n]*)/g, '<span class="sql-comment">--$1</span>');
9477
- return highlighted;
9478
- }
9479
-
9480
9438
  // app/assets/typescript/profiler/theme.ts
9481
9439
  var STORAGE_KEY2 = "profiler-theme";
9482
9440
  var THEME_ATTRIBUTE = "data-theme";
@@ -9663,8 +9621,5 @@ Duration: ${event.duration.toFixed(2)}ms`;
9663
9621
  if (timeline) {
9664
9622
  initTimeline(timeline);
9665
9623
  }
9666
- document.querySelectorAll('pre[data-language="sql"]').forEach((block) => {
9667
- formatSQL(block);
9668
- });
9669
9624
  });
9670
9625
  })();
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class ToolbarController < Profiler::ApplicationController
6
- skip_before_action :check_authorization
7
-
8
6
  def show
9
7
  profile = Profiler.storage.load(params[:token])
10
8
 
@@ -14,9 +14,7 @@ module Profiler
14
14
  private
15
15
 
16
16
  def check_authorization
17
- unless Profiler.configuration.enabled
18
- render plain: "Profiler is disabled", status: :forbidden
19
- end
17
+ deny("Profiler is disabled") unless Profiler.configuration.enabled
20
18
  end
21
19
 
22
20
  def authorize_request
@@ -1,4 +1,6 @@
1
1
  <div id="profiler-show" data-embedded="<%= @embedded %>"></div>
2
+ <%# json_escape turns <, >, & and U+2028/U+2029 into \u escapes, so that no captured value can
3
+ close the element, whatever the application sets for escape_html_entities_in_json. %>
2
4
  <script type="application/json" id="profiler-show-data">
3
- <%= @profile_data.to_json.html_safe %>
5
+ <%= raw json_escape(@profile_data.to_json) %>
4
6
  </script>
@@ -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)
@@ -1,9 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "strscan"
5
+ require "active_support/core_ext/string/output_safety"
6
+
3
7
  module Profiler
4
8
  module Middleware
5
9
  class ToolbarInjector
6
- CLOSING_BODY_TAG = "</body>"
10
+ # The only characters HTML counts as white space between attributes (\s also takes \v).
11
+ WS = "[\\t\\n\\f\\r ]"
12
+ # Elements whose content the browser does not parse as markup: a </body> inside one of
13
+ # them is text, not the end of the page. Each maps to the end tag that closes it.
14
+ RAW_TEXT_ELEMENTS = %w[script style textarea title xmp iframe noembed noframes noscript]
15
+ .to_h { |name| [name, %r{</#{name}(?=#{WS}|[/>])}i] }.freeze
16
+ # Never closed: whatever follows it is text.
17
+ PLAINTEXT = "plaintext"
18
+ # An attribute as the HTML tokenizer reads it: the name may start with = (or a quote),
19
+ # and a value only follows a name, after =. A quoted value may hold a > or a </body>.
20
+ ATTRIBUTE = %r{
21
+ [^\t\n\f\r\ />][^\t\n\f\r\ />=]*+
22
+ (?:#{WS}*+(?:=#{WS}*+(?:"[^"]*+"|'[^']*+'|[^\t\n\f\r\ >"'][^\t\n\f\r\ >]*+|(?=>))|(?!=)))
23
+ }x
24
+ # The rest of a tag, up to its >. A quoted value that is never closed fails the match,
25
+ # as does a tag without its >. Possessive, so that a failure costs no backtracking.
26
+ TAG_REST = %r{(?:#{WS}++|/|#{ATTRIBUTE})*+>}
27
+ # A whole start or end tag, read after its <: the slash and the name are captured.
28
+ TAG = %r{(/)?([a-z][^\t\n\f\r\ />]*+)#{TAG_REST}}i
7
29
 
8
30
  def initialize(body, token, nonce = nil)
9
31
  @body = body
@@ -13,9 +35,12 @@ module Profiler
13
35
 
14
36
  def inject
15
37
  content = extract_content(@body)
16
- return @body unless content.include?(CLOSING_BODY_TAG)
38
+ # Bytes, so that a page that is not valid UTF-8 can still be scanned.
39
+ bytes = content.b
40
+ position = closing_body_position(bytes)
41
+ return @body unless position
17
42
 
18
- injected_content = content.sub(CLOSING_BODY_TAG, toolbar_html + CLOSING_BODY_TAG)
43
+ injected_content = bytes.insert(position, toolbar_html.b).force_encoding(content.encoding)
19
44
 
20
45
  # Return as array for Rack compatibility
21
46
  [injected_content]
@@ -23,12 +48,70 @@ module Profiler
23
48
 
24
49
  private
25
50
 
51
+ # Byte offset of the </body> that closes the page: the last one outside comments, tags
52
+ # (an attribute value) and raw-text elements, where a page can carry the string
53
+ # "</body>" (a script building markup, a cached fragment in a final comment). nil when
54
+ # there is none, or when the page leaves a comment, a tag, a quoted value or a raw-text
55
+ # element open, or holds a <plaintext> or a double-escaped script: no toolbar rather than
56
+ # a toolbar in the wrong place.
57
+ def closing_body_position(bytes)
58
+ scanner = StringScanner.new(bytes)
59
+ position = nil
60
+
61
+ while scanner.skip_until(/</)
62
+ start = scanner.pos - 1
63
+
64
+ if scanner.skip(TAG)
65
+ name = scanner[2]
66
+ name = name.downcase if name.match?(/[A-Z]/)
67
+
68
+ if scanner[1]
69
+ position = start if name == "body"
70
+ next
71
+ end
72
+ return nil if name == PLAINTEXT
73
+ next unless (end_tag = RAW_TEXT_ELEMENTS[name])
74
+
75
+ text_start = scanner.pos
76
+ return nil unless scanner.skip_until(end_tag)
77
+ return nil if name == "script" && double_escaped?(bytes, text_start, scanner.pos - scanner.matched_size)
78
+ return nil unless scanner.skip(TAG_REST)
79
+ elsif scanner.match?(%r{/?[a-z]}i)
80
+ # A tag, or a quoted value in it, that is never closed.
81
+ return nil
82
+ elsif scanner.skip(/!--/)
83
+ # <!--> and <!---> are complete comments.
84
+ next if scanner.skip(/-?>/) || scanner.skip_until(/-->/)
85
+
86
+ return nil
87
+ elsif scanner.skip(/!\[CDATA\[/)
88
+ # A bogus comment up to the first > in HTML, a CDATA section up to ]]> in <svg> or
89
+ # <math>: go on only when both end at the same place.
90
+ return nil unless scanner.skip_until(/>/) && bytes.byteslice(scanner.pos - 3, 3) == "]]>"
91
+ elsif scanner.skip(%r{[!?/]})
92
+ # <!DOCTYPE>, <?xml ?>, </ not followed by a letter: a bogus comment, up to its >.
93
+ return nil unless scanner.skip_until(/>/)
94
+ end
95
+ end
96
+
97
+ position
98
+ end
99
+
100
+ # In a script, <!-- followed by <script enters a state where </script> does not close it.
101
+ # Only the script itself is searched, so that a page of many scripts is read in linear
102
+ # time.
103
+ def double_escaped?(bytes, from, to)
104
+ text = bytes.byteslice(from, to - from)
105
+ comment = text.index("<!--")
106
+ comment ? text.match?(%r{<script(?:#{WS}|[/>])}i, comment) : false
107
+ end
108
+
26
109
  def ajax_interceptor_script
27
110
  return "" unless Profiler.configuration.track_ajax
28
111
 
29
112
  <<~HTML
30
113
  <script#{nonce_attr}>
31
- window.__PROFILER_PARENT_TOKEN__ = '#{@token}';
114
+ window.__PROFILER_PARENT_TOKEN__ = #{js_token};
32
115
  </script>
33
116
  <script#{nonce_attr}>
34
117
  #{ajax_interceptor_code}
@@ -63,15 +146,20 @@ module Profiler
63
146
  <<~HTML
64
147
  #{ajax_interceptor_script}
65
148
  <style>#{toolbar_styles}</style>
66
- <div id="profiler-toolbar" class="profiler-root" data-token="#{@token}"></div>
149
+ <div id="profiler-toolbar" class="profiler-root" data-token="#{ERB::Util.html_escape(@token)}"></div>
67
150
  <button id="profiler-toolbar-toggle" class="profiler-root" title="Toggle profiler (Alt+P)"><span class="profiler-toggle-icon">&#9654;</span></button>
68
151
  <script#{nonce_attr}>(function(){var c=localStorage.getItem('profiler-toolbar-collapsed')==='true',t=localStorage.getItem('profiler-theme'),theme=t==='light'?'light':t==='dark'?'dark':(window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'),el=document.getElementById('profiler-toolbar'),tog=document.getElementById('profiler-toolbar-toggle');if(c){el.style.cssText='animation:none!important;transform:translateX(calc(100% + 44px))';tog.dataset.collapsed='true';}el.setAttribute('data-theme',theme);tog.setAttribute('data-theme',theme);})();</script>
69
152
  <script src="/_profiler/assets/profiler-toolbar.js" defer#{nonce_attr}></script>
70
153
  HTML
71
154
  end
72
155
 
156
+ # The token as a JavaScript string literal that cannot close the <script> element.
157
+ def js_token
158
+ ERB::Util.json_escape(@token.to_s.to_json)
159
+ end
160
+
73
161
  def nonce_attr
74
- @nonce ? " nonce=\"#{@nonce}\"" : ""
162
+ @nonce ? " nonce=\"#{ERB::Util.html_escape(@nonce)}\"" : ""
75
163
  end
76
164
 
77
165
  # Thermal design system — self-contained CSS for the injected toolbar.
@@ -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.11"
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.11
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