rails-profiler 0.31.4 → 0.31.5

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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -0
  3. data/app/assets/builds/profiler.js +9 -1
  4. data/app/controllers/profiler/profiles_controller.rb +2 -2
  5. data/lib/profiler/cluster/master_client.rb +7 -15
  6. data/lib/profiler/cluster/security.rb +3 -3
  7. data/lib/profiler/cluster/slave_proxy.rb +7 -4
  8. data/lib/profiler/collectors/lifecycle.rb +2 -2
  9. data/lib/profiler/collectors/log_collector.rb +54 -5
  10. data/lib/profiler/collectors/mailer_collector.rb +8 -4
  11. data/lib/profiler/collectors/request_collector.rb +6 -1
  12. data/lib/profiler/collectors/scoped_notifications.rb +1 -1
  13. data/lib/profiler/configuration.rb +15 -4
  14. data/lib/profiler/console_profiler.rb +1 -1
  15. data/lib/profiler/engine.rb +2 -1
  16. data/lib/profiler/env_override_store.rb +4 -4
  17. data/lib/profiler/explain_runner.rb +5 -1
  18. data/lib/profiler/instrumentation/net_http_instrumentation.rb +138 -40
  19. data/lib/profiler/job_profiler.rb +1 -1
  20. data/lib/profiler/local_request.rb +2 -6
  21. data/lib/profiler/mcp/file_cache.rb +2 -1
  22. data/lib/profiler/mcp/server.rb +1 -1
  23. data/lib/profiler/mcp/tools/get_profile_http.rb +6 -1
  24. data/lib/profiler/mcp/tools/query_console_profiles.rb +5 -1
  25. data/lib/profiler/mcp/tools/query_jobs.rb +5 -1
  26. data/lib/profiler/mcp/tools/query_mailers.rb +5 -1
  27. data/lib/profiler/mcp/tools/query_profiles.rb +5 -1
  28. data/lib/profiler/mcp/tools/query_test_profiles.rb +5 -1
  29. data/lib/profiler/mcp/tools/run_tests.rb +4 -1
  30. data/lib/profiler/middleware/profiler_middleware.rb +22 -19
  31. data/lib/profiler/middleware/streamed_profile.rb +1 -1
  32. data/lib/profiler/middleware/toolbar_injector.rb +21 -2
  33. data/lib/profiler/models/profile.rb +60 -2
  34. data/lib/profiler/process_snapshot.rb +8 -3
  35. data/lib/profiler/railtie.rb +1 -1
  36. data/lib/profiler/redaction.rb +1 -3
  37. data/lib/profiler/sse/redis_event_bus.rb +2 -1
  38. data/lib/profiler/storage/base_store.rb +4 -2
  39. data/lib/profiler/storage/blob_store.rb +1 -1
  40. data/lib/profiler/storage/file_store.rb +8 -3
  41. data/lib/profiler/storage/private_files.rb +3 -3
  42. data/lib/profiler/storage/redis_store.rb +2 -2
  43. data/lib/profiler/storage/sqlite_store.rb +3 -3
  44. data/lib/profiler/storage/unavailable.rb +1 -2
  45. data/lib/profiler/test_helpers/reporter.rb +1 -1
  46. data/lib/profiler/test_profiler.rb +1 -1
  47. data/lib/profiler/test_runner/runner.rb +3 -7
  48. data/lib/profiler/version.rb +1 -1
  49. data/lib/profiler.rb +126 -3
  50. metadata +1 -1
@@ -19,7 +19,7 @@ module Profiler
19
19
  :response_body, :response_body_encoding,
20
20
  :request_body_size, :request_body_truncated, :request_body_size_is_minimum,
21
21
  :response_body_size, :response_body_truncated, :response_body_size_is_minimum,
22
- :collectors_released_after_seconds,
22
+ :collectors_released_after_seconds, :params_truncated,
23
23
  :gem_version
24
24
 
25
25
  def initialize(request = nil)
@@ -34,7 +34,8 @@ module Profiler
34
34
  if request
35
35
  @path = request.path
36
36
  @method = request.request_method
37
- @params = sanitize_params(request.params)
37
+ @params, @params_truncated = cap_params(sanitize_params(request.params),
38
+ Profiler.configuration.max_captured_body_bytes)
38
39
  @headers = extract_headers(request.env)
39
40
  end
40
41
  end
@@ -133,6 +134,7 @@ module Profiler
133
134
  started_at: @started_at&.iso8601,
134
135
  finished_at: @finished_at&.iso8601,
135
136
  params: @params,
137
+ params_truncated: @params_truncated,
136
138
  headers: @headers,
137
139
  response_headers: @response_headers,
138
140
  request_body: req_body,
@@ -178,6 +180,7 @@ module Profiler
178
180
  profile.started_at = data[:started_at] ? Time.parse(data[:started_at]) : nil
179
181
  profile.finished_at = data[:finished_at] ? Time.parse(data[:finished_at]) : nil
180
182
  profile.params = data[:params]
183
+ profile.params_truncated = data[:params_truncated]
181
184
  profile.headers = data[:headers]
182
185
  profile.response_headers = data[:response_headers]
183
186
  profile.request_body = data[:request_body]
@@ -266,6 +269,61 @@ module Profiler
266
269
 
267
270
  LEGACY_FILTERED_PARAMS = %w[password password_confirmation token secret].freeze
268
271
 
272
+ # The key added to params cut at max_captured_body_bytes, so that the cut shows wherever
273
+ # the params are read.
274
+ PARAMS_TRUNCATED_KEY = "[profiler]"
275
+
276
+ # A copy of +params+ whose JSON stays within about +limit+ bytes: long strings are cut, and
277
+ # the entries past the limit left out. The application's own params are never touched.
278
+ # Returns the params and whether they were cut.
279
+ def cap_params(params, limit)
280
+ return [params, false] if limit.nil? || !params.is_a?(Hash)
281
+
282
+ budget = [limit]
283
+ cut = [false]
284
+ capped = cap_value(params, budget, cut)
285
+ return [capped, false] unless cut[0]
286
+
287
+ capped[PARAMS_TRUNCATED_KEY] = "params truncated at #{limit} bytes (max_captured_body_bytes)"
288
+ [capped, true]
289
+ end
290
+
291
+ def cap_value(value, budget, cut)
292
+ case value
293
+ when Hash
294
+ value.each_with_object({}) do |(key, item), out|
295
+ cost = key.to_s.bytesize + 4
296
+ if cost >= budget[0]
297
+ cut[0] = true
298
+ break out
299
+ end
300
+ budget[0] -= cost
301
+ out[key] = cap_value(item, budget, cut)
302
+ end
303
+ when Array
304
+ value.each_with_object([]) do |item, out|
305
+ if budget[0] <= 1
306
+ cut[0] = true
307
+ break out
308
+ end
309
+ budget[0] -= 1
310
+ out << cap_value(item, budget, cut)
311
+ end
312
+ when String
313
+ cost = value.bytesize + 2
314
+ if cost > budget[0]
315
+ cut[0] = true
316
+ value = Redaction.cut_bytes(value, [budget[0] - 2, 0].max).scrub("")
317
+ cost = value.bytesize + 2
318
+ end
319
+ budget[0] -= cost
320
+ value
321
+ else
322
+ budget[0] -= value.to_s.bytesize
323
+ value
324
+ end
325
+ end
326
+
269
327
  def sanitize_params(params)
270
328
  return {} unless params
271
329
 
@@ -36,7 +36,11 @@ module Profiler
36
36
  candidates.find { |route| route[:verb].split("|").include?(verb) } ||
37
37
  candidates.find { |route| route[:verb] == "ANY" } ||
38
38
  candidates.first
39
- rescue StandardError
39
+ rescue StandardError => e
40
+ # A path no route matches (a 404) has no route to show.
41
+ unless defined?(ActionController::RoutingError) && e.is_a?(ActionController::RoutingError)
42
+ Profiler.log_error_once(:snapshot_route, "could not find the route of a profile", e)
43
+ end
40
44
  nil
41
45
  end
42
46
 
@@ -61,7 +65,7 @@ module Profiler
61
65
  end
62
66
  profile
63
67
  rescue StandardError => e
64
- warn "Profiler: could not add the routes and ENV to profile #{profile.token}: #{e.message}"
68
+ Profiler.log_error("could not add the routes and ENV to profile #{profile.token}", e)
65
69
  profile
66
70
  end
67
71
 
@@ -84,7 +88,8 @@ module Profiler
84
88
  @routes = build_snapshot(fingerprint, list) unless @routes && @routes[0] == fingerprint
85
89
  @routes
86
90
  end
87
- rescue StandardError
91
+ rescue StandardError => e
92
+ Profiler.log_error_once(:snapshot_routes, "could not read the application's routes", e)
88
93
  [nil, [].freeze, {}.freeze]
89
94
  end
90
95
 
@@ -23,7 +23,7 @@ module Profiler
23
23
  if config.respond_to?("#{key}=")
24
24
  config.public_send("#{key}=", value)
25
25
  else
26
- Rails.logger&.warn("[Profiler] config.profiler.#{key} is not a profiler option, ignored")
26
+ Profiler.log_warn("config.profiler.#{key} is not a profiler option, ignored")
27
27
  end
28
28
  end
29
29
  end
@@ -518,9 +518,7 @@ module Profiler
518
518
  return if reported.include?(error.class)
519
519
 
520
520
  reported << error.class
521
- message = "[Profiler] Redaction: #{error.class} while #{during}; the value was masked instead."
522
- logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
523
- logger ? logger.warn(message) : Kernel.warn(message)
521
+ Profiler.log_warn("Redaction: #{error.class} while #{during}; the value was masked instead.")
524
522
  rescue StandardError
525
523
  nil
526
524
  end
@@ -29,7 +29,8 @@ module Profiler
29
29
  # The version of the last save of +token+, 0 when none is known or Redis cannot be read.
30
30
  def version(token)
31
31
  redis.get(key(token)).to_i
32
- rescue StandardError
32
+ rescue StandardError => e
33
+ Profiler.log_error_once(:redis_event_bus_version, "RedisEventBus: could not read the version of a profile", e)
33
34
  0
34
35
  end
35
36
 
@@ -34,7 +34,8 @@ module Profiler
34
34
 
35
35
  def exists?(token)
36
36
  !load(token).nil?
37
- rescue
37
+ rescue StandardError => e
38
+ Profiler.log_error_once(:store_exists, "#{self.class.name.split("::").last}: could not look up a profile", e)
38
39
  false
39
40
  end
40
41
 
@@ -54,8 +55,9 @@ module Profiler
54
55
 
55
56
  def broadcast_event(token)
56
57
  Profiler::SSE.current.broadcast(token)
57
- rescue StandardError
58
+ rescue StandardError => e
58
59
  # Never let a broadcast failure prevent profile persistence
60
+ Profiler.log_error_once(:store_broadcast, "#{self.class.name.split("::").last}: could not announce a saved profile", e)
59
61
  end
60
62
  end
61
63
  end
@@ -35,7 +35,7 @@ module Profiler
35
35
 
36
36
  JSON.parse(File.read(file))
37
37
  rescue => e
38
- warn "BlobStore: failed to read #{token}/#{collector_name}: #{e.message}"
38
+ Profiler.log_error("BlobStore: could not read #{token}/#{collector_name}", e)
39
39
  nil
40
40
  end
41
41
 
@@ -100,7 +100,7 @@ module Profiler
100
100
  json_data = File.read(file_path)
101
101
  Models::Profile.from_json(json_data)
102
102
  rescue StandardError => e
103
- warn "Failed to load profile #{token}: #{e.message}"
103
+ Profiler.log_error("FileStore: could not load profile #{token}", e)
104
104
  nil
105
105
  end
106
106
 
@@ -345,9 +345,14 @@ module Profiler
345
345
  json = PrivateFiles.open_for_reading(profile_file_path(token), &:read)
346
346
  record = put_record(token, Models::Profile.from_json(json).to_h, json.bytesize)
347
347
  Entry.new(token, record["at"], record["type"], record["parent"], record["bytes"], record["summary"])
348
- rescue StandardError
348
+ rescue StandardError => e
349
349
  # Unreadable: kept in the count, so that it is evicted in turn; listed by no one.
350
- stat = File.lstat(profile_file_path(token)) rescue nil
350
+ Profiler.log_error_once(:file_store_entry, "FileStore: could not read profile #{token} to index it", e)
351
+ stat = begin
352
+ File.lstat(profile_file_path(token))
353
+ rescue SystemCallError
354
+ nil
355
+ end
351
356
  Entry.new(token, stat ? stat.mtime.to_f : 0.0, nil, nil, stat ? stat.size : 0, nil)
352
357
  end
353
358
 
@@ -125,9 +125,9 @@ module Profiler
125
125
  first = @warn_mutex.synchronize { @warned[key] ? false : (@warned[key] = true) }
126
126
  return unless first
127
127
 
128
- warn "[Profiler] tmp_path #{key} belongs to another user or is writable by group or others " \
129
- "(mode #{format("%o", stat.mode & 0o777)}): other local users could place files there. Set " \
130
- "config.tmp_path to a directory of your own, or run chmod 700 on it."
128
+ Profiler.log_warn("tmp_path #{key} belongs to another user or is writable by group or others " \
129
+ "(mode #{format("%o", stat.mode & 0o777)}): other local users could place files there. Set " \
130
+ "config.tmp_path to a directory of your own, or run chmod 700 on it.")
131
131
  rescue SystemCallError
132
132
  nil
133
133
  end
@@ -64,7 +64,7 @@ module Profiler
64
64
 
65
65
  Models::Profile.from_json(json_data)
66
66
  rescue => e
67
- warn "Failed to load profile #{token}: #{e.message}"
67
+ Profiler.log_error("RedisStore: could not load profile #{token}", e)
68
68
  nil
69
69
  end
70
70
 
@@ -270,7 +270,7 @@ module Profiler
270
270
  data = JSON.parse(json, symbolize_names: true)
271
271
  [token, data[:started_at] ? Time.parse(data[:started_at]).to_f : 0.0, Summary.build(data)]
272
272
  rescue StandardError => e
273
- warn "RedisStore: could not index profile #{token}: #{e.message}"
273
+ Profiler.log_error("RedisStore: could not index profile #{token}", e)
274
274
  nil
275
275
  end
276
276
  @redis.pipelined do |pipe|
@@ -102,7 +102,7 @@ module Profiler
102
102
 
103
103
  row_to_profile(row)
104
104
  rescue => e
105
- warn "SqliteStore: failed to load profile #{token}: #{e.message}"
105
+ Profiler.log_error("SqliteStore: could not load profile #{token}", e)
106
106
  nil
107
107
  end
108
108
 
@@ -191,7 +191,7 @@ module Profiler
191
191
 
192
192
  Summary.to_profile(JSON.parse(row["summary"]))
193
193
  rescue JSON::ParserError => e
194
- warn "SqliteStore: failed to read the summary of #{row["token"]}: #{e.message}"
194
+ Profiler.log_error("SqliteStore: could not read the summary of #{row["token"]}", e)
195
195
  nil
196
196
  end
197
197
 
@@ -308,7 +308,7 @@ module Profiler
308
308
  collectors_data: collectors_data
309
309
  )
310
310
  rescue => e
311
- warn "SqliteStore: failed to deserialize profile #{row["token"]}: #{e.message}"
311
+ Profiler.log_error("SqliteStore: could not deserialize profile #{row["token"]}", e)
312
312
  nil
313
313
  end
314
314
 
@@ -21,8 +21,7 @@ module Profiler
21
21
  key = [Process.pid, error.class.name, error.message]
22
22
  first = @mutex.synchronize { @said[key] ? false : (@said[key] = true) }
23
23
  if first
24
- warn "[Profiler] storage unavailable, profiles are not saved until it is fixed: " \
25
- "#{error.class}: #{error.message}"
24
+ Profiler.log_warn("storage unavailable, profiles are not saved until it is fixed", error)
26
25
  end
27
26
  new(error)
28
27
  end
@@ -75,7 +75,7 @@ module Profiler
75
75
 
76
76
  $stdout.puts lines.join("\n")
77
77
  rescue => e
78
- warn "Profiler Reporter: failed to generate report: #{e.message}"
78
+ Profiler.log_error("Reporter: could not generate the report", e)
79
79
  end
80
80
 
81
81
  def self.has_n1?(profile)
@@ -114,7 +114,7 @@ module Profiler
114
114
  collector.collect if collector.respond_to?(:collect)
115
115
  profile.add_collector_metadata(collector)
116
116
  rescue => e
117
- warn "Profiler TestProfiler: Collector #{collector.class} failed: #{e.message}"
117
+ Profiler.log_error("TestProfiler: collector #{collector.class} failed", e)
118
118
  end
119
119
  end
120
120
 
@@ -51,7 +51,7 @@ module Profiler
51
51
  root = project_root
52
52
  allowed = if Profiler.configuration.test_runner_allow_undiscovered_files
53
53
  warn_once(:undiscovered,
54
- "[Profiler] config.test_runner_allow_undiscovered_files is enabled: the test runner " \
54
+ "config.test_runner_allow_undiscovered_files is enabled: the test runner " \
55
55
  "runs any file under the Rails root, not only the discovered tests.")
56
56
  nil
57
57
  else
@@ -101,11 +101,7 @@ module Profiler
101
101
  @warned[key] = true
102
102
  end
103
103
 
104
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
105
- Rails.logger.warn(message)
106
- else
107
- warn(message)
108
- end
104
+ Profiler.log_warn(message)
109
105
  end
110
106
 
111
107
  def self.reset_warnings!
@@ -227,7 +223,7 @@ module Profiler
227
223
 
228
224
  unless left_out.empty?
229
225
  warn_once([:env, left_out.sort],
230
- "[Profiler] Environment overrides not passed to the test runner, because they make it " \
226
+ "Environment overrides not passed to the test runner, because they make it " \
231
227
  "load other code: #{left_out.sort.join(", ")}.")
232
228
  end
233
229
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.31.4"
4
+ VERSION = "0.31.5"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -10,6 +10,10 @@ require_relative "profiler/allocation_counter"
10
10
  module Profiler
11
11
  class Error < StandardError; end
12
12
 
13
+ # The longest error message, in bytes, a log line carries: a parser error can quote what it
14
+ # was parsing.
15
+ LOG_ERROR_MESSAGE_LIMIT = 1000
16
+
13
17
  class << self
14
18
  attr_writer :configuration
15
19
  attr_accessor :function_profiling_enabled
@@ -43,11 +47,75 @@ module Profiler
43
47
 
44
48
  # Saves a profile from a path of the application (a job, a console command, a test, an
45
49
  # outbound HTTP call): a storage error loses the profile, never the application's work.
50
+ # True when saved; false when the error was logged instead, or when the store is unavailable
51
+ # (Storage::Unavailable drops the save, and has said why once already).
46
52
  def save_profile(profile, from:)
47
- storage.save(profile.token, profile)
53
+ target = storage
54
+ return false if target.is_a?(Storage::Unavailable)
55
+
56
+ target.save(profile.token, profile)
57
+ true
48
58
  rescue StandardError => e
49
- warn "[Profiler] #{from}: could not save profile #{profile.token}: #{e.class}: #{e.message}"
59
+ log_error("#{from}: could not save profile #{profile.token}", e)
60
+ false
61
+ end
62
+
63
+ # The profiler's own messages, to config.logger when the application sets one (read on each
64
+ # message), else to +logger+ when given (Rails.logger at boot), else to Rails.logger, else to
65
+ # $stderr.
66
+ # Prefixed [Profiler], with the profiler's credentials masked. Never raises: a logger that
67
+ # fails sends the line to $stderr instead. While the line is written, the thread is marked
68
+ # (Thread.current[:profiler_logging]) so that the Logs tab of a profile being recorded does
69
+ # not take it for one of the application's lines.
70
+ def log(level, message, error = nil, backtrace: false, logger: nil)
71
+ line = log_line(message, error, backtrace)
72
+ logger = configured_logger || logger || current_logger
73
+ if logger
74
+ begin
75
+ return write_log(logger, level, line)
76
+ rescue StandardError
77
+ # Below, to $stderr: the message is not lost.
78
+ end
79
+ end
80
+ $stderr.write("#{line}\n")
50
81
  nil
82
+ rescue StandardError
83
+ nil
84
+ end
85
+
86
+ def log_error(message, error = nil, backtrace: false)
87
+ log(:error, message, error, backtrace: backtrace)
88
+ end
89
+
90
+ def log_warn(message, error = nil, logger: nil)
91
+ log(:warn, message, error, logger: logger)
92
+ end
93
+
94
+ def log_info(message)
95
+ log(:info, message)
96
+ end
97
+
98
+ # Like log_error, once per +site+ and error class: for the paths called again and again (a
99
+ # store read on every poll), where the same failure would otherwise fill the log.
100
+ def log_error_once(site, message, error)
101
+ key = [site, error.class]
102
+ @logged_once_mutex.synchronize do
103
+ return nil if @logged_once.include?(key)
104
+
105
+ @logged_once << key
106
+ end
107
+ log_error(message, error)
108
+ end
109
+
110
+ # Runs the block without recording its outgoing HTTP calls in the current profile: the
111
+ # profiler's own calls (the cluster's registration and heartbeats, the master's requests to
112
+ # its slaves) are not the application's.
113
+ def untracked_http
114
+ previous = Thread.current[:profiler_http_untracked]
115
+ Thread.current[:profiler_http_untracked] = true
116
+ yield
117
+ ensure
118
+ Thread.current[:profiler_http_untracked] = previous
51
119
  end
52
120
 
53
121
  def env_override_store
@@ -94,10 +162,62 @@ module Profiler
94
162
  result
95
163
  end
96
164
 
165
+ private
166
+
167
+ def configured_logger
168
+ configuration.logger
169
+ rescue StandardError
170
+ nil
171
+ end
172
+
173
+ def current_logger
174
+ configured = configuration.logger
175
+ return configured if configured
176
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:logger)
177
+
178
+ ::Rails.logger
179
+ rescue StandardError
180
+ nil
181
+ end
182
+
183
+ def write_log(logger, level, line)
184
+ previous = Thread.current[:profiler_logging]
185
+ Thread.current[:profiler_logging] = true
186
+ logger.public_send(level, line)
187
+ ensure
188
+ Thread.current[:profiler_logging] = previous
189
+ end
190
+
191
+ # Masked whole, then cut: a cut first could leave the start of a credential, which the
192
+ # masking by value would no longer find.
193
+ def log_line(message, error, backtrace)
194
+ line = +"[Profiler] #{message}"
195
+ if error
196
+ line << ": #{error.class}"
197
+ text = error_text(error)
198
+ line << ": #{text}" unless text.empty?
199
+ line << "\n#{error.backtrace.join("\n")}" if backtrace && error.backtrace
200
+ end
201
+ Redaction.hide_credentials(line)
202
+ end
203
+
204
+ # The error's message, masked, then cut at LOG_ERROR_MESSAGE_LIMIT bytes. A message that
205
+ # raises leaves the class alone.
206
+ def error_text(error)
207
+ text = Redaction.hide_credentials(error.message.to_s)
208
+ return text if text.bytesize <= LOG_ERROR_MESSAGE_LIMIT
209
+
210
+ "#{Redaction.cut_bytes(text, LOG_ERROR_MESSAGE_LIMIT).scrub("")}..."
211
+ rescue StandardError
212
+ ""
213
+ end
214
+
215
+ public
216
+
97
217
  # Dump a variable to the profiler
98
218
  # Usage: Profiler.dump(variable, "optional label")
99
219
  def dump(value, label = nil)
100
- return unless enabled?
220
+ return value unless enabled?
101
221
 
102
222
  # The slot exists only while a DumpCollector profiles this thread: outside of one, nobody
103
223
  # would ever read the dump, and the thread would keep it for good.
@@ -122,6 +242,9 @@ module Profiler
122
242
  end
123
243
  end
124
244
 
245
+ @logged_once = Set.new
246
+ @logged_once_mutex = Mutex.new
247
+
125
248
  self.function_profiling_enabled = true
126
249
  self.function_profiling_max_frames = 2000
127
250
  self.function_profiling_mode = "lite"
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.31.4
4
+ version: 0.31.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy