├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── demo.gif ├── dial.gemspec ├── lib ├── dial.rb └── dial │ ├── configuration.rb │ ├── constants.rb │ ├── engine.rb │ ├── engine │ └── routes.rb │ ├── middleware.rb │ ├── middleware │ ├── panel.rb │ ├── rails_stat.rb │ └── ruby_stat.rb │ ├── prosopite.rb │ ├── prosopite_logger.rb │ ├── railtie.rb │ ├── util.rb │ └── version.rb └── test ├── app.rb ├── dial ├── test_configuration.rb └── test_panel.rb ├── test_dial.rb └── test_helper.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} Rails ${{ matrix.rails }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | ruby: 18 | - "3.3.8" 19 | - "3.4.4" 20 | - head 21 | rails: 22 | - "7.1" 23 | - "7.2" 24 | - "8.0" 25 | 26 | env: 27 | RAILS_VERSION: ${{ matrix.rails }} 28 | RAILS_ENV: development 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | - name: Set up Ruby 33 | uses: ruby/setup-ruby@v1 34 | with: 35 | ruby-version: ${{ matrix.ruby }} 36 | bundler-cache: true 37 | - name: Run the default task 38 | run: bundle exec rake 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /tmp/ 3 | Gemfile.lock 4 | *.gem 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.3.2] - 2025-05-14 4 | 5 | - Consolidate railtie initializers 6 | 7 | ## [0.3.1] - 2025-05-03 8 | 9 | - Only show truncated summary + one full query in N+1 logs 10 | - Fix parsing of multiline N+1 query logs 11 | - Truncate summary N+1 queries to 100 chars 12 | 13 | ## [0.3.0] - 2025-05-02 14 | 15 | - Require rails 7.1.0 or later 16 | - Add missing logger require 17 | - Add missing stringio require 18 | 19 | ## [0.2.9] - 2025-04-11 20 | 21 | - No changes 22 | 23 | ## [0.2.8] - 2025-04-11 24 | 25 | - Fix redeclaration of JS constants 26 | 27 | ## [0.2.7] - 2025-04-05 28 | 29 | - Prevent style inheritance in panel 30 | 31 | ## [0.2.6] - 2025-04-05 32 | 33 | - Decrease default vernier allocation interval from 20k to 2k 34 | 35 | ## [0.2.5] - 2025-04-04 36 | 37 | - Perf: Write prosopite logs to IO stream instead of file 38 | - Remove upper bound on rails dependencies 39 | 40 | ## [0.2.4] - 2025-03-03 41 | 42 | - Add configuration option for setting script CSP nonce (thanks @matthaigh27) 43 | 44 | ## [0.2.3] - 2025-02-28 45 | 46 | - Add configuration API 47 | - Make default prosopite ignore queries case insensitive 48 | 49 | ## [0.2.2] - 2025-02-27 50 | 51 | - Increase default vernier allocation interval from 200 to 20k 52 | 53 | ## [0.2.1] - 2025-02-23 54 | 55 | - Decrease default vernier allocation interval from 100k to 200 56 | - Decrease default vernier interval from 500ms to 200ms 57 | 58 | ## [0.2.0] - 2025-02-22 59 | 60 | - Perf: Eagerly check content type to avoid profiling incompatible requests 61 | - Perf: Manually write vernier output files in background thread 62 | - Use gzipped vernier output files by default 63 | - UI: Use inline color properties to prevent overriding by user styles 64 | 65 | ## [0.1.9] - 2025-01-27 66 | 67 | - Increase default vernier allocation interval from 10k to 100k 68 | 69 | ## [0.1.8] - 2025-01-27 70 | 71 | - Require ruby 3.3.0 or later 72 | - Handle stale file deletion in railtie, extend to log files 73 | - Make log files unique to each session - simplify installation 74 | 75 | ## [0.1.7] - 2025-01-25 76 | 77 | - Increase default vernier allocation interval from 1k to 10k 78 | 79 | ## [0.1.6] - 2025-01-25 80 | 81 | - Fix redeclaration of JS constants 82 | 83 | ## [0.1.5] - 2025-01-24 84 | 85 | - UI: Fix overflow and add vertical scroll 86 | 87 | ## [0.1.4] - 2024-12-27 88 | 89 | - Use vernier memory usage and rails hooks by default 90 | - Add support for N+1 detection with prosopite 91 | 92 | ## [0.1.3] - 2024-11-14 93 | 94 | - Enable allocation profiling by default 95 | 96 | ## [0.1.2] - 2024-11-10 97 | 98 | - Add support for request profiling with vernier 99 | 100 | ## [0.1.1] - 2024-11-08 101 | 102 | - Initial release 103 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "rake" 8 | 9 | gem "minitest" 10 | 11 | gem "debug" 12 | 13 | case ENV["RAILS_VERSION"] 14 | when "7.1" 15 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.5") 16 | gem "logger" 17 | gem "benchmark" 18 | end 19 | 20 | gem "sqlite3", "~> 1.4" 21 | when "7.2" 22 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.5") 23 | gem "benchmark" 24 | end 25 | 26 | gem "sqlite3", "~> 1.4" 27 | else 28 | gem "sqlite3", ">= 2.1" 29 | end 30 | 31 | gem "rails", ENV["RAILS_VERSION"] 32 | 33 | gem "rails-dom-testing" 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Joshua Young 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dial 2 | 3 |  4 |  5 | 6 | A modern profiler for your Rails application. 7 | 8 | Utilizes [vernier](https://github.com/jhawthorn/vernier) for profiling and 9 | [prosopite](https://github.com/charkost/prosopite) for N+1 query detection. 10 | 11 | > [!NOTE] 12 | > Check out the resources in the vernier project for more information on how to 13 | > interpret the viewer, as well as comparisons with other profilers, including stackprof. 14 | 15 |  16 | 17 | ## Installation 18 | 19 | 1. Add the gem to your Rails application's Gemfile: 20 | 21 | ```ruby 22 | group :development do 23 | gem "dial" 24 | end 25 | ``` 26 | 27 | 2. Install the gem: 28 | 29 | ```bash 30 | bundle install 31 | ``` 32 | 33 | 3. Mount the engine in your `config/routes.rb` file: 34 | 35 | ```ruby 36 | # this will mount the engine at /dial 37 | mount Dial::Engine, at: "/" if Rails.env.development? 38 | ``` 39 | 40 | 4. (Optional) Configure the gem in an initializer: 41 | 42 | ```ruby 43 | # config/initializers/dial.rb 44 | 45 | Dial.configure do |config| 46 | config.vernier_interval = 100 47 | config.vernier_allocation_interval = 10_000 48 | config.prosopite_ignore_queries += [/pg_sleep/i] 49 | end 50 | ``` 51 | 52 | ## Options 53 | 54 | Option | Description | Default 55 | :- | :- | :- 56 | `vernier_interval` | Sets the `interval` option for vernier. | `200` 57 | `vernier_allocation_interval` | Sets the `allocation_interval` option for vernier. | `2_000` 58 | `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 | 61 | ## Comparison with [rack-mini-profiler](https://github.com/MiniProfiler/rack-mini-profiler) 62 | 63 | | | rack-mini-profiler | Dial | 64 | | :------------------------ | :--------------------------------- | :------------------------------------------------------ | 65 | | Compatibility | Any Rack application | Only Rails applications | 66 | | Database Profiling | Yes | Yes (via vernier hook - marker table, chart) | 67 | | N+1 Query Detection | Yes (*needs to be inferred) | Yes (via prosopite) | 68 | | Ruby Profiling | Yes (with stackprof - flame graph) | Yes (via vernier - flame graph, stack chart, call tree) | 69 | | Ruby Allocation Profiling | Yes (with stackprof - flame graph) | Yes (via vernier - flame graph, stack chart, call tree) | 70 | | Memory Profiling | Yes (with memory_profiler) | Yes (*overall usage only) (via vernier hook - graph) | 71 | | View Profiling | Yes | Yes (via vernier hook - marker table, chart) | 72 | | Snapshot Sampling | Yes | No | 73 | | Production Support | Yes | No | 74 | 75 | > [!NOTE] 76 | > SQL queries displayed in the profile are not annotated with the caller location by default. If you're not using the 77 | > [marginalia](https://github.com/basecamp/marginalia) gem to annotate your queries, you will need to extend your 78 | > application's [ActiveRecord QueryLogs](https://edgeapi.rubyonrails.org/classes/ActiveRecord/QueryLogs.html) yourself. 79 | 80 | ## Development 81 | 82 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake test` to run the 83 | tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 84 | 85 | ## Contributing 86 | 87 | Bug reports and pull requests are welcome on GitHub at https://github.com/joshuay03/dial. 88 | 89 | ## License 90 | 91 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 92 | 93 | ## Code of Conduct 94 | 95 | Everyone interacting in the Dial project's codebase and issue tracker is expected to follow the 96 | [code of conduct](https://github.com/joshuay03/dial/blob/main/CODE_OF_CONDUCT.md). 97 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "minitest/test_task" 5 | 6 | Minitest::TestTask.create 7 | 8 | task default: %i[test] 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "dial" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # frozen_string_literal: true 3 | 4 | set -euo pipefail 5 | IFS=$'\n\t' 6 | set -vx 7 | 8 | bundle install 9 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuay03/dial/0006e6b3a4b859b62286b5e3959810547e3bb603/demo.gif -------------------------------------------------------------------------------- /dial.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/dial/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "dial" 7 | spec.version = Dial::VERSION 8 | spec.authors = ["Joshua Young"] 9 | spec.email = ["djry1999@gmail.com"] 10 | 11 | spec.summary = "A modern profiler for your Rails application" 12 | spec.homepage = "https://github.com/joshuay03/dial" 13 | spec.license = "MIT" 14 | spec.required_ruby_version = ">= 3.3.0" 15 | 16 | spec.metadata["source_code_uri"] = spec.homepage 17 | spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" 18 | 19 | spec.files = Dir["{lib}/**/*", "**/*.{gemspec,md,txt}"] 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_dependency "railties", ">= 7.1" 23 | spec.add_dependency "activerecord", ">= 7.1" 24 | spec.add_dependency "actionpack", ">= 7.1" 25 | spec.add_dependency "vernier" 26 | spec.add_dependency "prosopite" 27 | spec.add_dependency "pg_query" 28 | end 29 | -------------------------------------------------------------------------------- /lib/dial.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "dial/constants" 4 | require_relative "dial/util" 5 | 6 | require_relative "dial/configuration" 7 | 8 | require_relative "dial/railtie" 9 | require_relative "dial/engine" 10 | -------------------------------------------------------------------------------- /lib/dial/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dial 4 | def self.configure 5 | yield _configuration 6 | end 7 | 8 | def self._configuration 9 | @_configuration ||= Configuration.new 10 | end 11 | 12 | class Configuration 13 | def initialize 14 | @options = { 15 | vernier_interval: VERNIER_INTERVAL, 16 | vernier_allocation_interval: VERNIER_ALLOCATION_INTERVAL, 17 | prosopite_ignore_queries: PROSOPITE_IGNORE_QUERIES, 18 | content_security_policy_nonce: -> (env, _headers) { env[NONCE] || "" }, 19 | } 20 | 21 | @options.keys.each do |key| 22 | define_singleton_method key do 23 | @options[key] 24 | end 25 | 26 | define_singleton_method "#{key}=" do |value| 27 | @options[key] = value 28 | end 29 | end 30 | end 31 | 32 | def freeze 33 | @options.freeze 34 | 35 | super 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/dial/constants.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack" 4 | require "action_dispatch" 5 | require "stringio" 6 | 7 | require_relative "version" 8 | 9 | module Dial 10 | PROGRAM_ID = Process.getsid Process.pid 11 | 12 | HTTP_ACCEPT = "HTTP_ACCEPT" 13 | CONTENT_TYPE = ::Rack::CONTENT_TYPE 14 | CONTENT_TYPE_HTML = "text/html" 15 | CONTENT_LENGTH = ::Rack::CONTENT_LENGTH 16 | NONCE = ::ActionDispatch::ContentSecurityPolicy::Request::NONCE 17 | REQUEST_TIMING = "dial_request_timing" 18 | 19 | FILE_STALE_SECONDS = 60 * 60 20 | 21 | VERNIER_INTERVAL = 200 22 | VERNIER_ALLOCATION_INTERVAL = 2_000 23 | VERNIER_PROFILE_OUT_RELATIVE_DIRNAME = "tmp/dial/profiles" 24 | VERNIER_PROFILE_OUT_FILE_EXTENSION = ".json.gz" 25 | VERNIER_VIEWER_URL = "https://vernier.prof" 26 | 27 | PROSOPITE_IGNORE_QUERIES = [/schema_migrations/i].freeze 28 | PROSOPITE_LOG_IO = StringIO.new 29 | end 30 | -------------------------------------------------------------------------------- /lib/dial/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails" 4 | 5 | module Dial 6 | class Engine < ::Rails::Engine 7 | isolate_namespace Dial 8 | 9 | paths["config/routes.rb"] = ["lib/dial/engine/routes.rb"] 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/dial/engine/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Dial::Engine.routes.draw do 4 | scope path: "/dial", as: "dial" do 5 | get "profile", to: lambda { |env| 6 | uuid = env[::Rack::QUERY_STRING].sub "uuid=", "" 7 | path = String ::Rails.root.join Dial::VERNIER_PROFILE_OUT_RELATIVE_DIRNAME, (uuid + VERNIER_PROFILE_OUT_FILE_EXTENSION) 8 | 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)] 14 | ] 15 | else 16 | [ 17 | 404, 18 | { "Content-Type" => "text/plain" }, 19 | ["Not Found"] 20 | ] 21 | end 22 | } 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/dial/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "vernier" 4 | require "prosopite" 5 | 6 | require_relative "prosopite" 7 | require_relative "middleware/panel" 8 | require_relative "middleware/ruby_stat" 9 | require_relative "middleware/rails_stat" 10 | 11 | module Dial 12 | class Middleware 13 | include RubyStat 14 | include RailsStat 15 | 16 | def initialize app 17 | @app = app 18 | end 19 | 20 | def call env 21 | unless env[HTTP_ACCEPT]&.include? CONTENT_TYPE_HTML 22 | return @app.call env 23 | end 24 | 25 | start_time = Process.clock_gettime Process::CLOCK_MONOTONIC 26 | 27 | profile_out_filename = "#{Util.uuid}_vernier" + VERNIER_PROFILE_OUT_FILE_EXTENSION 28 | profile_out_pathname = "#{profile_out_dir_pathname}/#{profile_out_filename}" 29 | 30 | status, headers, rack_body, ruby_vm_stat, gc_stat, gc_stat_heap, vernier_result = nil 31 | ::Prosopite.scan do 32 | vernier_result = ::Vernier.profile interval: Dial._configuration.vernier_interval, \ 33 | allocation_interval: Dial._configuration.vernier_allocation_interval, \ 34 | hooks: [:memory_usage, :rails] do 35 | ruby_vm_stat, gc_stat, gc_stat_heap = with_diffed_ruby_stats do 36 | status, headers, rack_body = @app.call env 37 | end 38 | end 39 | end 40 | server_timing = server_timing headers 41 | 42 | unless headers[CONTENT_TYPE]&.include? CONTENT_TYPE_HTML 43 | return [status, headers, rack_body] 44 | end 45 | 46 | write_vernier_result! vernier_result, profile_out_pathname 47 | query_logs = clear_query_logs! 48 | 49 | finish_time = Process.clock_gettime Process::CLOCK_MONOTONIC 50 | env[REQUEST_TIMING] = ((finish_time - start_time) * 1_000).round 2 51 | 52 | body = String.new.tap do |str| 53 | rack_body.each { |chunk| str << chunk } 54 | rack_body.close if rack_body.respond_to? :close 55 | 56 | str.sub! "
53 |", <<~HTML 57 | #{Panel.html env, headers, profile_out_filename, query_logs, ruby_vm_stat, gc_stat, gc_stat_heap, server_timing} 58 | 59 | HTML 60 | end 61 | 62 | headers[CONTENT_LENGTH] = body.bytesize.to_s 63 | 64 | [status, headers, [body]] 65 | end 66 | 67 | private 68 | 69 | def with_diffed_ruby_stats 70 | ruby_vm_stat_before = RubyVM.stat 71 | gc_stat_before = GC.stat 72 | gc_stat_heap_before = GC.stat_heap 73 | yield 74 | [ 75 | ruby_vm_stat_diff(ruby_vm_stat_before, RubyVM.stat), 76 | gc_stat_diff(gc_stat_before, GC.stat), 77 | gc_stat_heap_diff(gc_stat_heap_before, GC.stat_heap) 78 | ] 79 | end 80 | 81 | def write_vernier_result! result, pathname 82 | Thread.new do 83 | Thread.current.name = "Dial::Middleware#write_vernier_result!" 84 | Thread.current.report_on_exception = false 85 | 86 | result.write out: pathname 87 | end 88 | end 89 | 90 | def clear_query_logs! 91 | [].tap do |query_logs| 92 | entry = section = count = nil 93 | PROSOPITE_LOG_IO.string.lines.each do |line| 94 | entry, section, count = process_query_log_line line, entry, section, count 95 | query_logs << entry if entry && section.nil? 96 | end 97 | 98 | PROSOPITE_LOG_IO.truncate 0 99 | PROSOPITE_LOG_IO.rewind 100 | end 101 | end 102 | 103 | def process_query_log_line line, entry, section, count 104 | case line 105 | when /N\+1 queries detected/ 106 | [[[],[]], :queries, 0] 107 | when /Call stack/ 108 | entry.first << "+ #{count - 1} more queries" if count > 1 109 | [entry, :call_stack, count] 110 | else 111 | case section 112 | when :queries 113 | count += 1 114 | entry.first << line.strip if count == 1 115 | [entry, :queries, count] 116 | when :call_stack 117 | if line.strip.empty? 118 | [entry, nil, count] 119 | else 120 | entry.last << line.strip 121 | [entry, section, count] 122 | end 123 | end 124 | end 125 | end 126 | 127 | def profile_out_dir_pathname 128 | ::Rails.root.join VERNIER_PROFILE_OUT_RELATIVE_DIRNAME 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/dial/middleware/panel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "uri" 4 | 5 | module Dial 6 | class Panel 7 | QUERY_CHARS_TRUNCATION_THRESHOLD = 100 8 | 9 | class << self 10 | def html env, headers, profile_out_filename, query_logs, ruby_vm_stat, gc_stat, gc_stat_heap, server_timing 11 | <<~HTML 12 | 13 | 14 |
73 |
74 |
77 | HTML
78 | end
79 |
80 | private
81 |
82 | def style
83 | <<~CSS
84 | #dial {
85 | all: initial;
86 | max-height: 50%;
87 | max-width: 50%;
88 | z-index: 9999;
89 | position: fixed;
90 | bottom: 0;
91 | right: 0;
92 | background-color: white;
93 | border-top-left-radius: 1rem;
94 | box-shadow: -0.2rem -0.2rem 0.4rem rgba(0, 0, 0, 0.5);
95 | display: flex;
96 | flex-direction: column;
97 | padding: 0.5rem;
98 | font-size: 0.85rem;
99 |
100 | #dial-preview {
101 | display: flex;
102 | flex-direction: column;
103 | cursor: pointer;
104 | }
105 |
106 | #dial-details {
107 | display: none;
108 | overflow-y: auto;
109 | }
110 |
111 | .section {
112 | display: flex;
113 | flex-direction: column;
114 | margin: 0.25rem 0 0 0;
115 | }
116 |
117 | .query-logs {
118 | padding-left: 0.75rem;
119 |
120 | details {
121 | margin-top: 0;
122 | margin-bottom: 0.25rem;
123 | }
124 | }
125 |
126 | span {
127 | text-align: left;
128 | color: black;
129 | }
130 |
131 | a {
132 | color: blue;
133 | }
134 |
135 | hr {
136 | width: -moz-available;
137 | margin: 0.65rem 0 0 0;
138 | border-color: black;
139 | }
140 |
141 | details {
142 | margin: 0.5rem 0 0 0;
143 | text-align: left;
144 | }
145 |
146 | summary {
147 | margin: 0.25rem 0 0 0;
148 | cursor: pointer;
149 | color: black;
150 | }
151 | }
152 | CSS
153 | end
154 |
155 | def script
156 | <<~JS
157 | var dialPreview = document.getElementById("dial-preview");
158 | var dialDetails = document.getElementById("dial-details");
159 |
160 | dialPreview.addEventListener("click", () => {
161 | var isCollapsed = ["", "none"].includes(dialDetails.style.display);
162 | dialDetails.style.display = isCollapsed ? "block" : "none";
163 | });
164 |
165 | document.addEventListener("click", (event) => {
166 | if (!dialPreview.contains(event.target) && !dialDetails.contains(event.target)) {
167 | dialDetails.style.display = "none";
168 |
169 | var detailsElements = dialDetails.querySelectorAll("details");
170 | detailsElements.forEach(detail => {
171 | detail.removeAttribute("open");
172 | });
173 | }
174 | });
175 | JS
176 | end
177 |
178 | def formatted_rails_route_info env
179 | begin
180 | ::Rails.application.routes.recognize_path env[::Rack::PATH_INFO], method: env[::Rack::REQUEST_METHOD]
181 | rescue ::ActionController::RoutingError
182 | {}
183 | end.then do |info|
184 | "Controller: #{info[:controller] || "N/A"} | Action: #{info[:action] || "N/A"}"
185 | end
186 | end
187 |
188 | def formatted_request_timing env
189 | "Request timing: #{env[REQUEST_TIMING]}ms"
190 | end
191 |
192 | def formatted_profile_output env, profile_out_filename
193 | url_base = ::Rails.application.routes.url_helpers.dial_url host: env[::Rack::HTTP_HOST]
194 | prefix = "/" unless url_base.end_with? "/"
195 | uuid = profile_out_filename.delete_suffix VERNIER_PROFILE_OUT_FILE_EXTENSION
196 | profile_out_url = URI.encode_www_form_component url_base + "#{prefix}dial/profile?uuid=#{uuid}"
197 |
198 | "View profile"
199 | end
200 |
201 | def formatted_rails_version
202 | "Rails version: #{::Rails::VERSION::STRING}"
203 | end
204 |
205 | def formatted_rack_version
206 | "Rack version: #{::Rack.release}"
207 | end
208 |
209 | def formatted_ruby_version
210 | "Ruby version: #{::RUBY_DESCRIPTION}"
211 | end
212 |
213 | def formatted_server_timing server_timing
214 | if server_timing.any?
215 | server_timing
216 | .sort_by { |_, timing| -timing }
217 | .map { |event, timing| "#{event}: #{timing}" }.join
218 | else
219 | "N/A"
220 | end
221 | end
222 |
223 | def formatted_query_logs query_logs
224 | if query_logs.any?
225 | query_logs.map do |(queries, stack_lines)|
226 | <<~HTML
227 | #{truncated_query queries.first}
229 |
264 | HTML 265 | end.join 266 | end 267 | 268 | def configured_nonce env, headers 269 | config_nonce = Dial._configuration.content_security_policy_nonce 270 | if config_nonce.instance_of? Proc 271 | config_nonce.call env, headers 272 | else 273 | config_nonce 274 | end 275 | end 276 | end 277 | end 278 | end 279 | -------------------------------------------------------------------------------- /lib/dial/middleware/rails_stat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dial 4 | module RailsStat 5 | private 6 | 7 | def server_timing headers 8 | timing = if ::ActionDispatch.const_defined? "Constants::SERVER_TIMING" 9 | headers[::ActionDispatch::Constants::SERVER_TIMING] 10 | else 11 | headers["Server-Timing"] 12 | end 13 | (timing || "").split(", ").to_h do |pair| 14 | event, duration = pair.split ";dur=" 15 | [event, duration.to_f] 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/dial/middleware/ruby_stat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dial 4 | module RubyStat 5 | private 6 | 7 | def ruby_vm_stat_diff before, after 8 | stat_diff before, after, no_diff: [:next_shape_id] 9 | end 10 | 11 | def gc_stat_diff before, after 12 | stat_diff before, after, no_diff: [ 13 | :heap_allocatable_slots, 14 | :malloc_increase_bytes_limit, 15 | :remembered_wb_unprotected_objects_limit, 16 | :old_objects_limit, 17 | :oldmalloc_increase_bytes_limit 18 | ] 19 | end 20 | 21 | def gc_stat_heap_diff before, after 22 | after.each_with_object({}) do |(slot_index, slot_stat), diff| 23 | diff[slot_index] = stat_diff before[slot_index], slot_stat, no_diff: [:slot_size] 24 | end 25 | end 26 | 27 | def stat_diff before, after, no_diff: [] 28 | after.except(*no_diff).each_with_object({}) do |(key, value), diff| 29 | diff[key] = value - before[key] 30 | end.merge after.slice(*no_diff) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/dial/prosopite.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/string/filters" 4 | 5 | module Dial 6 | module Prosopite 7 | def send_notifications 8 | tc[:prosopite_notifications] = tc[:prosopite_notifications].to_h do |queries, kaller| 9 | [queries.map { |query| query.squish }, kaller] 10 | end 11 | 12 | super 13 | end 14 | end 15 | end 16 | 17 | module ::Prosopite 18 | class << self 19 | prepend Dial::Prosopite 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/dial/prosopite_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | 5 | module Dial 6 | class ProsopiteLogger < Logger 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/dial/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails" 4 | require "active_record" 5 | require "prosopite" 6 | 7 | require_relative "middleware" 8 | require_relative "prosopite_logger" 9 | 10 | module Dial 11 | class Railtie < ::Rails::Railtie 12 | initializer "dial.setup", after: :load_config_initializers do |app| 13 | # use middleware 14 | app.middleware.insert_before 0, Middleware 15 | 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| 18 | File.delete profile_out_file rescue nil 19 | end 20 | 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 26 | if ::ActiveRecord::Base.configurations.configurations.any? { |config| config.adapter == "postgresql" } 27 | require "pg_query" 28 | end 29 | ::Prosopite.custom_logger = ProsopiteLogger.new PROSOPITE_LOG_IO 30 | 31 | # finalize configuration 32 | Dial._configuration.freeze 33 | ::Prosopite.ignore_queries = Dial._configuration.prosopite_ignore_queries 34 | end 35 | end 36 | 37 | private 38 | 39 | def stale_files glob_pattern 40 | Dir.glob(glob_pattern).select do |file| 41 | timestamp = Util.uuid_timestamp Util.file_name_uuid File.basename file 42 | timestamp < Time.now - FILE_STALE_SECONDS 43 | end 44 | end 45 | 46 | def profile_out_dir_pathname 47 | ::Rails.root.join VERNIER_PROFILE_OUT_RELATIVE_DIRNAME 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/dial/util.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | 5 | module Dial 6 | module Util 7 | class << self 8 | def uuid 9 | SecureRandom.uuid_v7 10 | end 11 | 12 | def file_name_uuid file_name 13 | file_name.split("_").first 14 | end 15 | 16 | def uuid_timestamp uuid 17 | high_bits_hex = uuid.split("-").first(2).join[0, 12].to_i 16 18 | Time.at high_bits_hex / 1000.0 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/dial/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dial 4 | VERSION = "0.3.2" 5 | end 6 | -------------------------------------------------------------------------------- /test/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/test" 4 | 5 | require "active_record/railtie" 6 | require "action_controller/railtie" 7 | 8 | ENV["DATABASE_URL"] = "sqlite3::memory:" 9 | 10 | class DialApp < Rails::Application 11 | config.eager_load = false 12 | config.logger = Logger.new nil 13 | config.secret_key_base = "secret_key_base" 14 | config.hosts << "example.org" 15 | config.server_timing = true 16 | 17 | config.active_support.deprecation = :silence 18 | def initialize! 19 | verbose = $VERBOSE 20 | $VERBOSE = nil 21 | 22 | super 23 | ensure 24 | $VERBOSE = verbose 25 | end 26 | end 27 | 28 | class Gauge < ActiveRecord::Base 29 | has_one :indicator 30 | end 31 | 32 | class Indicator < ActiveRecord::Base 33 | belongs_to :gauge 34 | 35 | default_scope { annotate <<~ANNOTATION } 36 | Long annotation so the query exceeds the maximum length for presentation 37 | and contains newlines 38 | ANNOTATION 39 | end 40 | 41 | class DialsController < ActionController::Base 42 | def dial 43 | Gauge.all.each do |gauge| 44 | gauge.reload.indicator # N+1 45 | end 46 | 47 | render plain: <<-HTML, content_type: "text/html" 48 | 49 |
50 |
51 | 52 |
55 |