├── .github └── workflows │ ├── build.yml │ ├── push_gem.yml │ └── standardrb.yaml ├── .gitignore ├── .rspec ├── .standard.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── console ├── rspec └── setup ├── exe └── singed ├── lib ├── singed.rb └── singed │ ├── backtrace_cleaner_ext.rb │ ├── cli.rb │ ├── controller_ext.rb │ ├── flamegraph.rb │ ├── kernel_ext.rb │ ├── rack_middleware.rb │ ├── railtie.rb │ ├── report.rb │ └── rspec.rb ├── singed.gemspec └── spec ├── singed └── kernel_ext_spec.rb └── spec_helper.rb /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI Test 2 | on: [ push ] 3 | jobs: 4 | build: 5 | name: Ruby ${{ matrix.ruby }} 6 | runs-on: ubuntu-latest 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | include: 11 | - ruby: 3.2 12 | bundler_version: 2.4.4 13 | - ruby: 3.1 14 | bundler_version: 2.4.4 15 | - ruby: 3.0 16 | bundler_version: 2.4.4 17 | env: 18 | CI: 1 19 | BUNDLER_VERSION: ${{ matrix.bundler_version }} 20 | USE_OFFICIAL_GEM_SOURCE: 1 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | - name: Install bundler 27 | run: gem install bundler -v $BUNDLER_VERSION 28 | - name: Install dependencies 29 | run: bundle install 30 | - run: bundle exec rspec 31 | -------------------------------------------------------------------------------- /.github/workflows/push_gem.yml: -------------------------------------------------------------------------------- 1 | name: Push Gem 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | push: 11 | if: github.repository == 'rubyatscale/singed' 12 | runs-on: ubuntu-latest 13 | 14 | environment: 15 | name: rubygems.org 16 | url: https://rubygems.org/gems/singed 17 | 18 | permissions: 19 | contents: write 20 | id-token: write 21 | 22 | steps: 23 | # Set up 24 | - name: Harden Runner 25 | uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 26 | with: 27 | egress-policy: audit 28 | 29 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 30 | - name: Set up Ruby 31 | uses: ruby/setup-ruby@2e007403fc1ec238429ecaa57af6f22f019cc135 # v1.234.0 32 | with: 33 | bundler-cache: true 34 | ruby-version: ruby 35 | 36 | # Release 37 | - uses: rubygems/release-gem@9e85cb11501bebc2ae661c1500176316d3987059 # v1 38 | -------------------------------------------------------------------------------- /.github/workflows/standardrb.yaml: -------------------------------------------------------------------------------- 1 | name: StandardRB 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: StandardRB Linter 11 | uses: standardrb/standard-ruby-action@v0.0.5 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /vendor/bundle 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | fix: true 2 | format: progress 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in singed.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | gem "standard" 10 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | singed (0.3.0) 5 | stackprof (>= 0.2.13) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | ast (2.4.2) 11 | diff-lcs (1.5.0) 12 | json (2.7.2) 13 | language_server-protocol (3.17.0.3) 14 | lint_roller (1.1.0) 15 | parallel (1.24.0) 16 | parser (3.3.1.0) 17 | ast (~> 2.4.1) 18 | racc 19 | racc (1.7.3) 20 | rainbow (3.1.1) 21 | rake (13.0.6) 22 | regexp_parser (2.9.0) 23 | rexml (3.2.6) 24 | rspec (3.12.0) 25 | rspec-core (~> 3.12.0) 26 | rspec-expectations (~> 3.12.0) 27 | rspec-mocks (~> 3.12.0) 28 | rspec-core (3.12.1) 29 | rspec-support (~> 3.12.0) 30 | rspec-expectations (3.12.2) 31 | diff-lcs (>= 1.2.0, < 2.0) 32 | rspec-support (~> 3.12.0) 33 | rspec-mocks (3.12.4) 34 | diff-lcs (>= 1.2.0, < 2.0) 35 | rspec-support (~> 3.12.0) 36 | rspec-support (3.12.0) 37 | rubocop (1.62.1) 38 | json (~> 2.3) 39 | language_server-protocol (>= 3.17.0) 40 | parallel (~> 1.10) 41 | parser (>= 3.3.0.2) 42 | rainbow (>= 2.2.2, < 4.0) 43 | regexp_parser (>= 1.8, < 3.0) 44 | rexml (>= 3.2.5, < 4.0) 45 | rubocop-ast (>= 1.31.1, < 2.0) 46 | ruby-progressbar (~> 1.7) 47 | unicode-display_width (>= 2.4.0, < 3.0) 48 | rubocop-ast (1.31.3) 49 | parser (>= 3.3.1.0) 50 | rubocop-performance (1.20.2) 51 | rubocop (>= 1.48.1, < 2.0) 52 | rubocop-ast (>= 1.30.0, < 2.0) 53 | ruby-progressbar (1.13.0) 54 | stackprof (0.2.17) 55 | standard (1.35.1) 56 | language_server-protocol (~> 3.17.0.2) 57 | lint_roller (~> 1.0) 58 | rubocop (~> 1.62.0) 59 | standard-custom (~> 1.0.0) 60 | standard-performance (~> 1.3) 61 | standard-custom (1.0.2) 62 | lint_roller (~> 1.0) 63 | rubocop (~> 1.50) 64 | standard-performance (1.3.1) 65 | lint_roller (~> 1.1) 66 | rubocop-performance (~> 1.20.2) 67 | unicode-display_width (2.5.0) 68 | 69 | PLATFORMS 70 | ruby 71 | 72 | DEPENDENCIES 73 | rake (~> 13.0) 74 | rspec 75 | singed! 76 | standard 77 | 78 | BUNDLED WITH 79 | 2.6.9 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Gusto 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Singed 2 | 3 | Singed makes it easy to get a flamegraph anywhere in your code base. It wraps profiling your code with [stackprof](https://github.com/tmm1/stackprof) or [rbspy](https://github.com/rbspy/rbspy), and then launching [speedscope](https://github.com/jlfwong/speedscope) to view it. 4 | 5 | ## Installation 6 | 7 | Add to `Gemfile`: 8 | 9 | ```ruby 10 | gem "singed" 11 | ``` 12 | 13 | Then run `bundle install` 14 | 15 | Then run `npm install -g speedscope` 16 | 17 | ## Usage 18 | 19 | Simplest is calling with a block: 20 | 21 | ```ruby 22 | flamegraph { 23 | # your code here 24 | } 25 | ``` 26 | 27 | Flamegraphs are saved for later review to `Singed.output_directory`, which is `tmp/speedscope` on Rails. You can adjust this like: 28 | 29 | ```ruby 30 | Singed.output_directory = "tmp/slowness-exploration" 31 | ``` 32 | 33 | ### Blockage 34 | If you are calling it in a loop, or with different variations, you can include a label on the filename: 35 | 36 | ```ruby 37 | flamegraph("rspec") { 38 | # your code here 39 | } 40 | ``` 41 | 42 | You can also skip opening speedscope automatically: 43 | 44 | ```ruby 45 | flamegraph(open: false) { 46 | # your code here 47 | } 48 | ``` 49 | 50 | ### RSpec 51 | 52 | If you are using RSpec, you can use the `flamegraph` metadata to capture it for you. 53 | 54 | ```ruby 55 | # make sure this is required at somepoint, like in a spec/support file! 56 | require 'singed/rspec' 57 | 58 | RSpec.describe YourClass do 59 | it "is slow :(", flamegraph: true do 60 | # your code here 61 | end 62 | end 63 | ``` 64 | 65 | ### Controllers 66 | 67 | If you want to capture a flamegraph of a controller action, you can call it like: 68 | 69 | ```ruby 70 | class EmployeesController < ApplicationController 71 | flamegraph :show 72 | 73 | def show 74 | # your code here 75 | end 76 | end 77 | ``` 78 | 79 | This won't catch the entire request though, just once it's been routed to controller and a response has been served (ie no middleware). 80 | 81 | ### Rack/Rails requests 82 | 83 | To capture the whole request, there is a middleware which checks for the `X-Singed` header to be 'true'. With curl, you can do this like: 84 | 85 | ```shell 86 | curl -H 'X-Singed: true' https://localhost:3000 87 | ``` 88 | 89 | PROTIP: use Chrome Developer Tools to record network activity, and copy requests as a curl command. Add `-H 'X-Singed: true'` to it, and you get flamegraphs! 90 | 91 | This can also be enabled to always run by setting `SINGED_MIDDLEWARE_ALWAYS_CAPTURE=1` in the environment. 92 | 93 | ### Command Line 94 | 95 | There is a `singed` command line you can use that will record a flamegraph from the entirety of a command run: 96 | 97 | ```shell 98 | $ bundle binstub singed # if you want to be able to call it like bin/singed 99 | $ bundle exec singed -- bin/rails runner 'Model.all.to_a' 100 | ``` 101 | 102 | The flamegraph is opened afterwards. 103 | 104 | 105 | ## Limitations 106 | 107 | When using the auto-opening feature, it's assumed that you are have a browser available on the same host you are profiling code. 108 | 109 | The `open` is expected to be available. 110 | 111 | ## Alternatives 112 | 113 | - using [rbspy](https://rbspy.github.io/) directly 114 | - using [stackprof](https://github.com/tmm1/stackprof) (a dependency of singed) directly 115 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | task default: %i[] 5 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "singed" 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 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rspec-core", "rspec") 28 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /exe/singed: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "singed/cli" 4 | if Singed::CLI.chdir_rails_root 5 | require "./config/environment" 6 | end 7 | 8 | Singed::CLI.new(ARGV).run 9 | -------------------------------------------------------------------------------- /lib/singed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | require "stackprof" 5 | 6 | module Singed 7 | extend self 8 | 9 | # Where should flamegraphs be saved? 10 | def output_directory=(directory) 11 | @output_directory = Pathname.new(directory) 12 | end 13 | 14 | def self.output_directory 15 | @output_directory 16 | end 17 | 18 | def enabled=(enabled) 19 | @enabled = enabled 20 | end 21 | 22 | def enabled? 23 | return @enabled if defined?(@enabled) 24 | 25 | @enabled = true 26 | end 27 | 28 | def backtrace_cleaner=(backtrace_cleaner) 29 | @backtrace_cleaner = backtrace_cleaner 30 | end 31 | 32 | def backtrace_cleaner 33 | @backtrace_cleaner 34 | end 35 | 36 | def silence_line?(line) 37 | return backtrace_cleaner.silence_line?(line) if backtrace_cleaner 38 | 39 | false 40 | end 41 | 42 | def filter_line(line) 43 | return backtrace_cleaner.filter_line(line) if backtrace_cleaner 44 | 45 | line 46 | end 47 | 48 | autoload :Flamegraph, "singed/flamegraph" 49 | autoload :Report, "singed/report" 50 | autoload :RackMiddleware, "singed/rack_middleware" 51 | end 52 | 53 | require "singed/kernel_ext" 54 | require "singed/railtie" if defined?(Rails::Railtie) 55 | require "singed/rspec" if defined?(RSpec) && RSpec.respond_to?(:configure) 56 | -------------------------------------------------------------------------------- /lib/singed/backtrace_cleaner_ext.rb: -------------------------------------------------------------------------------- 1 | module ActiveSupport 2 | class BacktraceCleaner 3 | def filter_line(line) 4 | filtered_line = line 5 | @filters.each do |f| 6 | filtered_line = f.call(filtered_line) 7 | end 8 | 9 | filtered_line 10 | end 11 | 12 | def silence_line?(line) 13 | @silencers.any? { |s| s.call(line) } 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/singed/cli.rb: -------------------------------------------------------------------------------- 1 | require "shellwords" 2 | require "tmpdir" 3 | require "optionparser" 4 | require "pathname" 5 | 6 | # NOTE: we defer requiring singed until we run. that lets Rails load it if its in the gemfile, so the railtie has had a chance to run 7 | 8 | module Singed 9 | class CLI 10 | attr_accessor :argv, :filename, :opts 11 | 12 | def initialize(argv) 13 | @argv = argv 14 | @opts = OptionParser.new 15 | 16 | parse_argv! 17 | end 18 | 19 | def parse_argv! 20 | opts.banner = "Usage: singed [options] " 21 | 22 | opts.on("-h", "--help", "Show this message") do 23 | @show_help = true 24 | end 25 | 26 | opts.on("-o", "--output-directory DIRECTORY", "Directory to write flamegraph to") do |directory| 27 | @output_directory = directory 28 | end 29 | 30 | opts.order(@argv) do |arg| 31 | opts.terminate if arg == "--" 32 | break 33 | end 34 | 35 | if @argv.empty? 36 | @show_help = true 37 | @error_message = "missing command to profile" 38 | return 39 | end 40 | 41 | return if @show_help 42 | 43 | begin 44 | @opts.parse!(argv) 45 | rescue OptionParser::InvalidOption => e 46 | @show_help = true 47 | @error_message = e 48 | end 49 | end 50 | 51 | def run 52 | require "singed" 53 | 54 | if @error_message 55 | puts @error_message 56 | puts 57 | puts @opts.help 58 | exit 1 59 | end 60 | 61 | if show_help? 62 | puts @opts.help 63 | exit 0 64 | end 65 | 66 | Singed.output_directory = @output_directory if @output_directory 67 | Singed.output_directory ||= Dir.tmpdir 68 | FileUtils.mkdir_p Singed.output_directory 69 | @filename = Singed::Flamegraph.generate_filename(label: "cli") 70 | 71 | options = { 72 | format: "speedscope", 73 | file: filename.to_s, 74 | silent: nil 75 | } 76 | 77 | rbspy_args = [ 78 | "record", 79 | *options.map { |k, v| ["--#{k}", v].compact }.flatten, 80 | "--", 81 | *argv 82 | ] 83 | 84 | loop do 85 | break unless password_needed? 86 | 87 | puts "🔥📈 Singed needs to run as root, but will drop permissions back to your user. Prompting with sudo now..." 88 | prompt_password 89 | end 90 | 91 | rbspy = lambda do 92 | # don't run things with spring, because it forks and rbspy won't see it 93 | sudo ["rbspy", *rbspy_args], reason: "Singed needs to run as root, but will drop permissions back to your user.", env: {"DISABLE_SPRING" => "1"} 94 | end 95 | 96 | if defined?(Bundler) 97 | Bundler.with_unbundled_env do 98 | rbspy.call 99 | end 100 | else 101 | rbspy.call 102 | end 103 | 104 | unless filename.exist? 105 | puts "#{filename} doesn't exist. Maybe rbspy had a failure capturing it? Check the scrollback." 106 | exit 1 107 | end 108 | 109 | unless adjust_ownership! 110 | puts "#{filename} isn't writable!" 111 | exit 1 112 | end 113 | 114 | # clean the report, similar to how Singed::Report does 115 | json = JSON.parse(filename.read) 116 | json["shared"]["frames"].each do |frame| 117 | frame["file"] = Singed.filter_line(frame["file"]) 118 | end 119 | filename.write(JSON.dump(json)) 120 | 121 | flamegraph = Singed::Flamegraph.new(filename: filename) 122 | flamegraph.open 123 | end 124 | 125 | def password_needed? 126 | !system("sudo --non-interactive true >/dev/null 2>&1") 127 | end 128 | 129 | def prompt_password 130 | system("sudo true") 131 | end 132 | 133 | def adjust_ownership! 134 | sudo ["chown", ENV["USER"], filename], reason: "Adjusting ownership of #{filename}, but need root." 135 | end 136 | 137 | def show_help? 138 | @show_help 139 | end 140 | 141 | def sudo(system_args, reason:, env: {}) 142 | loop do 143 | break unless password_needed? 144 | 145 | puts "🔥📈 #{reason} Prompting with sudo now..." 146 | prompt_password 147 | end 148 | 149 | sudo_args = [ 150 | "sudo", 151 | "--preserve-env", 152 | *system_args.map(&:to_s) 153 | ] 154 | 155 | puts "$ #{Shellwords.join(sudo_args)}" 156 | 157 | system(env, *sudo_args, exception: true) 158 | end 159 | 160 | def self.chdir_rails_root 161 | original_cwd = Dir.pwd 162 | 163 | loop do 164 | if File.file?("config/environment.rb") 165 | return Dir.pwd 166 | end 167 | 168 | if Pathname.new(Dir.pwd).root? 169 | Dir.chdir(original_cwd) 170 | return 171 | end 172 | 173 | # Otherwise keep moving upwards in search of an executable. 174 | Dir.chdir("..") 175 | end 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/singed/controller_ext.rb: -------------------------------------------------------------------------------- 1 | module Singed 2 | module ControllerExt 3 | def self.included(base) 4 | base.extend(ClassMethods) 5 | end 6 | 7 | module ClassMethods 8 | # Define an around_action to generate flamegraph for a controller action. 9 | def flamegraph(target_action, ignore_gc: false, interval: 1000) 10 | around_action(only: target_action) do |controller, action| 11 | controller.flamegraph(ignore_gc: ignore_gc, interval: interval, &action) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/singed/flamegraph.rb: -------------------------------------------------------------------------------- 1 | module Singed 2 | class Flamegraph 3 | attr_accessor :profile, :filename 4 | 5 | def initialize(label: nil, ignore_gc: false, interval: 1000, filename: nil) 6 | # it's been created elsewhere, ie rbspy 7 | if filename 8 | if ignore_gc 9 | raise ArgumentError, "ignore_gc not supported when given an existing file" 10 | end 11 | 12 | if label 13 | raise ArgumentError, "label not supported when given an existing file" 14 | end 15 | 16 | @filename = filename 17 | else 18 | @ignore_gc = ignore_gc 19 | @interval = interval 20 | @time = Time.now # rubocop:disable Rails/TimeZone 21 | @filename = self.class.generate_filename(label: label, time: @time) 22 | end 23 | end 24 | 25 | def record 26 | return yield unless Singed.enabled? 27 | return yield if filename.exist? # file existing means its been captured already 28 | 29 | result = nil 30 | @profile = StackProf.run(mode: :wall, raw: true, ignore_gc: @ignore_gc, interval: @interval) do 31 | result = yield 32 | end 33 | result 34 | end 35 | 36 | def save 37 | if filename.exist? 38 | raise ArgumentError, "File #{filename} already exists" 39 | end 40 | 41 | report = Singed::Report.new(@profile) 42 | report.filter! 43 | filename.dirname.mkpath 44 | filename.open("w") { |f| report.print_json(f) } 45 | end 46 | 47 | def open 48 | system open_command 49 | end 50 | 51 | def open_command 52 | @open_command ||= "npx speedscope #{@filename}" 53 | end 54 | 55 | def self.generate_filename(label: nil, time: Time.now) # rubocop:disable Rails/TimeZone 56 | formatted_time = time.strftime("%Y%m%d%H%M%S-%6N") 57 | basename_parts = ["speedscope", label, formatted_time].compact 58 | 59 | file = Singed.output_directory.join("#{basename_parts.join("-")}.json") 60 | # convert to relative directory if it's an absolute path and within the current 61 | pwd = Pathname.pwd 62 | file = file.relative_path_from(pwd) if file.absolute? && file.to_s.start_with?(pwd.to_s) 63 | file 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/singed/kernel_ext.rb: -------------------------------------------------------------------------------- 1 | module Kernel 2 | def flamegraph(label = nil, open: true, ignore_gc: false, interval: 1000, io: $stdout, &) 3 | fg = Singed::Flamegraph.new(label: label, ignore_gc: ignore_gc, interval: interval) 4 | result = fg.record(&) 5 | fg.save 6 | 7 | # avoid a dep on a colorizing gem by doing this ourselves 8 | bright_red = "\e[91m" 9 | none = "\e[0m" 10 | if open 11 | # use npx, so we don't have to add it as a dependency 12 | io.puts "🔥📈 #{bright_red}Captured flamegraph, opening with#{none}: #{fg.open_command}" 13 | fg.open 14 | else 15 | io.puts "🔥📈 #{bright_red}Captured flamegraph to file#{none}: #{fg.filename}" 16 | end 17 | 18 | result 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/singed/rack_middleware.rb: -------------------------------------------------------------------------------- 1 | # Rack Middleware 2 | 3 | require "rack" 4 | 5 | module Singed 6 | class RackMiddleware 7 | def initialize(app) 8 | @app = app 9 | end 10 | 11 | def call(env) 12 | status, headers, body = if capture_flamegraph?(env) 13 | flamegraph do 14 | @app.call(env) 15 | end 16 | else 17 | @app.call(env) 18 | end 19 | 20 | [status, headers, body] 21 | end 22 | 23 | def capture_flamegraph?(env) 24 | self.class.always_capture? || env["HTTP_X_SINGED"] == "true" 25 | end 26 | 27 | TRUTHY_STRINGS = ["true", "1", "yes"].freeze 28 | 29 | def self.always_capture? 30 | return @always_capture if defined?(@always_capture) 31 | 32 | @always_capture = TRUTHY_STRINGS.include?(ENV.fetch("SINGED_MIDDLEWARE_ALWAYS_CAPTURE", "false")) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/singed/railtie.rb: -------------------------------------------------------------------------------- 1 | require "singed/backtrace_cleaner_ext" 2 | require "singed/controller_ext" 3 | 4 | module Singed 5 | class Railtie < Rails::Railtie 6 | initializer "singed.configure_rails_initialization" do |app| 7 | self.class.init! 8 | 9 | app.middleware.use Singed::RackMiddleware 10 | 11 | ActiveSupport.on_load(:action_controller) do 12 | ActionController::Base.include(Singed::ControllerExt) 13 | end 14 | end 15 | 16 | def self.init! 17 | Singed.output_directory ||= Rails.root.join("tmp/speedscope") 18 | Singed.backtrace_cleaner = Rails.backtrace_cleaner 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/singed/report.rb: -------------------------------------------------------------------------------- 1 | module Singed 2 | class Report < StackProf::Report 3 | def filter! 4 | # copy and paste from StackProf::Report#print_graphviz that does filtering 5 | # mark_stack = [] 6 | list = frames(true) 7 | # WIP to filter out frames we care about... unfortunately, speedscope just hangs while loading as is 8 | # # build list of frames to mark for keeping 9 | # list.each do |addr, frame| 10 | # mark_stack << addr unless Singed.silence_line?(frame[:file]) 11 | # end 12 | 13 | # # while more addresses to mark 14 | # while addr = mark_stack.pop 15 | # frame = list[addr] 16 | # # if it hasn't been marked yet 17 | # unless frame[:marked] 18 | # # collect edges to mark 19 | # if frame[:edges] 20 | # mark_stack += frame[:edges].map{ |addr, weight| addr if list[addr][:total_samples] <= weight*1.2 }.compact 21 | # end 22 | # # mark it so we don't process again 23 | # frame[:marked] = true 24 | # end 25 | # end 26 | # list = list.select{ |_addr, frame| frame[:marked] } 27 | # list.each{ |_addr, frame| frame[:edges]&.delete_if{ |k,v| list[k].nil? } } 28 | # end copy-pasted section 29 | 30 | list.each do |_addr, frame| 31 | frame[:file] = Singed.filter_line(frame[:file]) 32 | end 33 | 34 | @data[:frames] = list 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/singed/rspec.rb: -------------------------------------------------------------------------------- 1 | require "singed" 2 | 3 | RSpec.configure do |config| 4 | config.around(flamegraph: true) do |example| 5 | flamegraph { example.run } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /singed.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "singed" 5 | 6 | spec.version = "0.3.0" 7 | spec.license = "MIT" 8 | spec.authors = ["Josh Nichols"] 9 | spec.email = ["josh.nichols@gusto.com"] 10 | spec.summary = "Quick and easy way to get flamegraphs from a specific part of your code base" 11 | spec.required_ruby_version = ">= 2.7.0" 12 | spec.homepage = "https://github.com/rubyatscale/singed" 13 | spec.metadata = { 14 | "source_code_uri" => "https://github.com/rubyatscale/singed", 15 | "bug_tracker_uri" => "https://github.com/rubyatscale/singed/issues", 16 | "homepage_uri" => "https://github.com/rubyatscale/singed" 17 | } 18 | 19 | spec.files = Dir["README.md", "*.gemspec", "lib/**/*", "exe/**/*"] 20 | spec.bindir = "exe" 21 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 22 | spec.require_paths = ["lib"] 23 | 24 | spec.add_dependency "stackprof", ">= 0.2.13" 25 | 26 | spec.add_development_dependency "rake", "~> 13.0" 27 | spec.add_development_dependency "rspec" 28 | end 29 | -------------------------------------------------------------------------------- /spec/singed/kernel_ext_spec.rb: -------------------------------------------------------------------------------- 1 | describe Kernel, "extension" do 2 | let(:flamegraph) { 3 | instance_double(Singed::Flamegraph) 4 | } 5 | 6 | before do 7 | allow(Singed::Flamegraph).to receive(:new).and_return(flamegraph) 8 | allow(flamegraph).to receive(:record) 9 | allow(flamegraph).to receive(:save) 10 | allow(flamegraph).to receive(:open) 11 | allow(flamegraph).to receive(:open_command) 12 | allow(flamegraph).to receive(:filename) 13 | end 14 | 15 | let(:io) { StringIO.new } 16 | 17 | it "works without any arguments" do 18 | # * except what's needed to test 19 | # note: use Object.new to get the actual flamegraph kernel extension, instead of the rspec-specific flamegraph 20 | Object.new.flamegraph io: io do 21 | end 22 | 23 | expect(Singed::Flamegraph).to have_received(:new).with(label: nil, ignore_gc: false, interval: 1000) 24 | end 25 | 26 | it "works with explicit arguments" do 27 | # note: use Object.new to get the actual flamegraph kernel extension, instead of the rspec-specific flamegraph 28 | Object.new.flamegraph "yellowjackets", ignore_gc: true, interval: 2000, io: io do 29 | end 30 | 31 | expect(Singed::Flamegraph).to have_received(:new).with(label: "yellowjackets", ignore_gc: true, interval: 2000) 32 | end 33 | 34 | context "default" do 35 | it "opens" do 36 | Object.new.flamegraph open: true, io: io do 37 | end 38 | 39 | expect(flamegraph).to have_received(:open) 40 | end 41 | end 42 | 43 | context "open: true" do 44 | it "opens" do 45 | Object.new.flamegraph open: true, io: io do 46 | end 47 | 48 | expect(flamegraph).to have_received(:open) 49 | end 50 | end 51 | 52 | context "open: false" do 53 | it "doesn't open" do 54 | Object.new.flamegraph open: false, io: io do 55 | end 56 | 57 | expect(flamegraph).to_not have_received(:open) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 16 | 17 | require "singed" 18 | 19 | RSpec.configure do |config| 20 | # rspec-expectations config goes here. You can use an alternate 21 | # assertion/expectation library such as wrong or the stdlib/minitest 22 | # assertions if you prefer. 23 | config.expect_with :rspec do |expectations| 24 | # This option will default to `true` in RSpec 4. It makes the `description` 25 | # and `failure_message` of custom matchers include text for helper methods 26 | # defined using `chain`, e.g.: 27 | # be_bigger_than(2).and_smaller_than(4).description 28 | # # => "be bigger than 2 and smaller than 4" 29 | # ...rather than: 30 | # # => "be bigger than 2" 31 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 32 | end 33 | 34 | # rspec-mocks config goes here. You can use an alternate test double 35 | # library (such as bogus or mocha) by changing the `mock_with` option here. 36 | config.mock_with :rspec do |mocks| 37 | # Prevents you from mocking or stubbing a method that does not exist on 38 | # a real object. This is generally recommended, and will default to 39 | # `true` in RSpec 4. 40 | mocks.verify_partial_doubles = true 41 | end 42 | 43 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 44 | # have no way to turn it off -- the option exists only for backwards 45 | # compatibility in RSpec 3). It causes shared context metadata to be 46 | # inherited by the metadata hash of host groups and examples, rather than 47 | # triggering implicit auto-inclusion in groups with matching metadata. 48 | config.shared_context_metadata_behavior = :apply_to_host_groups 49 | 50 | # The settings below are suggested to provide a good initial experience 51 | # with RSpec, but feel free to customize to your heart's content. 52 | # # This allows you to limit a spec run to individual examples or groups 53 | # # you care about by tagging them with `:focus` metadata. When nothing 54 | # # is tagged with `:focus`, all examples get run. RSpec also provides 55 | # # aliases for `it`, `describe`, and `context` that include `:focus` 56 | # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 57 | # config.filter_run_when_matching :focus 58 | # 59 | # # Allows RSpec to persist some state between runs in order to support 60 | # # the `--only-failures` and `--next-failure` CLI options. We recommend 61 | # # you configure your source control system to ignore this file. 62 | # config.example_status_persistence_file_path = "spec/examples.txt" 63 | # 64 | # # Limits the available syntax to the non-monkey patched syntax that is 65 | # # recommended. For more details, see: 66 | # # https://relishapp.com/rspec/rspec-core/docs/configuration/zero-monkey-patching-mode 67 | # config.disable_monkey_patching! 68 | # 69 | # # This setting enables warnings. It's recommended, but in some cases may 70 | # # be too noisy due to issues in dependencies. 71 | # config.warnings = true 72 | # 73 | # # Many RSpec users commonly either run the entire suite or an individual 74 | # # file, and it's useful to allow more verbose output when running an 75 | # # individual spec file. 76 | # if config.files_to_run.one? 77 | # # Use the documentation formatter for detailed output, 78 | # # unless a formatter has already been configured 79 | # # (e.g. via a command-line flag). 80 | # config.default_formatter = "doc" 81 | # end 82 | # 83 | # # Print the 10 slowest examples and example groups at the 84 | # # end of the spec run, to help surface which specs are running 85 | # # particularly slow. 86 | # config.profile_examples = 10 87 | # 88 | # # Run specs in random order to surface order dependencies. If you find an 89 | # # order dependency and want to debug it, you can fix the order by providing 90 | # # the seed, which is printed after each run. 91 | # # --seed 1234 92 | # config.order = :random 93 | # 94 | # # Seed global randomization in this process using the `--seed` CLI option. 95 | # # Setting this allows you to use `--seed` to deterministically reproduce 96 | # # test failures related to randomization by passing the same `--seed` value 97 | # # as the one that triggered the failure. 98 | # Kernel.srand config.seed 99 | end 100 | --------------------------------------------------------------------------------