dial 0.3.1 → 0.4.0

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: c57fbfd8d0f023bcfd0606a5d61cfe84dd7b93d618dd844d50332b576dcce3d5
4
- data.tar.gz: 27db795cf357bd7c878ea939c9c66374cc50f54e304466f91b9add37e0535a95
3
+ metadata.gz: dc0b70ff1d770bd0fc85123280a07bb683d649a059df325b2b9cad938ea346e2
4
+ data.tar.gz: ef4ab7cdb79f4e773b9d19d35d40624085a1ea15b47798fc9a092c476e4d5f25
5
5
  SHA512:
6
- metadata.gz: ea4ba198fd052cdfed3a35d95a5424f6da32c94da85dec1cad9d78edd6b92e336d35afa76704349c5ef0475ac8a60f9ba90026b753109f4a0cea8caba994d3c4
7
- data.tar.gz: 4d1578a97dd8439a4e6060698d24a0605f9747fd471d47844dd5f981f257790a00ecd4a5ed097377ef3af6d61062dd0b76d6023f5fd5dca0990a8f2642421c5a
6
+ metadata.gz: 5fd19ba186d0689ad63827557f944c0fcee39c41c0d77053450d1a81ff52bb74442f26e7eb4bb377efacea763f87903d467c858834da56483a3d4ddf01307acc
7
+ data.tar.gz: 45ccf2409afdc88cca32063aa3583737f00eaffea57f10701a9d6d2c45d066586c85e52eea2387ee9cacb2bc0712feca2159c16e711c52c1c8e2c4f8e34ef162
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2025-08-30
4
+
5
+ - Add sampling configuration to control percentage of requests profiled
6
+ - Replace response body buffering with streaming response wrapper
7
+ - Make prosopite logging thread-safe with thread-local storage
8
+ - Fix missing namespace for VERNIER_PROFILE_OUT_FILE_EXTENSION in engine routes
9
+ - Improve error handling and input validation
10
+
11
+ ## [0.3.2] - 2025-05-14
12
+
13
+ - Consolidate railtie initializers
14
+
3
15
  ## [0.3.1] - 2025-05-03
4
16
 
5
17
  - Only show truncated summary + one full query in N+1 logs
data/README.md CHANGED
@@ -43,6 +43,7 @@ mount Dial::Engine, at: "/" if Rails.env.development?
43
43
  # config/initializers/dial.rb
44
44
 
45
45
  Dial.configure do |config|
46
+ config.sampling_percentage = 50
46
47
  config.vernier_interval = 100
47
48
  config.vernier_allocation_interval = 10_000
48
49
  config.prosopite_ignore_queries += [/pg_sleep/i]
@@ -53,10 +54,11 @@ end
53
54
 
54
55
  Option | Description | Default
55
56
  :- | :- | :-
57
+ `sampling_percentage` | Percentage of requests to profile. | `100` in development, `1` in production
58
+ `content_security_policy_nonce` | Sets the content security policy nonce to use when inserting Dial's script. Can be a string, or a Proc which receives `env` and response `headers` as arguments and returns the nonce string. | Rails generated nonce or `nil`
56
59
  `vernier_interval` | Sets the `interval` option for vernier. | `200`
57
60
  `vernier_allocation_interval` | Sets the `allocation_interval` option for vernier. | `2_000`
58
61
  `prosopite_ignore_queries` | Sets the `ignore_queries` option for prosopite. | `[/schema_migrations/i]`
59
- `content_security_policy_nonce` | Sets the content security policy nonce to use when inserting Dial's script. Can be a string, or a Proc which receives `env` and response `headers` as arguments and returns the nonce string. | Rails generated nonce or `nil`
60
62
 
61
63
  ## Comparison with [rack-mini-profiler](https://github.com/MiniProfiler/rack-mini-profiler)
62
64
 
@@ -70,7 +72,7 @@ Option | Description | Default
70
72
  | Memory Profiling | Yes (with memory_profiler) | Yes (*overall usage only) (via vernier hook - graph) |
71
73
  | View Profiling | Yes | Yes (via vernier hook - marker table, chart) |
72
74
  | Snapshot Sampling | Yes | No |
73
- | Production Support | Yes | No (WIP) |
75
+ | Production Support | Yes | No |
74
76
 
75
77
  > [!NOTE]
76
78
  > SQL queries displayed in the profile are not annotated with the caller location by default. If you're not using the
@@ -12,10 +12,11 @@ module Dial
12
12
  class Configuration
13
13
  def initialize
14
14
  @options = {
15
+ sampling_percentage: ::Rails.env.development? ? 100 : 1,
16
+ content_security_policy_nonce: -> (env, _headers) { env[NONCE] || "" },
15
17
  vernier_interval: VERNIER_INTERVAL,
16
18
  vernier_allocation_interval: VERNIER_ALLOCATION_INTERVAL,
17
19
  prosopite_ignore_queries: PROSOPITE_IGNORE_QUERIES,
18
- content_security_policy_nonce: -> (env, _headers) { env[NONCE] || "" },
19
20
  }
20
21
 
21
22
  @options.keys.each do |key|
@@ -31,6 +32,7 @@ module Dial
31
32
 
32
33
  def freeze
33
34
  @options.freeze
35
+
34
36
  super
35
37
  end
36
38
  end
@@ -21,8 +21,8 @@ module Dial
21
21
  VERNIER_INTERVAL = 200
22
22
  VERNIER_ALLOCATION_INTERVAL = 2_000
23
23
  VERNIER_PROFILE_OUT_RELATIVE_DIRNAME = "tmp/dial/profiles"
24
+ VERNIER_PROFILE_OUT_FILE_EXTENSION = ".json.gz"
24
25
  VERNIER_VIEWER_URL = "https://vernier.prof"
25
26
 
26
27
  PROSOPITE_IGNORE_QUERIES = [/schema_migrations/i].freeze
27
- PROSOPITE_LOG_IO = StringIO.new
28
28
  end
@@ -4,14 +4,33 @@ Dial::Engine.routes.draw do
4
4
  scope path: "/dial", as: "dial" do
5
5
  get "profile", to: lambda { |env|
6
6
  uuid = env[::Rack::QUERY_STRING].sub "uuid=", ""
7
- path = String ::Rails.root.join Dial::VERNIER_PROFILE_OUT_RELATIVE_DIRNAME, "#{uuid}.json.gz"
8
7
 
9
- if File.exist? path
10
- [
11
- 200,
12
- { "Content-Type" => "application/json", "Access-Control-Allow-Origin" => Dial::VERNIER_VIEWER_URL },
13
- [File.read(path)]
8
+ # Validate UUID format (should end with _vernier)
9
+ unless uuid.match?(/\A[0-9a-f-]+_vernier\z/)
10
+ return [
11
+ 400,
12
+ { "Content-Type" => "text/plain" },
13
+ ["Bad Request"]
14
14
  ]
15
+ end
16
+
17
+ path = String ::Rails.root.join Dial::VERNIER_PROFILE_OUT_RELATIVE_DIRNAME, (uuid + Dial::VERNIER_PROFILE_OUT_FILE_EXTENSION)
18
+
19
+ if File.exist? path
20
+ begin
21
+ content = File.read path
22
+ [
23
+ 200,
24
+ { "Content-Type" => "application/json", "Access-Control-Allow-Origin" => Dial::VERNIER_VIEWER_URL },
25
+ [content]
26
+ ]
27
+ rescue
28
+ [
29
+ 500,
30
+ { "Content-Type" => "text/plain" },
31
+ ["Internal Server Error"]
32
+ ]
33
+ end
15
34
  else
16
35
  [
17
36
  404,
@@ -192,7 +192,7 @@ module Dial
192
192
  def formatted_profile_output env, profile_out_filename
193
193
  url_base = ::Rails.application.routes.url_helpers.dial_url host: env[::Rack::HTTP_HOST]
194
194
  prefix = "/" unless url_base.end_with? "/"
195
- uuid = profile_out_filename.delete_suffix ".json.gz"
195
+ uuid = profile_out_filename.delete_suffix VERNIER_PROFILE_OUT_FILE_EXTENSION
196
196
  profile_out_url = URI.encode_www_form_component url_base + "#{prefix}dial/profile?uuid=#{uuid}"
197
197
 
198
198
  "<a href='https://vernier.prof/from-url/#{profile_out_url}' target='_blank'>View profile</a>"
@@ -3,6 +3,7 @@
3
3
  require "vernier"
4
4
  require "prosopite"
5
5
 
6
+ require_relative "prosopite"
6
7
  require_relative "middleware/panel"
7
8
  require_relative "middleware/ruby_stat"
8
9
  require_relative "middleware/rails_stat"
@@ -21,9 +22,13 @@ module Dial
21
22
  return @app.call env
22
23
  end
23
24
 
25
+ unless should_profile?
26
+ return @app.call env
27
+ end
28
+
24
29
  start_time = Process.clock_gettime Process::CLOCK_MONOTONIC
25
30
 
26
- profile_out_filename = "#{Util.uuid}_vernier.json.gz"
31
+ profile_out_filename = "#{Util.uuid}_vernier" + VERNIER_PROFILE_OUT_FILE_EXTENSION
27
32
  profile_out_pathname = "#{profile_out_dir_pathname}/#{profile_out_filename}"
28
33
 
29
34
  status, headers, rack_body, ruby_vm_stat, gc_stat, gc_stat_heap, vernier_result = nil
@@ -48,19 +53,12 @@ module Dial
48
53
  finish_time = Process.clock_gettime Process::CLOCK_MONOTONIC
49
54
  env[REQUEST_TIMING] = ((finish_time - start_time) * 1_000).round 2
50
55
 
51
- body = String.new.tap do |str|
52
- rack_body.each { |chunk| str << chunk }
53
- rack_body.close if rack_body.respond_to? :close
54
-
55
- str.sub! "</body>", <<~HTML
56
- #{Panel.html env, headers, profile_out_filename, query_logs, ruby_vm_stat, gc_stat, gc_stat_heap, server_timing}
57
- </body>
58
- HTML
59
- end
56
+ panel_html = Panel.html env, headers, profile_out_filename, query_logs, ruby_vm_stat, gc_stat, gc_stat_heap, server_timing
57
+ body = PanelInjector.new rack_body, panel_html
60
58
 
61
- headers[CONTENT_LENGTH] = body.bytesize.to_s
59
+ headers.delete CONTENT_LENGTH
62
60
 
63
- [status, headers, [body]]
61
+ [status, headers, body]
64
62
  end
65
63
 
66
64
  private
@@ -89,13 +87,13 @@ module Dial
89
87
  def clear_query_logs!
90
88
  [].tap do |query_logs|
91
89
  entry = section = count = nil
92
- PROSOPITE_LOG_IO.string.lines.each do |line|
90
+ ProsopiteLogger.log_io.string.lines.each do |line|
93
91
  entry, section, count = process_query_log_line line, entry, section, count
94
92
  query_logs << entry if entry && section.nil?
95
93
  end
96
94
 
97
- PROSOPITE_LOG_IO.truncate 0
98
- PROSOPITE_LOG_IO.rewind
95
+ ProsopiteLogger.log_io.truncate 0
96
+ ProsopiteLogger.log_io.rewind
99
97
  end
100
98
  end
101
99
 
@@ -126,5 +124,42 @@ module Dial
126
124
  def profile_out_dir_pathname
127
125
  ::Rails.root.join VERNIER_PROFILE_OUT_RELATIVE_DIRNAME
128
126
  end
127
+
128
+ def should_profile?
129
+ rand(100) < Dial._configuration.sampling_percentage
130
+ end
131
+ end
132
+
133
+ class PanelInjector
134
+ def initialize original_body, panel_html
135
+ @original_body = original_body
136
+ @panel_html = panel_html
137
+ @injected = false
138
+ end
139
+
140
+ def each
141
+ @original_body.each do |chunk|
142
+ if !@injected && chunk.include?("</body>")
143
+ @injected = true
144
+ yield chunk.sub("</body>", "#{@panel_html}\n</body>")
145
+ else
146
+ yield chunk
147
+ end
148
+ end
149
+
150
+ yield @panel_html unless @injected
151
+ ensure
152
+ close
153
+ end
154
+
155
+ def close
156
+ @original_body.close if @original_body.respond_to? :close
157
+ end
158
+
159
+ def call stream
160
+ each { |chunk| stream.write chunk }
161
+ ensure
162
+ close
163
+ end
129
164
  end
130
165
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/string/filters"
4
+
3
5
  module Dial
4
6
  module Prosopite
5
7
  def send_notifications
@@ -4,5 +4,20 @@ require "logger"
4
4
 
5
5
  module Dial
6
6
  class ProsopiteLogger < Logger
7
+ def self.log_io
8
+ Thread.current[:dial_prosopite_log_io] ||= StringIO.new
9
+ end
10
+
11
+ def initialize
12
+ super StringIO.new
13
+ end
14
+
15
+ def add severity, message = nil, progname = nil
16
+ return if severity < level
17
+
18
+ progname = @progname if progname.nil?
19
+ formatted_message = format_message format_severity(severity), Time.now, progname, message
20
+ self.class.log_io.write formatted_message
21
+ end
7
22
  end
8
23
  end
data/lib/dial/railtie.rb CHANGED
@@ -4,42 +4,32 @@ require "rails"
4
4
  require "active_record"
5
5
  require "prosopite"
6
6
 
7
- require_relative "prosopite"
8
7
  require_relative "middleware"
9
8
  require_relative "prosopite_logger"
10
9
 
11
10
  module Dial
12
11
  class Railtie < ::Rails::Railtie
13
- initializer "dial.use_middleware", after: :load_config_initializers do |app|
12
+ initializer "dial.setup", after: :load_config_initializers do |app|
13
+ # use middleware
14
14
  app.middleware.insert_before 0, Middleware
15
- end
16
-
17
- initializer "dial.set_up_vernier", after: :load_config_initializers do |app|
18
- app.config.after_initialize do
19
- FileUtils.mkdir_p ::Rails.root.join VERNIER_PROFILE_OUT_RELATIVE_DIRNAME
20
- end
21
- end
22
15
 
23
- initializer "dial.clean_up_vernier_profile_out_files", after: :load_config_initializers do |app|
24
- stale_files("#{profile_out_dir_pathname}/*.json.gz").each do |profile_out_file|
16
+ # clean up stale vernier profile output files
17
+ stale_files("#{profile_out_dir_pathname}/*" + VERNIER_PROFILE_OUT_FILE_EXTENSION).each do |profile_out_file|
25
18
  File.delete profile_out_file rescue nil
26
19
  end
27
- end
28
20
 
29
- initializer "dial.set_up_prosopite", after: :load_config_initializers do |app|
30
21
  app.config.after_initialize do
22
+ # set up vernier
23
+ FileUtils.mkdir_p ::Rails.root.join VERNIER_PROFILE_OUT_RELATIVE_DIRNAME
24
+
25
+ # set up prosopite
31
26
  if ::ActiveRecord::Base.configurations.configurations.any? { |config| config.adapter == "postgresql" }
32
27
  require "pg_query"
33
28
  end
29
+ ::Prosopite.custom_logger = ProsopiteLogger.new
34
30
 
35
- ::Prosopite.custom_logger = ProsopiteLogger.new PROSOPITE_LOG_IO
36
- end
37
- end
38
-
39
- initializer "dial.setup", after: :load_config_initializers do |app|
40
- app.config.after_initialize do
31
+ # finalize configuration
41
32
  Dial._configuration.freeze
42
-
43
33
  ::Prosopite.ignore_queries = Dial._configuration.prosopite_ignore_queries
44
34
  end
45
35
  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.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/dial.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "dial/util"
4
3
  require_relative "dial/constants"
4
+ require_relative "dial/util"
5
5
 
6
6
  require_relative "dial/configuration"
7
7
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dial
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young
@@ -138,7 +138,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
138
138
  - !ruby/object:Gem::Version
139
139
  version: '0'
140
140
  requirements: []
141
- rubygems_version: 3.6.7
141
+ rubygems_version: 3.7.1
142
142
  specification_version: 4
143
143
  summary: A modern profiler for your Rails application
144
144
  test_files: []