rails-profiler 0.32.3 → 0.33.1

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: 97fa343641a35a6bb20283e405ed8955b7039d4da924a7ce4195feeaeffacec6
4
- data.tar.gz: d7f9d92015e47e0f20c2b344e2d297b169fcf5d9b184aad17f8d3c68f109f5f2
3
+ metadata.gz: d632e07d28796ba7902cd20f9466b4c529c77ab76b5627c2b946e6b059b23c1f
4
+ data.tar.gz: 7c91607d3ae24180f6708938a8dfa118ad297b11a6b9749cf155adeef5b81336
5
5
  SHA512:
6
- metadata.gz: cd484fea1576a382bc4a6ecef0a39bad2d25b0197d672c62b41e83fed0ef39dfbaf96e8d6100d9d34ed8fca48d765dacbf97099b8963f505503cfc56058be646
7
- data.tar.gz: de36e884fb0fd828d6d498c93858c73998762ec5823c7be1806897bd66ab36a97e61c2a1aa9d5a854694a6d184495855c812afcc0b6646cfe5318aac0f0dd037
6
+ metadata.gz: a7781894a3b105e730d47d63c0e7a2a15b28a58a51a38fc4662fb193c0665c1cdb8fd7072cadc7926f38a96e6a68a350ae19f16ef2560a119c89355fdbe3f4a6
7
+ data.tar.gz: e626c7d2d344ad58473fdecbbbaf3bfae340faf17eb1dd25ca7f88325ffe87c9a9837fefcdc9b1dea3cdc3541ce46a4156d1d4b17acd3f4b049defd2c08fcd03
data/CHANGELOG.md CHANGED
@@ -19,6 +19,55 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.33.1] - 2026-10-14
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Fixed
27
+
28
+ - **Packaging:** The published gem now contains `app/assets/javascript/profiler-ajax-interceptor.js`,
29
+ the script the toolbar injects to link the `fetch`, `XMLHttpRequest` and Turbo requests of a page
30
+ to that page. It was missing from every published version: with the gem installed from a
31
+ registry, those requests were never linked to their page, and the only trace was the log line
32
+ `ToolbarInjector: could not load the AJAX interceptor script`. A gem used from a git checkout or
33
+ a local path was not affected.
34
+ - **Release:** A version is now published only once both test suites (Ruby 3.3 and 3.4) have
35
+ passed, as written in the `needs` of the `release` and `canary` jobs, and once the built gem
36
+ has been installed and found to carry every file it reads at run time. The suites already held
37
+ publication back before, but only through the order of the pipeline stages.
38
+
39
+ ## [0.33.0] - 2026-10-14
40
+
41
+ <!-- stamped -->
42
+
43
+ ### Added
44
+
45
+ - **AJAX:** Requests made by Turbo (Hotwire) for a `<turbo-frame>`, and requests answered with
46
+ Turbo Stream actions, carry a `FRAME` or `STREAM` badge in the AJAX panel of the toolbar and in
47
+ the AJAX tab of the dashboard. The AJAX data of a page in the API gives each request a `turbo`
48
+ field: `frame`, `stream` or `null`. The MCP tool `get_profile_ajax` gives the same kind on a
49
+ `Turbo` line of each Turbo request, and none for a plain fetch. The Request tab of a frame
50
+ request shows its `Turbo-Frame` header.
51
+
52
+ ### Fixed
53
+
54
+ - **AJAX:** A Turbo Drive visit is no longer listed as an AJAX request of the page it leaves,
55
+ and neither is a link Turbo prefetches on hover: they are the next page, with their own
56
+ profile. The requests a page reached by a Drive visit makes are linked to that page instead of
57
+ to the first page of the session.
58
+ - **Toolbar:** After a Turbo Drive visit, the toolbar of the previous page is taken down: it no
59
+ longer keeps checking for updates of the previous profile, and `Alt+P` toggles the toolbar
60
+ once instead of once per page visited.
61
+
62
+ ### Limitations
63
+
64
+ - **Toolbar:** A Turbo 8 page refresh with morphing (`turbo_refreshes_with method: :morph`, or
65
+ a `broadcasts_refreshes` model) is not handled: it empties the toolbar, which keeps checking
66
+ for updates of the profile of the page as first loaded, and the requests made after it stay
67
+ linked to that profile. This predates this release and is left for a later one.
68
+ - **Toolbar:** After going back in the browser history, two toolbar containers are left in the
69
+ page, only one of them visible. This predates this release too.
70
+
22
71
  ## [0.32.3] - 2026-10-14
23
72
 
24
73
  <!-- stamped -->
@@ -3356,6 +3356,10 @@
3356
3356
  };
3357
3357
  return map[method] || "";
3358
3358
  }
3359
+ var TURBO_TITLES = {
3360
+ frame: "Turbo Frame request",
3361
+ stream: "Turbo Stream response"
3362
+ };
3359
3363
  function AjaxPanel({ ajaxData }) {
3360
3364
  return /* @__PURE__ */ u3(k, { children: [
3361
3365
  /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-header", children: [
@@ -3373,6 +3377,7 @@
3373
3377
  return /* @__PURE__ */ u3("div", { class: `profiler-ajax-card ${statusCls}`, children: /* @__PURE__ */ u3("div", { class: "profiler-ajax-card__row", children: [
3374
3378
  /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", children: [
3375
3379
  /* @__PURE__ */ u3("span", { class: `badge ${methodBadgeClass(req.method)}`, children: req.method }),
3380
+ req.turbo && /* @__PURE__ */ u3("span", { class: "badge badge-default", title: TURBO_TITLES[req.turbo], children: req.turbo.toUpperCase() }),
3376
3381
  /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--truncate", children: req.path })
3377
3382
  ] }),
3378
3383
  /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", children: [
@@ -4367,14 +4372,26 @@
4367
4372
  if (!el || !toggleEl) return;
4368
4373
  const token = el.dataset.token;
4369
4374
  if (!token) return;
4375
+ window.__PROFILER_TOOLBAR_TEARDOWN__?.();
4376
+ const listeners = new AbortController();
4377
+ const { signal } = listeners;
4378
+ const teardown = () => {
4379
+ listeners.abort();
4380
+ J(null, el);
4381
+ if (window.__PROFILER_TOOLBAR_TEARDOWN__ === teardown) delete window.__PROFILER_TOOLBAR_TEARDOWN__;
4382
+ };
4383
+ window.__PROFILER_TOOLBAR_TEARDOWN__ = teardown;
4384
+ document.addEventListener("turbo:load", () => {
4385
+ if (!el.isConnected) teardown();
4386
+ }, { signal });
4370
4387
  const themeEls = [el, toggleEl];
4371
4388
  applyTheme(themeEls);
4372
4389
  window.addEventListener("storage", (e3) => {
4373
4390
  if (e3.key === "profiler-theme") applyTheme(themeEls);
4374
- });
4391
+ }, { signal });
4375
4392
  window.addEventListener("profiler:theme-change", ((e3) => {
4376
4393
  if (e3.detail?.theme) themeEls.forEach((elem) => elem.setAttribute("data-theme", e3.detail.theme));
4377
- }));
4394
+ }), { signal });
4378
4395
  let isCollapsed = localStorage.getItem("profiler-toolbar-collapsed") === "true";
4379
4396
  toggleEl.dataset.collapsed = String(isCollapsed);
4380
4397
  if (isCollapsed && !el.style.transform) {
@@ -4406,13 +4423,13 @@
4406
4423
  }, ANIM_MS + 50);
4407
4424
  }
4408
4425
  }
4409
- toggleEl.addEventListener("click", toggleCollapse);
4426
+ toggleEl.addEventListener("click", toggleCollapse, { signal });
4410
4427
  document.addEventListener("keydown", (e3) => {
4411
4428
  if (e3.altKey && e3.key === "p") {
4412
4429
  e3.preventDefault();
4413
4430
  toggleCollapse();
4414
4431
  }
4415
- });
4432
+ }, { signal });
4416
4433
  J(
4417
4434
  /* @__PURE__ */ u3(QueryClientProvider, { client: toolbarQueryClient, children: /* @__PURE__ */ u3(ToolbarMount, { token }) }),
4418
4435
  el
@@ -8173,6 +8173,10 @@
8173
8173
  }
8174
8174
 
8175
8175
  // app/assets/typescript/profiler/components/dashboard/tabs/AjaxTab.tsx
8176
+ var TURBO_TITLES = {
8177
+ frame: "Turbo Frame request",
8178
+ stream: "Turbo Stream response"
8179
+ };
8176
8180
  function AjaxTab({ ajaxData }) {
8177
8181
  if (!ajaxData?.requests?.length) {
8178
8182
  return /* @__PURE__ */ u3("div", { class: "profiler-empty", children: [
@@ -8225,6 +8229,7 @@
8225
8229
  /* @__PURE__ */ u3("div", { class: "profiler-ajax-card__row", children: [
8226
8230
  /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-3", children: [
8227
8231
  /* @__PURE__ */ u3("span", { class: `profiler-ajax-card__method badge-${methodBadge(req.method)}`, children: req.method }),
8232
+ req.turbo && /* @__PURE__ */ u3("span", { class: "badge-default", title: TURBO_TITLES[req.turbo], children: req.turbo.toUpperCase() }),
8228
8233
  /* @__PURE__ */ u3("strong", { class: "profiler-ajax-card__path", children: req.path })
8229
8234
  ] }),
8230
8235
  /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", children: [
@@ -0,0 +1,193 @@
1
+ // Profiler AJAX Request Interceptor
2
+ // This script intercepts fetch() and XMLHttpRequest calls to link AJAX requests to their parent page
3
+
4
+ (function() {
5
+ 'use strict';
6
+
7
+ // Get parent token from window variable set by the profiler
8
+ if (!window.__PROFILER_PARENT_TOKEN__) {
9
+ console.warn('[Profiler] Parent token not found, AJAX tracking disabled');
10
+ return;
11
+ }
12
+
13
+ // Check if already intercepted (by Chrome extension or another script)
14
+ if (window.__PROFILER_INTERCEPTOR_ACTIVE__) {
15
+ console.debug('[Profiler] Interceptor already active, skipping');
16
+ return;
17
+ }
18
+ window.__PROFILER_INTERCEPTOR_ACTIVE__ = true;
19
+
20
+ // Save references to originals (they might already be wrapped by extension)
21
+ const originalFetch = window.fetch;
22
+ const originalXHROpen = XMLHttpRequest.prototype.open;
23
+ const originalXHRSend = XMLHttpRequest.prototype.send;
24
+ const pendingXHRRequests = new WeakMap();
25
+ // The fetches Turbo makes, by the AbortSignal it hands to fetch(): { frame: true } when the
26
+ // request loads a <turbo-frame>.
27
+ const turboRequests = new WeakMap();
28
+ const TURBO_STREAM_MIME = 'text/vnd.turbo-stream.html';
29
+
30
+ // The page the sub-requests belong to. Read at each link, not once: a Turbo Drive visit
31
+ // renders a new page, whose inline script sets the token again, without reloading this one.
32
+ function currentParentToken() {
33
+ return window.__PROFILER_PARENT_TOKEN__;
34
+ }
35
+
36
+ function isRequest(input) {
37
+ return typeof Request !== 'undefined' && input instanceof Request;
38
+ }
39
+
40
+ function hasHeader(headers, name) {
41
+ if (!headers) return false;
42
+ if (typeof headers.has === 'function') return headers.has(name);
43
+ const wanted = name.toLowerCase();
44
+ return Object.keys(headers).some(key => key.toLowerCase() === wanted && headers[key] != null && headers[key] !== '');
45
+ }
46
+
47
+ // Turbo (7 and 8) announces each of its requests before calling fetch(), with the options it
48
+ // will pass to it, signal included.
49
+ document.addEventListener('turbo:before-fetch-request', function(event) {
50
+ const options = event.detail && event.detail.fetchOptions;
51
+ if (!options || !options.signal) return;
52
+ turboRequests.set(options.signal, { frame: hasHeader(options.headers, 'Turbo-Frame') });
53
+ });
54
+
55
+ // A Turbo Drive visit, a prefetch on hover, or a form submission answered with a page is the
56
+ // next page, which carries its own toolbar: not a sub-request of this one. A <turbo-frame>
57
+ // load, and any request answered with Turbo Stream actions, update this page: they are.
58
+ function isNavigation(args, response) {
59
+ const init = args[1];
60
+ const signal = (init && init.signal) || (isRequest(args[0]) ? args[0].signal : null);
61
+ const turbo = signal && turboRequests.get(signal);
62
+ if (!turbo || turbo.frame) return false;
63
+
64
+ const contentType = response.headers.get('Content-Type') || '';
65
+ return !contentType.startsWith(TURBO_STREAM_MIME);
66
+ }
67
+
68
+ // Helper function to check if path should be skipped
69
+ function shouldSkipPath(url) {
70
+ try {
71
+ const urlObj = new URL(url, window.location.origin);
72
+ const path = urlObj.pathname;
73
+
74
+ // Skip profiler's own requests
75
+ if (path.startsWith('/_profiler')) {
76
+ return true;
77
+ }
78
+
79
+ return false;
80
+ } catch (e) {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ // Helper function to link child profile to parent
86
+ function linkChildProfile(childToken) {
87
+ const parentToken = currentParentToken();
88
+ if (!childToken || !parentToken) return;
89
+
90
+ console.debug('[Profiler] Linking AJAX request:', { parent: parentToken, child: childToken });
91
+
92
+ // Post to the link API endpoint
93
+ const linkUrl = '/_profiler/api/ajax/link';
94
+ const body = JSON.stringify({
95
+ parent_token: parentToken,
96
+ child_token: childToken
97
+ });
98
+
99
+ // Use original fetch to avoid infinite loop
100
+ originalFetch.call(window, linkUrl, {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ 'Accept': 'application/json',
105
+ 'X-Profiler-Request': '1'
106
+ },
107
+ body: body
108
+ }).then(response => {
109
+ if (response.ok) {
110
+ // Refresh toolbar after successful link (with small delay to let backend process)
111
+ setTimeout(() => {
112
+ if (typeof window.__PROFILER_REFRESH_TOOLBAR__ === 'function') {
113
+ window.__PROFILER_REFRESH_TOOLBAR__();
114
+ }
115
+ }, 100);
116
+ }
117
+ }).catch(err => {
118
+ // Fail silently - don't break the page if profiler API fails
119
+ console.debug('[Profiler] Failed to link AJAX request:', err);
120
+ });
121
+ }
122
+
123
+ // Listen for events from Chrome extension (if installed)
124
+ window.addEventListener('__profilerRequest', function(event) {
125
+ const { token } = event.detail;
126
+ if (token) {
127
+ console.debug('[Profiler] Received profiler event from extension:', token);
128
+ linkChildProfile(token);
129
+ }
130
+ });
131
+
132
+ // Override fetch()
133
+ window.fetch = async function(...args) {
134
+ const url = isRequest(args[0]) ? args[0].url : String(args[0]);
135
+
136
+ // Skip profiler paths
137
+ if (shouldSkipPath(url)) {
138
+ return originalFetch.apply(this, args);
139
+ }
140
+
141
+ try {
142
+ const response = await originalFetch.apply(this, args);
143
+
144
+ // Extract profiler token from response headers
145
+ const profilerToken = response.headers.get('X-Profiler-Token');
146
+
147
+ if (profilerToken && !isNavigation(args, response)) {
148
+ linkChildProfile(profilerToken);
149
+ }
150
+
151
+ return response;
152
+ } catch (error) {
153
+ throw error;
154
+ }
155
+ };
156
+
157
+ // Override XMLHttpRequest.open()
158
+ XMLHttpRequest.prototype.open = function(method, url, ...args) {
159
+ const urlString = typeof url === 'string' ? url : url.toString();
160
+
161
+ // Store request metadata
162
+ pendingXHRRequests.set(this, {
163
+ url: urlString,
164
+ method: method,
165
+ timestamp: Date.now()
166
+ });
167
+
168
+ return originalXHROpen.apply(this, [method, url, ...args]);
169
+ };
170
+
171
+ // Override XMLHttpRequest.send()
172
+ XMLHttpRequest.prototype.send = function(...args) {
173
+ const requestData = pendingXHRRequests.get(this);
174
+
175
+ // Skip profiler paths
176
+ if (requestData && shouldSkipPath(requestData.url)) {
177
+ return originalXHRSend.apply(this, args);
178
+ }
179
+
180
+ // Add load event listener to capture response
181
+ this.addEventListener('load', function() {
182
+ const profilerToken = this.getResponseHeader('X-Profiler-Token');
183
+
184
+ if (profilerToken && requestData) {
185
+ linkChildProfile(profilerToken);
186
+ }
187
+ });
188
+
189
+ return originalXHRSend.apply(this, args);
190
+ };
191
+
192
+ console.debug('[Profiler] AJAX interceptor initialized with parent token:', currentParentToken());
193
+ })();
@@ -105,9 +105,30 @@ module Profiler
105
105
  "method" => profile.method,
106
106
  "status" => profile.status,
107
107
  "duration" => profile.duration,
108
- "started_at" => profile.started_at&.iso8601
108
+ "started_at" => profile.started_at&.iso8601,
109
+ "turbo" => turbo_kind(profile)
109
110
  }
110
111
  end
112
+
113
+ TURBO_STREAM_MIME = "text/vnd.turbo-stream.html"
114
+
115
+ # "frame" for a request a <turbo-frame> made (Turbo sends its id in Turbo-Frame), "stream"
116
+ # for one answered with Turbo Stream actions, nil for any other fetch or XHR.
117
+ def turbo_kind(profile)
118
+ return "frame" if header_value(profile.headers, "Turbo-Frame").to_s != ""
119
+ return "stream" if header_value(profile.response_headers, "Content-Type").to_s.start_with?(TURBO_STREAM_MIME)
120
+
121
+ nil
122
+ end
123
+
124
+ # Header names keep the case they were saved with: Content-Type from Rack 2, content-type
125
+ # from Rack 3.
126
+ def header_value(headers, name)
127
+ return nil unless headers.respond_to?(:each)
128
+
129
+ headers.each { |key, value| return value if key.to_s.casecmp?(name) }
130
+ nil
131
+ end
111
132
  end
112
133
  end
113
134
  end
@@ -61,6 +61,7 @@ module Profiler
61
61
  lines << "- **Path:** #{req['path']}"
62
62
  lines << "- **Status:** #{req['status']}"
63
63
  lines << "- **Duration:** #{req['duration'].round(2)} ms"
64
+ lines << "- **Turbo:** #{req['turbo']}" if req['turbo']
64
65
  lines << "- **Token:** #{req['token']}" if req['token']
65
66
  lines << "- **Started At:** #{req['started_at']}" if req['started_at']
66
67
  end
@@ -346,7 +346,7 @@ module Profiler
346
346
  Accept Accept-Charset Accept-Encoding Accept-Language
347
347
  Authorization Cache-Control Connection Content-Length Content-Type
348
348
  Cookie Host If-Modified-Since If-None-Match Origin
349
- Referer User-Agent
349
+ Referer User-Agent Turbo-Frame
350
350
  ].freeze
351
351
 
352
352
  def extract_headers(env)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.32.3"
4
+ VERSION = "0.33.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.32.3
4
+ version: 0.33.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -109,6 +109,7 @@ files:
109
109
  - app/assets/builds/profiler-toolbar.js
110
110
  - app/assets/builds/profiler.css
111
111
  - app/assets/builds/profiler.js
112
+ - app/assets/javascript/profiler-ajax-interceptor.js
112
113
  - app/controllers/profiler/api/ajax_controller.rb
113
114
  - app/controllers/profiler/api/cluster_controller.rb
114
115
  - app/controllers/profiler/api/console_controller.rb