singed 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml 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: []