singed 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5276eb42b69baf43de15c7ad5a3549f048d00865f225998eb4479b6e7723f2b8
4
+ data.tar.gz: 0f46bfb076f12a4766e060a017f2cb0e9ad6af98a96b8f96ebf05b58205aac21
5
+ SHA512:
6
+ metadata.gz: 81b34cf130fbe187680d65ba593ee68ce606e72cbb5db99284aba82beb6bf9ac17cef5104850a49ebba7bce87cc8a11b39f4b0897accb9bc6e0cb268ee35168e
7
+ data.tar.gz: 23e265212f1f70ebe105c218dde2025728782ae1574ef2abcf4705b04e13eeab7bd9b6197442b950517492c5a540a2ebaa99478d7841b4170a8611ff668094a9
data/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # Singed
2
+
3
+ Singed makes it easy to get a flamegraph anywhere in your code base:
4
+
5
+ ## Usage
6
+
7
+ To profile your code, and launch [speedscope](https://github.com/jlfwong/speedscope) for viewing it:
8
+
9
+ ```ruby
10
+ flamegraph {
11
+ # your code here
12
+ }
13
+ ```
14
+
15
+ Flamegraphs are saved for later review to `Singed.output_directory`, which is `tmp/speedscope` on Rails. You can adjust this like:
16
+
17
+ ```ruby
18
+ Singed.output_directory = "tmp/slowness-exploration"
19
+ ```
20
+
21
+ ### Blockage
22
+ If you are calling it in a loop, or with different variations, you can include a label on the filename:
23
+
24
+ ```ruby
25
+ flamegraph(label: "rspec") {
26
+ # your code here
27
+ }
28
+ ```
29
+
30
+ You can also skip opening speedscope automatically:
31
+
32
+ ```ruby
33
+ flamegraph(open: false) {
34
+ # your code here
35
+ }
36
+ ```
37
+
38
+ ### RSpec
39
+
40
+ If you are using RSpec, you can use the `flamegraph` metadata to capture it for you.
41
+
42
+ ```ruby
43
+ # make sure this is required at somepoint, like in a spec/support file!
44
+ require 'singed/rspec'
45
+
46
+ RSpec.describe YourClass do
47
+ it "is slow :(", flamegraph: true do
48
+ # your code here
49
+ end
50
+ end
51
+ ```
52
+
53
+ ### Controllers
54
+
55
+ If you want to capture a flamegraph of a controller action, you can call it like:
56
+
57
+ ```ruby
58
+ class EmployeesController < ApplicationController
59
+ flamegraph :show
60
+
61
+ def show
62
+ # your code here
63
+ end
64
+ end
65
+ ```
66
+
67
+ This won't catch the entire request though, just once it's been routed to controller and a response has been served.
68
+
69
+ ### Rack/Rails requests
70
+
71
+ 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:
72
+
73
+ ```shell
74
+ curl -H 'X-Singed: true' https://localhost:3000
75
+ ```
76
+
77
+ 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!
78
+
79
+ This can also be enabled to always run by setting `SINGED_MIDDLEWARE_ALWAYS_CAPTURE=1` in the environment.
80
+
81
+ ### Command Line
82
+
83
+ There is a `singed` command line you can use that will record a flamegraph from the entirety of a command run:
84
+
85
+ ```shell
86
+ $ bundle binstub singed # if you want to be able to call it like bin/singed
87
+ $ bundle exec singed -- bin/rails
88
+ ```
89
+
90
+ The flamegraph is opened afterwards.
91
+
92
+
93
+ ## Limitations
94
+
95
+ When using the auto-opening feature, it's assumed that you are have a browser available on the same host you are profiling code.
96
+
97
+ The `open` is expected to be available.
98
+
99
+ ## Alternatives
100
+
101
+ - using [rbspy](https://rbspy.github.io/) directory
102
+ - using [stackprof](https://github.com/tmm1/stackprof) (a dependency of singed) directly
data/exe/singed ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+
4
+ require 'singed/cli'
5
+ if Singed::CLI.chdir_rails_root
6
+ require './config/environment'
7
+ end
8
+
9
+ Singed::CLI.new(ARGV).run
@@ -0,0 +1,16 @@
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
data/lib/singed/cli.rb ADDED
@@ -0,0 +1,168 @@
1
+ require 'shellwords'
2
+ require 'tmpdir'
3
+ require 'optionparser'
4
+
5
+ # 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
6
+
7
+ module Singed
8
+ class CLI
9
+ attr_accessor :argv, :filename, :opts
10
+
11
+ def initialize(argv)
12
+ @argv = argv
13
+ @opts = OptionParser.new
14
+
15
+ parse_argv!
16
+ end
17
+
18
+ def parse_argv!
19
+ opts.banner = 'Usage: singed [options] <command>'
20
+
21
+ opts.on('-h', '--help', 'Show this message') do
22
+ @show_help = true
23
+ end
24
+
25
+ opts.on('-o', '--output-directory DIRECTORY', 'Directory to write flamegraph to') do |directory|
26
+ @output_directory = directory
27
+ end
28
+
29
+ opts.order(@argv) do |arg|
30
+ opts.terminate if arg == '--'
31
+ break
32
+ end
33
+
34
+ if @argv.empty?
35
+ @show_help = true
36
+ @error_message = 'missing command to profile'
37
+ return
38
+ end
39
+
40
+ return if @show_help
41
+
42
+ begin
43
+ @opts.parse!(argv)
44
+ rescue OptionParser::InvalidOption => e
45
+ @show_help = true
46
+ @error_message = e
47
+ end
48
+ end
49
+
50
+ def run
51
+ require 'singed'
52
+
53
+ if @error_message
54
+ puts @error_message
55
+ puts
56
+ puts @opts.help
57
+ exit 1
58
+ end
59
+
60
+ if show_help?
61
+ puts @opts.help
62
+ exit 0
63
+ end
64
+
65
+ Singed.output_directory = @output_directory if @output_directory
66
+ Singed.output_directory ||= Dir.tmpdir
67
+ @filename = Singed::Flamegraph.generate_filename(label: 'cli')
68
+
69
+ options = {
70
+ format: 'speedscope',
71
+ file: filename.to_s,
72
+ silent: nil,
73
+ }
74
+
75
+ rbspy_args = [
76
+ 'record',
77
+ *options.map { |k, v| ["--#{k}", v].compact }.flatten,
78
+ '--',
79
+ *argv,
80
+ ]
81
+
82
+ loop do
83
+ break unless password_needed?
84
+
85
+ puts '🔥📈 Singed needs to run as root, but will drop permissions back to your user. Prompting with sudo now...'
86
+ prompt_password
87
+ end
88
+
89
+ Bundler.with_unbundled_env do
90
+ # don't run things with spring, because it forks and rbspy won't see it
91
+ sudo ['rbspy', *rbspy_args], reason: 'Singed needs to run as root, but will drop permissions back to your user.', env: { 'DISABLE_SPRING' => '1' }
92
+ end
93
+
94
+ unless filename.exist?
95
+ puts "#{filename} doesn't exist. Maybe rbspy had a failure capturing it? Check the scrollback."
96
+ exit 1
97
+ end
98
+
99
+ unless adjust_ownership!
100
+ puts "#{filename} isn't writable!"
101
+ exit 1
102
+ end
103
+
104
+ # clean the report, similar to how Singed::Report does
105
+ json = JSON.parse(filename.read).with_indifferent_access
106
+ json['shared']['frames'].each do |frame|
107
+ frame[:file] = Singed.filter_line(frame[:file])
108
+ end
109
+ filename.write(JSON.dump(json))
110
+
111
+ flamegraph = Singed::Flamegraph.new(filename: filename)
112
+ flamegraph.open
113
+ end
114
+
115
+ def password_needed?
116
+ !system('sudo --non-interactive true >/dev/null 2>&1')
117
+ end
118
+
119
+ def prompt_password
120
+ system('sudo true')
121
+ end
122
+
123
+ def adjust_ownership!
124
+ sudo ['chown', ENV['USER'], filename], reason: "Adjusting ownership of #{filename}, but need root."
125
+ end
126
+
127
+ def show_help?
128
+ @show_help
129
+ end
130
+
131
+ def sudo(system_args, reason:, env: {})
132
+ loop do
133
+ break unless password_needed?
134
+
135
+ puts "🔥📈 #{reason} Prompting with sudo now..."
136
+ prompt_password
137
+ end
138
+
139
+ sudo_args = [
140
+ 'sudo',
141
+ '--preserve-env',
142
+ *system_args.map(&:to_s),
143
+ ]
144
+
145
+ puts "$ #{Shellwords.join(sudo_args)}"
146
+
147
+ system(env, *sudo_args, exception: true)
148
+ end
149
+
150
+ def self.chdir_rails_root
151
+ original_cwd = Dir.pwd
152
+
153
+ loop do
154
+ if File.file?('config/environment.rb')
155
+ return Dir.pwd
156
+ end
157
+
158
+ if Pathname.new(Dir.pwd).root?
159
+ Dir.chdir(original_cwd)
160
+ return
161
+ end
162
+
163
+ # Otherwise keep moving upwards in search of an executable.
164
+ Dir.chdir('..')
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,66 @@
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.to_formatted_s(:number)
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
@@ -0,0 +1,17 @@
1
+ module Kernel
2
+ def flamegraph(label = nil, open: true, ignore_gc: false, interval: 1000, &block)
3
+ fg = Singed::Flamegraph.new(label: label, ignore_gc: ignore_gc, interval: interval)
4
+ result = fg.record(&block)
5
+ fg.save
6
+
7
+ if open
8
+ # use npx, so we don't have to add it as a dependency
9
+ puts "🔥📈 #{'Captured flamegraph, opening with'.colorize(:bold).colorize(:red)}: #{fg.open_command}"
10
+ fg.open
11
+ else
12
+ puts "🔥📈 #{'Captured flamegraph to file'.colorize(:bold).colorize(:red)}: #{fg.filename}"
13
+ end
14
+
15
+ result
16
+ end
17
+ end
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,37 @@
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
@@ -0,0 +1,11 @@
1
+ require 'singed'
2
+
3
+ RSpec.configure do |config|
4
+ config.around do |example|
5
+ if example.metadata[:flamegraph]
6
+ flamegraph { example.run }
7
+ else
8
+ example.run
9
+ end
10
+ end
11
+ end
data/lib/singed.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'stackprof'
5
+ require 'colorize'
6
+
7
+ module Singed
8
+ extend self
9
+
10
+ # Where should flamegraphs be saved?
11
+ def output_directory=(directory)
12
+ @output_directory = Pathname.new(directory)
13
+ end
14
+
15
+ def self.output_directory
16
+ @output_directory || raise("output directory hasn't been set!")
17
+ end
18
+
19
+ def enabled=(enabled)
20
+ @enabled = enabled
21
+ end
22
+
23
+ def enabled?
24
+ return @enabled if defined?(@enabled)
25
+
26
+ @enabled = true
27
+ end
28
+
29
+ def backtrace_cleaner=(backtrace_cleaner)
30
+ @backtrace_cleaner = backtrace_cleaner
31
+ end
32
+
33
+ def backtrace_cleaner
34
+ @backtrace_cleaner
35
+ end
36
+
37
+ def silence_line?(line)
38
+ return backtrace_cleaner.silence_line?(line) if backtrace_cleaner
39
+
40
+ false
41
+ end
42
+
43
+ def filter_line(line)
44
+ return backtrace_cleaner.filter_line(line) if backtrace_cleaner
45
+
46
+ line
47
+ end
48
+
49
+ autoload :Flamegraph, 'singed/flamegraph'
50
+ autoload :Report, 'singed/report'
51
+ autoload :RackMiddleware, 'singed/rack_middleware'
52
+ end
53
+
54
+ require 'singed/kernel_ext'
55
+ require 'singed/railtie' if defined?(Rails::Railtie)
56
+ require 'singed/rspec' if defined?(RSpec)
data/singed.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = 'singed'
5
+
6
+ spec.version = '0.1.0'
7
+ spec.authors = ['Josh Nichols']
8
+ spec.email = ['josh.nichols@gusto.com']
9
+
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
+
13
+ # spec.metadata['allowed_push_host'] = "TODO: Set to your gem server 'https://example.com'"
14
+
15
+ spec.files = Dir['README.md', '*.gemspec', 'lib/**/*', 'exe/**/*']
16
+ spec.bindir = 'exe'
17
+ spec.executables = spec.files.grep(%r(\Aexe/)) { |f| File.basename(f) }
18
+ spec.require_paths = ['lib']
19
+
20
+ # Uncomment to register a new dependency of your gem
21
+ spec.add_dependency 'colorize'
22
+ spec.add_dependency 'stackprof'
23
+
24
+ spec.add_development_dependency 'rake', '~> 13.0'
25
+
26
+ # For more information and examples about making a new gem, checkout our
27
+ # guide at: https://bundler.io/guides/creating_gem.html
28
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: singed
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Josh Nichols
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-01-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: colorize
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: stackprof
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ description:
56
+ email:
57
+ - josh.nichols@gusto.com
58
+ executables:
59
+ - singed
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - README.md
64
+ - exe/singed
65
+ - lib/singed.rb
66
+ - lib/singed/backtrace_cleaner_ext.rb
67
+ - lib/singed/cli.rb
68
+ - lib/singed/controller_ext.rb
69
+ - lib/singed/flamegraph.rb
70
+ - lib/singed/kernel_ext.rb
71
+ - lib/singed/rack_middleware.rb
72
+ - lib/singed/railtie.rb
73
+ - lib/singed/report.rb
74
+ - lib/singed/rspec.rb
75
+ - singed.gemspec
76
+ homepage:
77
+ licenses: []
78
+ metadata: {}
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 2.7.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.4.4
95
+ signing_key:
96
+ specification_version: 4
97
+ summary: Quick and easy way to get flamegraphs from a specific part of your code base
98
+ test_files: []