rails-profiler 0.33.0 → 0.33.2

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: 4dc81c1f51aaa36f9a01703962763e6526c95ea44a86aec0f6580a46c6239bfe
4
- data.tar.gz: 1555289065c0d81d6267b21ddb9d3ae2c2c0716e1290f33e30df30590ff4b8c9
3
+ metadata.gz: 8e1caccb1662a8f09108682f5933e943d41a0b36ed7d60930c60eb8c385b5159
4
+ data.tar.gz: c10d767853cc07caa36484f9693a4c4adeef415e0874d29ca817d8d7d1b17730
5
5
  SHA512:
6
- metadata.gz: 967050b5160cb75653481fd86444696cb81fa84c46aa7faa34064a3c281c0acaa5ec08d4029a8b1aca2525da8ffb5d0033b934025d885a8b53e969faca151293
7
- data.tar.gz: e63d49ccaf002fb7b4911920fc3e279597451fa276d669f557fde971e13e2855cbc347cc16bf254ff2d995b14ba0c18454e929910326a41d0104ee83db49c327
6
+ metadata.gz: 8aacfc0469f52d9f6a11a58356eebb853ff716f2729ca47833b9b0ca99197f767a0dad5904f8c9e7d8a8501869dd712a0c5e6e246fa8ea0e55976b546589d1ac
7
+ data.tar.gz: 3ce33e8db95c0ae4028a68e11d941bb72072b5a6edcf539cfc0fb918c2248d675c4df92e28788da0d59d942546458384b22ec5ac974bb58404be1d0049130627
data/CHANGELOG.md CHANGED
@@ -19,6 +19,64 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.33.2] - 2026-10-14
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Changed
27
+
28
+ - **Configuration:** A String in `config.http_skip_hosts` is now a host name, compared whole and
29
+ ignoring case, instead of a pattern: `"example.com"` no longer leaves out `notexample.com`,
30
+ `exampleXcom` or `example.com.attacker.net`. A String that starts with a dot is a domain whose
31
+ subdomains are left out (`".internal"` leaves out `api.internal`). A Regexp is matched as
32
+ before. An entry that is not a host name, a wildcard String (`"*.internal"`), an empty one, or
33
+ anything that is neither a String nor a Regexp (`nil`), is left out when the list is set, with
34
+ a warning in the log that says what to write, and the other entries are kept. It does not
35
+ raise: the gem is loaded in production too, where a mistake in this list must not stop the
36
+ application from booting, even with the profiler disabled.
37
+ - **Dumps:** A dump is stored as its formatted text only: the raw `value` field, which no tab
38
+ read and the API did not document, is no longer part of the dump data.
39
+
40
+ ### Fixed
41
+
42
+ - **Configuration:** A String in `config.skip_paths` no longer fails every request with a
43
+ `TypeError`: it skips the path equal to it, and a Regexp is matched as before. A single
44
+ pattern, a String or a Regexp, set without a list is read as a list of one.
45
+ - **Outbound HTTP:** A String in `config.http_skip_hosts` that is not a valid Regexp, such as
46
+ `"*.internal"` added with `<<`, no longer fails every `Net::HTTP` call of a profiled request
47
+ with a `RegexpError`.
48
+ - **Database:** A query that binds bytes, a `blob` or `bytea` column such as an image, no longer
49
+ loses the profile of its request when it is saved: the bind shows as text when its bytes are
50
+ valid UTF-8, and as `[binary data, N bytes]` otherwise.
51
+ - **Logs:** A line logged with bytes that are not valid UTF-8 no longer raises an
52
+ `Encoding::CompatibilityError` in the application's own logger call (Rails 7.1 and later), and
53
+ a binary line no longer loses the profile when it is saved: the line is kept as UTF-8, what
54
+ cannot be read replaced with U+FFFD, and a line in another encoding is converted.
55
+ - **Dumps:** `Profiler.dump` of an object that refers to itself, directly or through an instance
56
+ variable, no longer fails the request with a `SystemStackError` (a 500 with the file store), and
57
+ an Array or a Hash holding itself no longer loses the profile. An object whose own `inspect`
58
+ recurses without end shows `[Error formatting value: stack level too deep]`.
59
+ - **Middleware:** A page whose body is made of fragments of different encodings, a binary one
60
+ among UTF-8 ones, no longer fails with an `Encoding::CompatibilityError` once profiled: its
61
+ bytes are joined as the application gave them, and it gets its toolbar.
62
+
63
+ ## [0.33.1] - 2026-10-14
64
+
65
+ <!-- stamped -->
66
+
67
+ ### Fixed
68
+
69
+ - **Packaging:** The published gem now contains `app/assets/javascript/profiler-ajax-interceptor.js`,
70
+ the script the toolbar injects to link the `fetch`, `XMLHttpRequest` and Turbo requests of a page
71
+ to that page. It was missing from every published version: with the gem installed from a
72
+ registry, those requests were never linked to their page, and the only trace was the log line
73
+ `ToolbarInjector: could not load the AJAX interceptor script`. A gem used from a git checkout or
74
+ a local path was not affected.
75
+ - **Release:** A version is now published only once both test suites (Ruby 3.3 and 3.4) have
76
+ passed, as written in the `needs` of the `release` and `canary` jobs, and once the built gem
77
+ has been installed and found to carry every file it reads at run time. The suites already held
78
+ publication back before, but only through the order of the pipeline stages.
79
+
22
80
  ## [0.33.0] - 2026-10-14
23
81
 
24
82
  <!-- stamped -->
@@ -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
+ })();
@@ -9,6 +9,8 @@ module Profiler
9
9
  BACKTRACE_DEPTH = 10
10
10
  MAX_SCANNED_FRAMES = 100
11
11
  FRAMES_PAST_APPLICATION = 15
12
+ # What a bind holding bytes that are not text shows instead (see #displayable_bind).
13
+ BINARY_BIND = "[binary data, %d bytes]"
12
14
 
13
15
 
14
16
  # Frames between the application's code and the subscriber: the profiler itself, the
@@ -122,9 +124,9 @@ module Profiler
122
124
  binds.map do |bind|
123
125
  if bind.respond_to?(:value)
124
126
  name = bind.name if bind.respond_to?(:name)
125
- Profiler::Redaction.filter_named(name, bind.value)
127
+ displayable_bind(Profiler::Redaction.filter_named(name, bind.value))
126
128
  else
127
- bind
129
+ displayable_bind(bind)
128
130
  end
129
131
  end
130
132
  rescue StandardError
@@ -132,6 +134,18 @@ module Profiler
132
134
  binds.map { Profiler::Redaction::MASK }
133
135
  end
134
136
 
137
+ # A blob or bytea value is bytes, not text: kept as it is, it fails the JSON of the whole
138
+ # profile when it is saved. Bytes that are valid UTF-8 are shown as text, others as their
139
+ # size. Text in another encoding stays as it is: JSON converts it.
140
+ def displayable_bind(value)
141
+ value = value.to_s if defined?(ActiveModel::Type::Binary::Data) && value.is_a?(ActiveModel::Type::Binary::Data)
142
+ return value unless value.is_a?(String)
143
+ return value if value.valid_encoding? && value.encoding != Encoding::BINARY
144
+
145
+ utf8 = value.dup.force_encoding(Encoding::UTF_8)
146
+ utf8.valid_encoding? ? utf8 : format(BINARY_BIND, value.bytesize)
147
+ end
148
+
135
149
  # Capturing the caller costs tens of microseconds. The first run of a statement locates
136
150
  # it, an N+1 loop included (its repeats are the same statement with other values); a slow
137
151
  # query is always located.
@@ -33,9 +33,10 @@ module Profiler
33
33
  dumps = @dumps || Thread.current[:profiler_dumps] || []
34
34
  unsubscribe
35
35
 
36
+ # Only the formatted text is kept: the object itself can refer to itself, or hold what
37
+ # JSON cannot write, and would make the profile fail when it is saved.
36
38
  formatted_dumps = dumps.map do |dump|
37
39
  {
38
- value: dump[:value],
39
40
  formatted: format_value(dump[:value]),
40
41
  file: dump[:file],
41
42
  line: dump[:line],
@@ -97,7 +98,8 @@ module Profiler
97
98
  formatted += ">"
98
99
  formatted
99
100
  end
100
- rescue => e
101
+ # An inspect of the application's that recurses without end is said, like any other error.
102
+ rescue StandardError, SystemStackError => e
101
103
  "[Error formatting value: #{e.message}]"
102
104
  end
103
105
  end
@@ -68,7 +68,7 @@ module Profiler
68
68
 
69
69
  entry = {
70
70
  level: SEVERITY_LABELS[severity] || "UNKNOWN",
71
- message: msg.to_s.strip,
71
+ message: self.class.readable(msg.to_s).strip,
72
72
  timestamp: Time.now.iso8601(3)
73
73
  }
74
74
  logs.respond_to?(:record) ? logs.record(entry) : logs << entry
@@ -78,6 +78,20 @@ module Profiler
78
78
  def <<(msg)
79
79
  add(0, msg.to_s)
80
80
  end
81
+
82
+ # The line as valid UTF-8, a copy when it has to change: bytes that are not text in its
83
+ # encoding would raise in strip, inside the application's own logger call, or fail the
84
+ # JSON of the profile when it is saved. Binary bytes are read as UTF-8; a line in another
85
+ # encoding is converted. What cannot be read becomes U+FFFD.
86
+ def self.readable(text)
87
+ return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
88
+
89
+ if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(text.encoding)
90
+ text.dup.force_encoding(Encoding::UTF_8).scrub
91
+ else
92
+ text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
93
+ end
94
+ end
81
95
  end
82
96
 
83
97
  def icon
@@ -38,13 +38,13 @@ module Profiler
38
38
  LOCAL_HTTP_HOSTS = [/\A127\.0\.0\.1\z/, /\Alocalhost\z/i, /\A::1\z/].freeze
39
39
 
40
40
  attr_accessor :storage_options, :collectors,
41
- :skip_paths, :slow_query_threshold, :max_queries_warning, :sql_backtrace,
41
+ :slow_query_threshold, :max_queries_warning, :sql_backtrace,
42
42
  :track_memory, :allocated_objects_warning_threshold,
43
43
  :mcp_enabled, :mcp_transport, :mcp_port,
44
44
  :authorization_mode, :extension_cors_enabled,
45
45
  :cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
46
46
  :track_ajax, :ajax_skip_paths,
47
- :track_http, :slow_http_threshold, :http_skip_hosts, :http_backtrace_depth,
47
+ :track_http, :slow_http_threshold, :http_backtrace_depth,
48
48
  :track_jobs,
49
49
  :track_console,
50
50
  :apply_env_overrides_when_disabled, :restrict_storage_permissions,
@@ -148,7 +148,23 @@ module Profiler
148
148
  @tmp_path = value.nil? ? nil : Pathname.new(value)
149
149
  end
150
150
 
151
- attr_reader :cluster_allowed_slave_urls, :mcp_allowed_hosts
151
+ attr_reader :cluster_allowed_slave_urls, :mcp_allowed_hosts, :skip_paths, :http_skip_hosts
152
+
153
+ # Paths that are not profiled: a Regexp matched against the path, or a String equal to it.
154
+ # A single pattern is a list of one.
155
+ def skip_paths=(value)
156
+ @skip_paths = Array(value)
157
+ end
158
+
159
+ # Hosts whose outbound calls are left out: a String is a host name, matched whole and
160
+ # case-insensitively, or, when it starts with a dot (".internal"), a domain whose subdomains
161
+ # are left out; a Regexp is matched against the host name. Any other entry (a wildcard
162
+ # "*.internal", an empty String, nil) is left out with a warning in the log, the others kept:
163
+ # the gem is loaded in production too, where a mistake here must not stop the application
164
+ # from booting.
165
+ def http_skip_hosts=(value)
166
+ @http_skip_hosts = Array(value).select { |entry| valid_http_skip_host?(entry) }
167
+ end
152
168
 
153
169
  # Host names the MCP HTTP endpoint accepts beyond the loopback ones and config.hosts: a String
154
170
  # (a host name, any port, or a host:port) or a Regexp anchored with \A and \z, matched
@@ -323,6 +339,22 @@ module Profiler
323
339
  end
324
340
  end
325
341
 
342
+ def valid_http_skip_host?(entry)
343
+ problem =
344
+ case entry
345
+ when Regexp then nil
346
+ when String
347
+ if entry.strip.empty? || entry.match?(/[*\s]/)
348
+ "is not a host name; list each host, a domain as \".internal\" for its subdomains, or use a Regexp"
349
+ end
350
+ else "must be a String or a Regexp"
351
+ end
352
+ return true unless problem
353
+
354
+ Profiler.log_warn("config.http_skip_hosts: #{entry.inspect} #{problem}; ignored")
355
+ false
356
+ end
357
+
326
358
  def validate_mcp_allowed_host_pattern(pattern)
327
359
  unless Profiler::Cluster::Security.anchored_pattern?(pattern)
328
360
  raise ArgumentError, "config.mcp_allowed_hosts: #{pattern.inspect} must start with \\A and end with \\z, " \
@@ -122,12 +122,29 @@ module Profiler
122
122
  # it are the profiler's, whichever code makes them.
123
123
  def self.skip_host?(host, port = nil)
124
124
  config = Profiler.configuration
125
- return true if config.http_skip_hosts.any? { |pattern| host.match?(pattern) }
125
+ return true if config.http_skip_hosts.any? { |pattern| skip_host_pattern?(pattern, host) }
126
126
 
127
127
  master = master_address(config.master_url)
128
128
  !master.nil? && master[0].casecmp?(host) && (port.nil? || master[1] == port)
129
129
  end
130
130
 
131
+ # A String is never read as a Regexp: "example.com" would match notexample.com, and
132
+ # "*.internal" would raise on every call. It is the host name, or, starting with a dot, a
133
+ # domain whose subdomains match. A list changed in place (<<) is not validated: anything
134
+ # else matches nothing.
135
+ def self.skip_host_pattern?(pattern, host)
136
+ case pattern
137
+ when Regexp then pattern.match?(host)
138
+ when String
139
+ if pattern.start_with?(".")
140
+ host.length > pattern.length && host.downcase.end_with?(pattern.downcase)
141
+ else
142
+ pattern.casecmp?(host)
143
+ end
144
+ else false
145
+ end
146
+ end
147
+
131
148
  def self.master_address(url)
132
149
  return nil if url.nil? || url.to_s.empty?
133
150
 
@@ -278,7 +278,8 @@ module Profiler
278
278
 
279
279
  # Skip profiler's own paths and static assets
280
280
  path = env["PATH_INFO"]
281
- Profiler.configuration.skip_paths.none? { |pattern| path =~ pattern }
281
+ # === matches a Regexp against the path and compares a String with it.
282
+ Profiler.configuration.skip_paths.none? { |pattern| pattern === path }
282
283
  end
283
284
 
284
285
  def build_request(env)
@@ -371,13 +372,7 @@ module Profiler
371
372
  def read_buffered_body(body)
372
373
  content =
373
374
  begin
374
- if body.respond_to?(:to_ary)
375
- body.to_ary.join
376
- else
377
- parts = +""
378
- body.each { |part| parts << part }
379
- parts
380
- end
375
+ join_parts(body.respond_to?(:to_ary) ? body.to_ary : body)
381
376
  rescue Exception # rubocop:disable Lint/RescueException
382
377
  begin
383
378
  close_body(body)
@@ -390,6 +385,20 @@ module Profiler
390
385
  content
391
386
  end
392
387
 
388
+ # The fragments' bytes, joined as they are: fragments of different encodings (a binary one
389
+ # among UTF-8 ones) cannot be joined as text, and converting them would change the page.
390
+ # The result takes the encoding of the first fragment, as CapturingBody does.
391
+ def join_parts(parts)
392
+ content = String.new(encoding: Encoding::BINARY)
393
+ encoding = nil
394
+ parts.each do |part|
395
+ part = part.to_s
396
+ encoding ||= part.encoding
397
+ content << part.b
398
+ end
399
+ content.force_encoding(encoding || Encoding::UTF_8)
400
+ end
401
+
393
402
  def close_body(body)
394
403
  body.close if body.respond_to?(:close) && !(body.respond_to?(:closed?) && body.closed?)
395
404
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.33.0"
4
+ VERSION = "0.33.2"
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.33.0
4
+ version: 0.33.2
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