dial 0.1.3 → 0.1.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.
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: []