dial 0.1.3 → 0.1.5

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dcc8b8f15acf035328c3330930476af9621529608cabcb511b240cc7da9a9ecf
4
- data.tar.gz: 05b4c3610edad8e1b6b4cb1cad387ea962d7e250c8cb165a845926bbd668e37c
3
+ metadata.gz: 82171550e05c2bc884c0ea9a55e6464f42d9bfef043af62e123c03fdbce943b6
4
+ data.tar.gz: f7c98017bbcaa66ad376c0b01570007f6b5b70c36ef25843a34254b0e4c82714
5
5
  SHA512:
6
- metadata.gz: 9bf37053b4f2bc59bd322646d7485326378c6d234d822571e59f14530ea4382830a6b842f514bef3f9fc7d6719b9b212251023b5c6a214304fb920aac0c4abd3
7
- data.tar.gz: c5688daab1bd6d82310bab1270a243e507eb54bbc12cbf187c2d3237e43fe1d5022df34fa7f105b642ca563f10f7b343181a74bc5c1864a1dd36d5cca6d15f85
6
+ metadata.gz: 64e51ddaf1029231903836e08c8943afe75df9ec068f4506cba3a85e4c2a5059651af66e8ce3c797132f0ee1943c773abcd52a8ea9fc13fec8280253dbf5dfb3
7
+ data.tar.gz: cf6d09efec8cd1e581d0b13a91cd6bfce0c1d86612d9b2bf778c01ac6f1766c40fe122eeca19e6133749c396eec2bea34610be2e3d6cd231a4c6eaeebf0d99d6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.5] - 2025-01-24
4
+
5
+ - UI: Fix overflow and add vertical scroll
6
+
7
+ ## [0.1.4] - 2024-12-27
8
+
9
+ - Use vernier memory usage and rails hooks by default
10
+ - Add support for N+1 detection with prosopite
11
+
3
12
  ## [0.1.3] - 2024-11-14
4
13
 
5
14
  - Enable allocation profiling by default
data/README.md CHANGED
@@ -7,17 +7,30 @@ WIP
7
7
 
8
8
  A modern profiler for Rails applications.
9
9
 
10
- https://github.com/user-attachments/assets/bae59681-ebeb-42b3-9489-9692c072c3dc
10
+ Check out the demo:
11
+ [![Demo](https://img.youtube.com/vi/LPXtfJ0c284/maxresdefault.jpg)](https://youtu.be/LPXtfJ0c284)
11
12
 
12
13
  ## Installation
13
14
 
14
- Install the gem and add it to your Rails application's Gemfile by executing:
15
+ 1. Add the gem to your Rails application's Gemfile (adjust the `require` option to match your server of choice):
16
+
17
+ ```ruby
18
+ # require in just the server process
19
+ gem "dial", require: !!($PROGRAM_NAME =~ /puma/)
20
+ ```
21
+
22
+ 2. Install the gem:
15
23
 
16
24
  ```bash
17
- bundle add dial
25
+ bundle install
18
26
  ```
19
27
 
20
- and everything should just work.
28
+ 3. Mount the engine in your `config/routes.rb` file:
29
+
30
+ ```ruby
31
+ # this will mount the engine at /dial
32
+ mount Dial::Engine, at: "/" if Object.const_defined?("Dial::Engine")
33
+ ```
21
34
 
22
35
  ## Development
23
36
 
data/dial.gemspec CHANGED
@@ -22,5 +22,7 @@ Gem::Specification.new do |spec|
22
22
  spec.add_dependency "railties", ">= 7", "< 8.2"
23
23
  spec.add_dependency "activerecord", ">= 7", "< 8.2"
24
24
  spec.add_dependency "actionpack", ">= 7", "< 8.2"
25
- spec.add_dependency "vernier", "~> 1.3"
25
+ spec.add_dependency "vernier", "~> 1.5"
26
+ spec.add_dependency "prosopite", "~> 1.4"
27
+ spec.add_dependency "pg_query", "~> 5.1"
26
28
  end
@@ -1,6 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "version"
4
+
3
5
  module Dial
4
6
  REQUEST_TIMING_HEADER = "dial_request_timing"
5
- PROFILE_OUT_RELATIVE_DIRNAME = "tmp/dial/profile/"
7
+
8
+ PROFILE_OUT_STALE_SECONDS = 60 * 60
9
+ PROFILE_OUT_RELATIVE_DIRNAME = "tmp/dial/profiles/"
10
+
11
+ PROSOPITE_IGNORE_QUERIES = [/schema_migrations/]
12
+ PROSOPITE_LOG_RELATIVE_PATHNAME = "log/dial/prosopite.log"
6
13
  end
@@ -5,7 +5,7 @@ require "uri"
5
5
  module Dial
6
6
  class Panel
7
7
  class << self
8
- def html env, profile_out_filename, ruby_vm_stat, gc_stat, gc_stat_heap, server_timing
8
+ def html env, profile_out_filename, query_logs, ruby_vm_stat, gc_stat, gc_stat_heap, server_timing
9
9
  <<~HTML
10
10
  <style>#{style}</style>
11
11
 
@@ -21,7 +21,16 @@ module Dial
21
21
  <span>#{formatted_ruby_version}</span>
22
22
  </div>
23
23
 
24
+ <hr>
25
+
24
26
  <div id="dial-details">
27
+ <details>
28
+ <summary>N+1s</summary>
29
+ <div class="section query-logs">
30
+ #{formatted_query_logs query_logs}
31
+ </div>
32
+ </details>
33
+
25
34
  <hr>
26
35
 
27
36
  <details>
@@ -31,6 +40,8 @@ module Dial
31
40
  </div>
32
41
  </details>
33
42
 
43
+ <hr>
44
+
34
45
  <details>
35
46
  <summary>RubyVM stat</summary>
36
47
  <div class="section">
@@ -38,6 +49,8 @@ module Dial
38
49
  </div>
39
50
  </details>
40
51
 
52
+ <hr>
53
+
41
54
  <details>
42
55
  <summary>GC stat</summary>
43
56
  <div class="section">
@@ -45,6 +58,8 @@ module Dial
45
58
  </div>
46
59
  </details>
47
60
 
61
+ <hr>
62
+
48
63
  <details>
49
64
  <summary>GC stat heap</summary>
50
65
  <div class="section">
@@ -63,6 +78,8 @@ module Dial
63
78
  def style
64
79
  <<~CSS
65
80
  #dial {
81
+ max-height: 50%;
82
+ max-width: 50%;
66
83
  z-index: 9999;
67
84
  position: fixed;
68
85
  bottom: 0;
@@ -84,6 +101,7 @@ module Dial
84
101
 
85
102
  #dial-details {
86
103
  display: none;
104
+ overflow-y: auto;
87
105
  }
88
106
 
89
107
  .section {
@@ -92,6 +110,15 @@ module Dial
92
110
  margin: 0.25rem 0 0 0;
93
111
  }
94
112
 
113
+ .query-logs {
114
+ padding-left: 0.75rem;
115
+
116
+ details {
117
+ margin-top: 0;
118
+ margin-bottom: 0.25rem;
119
+ }
120
+ }
121
+
95
122
  span {
96
123
  text-align: left;
97
124
  }
@@ -99,6 +126,7 @@ module Dial
99
126
  hr {
100
127
  width: -moz-available;
101
128
  margin: 0.65rem 0 0 0;
129
+ background-color: black;
102
130
  }
103
131
 
104
132
  details {
@@ -118,10 +146,22 @@ module Dial
118
146
  <<~JS
119
147
  const dialPreview = document.getElementById("dial-preview");
120
148
  const dialDetails = document.getElementById("dial-details");
149
+
121
150
  dialPreview.addEventListener("click", () => {
122
151
  const collapsed = ["", "none"].includes(dialDetails.style.display);
123
152
  dialDetails.style.display = collapsed ? "block" : "none";
124
153
  });
154
+
155
+ document.addEventListener("click", (event) => {
156
+ if (!dialPreview.contains(event.target) && !dialDetails.contains(event.target)) {
157
+ dialDetails.style.display = "none";
158
+
159
+ const detailsElements = dialDetails.querySelectorAll("details");
160
+ detailsElements.forEach(detail => {
161
+ detail.removeAttribute("open");
162
+ });
163
+ }
164
+ });
125
165
  JS
126
166
  end
127
167
 
@@ -140,10 +180,10 @@ module Dial
140
180
  end
141
181
 
142
182
  def formatted_profile_output env, profile_out_filename
183
+ url_base = ::Rails.application.routes.url_helpers.dial_url host: env[::Rack::HTTP_HOST]
184
+ prefix = "/" unless url_base.end_with? "/"
143
185
  uuid = profile_out_filename.delete_suffix ".json"
144
- host = env[::Rack::HTTP_HOST]
145
- base_url = ::Rails.application.routes.url_helpers.dial_url host: host
146
- profile_out_url = URI.encode_www_form_component base_url + "dial/profile?uuid=#{uuid}"
186
+ profile_out_url = URI.encode_www_form_component url_base + "#{prefix}dial/profile?uuid=#{uuid}"
147
187
 
148
188
  "<a href='https://vernier.prof/from-url/#{profile_out_url}' target='_blank'>View profile</a>"
149
189
  end
@@ -163,7 +203,6 @@ module Dial
163
203
  def formatted_server_timing server_timing
164
204
  if server_timing.any?
165
205
  server_timing
166
- # TODO: Nested sorting
167
206
  .sort_by { |_, timing| -timing }
168
207
  .map { |event, timing| "<span><b>#{event}:</b> #{timing}</span>" }.join
169
208
  else
@@ -171,6 +210,24 @@ module Dial
171
210
  end
172
211
  end
173
212
 
213
+ def formatted_query_logs query_logs
214
+ if query_logs.any?
215
+ query_logs.map do |(queries, stack_lines)|
216
+ <<~HTML
217
+ <details>
218
+ <summary>#{queries.shift}</summary>
219
+ <div class="section query-logs">
220
+ #{queries.map { |query| "<span>#{query}</span>" }.join}
221
+ #{stack_lines.map { |stack_line| "<span>#{stack_line}</span>" }.join}
222
+ </div>
223
+ </details>
224
+ HTML
225
+ end.join
226
+ else
227
+ "NA"
228
+ end
229
+ end
230
+
174
231
  def formatted_ruby_vm_stat ruby_vm_stat
175
232
  ruby_vm_stat.map { |key, value| "<span><b>#{key}:</b> #{value}</span>" }.join
176
233
  end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  require "vernier"
6
+ require "prosopite"
4
7
 
5
- require_relative "constants"
6
8
  require_relative "middleware/panel"
7
9
  require_relative "middleware/ruby_stat"
8
10
  require_relative "middleware/rails_stat"
@@ -19,38 +21,36 @@ module Dial
19
21
  def call env
20
22
  start_time = Process.clock_gettime Process::CLOCK_MONOTONIC
21
23
 
22
- ruby_vm_stat_before = RubyVM.stat
23
- gc_stat_before = GC.stat
24
- gc_stat_heap_before = GC.stat_heap
25
-
26
- profile_out_dirname = String ::Rails.root.join PROFILE_OUT_RELATIVE_DIRNAME
27
- FileUtils.mkdir_p profile_out_dirname
28
- profile_out_filename = "#{SecureRandom.uuid}.json"
29
- profile_out_pathname = "#{profile_out_dirname}#{profile_out_filename}"
24
+ profile_out_filename = "#{SecureRandom.uuid_v7}.json"
25
+ profile_out_pathname = "#{profile_out_dir_pathname}#{profile_out_filename}"
30
26
 
31
27
  status, headers, rack_body = nil
32
- ::Vernier.profile out: profile_out_pathname, interval: 500, allocation_interval: 1000 do
33
- status, headers, rack_body = @app.call env
28
+ ruby_vm_stat, gc_stat, gc_stat_heap = nil
29
+ ::Prosopite.scan do
30
+ ::Vernier.profile out: profile_out_pathname, interval: 500, allocation_interval: 1000, hooks: [:memory_usage, :rails] do
31
+ ruby_vm_stat, gc_stat, gc_stat_heap = with_diffed_ruby_stats do
32
+ status, headers, rack_body = @app.call env
33
+ end
34
+ end
34
35
  end
36
+ server_timing = server_timing headers
35
37
 
36
38
  unless headers[::Rack::CONTENT_TYPE]&.include? "text/html"
37
39
  File.delete profile_out_pathname if File.exist? profile_out_pathname
38
40
  return [status, headers, rack_body]
39
41
  end
40
42
 
43
+ query_logs = clear_query_logs!
44
+ remove_stale_profile_out_files!
45
+
41
46
  finish_time = Process.clock_gettime Process::CLOCK_MONOTONIC
42
47
  env[REQUEST_TIMING_HEADER] = ((finish_time - start_time) * 1_000).round 2
43
48
 
44
- ruby_vm_stat = ruby_vm_stat_diff ruby_vm_stat_before, RubyVM.stat
45
- gc_stat = gc_stat_diff gc_stat_before, GC.stat
46
- gc_stat_heap = gc_stat_heap_diff gc_stat_heap_before, GC.stat_heap
47
- server_timing = server_timing headers
48
-
49
49
  body = String.new.tap do |str|
50
50
  rack_body.each { |chunk| str << chunk }
51
51
  rack_body.close if rack_body.respond_to? :close
52
52
  end.sub "</body>", <<~HTML
53
- #{Panel.html env, profile_out_filename, ruby_vm_stat, gc_stat, gc_stat_heap, server_timing}
53
+ #{Panel.html env, profile_out_filename, query_logs, ruby_vm_stat, gc_stat, gc_stat_heap, server_timing}
54
54
  </body>
55
55
  HTML
56
56
 
@@ -58,5 +58,79 @@ module Dial
58
58
 
59
59
  [status, headers, [body]]
60
60
  end
61
+
62
+ private
63
+
64
+ def with_diffed_ruby_stats
65
+ ruby_vm_stat_before = RubyVM.stat
66
+ gc_stat_before = GC.stat
67
+ gc_stat_heap_before = GC.stat_heap
68
+ yield
69
+ [
70
+ ruby_vm_stat_diff(ruby_vm_stat_before, RubyVM.stat),
71
+ gc_stat_diff(gc_stat_before, GC.stat),
72
+ gc_stat_heap_diff(gc_stat_heap_before, GC.stat_heap)
73
+ ]
74
+ end
75
+
76
+ def remove_stale_profile_out_files!
77
+ stale_profile_out_files.each do |profile_out_file|
78
+ File.delete profile_out_file
79
+ end
80
+ end
81
+
82
+ def stale_profile_out_files
83
+ Dir.glob("#{profile_out_dir_pathname}/*.json").select do |profile_out_file|
84
+ timestamp = Util.uuid_v7_timestamp File.basename profile_out_file
85
+ timestamp < Time.now - PROFILE_OUT_STALE_SECONDS
86
+ end
87
+ end
88
+
89
+ def profile_out_dir_pathname
90
+ @_profile_out_dir_pathname ||= ::Rails.root.join PROFILE_OUT_RELATIVE_DIRNAME
91
+ end
92
+
93
+ def clear_query_logs!
94
+ [].tap do |query_logs|
95
+ File.open(query_log_pathname, "r+") do |file|
96
+ entry = section = count = nil
97
+ file.each_line do |line|
98
+ entry, section, count = process_query_log_line line, entry, section, count
99
+ query_logs << entry if entry && section.nil?
100
+ end
101
+
102
+ file.truncate 0
103
+ file.rewind
104
+ end
105
+ end
106
+ end
107
+
108
+ def process_query_log_line line, entry, section, count
109
+ case line
110
+ when /N\+1 queries detected/
111
+ [[[],[]], :queries, 0]
112
+ when /Call stack/
113
+ entry.first << "+ #{count - 5} more queries" if count > 5
114
+ [entry, :call_stack, count]
115
+ else
116
+ case section
117
+ when :queries
118
+ count += 1
119
+ entry.first << line.strip if count <= 5
120
+ [entry, :queries, count]
121
+ when :call_stack
122
+ if line.strip.empty?
123
+ [entry, nil, count]
124
+ else
125
+ entry.last << line.strip
126
+ [entry, section, count]
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ def query_log_pathname
133
+ @_query_log_dir_pathname ||= ::Rails.root.join PROSOPITE_LOG_RELATIVE_PATHNAME
134
+ end
61
135
  end
62
136
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dial
4
+ class ProsopiteLogger < Logger
5
+ end
6
+ end
data/lib/dial/railtie.rb CHANGED
@@ -1,13 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails"
4
+ require "prosopite"
4
5
 
5
6
  require_relative "middleware"
7
+ require_relative "prosopite_logger"
6
8
 
7
9
  module Dial
8
10
  class Railtie < ::Rails::Railtie
9
11
  initializer "dial.use_middleware" do |app|
10
12
  app.middleware.insert_before 0, Middleware
11
13
  end
14
+
15
+ initializer "dial.set_up_vernier" do |app|
16
+ app.config.after_initialize do
17
+ FileUtils.mkdir_p ::Rails.root.join PROFILE_OUT_RELATIVE_DIRNAME
18
+ end
19
+ end
20
+
21
+ initializer "dial.set_up_prosopite" do |app|
22
+ app.config.after_initialize do
23
+ if ::ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
24
+ require "pg_query"
25
+ end
26
+
27
+ prosopite_log_pathname = ::Rails.root.join PROSOPITE_LOG_RELATIVE_PATHNAME
28
+ FileUtils.mkdir_p File.dirname prosopite_log_pathname
29
+ FileUtils.touch prosopite_log_pathname
30
+ ::Prosopite.custom_logger = ProsopiteLogger.new prosopite_log_pathname
31
+
32
+ ::Prosopite.ignore_queries = PROSOPITE_IGNORE_QUERIES
33
+ end
34
+ end
12
35
  end
13
36
  end
data/lib/dial/util.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dial
4
+ module Util
5
+ class << self
6
+ def uuid_v7_timestamp uuid
7
+ high_bits_hex = uuid.split("-").first(2).join[0, 12].to_i 16
8
+ Time.at high_bits_hex / 1000.0
9
+ end
10
+ end
11
+ end
12
+ end
data/lib/dial/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dial
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.5"
5
5
  end
data/lib/dial.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "dial/version"
3
+ require_relative "dial/constants"
4
+ require_relative "dial/util"
5
+
4
6
  require_relative "dial/railtie"
5
7
  require_relative "dial/engine"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dial
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2024-11-14 00:00:00.000000000 Z
10
+ date: 2025-01-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties
@@ -75,14 +75,42 @@ dependencies:
75
75
  requirements:
76
76
  - - "~>"
77
77
  - !ruby/object:Gem::Version
78
- version: '1.3'
78
+ version: '1.5'
79
79
  type: :runtime
80
80
  prerelease: false
81
81
  version_requirements: !ruby/object:Gem::Requirement
82
82
  requirements:
83
83
  - - "~>"
84
84
  - !ruby/object:Gem::Version
85
- version: '1.3'
85
+ version: '1.5'
86
+ - !ruby/object:Gem::Dependency
87
+ name: prosopite
88
+ requirement: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - "~>"
91
+ - !ruby/object:Gem::Version
92
+ version: '1.4'
93
+ type: :runtime
94
+ prerelease: false
95
+ version_requirements: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - "~>"
98
+ - !ruby/object:Gem::Version
99
+ version: '1.4'
100
+ - !ruby/object:Gem::Dependency
101
+ name: pg_query
102
+ requirement: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - "~>"
105
+ - !ruby/object:Gem::Version
106
+ version: '5.1'
107
+ type: :runtime
108
+ prerelease: false
109
+ version_requirements: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - "~>"
112
+ - !ruby/object:Gem::Version
113
+ version: '5.1'
86
114
  email:
87
115
  - djry1999@gmail.com
88
116
  executables: []
@@ -102,7 +130,9 @@ files:
102
130
  - lib/dial/middleware/panel.rb
103
131
  - lib/dial/middleware/rails_stat.rb
104
132
  - lib/dial/middleware/ruby_stat.rb
133
+ - lib/dial/prosopite_logger.rb
105
134
  - lib/dial/railtie.rb
135
+ - lib/dial/util.rb
106
136
  - lib/dial/version.rb
107
137
  homepage: https://github.com/joshuay03/dial
108
138
  licenses:
@@ -124,7 +154,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
124
154
  - !ruby/object:Gem::Version
125
155
  version: '0'
126
156
  requirements: []
127
- rubygems_version: 3.6.0.dev
157
+ rubygems_version: 3.6.2
128
158
  specification_version: 4
129
159
  summary: A modern Rails profiler
130
160
  test_files: []