rack-mini-profiler 2.1.0 → 2.3.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +37 -9
  4. data/lib/html/includes.css +3 -1
  5. data/lib/html/includes.js +225 -179
  6. data/lib/html/includes.scss +4 -1
  7. data/lib/html/includes.tmpl +29 -8
  8. data/lib/html/profile_handler.js +1 -1
  9. data/lib/html/speedscope/LICENSE +21 -0
  10. data/lib/html/speedscope/README.md +3 -0
  11. data/lib/html/speedscope/demangle-cpp.1768f4cc.js +4 -0
  12. data/lib/html/speedscope/favicon-16x16.f74b3187.png +0 -0
  13. data/lib/html/speedscope/favicon-32x32.bc503437.png +0 -0
  14. data/lib/html/speedscope/file-format-schema.json +324 -0
  15. data/lib/html/speedscope/fonts/source-code-pro-regular.css +8 -0
  16. data/lib/html/speedscope/fonts/source-code-pro-v13-regular.woff +0 -0
  17. data/lib/html/speedscope/fonts/source-code-pro-v13-regular.woff2 +0 -0
  18. data/lib/html/speedscope/import.cf0fa83f.js +115 -0
  19. data/lib/html/speedscope/index.html +2 -0
  20. data/lib/html/speedscope/release.txt +3 -0
  21. data/lib/html/speedscope/reset.8c46b7a1.css +2 -0
  22. data/lib/html/speedscope/source-map.438fa06b.js +24 -0
  23. data/lib/html/speedscope/speedscope.44364064.js +200 -0
  24. data/lib/html/vendor.js +5 -5
  25. data/lib/mini_profiler/asset_version.rb +1 -1
  26. data/lib/mini_profiler/client_settings.rb +6 -5
  27. data/lib/mini_profiler/config.rb +28 -2
  28. data/lib/mini_profiler/profiler.rb +104 -25
  29. data/lib/mini_profiler/profiling_methods.rb +11 -2
  30. data/lib/mini_profiler/snapshots_transporter.rb +109 -0
  31. data/lib/mini_profiler/storage/abstract_store.rb +14 -8
  32. data/lib/mini_profiler/timer_struct/page.rb +57 -4
  33. data/lib/mini_profiler/timer_struct/sql.rb +2 -2
  34. data/lib/mini_profiler/version.rb +1 -1
  35. data/lib/mini_profiler_rails/railtie.rb +1 -1
  36. data/lib/patches/db/mysql2.rb +4 -27
  37. data/lib/patches/db/mysql2/alias_method.rb +30 -0
  38. data/lib/patches/db/mysql2/prepend.rb +34 -0
  39. data/lib/prepend_mysql2_patch.rb +5 -0
  40. data/lib/rack-mini-profiler.rb +1 -0
  41. data/rack-mini-profiler.gemspec +6 -4
  42. metadata +61 -14
@@ -52,6 +52,11 @@ module Rack
52
52
  @toggle_shortcut = 'alt+p'
53
53
  @html_container = 'body'
54
54
  @position = "top-left"
55
+ @snapshot_hidden_custom_fields = []
56
+ @snapshots_transport_destination_url = nil
57
+ @snapshots_transport_auth_key = nil
58
+ @snapshots_redact_sql_queries = true
59
+ @snapshots_transport_gzip_requests = false
55
60
 
56
61
  self
57
62
  }
@@ -63,19 +68,40 @@ module Rack
63
68
  :flamegraph_sample_rate, :logger, :pre_authorize_cb, :skip_paths,
64
69
  :skip_schema_queries, :storage, :storage_failure, :storage_instance,
65
70
  :storage_options, :user_provider, :enable_advanced_debugging_tools,
66
- :snapshot_every_n_requests, :snapshots_limit
67
- attr_accessor :skip_sql_param_names, :suppress_encoding, :max_sql_param_length
71
+ :skip_sql_param_names, :suppress_encoding, :max_sql_param_length
68
72
 
69
73
  # ui accessors
70
74
  attr_accessor :collapse_results, :max_traces_to_show, :position,
71
75
  :show_children, :show_controls, :show_trivial, :show_total_sql_count,
72
76
  :start_hidden, :toggle_shortcut, :html_container
73
77
 
78
+ # snapshot related config
79
+ attr_accessor :snapshot_every_n_requests, :snapshots_limit,
80
+ :snapshot_hidden_custom_fields, :snapshots_transport_destination_url,
81
+ :snapshots_transport_auth_key, :snapshots_redact_sql_queries,
82
+ :snapshots_transport_gzip_requests
83
+
74
84
  # Deprecated options
75
85
  attr_accessor :use_existing_jquery
76
86
 
77
87
  attr_reader :assets_url
78
88
 
89
+ # redefined - since the accessor defines it first
90
+ undef :authorization_mode=
91
+ def authorization_mode=(mode)
92
+ if mode == :whitelist
93
+ warn "[DEPRECATION] `:whitelist` authorization mode is deprecated. Please use `:allow_authorized` instead."
94
+
95
+ mode = :allow_authorized
96
+ end
97
+
98
+ warn <<~DEP unless mode == :allow_authorized || mode == :allow_all
99
+ [DEPRECATION] unknown authorization mode #{mode}. Expected `:allow_all` or `:allow_authorized`.
100
+ DEP
101
+
102
+ @authorization_mode = mode
103
+ end
104
+
79
105
  def assets_url=(lmbda)
80
106
  if defined?(Rack::MiniProfilerRails)
81
107
  Rack::MiniProfilerRails.create_engine
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'cgi'
4
+
3
5
  module Rack
4
6
  class MiniProfiler
5
7
  class << self
@@ -39,6 +41,7 @@ module Rack
39
41
  def current=(c)
40
42
  # we use TLS cause we need access to this from sql blocks and code blocks that have no access to env
41
43
  Thread.current[:mini_profiler_snapshot_custom_fields] = nil
44
+ Thread.current[:mp_ongoing_snapshot] = nil
42
45
  Thread.current[:mini_profiler_private] = c
43
46
  end
44
47
 
@@ -94,6 +97,16 @@ module Rack
94
97
  params
95
98
  end
96
99
  end
100
+
101
+ def snapshots_transporter?
102
+ !!config.snapshots_transport_destination_url &&
103
+ !!config.snapshots_transport_auth_key
104
+ end
105
+
106
+ def redact_sql_queries?
107
+ Thread.current[:mp_ongoing_snapshot] == true &&
108
+ Rack::MiniProfiler.config.snapshots_redact_sql_queries
109
+ end
97
110
  end
98
111
 
99
112
  #
@@ -169,6 +182,7 @@ module Rack
169
182
 
170
183
  return serve_results(env) if file_name.eql?('results')
171
184
  return handle_snapshots_request(env) if file_name.eql?('snapshots')
185
+ return serve_flamegraph(env) if file_name.eql?('flamegraph')
172
186
 
173
187
  resources_env = env.dup
174
188
  resources_env['PATH_INFO'] = file_name
@@ -200,7 +214,7 @@ module Rack
200
214
  def call(env)
201
215
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
202
216
  client_settings = ClientSettings.new(env, @storage, start)
203
- MiniProfiler.deauthorize_request if @config.authorization_mode == :whitelist
217
+ MiniProfiler.deauthorize_request if @config.authorization_mode == :allow_authorized
204
218
 
205
219
  status = headers = body = nil
206
220
  query_string = env['QUERY_STRING']
@@ -226,7 +240,7 @@ module Rack
226
240
  skip_it = (@config.pre_authorize_cb && !@config.pre_authorize_cb.call(env))
227
241
 
228
242
  if skip_it || (
229
- @config.authorization_mode == :whitelist &&
243
+ @config.authorization_mode == :allow_authorized &&
230
244
  !client_settings.has_valid_cookie?
231
245
  )
232
246
  if take_snapshot?(path)
@@ -268,6 +282,15 @@ module Rack
268
282
  # profile memory
269
283
  if query_string =~ /pp=profile-memory/
270
284
  return tool_disabled_message(client_settings) if !advanced_debugging_enabled?
285
+
286
+ unless defined?(MemoryProfiler) && MemoryProfiler.respond_to?(:report)
287
+ message = "Please install the memory_profiler gem and require it: add gem 'memory_profiler' to your Gemfile"
288
+ _, _, body = @app.call(env)
289
+ body.close if body.respond_to? :close
290
+
291
+ return client_settings.handle_cookie(text_result(message))
292
+ end
293
+
271
294
  query_params = Rack::Utils.parse_nested_query(query_string)
272
295
  options = {
273
296
  ignore_files: query_params['memory_profiler_ignore_files'],
@@ -323,24 +346,28 @@ module Rack
323
346
  # Prevent response body from being compressed
324
347
  env['HTTP_ACCEPT_ENCODING'] = 'identity' if config.suppress_encoding
325
348
 
326
- if query_string =~ /pp=flamegraph/
327
- unless defined?(Flamegraph) && Flamegraph.respond_to?(:generate)
328
-
329
- flamegraph = "Please install the flamegraph gem and require it: add gem 'flamegraph' to your Gemfile"
330
- status, headers, body = @app.call(env)
349
+ if query_string =~ /pp=(async-)?flamegraph/ || env['HTTP_REFERER'] =~ /pp=async-flamegraph/
350
+ unless defined?(StackProf) && StackProf.respond_to?(:run)
351
+ headers = { 'Content-Type' => 'text/html' }
352
+ message = "Please install the stackprof gem and require it: add gem 'stackprof' to your Gemfile"
353
+ body.close if body.respond_to? :close
354
+ return client_settings.handle_cookie([500, headers, message])
331
355
  else
332
356
  # do not sully our profile with mini profiler timings
333
357
  current.measure = false
334
358
  match_data = query_string.match(/flamegraph_sample_rate=([\d\.]+)/)
335
359
 
336
- mode = query_string =~ /mode=c/ ? :c : :ruby
337
-
338
360
  if match_data && !match_data[1].to_f.zero?
339
361
  sample_rate = match_data[1].to_f
340
362
  else
341
363
  sample_rate = config.flamegraph_sample_rate
342
364
  end
343
- flamegraph = Flamegraph.generate(nil, fidelity: sample_rate, embed_resources: query_string =~ /embed/, mode: mode) do
365
+ flamegraph = StackProf.run(
366
+ mode: :wall,
367
+ raw: true,
368
+ aggregate: false,
369
+ interval: (sample_rate * 1000).to_i
370
+ ) do
344
371
  status, headers, body = @app.call(env)
345
372
  end
346
373
  end
@@ -363,7 +390,7 @@ module Rack
363
390
 
364
391
  skip_it = current.discard
365
392
 
366
- if (config.authorization_mode == :whitelist && !MiniProfiler.request_authorized?)
393
+ if (config.authorization_mode == :allow_authorized && !MiniProfiler.request_authorized?)
367
394
  skip_it = true
368
395
  end
369
396
 
@@ -404,9 +431,12 @@ module Rack
404
431
  page_struct[:user] = user(env)
405
432
  page_struct[:root].record_time((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000)
406
433
 
407
- if flamegraph
434
+ if flamegraph && query_string =~ /pp=flamegraph/
408
435
  body.close if body.respond_to? :close
409
- return client_settings.handle_cookie(self.flamegraph(flamegraph))
436
+ return client_settings.handle_cookie(self.flamegraph(flamegraph, path))
437
+ elsif flamegraph # async-flamegraph
438
+ page_struct[:has_flamegraph] = true
439
+ page_struct[:flamegraph] = flamegraph
410
440
  end
411
441
 
412
442
  begin
@@ -623,13 +653,14 @@ Append the following to your query string:
623
653
  #{make_link "full-backtrace", env} #{"(*) " if client_settings.backtrace_full?}: enable full backtraces for SQL executed (use pp=normal-backtrace to disable)
624
654
  #{make_link "disable", env} : disable profiling for this session
625
655
  #{make_link "enable", env} : enable profiling for this session (if previously disabled)
626
- #{make_link "profile-gc", env} : perform gc profiling on this request, analyzes ObjectSpace generated by request (ruby 1.9.3 only)
656
+ #{make_link "profile-gc", env} : perform gc profiling on this request, analyzes ObjectSpace generated by request
627
657
  #{make_link "profile-memory", env} : requires the memory_profiler gem, new location based report
628
- #{make_link "flamegraph", env} : works best on Ruby 2.0, a graph representing sampled activity (requires the flamegraph gem).
658
+ #{make_link "flamegraph", env} : a graph representing sampled activity (requires the stackprof gem).
659
+ #{make_link "async-flamegraph", env} : store flamegraph data for this page and all its AJAX requests. Flamegraph links will be available in the mini-profiler UI (requires the stackprof gem).
629
660
  #{make_link "flamegraph&flamegraph_sample_rate=1", env}: creates a flamegraph with the specified sample rate (in ms). Overrides value set in config
630
- #{make_link "flamegraph_embed", env} : works best on Ruby 2.0, a graph representing sampled activity (requires the flamegraph gem), embedded resources for use on an intranet.
631
- #{make_link "trace-exceptions", env} : requires Ruby 2.0, will return all the spots where your application raises exceptions
632
- #{make_link "analyze-memory", env} : requires Ruby 2.0, will perform basic memory analysis of heap
661
+ #{make_link "flamegraph_embed", env} : a graph representing sampled activity (requires the stackprof gem), embedded resources for use on an intranet.
662
+ #{make_link "trace-exceptions", env} : will return all the spots where your application raises exceptions
663
+ #{make_link "analyze-memory", env} : will perform basic memory analysis of heap
633
664
  </pre>
634
665
  </body>
635
666
  </html>
@@ -638,9 +669,33 @@ Append the following to your query string:
638
669
  [200, headers, [body]]
639
670
  end
640
671
 
641
- def flamegraph(graph)
672
+ def flamegraph(graph, path)
642
673
  headers = { 'Content-Type' => 'text/html' }
643
- [200, headers, [graph]]
674
+ html = <<~HTML
675
+ <!DOCTYPE html>
676
+ <html>
677
+ <head>
678
+ <style>
679
+ body { margin: 0; height: 100vh; }
680
+ #speedscope-iframe { width: 100%; height: 100%; border: none; }
681
+ </style>
682
+ </head>
683
+ <body>
684
+ <script type="text/javascript">
685
+ var graph = #{JSON.generate(graph)};
686
+ var json = JSON.stringify(graph);
687
+ var blob = new Blob([json], { type: 'text/plain' });
688
+ var objUrl = encodeURIComponent(URL.createObjectURL(blob));
689
+ var iframe = document.createElement('IFRAME');
690
+ iframe.setAttribute('id', 'speedscope-iframe');
691
+ document.body.appendChild(iframe);
692
+ var iframeUrl = '#{@config.base_url_path}speedscope/index.html#profileURL=' + objUrl + '&title=' + 'Flamegraph for #{CGI.escape(path)}';
693
+ iframe.setAttribute('src', iframeUrl);
694
+ </script>
695
+ </body>
696
+ </html>
697
+ HTML
698
+ [200, headers, [html]]
644
699
  end
645
700
 
646
701
  def ids(env)
@@ -689,7 +744,8 @@ Append the following to your query string:
689
744
  toggleShortcut: @config.toggle_shortcut,
690
745
  startHidden: @config.start_hidden,
691
746
  collapseResults: @config.collapse_results,
692
- htmlContainer: @config.html_container
747
+ htmlContainer: @config.html_container,
748
+ hiddenCustomFields: @config.snapshot_hidden_custom_fields.join(',')
693
749
  }
694
750
 
695
751
  if current && current.page_struct
@@ -770,6 +826,24 @@ Append the following to your query string:
770
826
  response.finish
771
827
  end
772
828
 
829
+ def serve_flamegraph(env)
830
+ request = Rack::Request.new(env)
831
+ id = request.params['id']
832
+ page_struct = @storage.load(id)
833
+
834
+ if !page_struct
835
+ id = ERB::Util.html_escape(id)
836
+ user_info = ERB::Util.html_escape(user(env))
837
+ return [404, {}, ["Request not found: #{id} - user #{user_info}"]]
838
+ end
839
+
840
+ if !page_struct[:flamegraph]
841
+ return [404, {}, ["No flamegraph available for #{ERB::Util.html_escape(id)}"]]
842
+ end
843
+
844
+ self.flamegraph(page_struct[:flamegraph], page_struct[:request_path])
845
+ end
846
+
773
847
  def rails_route_from_path(path, method)
774
848
  if defined?(Rails) && defined?(ActionController::RoutingError)
775
849
  hash = Rails.application.routes.recognize_path(path, method: method)
@@ -799,6 +873,7 @@ Append the following to your query string:
799
873
 
800
874
  def take_snapshot(env, start)
801
875
  MiniProfiler.create_current(env, @config)
876
+ Thread.current[:mp_ongoing_snapshot] = true
802
877
  results = @app.call(env)
803
878
  status = results[0].to_i
804
879
  if status >= 200 && status < 300
@@ -808,10 +883,14 @@ Append the following to your query string:
808
883
  )
809
884
  custom_fields = MiniProfiler.get_snapshot_custom_fields
810
885
  page_struct[:custom_fields] = custom_fields if custom_fields
811
- @storage.push_snapshot(
812
- page_struct,
813
- @config
814
- )
886
+ if Rack::MiniProfiler.snapshots_transporter?
887
+ Rack::MiniProfiler::SnapshotsTransporter.transport(page_struct)
888
+ else
889
+ @storage.push_snapshot(
890
+ page_struct,
891
+ @config
892
+ )
893
+ end
815
894
  end
816
895
  self.current = nil
817
896
  results
@@ -7,7 +7,14 @@ module Rack
7
7
  def record_sql(query, elapsed_ms, params = nil)
8
8
  return unless current && current.current_timer
9
9
  c = current
10
- c.current_timer.add_sql(query, elapsed_ms, c.page_struct, params, c.skip_backtrace, c.full_backtrace)
10
+ c.current_timer.add_sql(
11
+ redact_sql_queries? ? nil : query,
12
+ elapsed_ms,
13
+ c.page_struct,
14
+ redact_sql_queries? ? nil : params,
15
+ c.skip_backtrace,
16
+ c.full_backtrace
17
+ )
11
18
  end
12
19
 
13
20
  def start_step(name)
@@ -108,6 +115,9 @@ module Rack
108
115
  end
109
116
  end
110
117
  end
118
+ if klass.respond_to?(:ruby2_keywords, true)
119
+ klass.send(:ruby2_keywords, with_profiling)
120
+ end
111
121
  klass.send :alias_method, method, with_profiling
112
122
  end
113
123
 
@@ -147,7 +157,6 @@ module Rack
147
157
  def clean_method_name(method)
148
158
  method.to_s.gsub(/[\?\!]/, "")
149
159
  end
150
-
151
160
  end
152
161
  end
153
162
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ::Rack::MiniProfiler::SnapshotsTransporter
4
+ @@transported_snapshots_count = 0
5
+ @@successful_http_requests_count = 0
6
+ @@failed_http_requests_count = 0
7
+
8
+ class << self
9
+ def transported_snapshots_count
10
+ @@transported_snapshots_count
11
+ end
12
+ def successful_http_requests_count
13
+ @@successful_http_requests_count
14
+ end
15
+ def failed_http_requests_count
16
+ @@failed_http_requests_count
17
+ end
18
+
19
+ def transport(snapshot)
20
+ @transporter ||= self.new(Rack::MiniProfiler.config)
21
+ @transporter.ship(snapshot)
22
+ end
23
+ end
24
+
25
+ attr_reader :buffer
26
+ attr_accessor :max_buffer_size, :gzip_requests
27
+
28
+ def initialize(config)
29
+ @uri = URI(config.snapshots_transport_destination_url)
30
+ @auth_key = config.snapshots_transport_auth_key
31
+ @gzip_requests = config.snapshots_transport_gzip_requests
32
+ @thread = nil
33
+ @thread_mutex = Mutex.new
34
+ @buffer = []
35
+ @buffer_mutex = Mutex.new
36
+ @max_buffer_size = 100
37
+ @consecutive_failures_count = 0
38
+ @testing = false
39
+ end
40
+
41
+ def ship(snapshot)
42
+ @buffer_mutex.synchronize do
43
+ @buffer << snapshot
44
+ @buffer.shift if @buffer.size > @max_buffer_size
45
+ end
46
+ @thread_mutex.synchronize { start_thread }
47
+ end
48
+
49
+ def flush_buffer
50
+ buffer_content = @buffer_mutex.synchronize do
51
+ @buffer.dup if @buffer.size > 0
52
+ end
53
+ if buffer_content
54
+ headers = {
55
+ 'Content-Type' => 'application/json',
56
+ 'Mini-Profiler-Transport-Auth' => @auth_key
57
+ }
58
+ json = { snapshots: buffer_content }.to_json
59
+ body = if @gzip_requests
60
+ require 'zlib'
61
+ io = StringIO.new
62
+ gzip_writer = Zlib::GzipWriter.new(io)
63
+ gzip_writer.write(json)
64
+ gzip_writer.close
65
+ headers['Content-Encoding'] = 'gzip'
66
+ io.string
67
+ else
68
+ json
69
+ end
70
+ request = Net::HTTP::Post.new(@uri, headers)
71
+ request.body = body
72
+ http = Net::HTTP.new(@uri.hostname, @uri.port)
73
+ http.use_ssl = @uri.scheme == 'https'
74
+ res = http.request(request)
75
+ if res.code.to_i == 200
76
+ @@successful_http_requests_count += 1
77
+ @@transported_snapshots_count += buffer_content.size
78
+ @buffer_mutex.synchronize do
79
+ @buffer -= buffer_content
80
+ end
81
+ @consecutive_failures_count = 0
82
+ else
83
+ @@failed_http_requests_count += 1
84
+ @consecutive_failures_count += 1
85
+ end
86
+ end
87
+ end
88
+
89
+ def requests_interval
90
+ [30 + backoff_delay, 60 * 60].min
91
+ end
92
+
93
+ private
94
+
95
+ def backoff_delay
96
+ return 0 if @consecutive_failures_count == 0
97
+ 2**@consecutive_failures_count
98
+ end
99
+
100
+ def start_thread
101
+ return if @thread&.alive? || @testing
102
+ @thread = Thread.new do
103
+ while true
104
+ sleep requests_interval
105
+ flush_buffer
106
+ end
107
+ end
108
+ end
109
+ end
@@ -36,7 +36,7 @@ module Rack
36
36
  ""
37
37
  end
38
38
 
39
- # a list of tokens that are permitted to access profiler in whitelist mode
39
+ # a list of tokens that are permitted to access profiler in explicit mode
40
40
  def allowed_tokens
41
41
  raise NotImplementedError.new("allowed_tokens is not implemented")
42
42
  end
@@ -58,17 +58,21 @@ module Rack
58
58
  fetch_snapshots do |batch|
59
59
  batch.each do |snapshot|
60
60
  group_name = default_snapshot_grouping(snapshot)
61
- if !groups[group_name] || groups[group_name] < snapshot.duration_ms
62
- groups[group_name] = snapshot.duration_ms
61
+ hash = groups[group_name] ||= {}
62
+ hash[:snapshots_count] ||= 0
63
+ hash[:snapshots_count] += 1
64
+ if !hash[:worst_score] || hash[:worst_score] < snapshot.duration_ms
65
+ groups[group_name][:worst_score] = snapshot.duration_ms
66
+ end
67
+ if !hash[:best_score] || hash[:best_score] > snapshot.duration_ms
68
+ groups[group_name][:best_score] = snapshot.duration_ms
63
69
  end
64
70
  end
65
71
  end
66
72
  groups = groups.to_a
67
- groups.sort_by! { |name, score| score }
73
+ groups.sort_by! { |name, hash| hash[:worst_score] }
68
74
  groups.reverse!
69
- groups.map! do |name, score|
70
- { name: name, worst_score: score }
71
- end
75
+ groups.map! { |name, hash| hash.merge(name: name) }
72
76
  groups
73
77
  end
74
78
 
@@ -81,7 +85,9 @@ module Rack
81
85
  data << {
82
86
  id: snapshot[:id],
83
87
  duration: snapshot.duration_ms,
84
- timestamp: snapshot[:started_at]
88
+ sql_count: snapshot[:sql_count],
89
+ timestamp: snapshot[:started_at],
90
+ custom_fields: snapshot[:custom_fields]
85
91
  }
86
92
  end
87
93
  end