├── .document ├── .gitignore ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── VERSION ├── bin ├── stackprof-cli └── stackprof-remote ├── lib ├── stackprof-remote.rb └── stackprof │ ├── cli.rb │ └── remote │ ├── client.rb │ ├── middleware.rb │ └── process_report_collector.rb ├── stackprof-remote.gemspec └── test ├── helper.rb ├── test.dump └── test_stackprof-remote.rb /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # rcov generated 2 | coverage 3 | coverage.data 4 | 5 | # rdoc generated 6 | rdoc 7 | 8 | # yard generated 9 | doc 10 | .yardoc 11 | 12 | # bundler 13 | .bundle 14 | 15 | # jeweler generated 16 | pkg 17 | 18 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 19 | # 20 | # * Create a file at ~/.gitignore 21 | # * Include files you want ignored 22 | # * Run: git config --global core.excludesfile ~/.gitignore 23 | # 24 | # After doing this, these files will be ignored in all your git projects, 25 | # saving you from having to 'pollute' every project you touch with them 26 | # 27 | # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) 28 | # 29 | # For MacOS: 30 | # 31 | #.DS_Store 32 | 33 | # For TextMate 34 | #*.tmproj 35 | #tmtags 36 | 37 | # For emacs: 38 | #*~ 39 | #\#* 40 | #.\#* 41 | 42 | # For vim: 43 | #*.swp 44 | 45 | # For redcar: 46 | #.redcar 47 | 48 | # For rubinius: 49 | #*.rbc 50 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | # Add dependencies required to use your gem here. 3 | # Example: 4 | # gem "activesupport", ">= 2.3.5" 5 | gem "rbtrace", "~>0.4.4" 6 | gem "stackprof", "~>0.2.7" 7 | gem "pry", "~>0.10.0" 8 | 9 | # Add dependencies to develop your gem here. 10 | # Include everything needed to run rake, tests, features, etc. 11 | group :development do 12 | gem "rdoc", "~> 3.12" 13 | gem "bundler", "~> 1.0" 14 | gem "jeweler", "~> 2.0.1" 15 | gem "simplecov", ">= 0" 16 | end 17 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | addressable (2.3.6) 5 | builder (3.2.2) 6 | coderay (1.1.0) 7 | descendants_tracker (0.0.4) 8 | thread_safe (~> 0.3, >= 0.3.1) 9 | docile (1.1.4) 10 | faraday (0.9.0) 11 | multipart-post (>= 1.2, < 3) 12 | ffi (1.9.3) 13 | git (1.2.7) 14 | github_api (0.11.3) 15 | addressable (~> 2.3) 16 | descendants_tracker (~> 0.0.1) 17 | faraday (~> 0.8, < 0.10) 18 | hashie (>= 1.2) 19 | multi_json (>= 1.7.5, < 2.0) 20 | nokogiri (~> 1.6.0) 21 | oauth2 22 | hashie (3.0.0) 23 | highline (1.6.21) 24 | jeweler (2.0.1) 25 | builder 26 | bundler (>= 1.0) 27 | git (>= 1.2.5) 28 | github_api 29 | highline (>= 1.6.15) 30 | nokogiri (>= 1.5.10) 31 | rake 32 | rdoc 33 | json (1.8.1) 34 | jwt (1.0.0) 35 | method_source (0.8.2) 36 | mini_portile (0.6.0) 37 | msgpack (0.5.8) 38 | multi_json (1.10.1) 39 | multi_xml (0.5.5) 40 | multipart-post (2.0.0) 41 | nokogiri (1.6.2.1) 42 | mini_portile (= 0.6.0) 43 | oauth2 (0.9.4) 44 | faraday (>= 0.8, < 0.10) 45 | jwt (~> 1.0) 46 | multi_json (~> 1.3) 47 | multi_xml (~> 0.5) 48 | rack (~> 1.2) 49 | pry (0.10.0) 50 | coderay (~> 1.1.0) 51 | method_source (~> 0.8.1) 52 | slop (~> 3.4) 53 | rack (1.5.2) 54 | rake (10.3.2) 55 | rbtrace (0.4.4) 56 | ffi (>= 1.0.6) 57 | msgpack (>= 0.4.3) 58 | trollop (>= 1.16.2) 59 | rdoc (3.12.2) 60 | json (~> 1.4) 61 | simplecov (0.8.2) 62 | docile (~> 1.1.0) 63 | multi_json 64 | simplecov-html (~> 0.8.0) 65 | simplecov-html (0.8.0) 66 | slop (3.5.0) 67 | stackprof (0.2.7) 68 | thread_safe (0.3.4) 69 | trollop (2.0) 70 | 71 | PLATFORMS 72 | ruby 73 | 74 | DEPENDENCIES 75 | bundler (~> 1.0) 76 | jeweler (~> 2.0.1) 77 | pry (~> 0.10.0) 78 | rbtrace (~> 0.4.4) 79 | rdoc (~> 3.12) 80 | simplecov 81 | stackprof (~> 0.2.7) 82 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Aaron Quint 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stackprof-remote 2 | 3 | A Middleware and CLI for fetching and interacting with [StackProf](https://github.com/tmm1/stackprof) dumps. 4 | 5 | ## Description 6 | 7 | stackprof-remote consists of a middleware for easy creation and retrieval of StackProf sampling profiler dumps from a remote machine, and a wrapper around pry (stackprof-cli) to create an interactive session for navigating dump files. 8 | 9 | Currently, this is aimed at Rails apps running with unicorn, but there are options that should make it usable with any Rack app. In the future, I'd like to see it work with Resque and non-rack applications, too. 10 | 11 | ## Why 12 | 13 | StackProf is amazing (BIG UPS TO @TMM1) but is not very operator friendly when it comes to collecting data about a current process. I was inspired by the [`go tool pprof` process](http://golang.org/pkg/net/http/pprof/) to make something that could wrap StackProf in an interface that should be as easy as including a middleware and pointing a bin at it to fetch and navigate a dump. 14 | 15 | ## Usage 16 | 17 | 1 - Add the Middleware to your app. 18 | 19 | ``` ruby 20 | # rails 2.3 style 21 | require 'stackprof/remote/middleware' 22 | 23 | # Should we enable stackprof-remote for this request. 24 | # enabled can be a boolean or a proc that takes the Rack env hash 25 | enabled = proc do |env| 26 | env['HOST_INFO'] =~ /private-hostname/ || Rails.env.development? 27 | end 28 | # Register the middleware 29 | ActionController::Dispatcher.middleware.use StackProf::Remote::Middleware, enabled: enabled, logger: Rails.logger 30 | ``` 31 | 32 | 2 - Run/restart your app. 33 | 3 - Attach to your application. 34 | 35 | ``` bash 36 | $ stackprof-remote localhost 37 | === StackProf on localhost === 38 | Starting 39 | [localhost] StackProf Started 40 | Waiting for 30 seconds 41 | [localhost] Results: 3023kb 42 | Saved results to /home/paperless/.sp/sp-localhost-1402684964.dump 43 | >>> sp-localhost-1402684964.dump loaded 44 | stackprof> top 5 45 | ================================== 46 | Mode: cpu(1000) 47 | Samples: 5045 (3.28% miss rate) 48 | GC: 355 (7.04%) 49 | ================================== 50 | TOTAL (pct) SAMPLES (pct) FRAME 51 | 736 (14.6%) 707 (14.0%) ActiveSupport::LogSubscriber#start 52 | 379 (7.5%) 379 (7.5%) block in ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#execute 53 | 5248 (104.0%) 168 (3.3%) Benchmark#realtime 54 | 282 (5.6%) 117 (2.3%) ActiveSupport::LogSubscriber#finish 55 | 88 (1.7%) 88 (1.7%) block (2 levels) in Sass::Importers::Filesystem#find_real_file 56 | ``` 57 | 58 | ## CLI 59 | 60 | At the end of `stackprof-remote` it actually just enters a separate process `stackprof-cli`. This is a wrapper around [pry](https://github.com/pry/pry) that loads the dump file in an interactive session. It gives you a number of methods to interact with the dump: 61 | 62 | * top N: show the top methods ordered by inner sample time. 63 | * total N: show the top methods ordered by total time. 64 | * all: Show all the methods ordered by sample time. 65 | * method Name: show details about the callers and callees of Name 66 | 67 | You can use `stackprof-cli` on its own by calling `stackprof-cli [dump-name]` 68 | 69 | ## Notes/Caveats 70 | 71 | - You should use `enabled` on the Middleware to lock this down in production environments. 72 | - Collecting dumps uses [`rbtrace`](https://github.com/tmm1/rbtrace) to execute the stackprof methods against the pool of unicorns running. If you're running something other than `unicorn` or you mess with the procline, you'll need to set the `:pid_finder` option. 73 | - In order to get line level code output when using the `method` view you need to execute `stackprof-cli` in the same directory structure that your unicorn runs in. This doesn't necessarily mean the same server - we use remote dumps and inspect them in our local Vagrant environments that have the same directory structure. 74 | 75 | ## Requirements 76 | 77 | Only works on MRI Ruby 2.1 (Upgrade already!). Its only been tested against Ruby 2.1.2 running on Linux (Centos 6.4). 78 | 79 | ## Contributing to stackprof-remote 80 | 81 | * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet. 82 | * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it. 83 | * Fork the project. 84 | * Start a feature/bugfix branch. 85 | * Commit and push until you are happy with your contribution. 86 | * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. 87 | * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it. 88 | 89 | ## Copyright 90 | 91 | Copyright (c) 2014 Aaron Quint. See LICENSE.txt for further details. 92 | 93 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | begin 6 | Bundler.setup(:default, :development) 7 | rescue Bundler::BundlerError => e 8 | $stderr.puts e.message 9 | $stderr.puts "Run `bundle install` to install missing gems" 10 | exit e.status_code 11 | end 12 | require 'rake' 13 | 14 | require 'jeweler' 15 | Jeweler::Tasks.new do |gem| 16 | # gem is a Gem::Specification... see http://guides.rubygems.org/specification-reference/ for more options 17 | gem.name = "stackprof-remote" 18 | gem.homepage = "http://github.com/quirkey/stackprof-remote" 19 | gem.license = "MIT" 20 | gem.summary = %Q{A Middleware and CLI for fetching and interacting with StackProf dumps} 21 | gem.description = %Q{stackprof-remote consists of a middleware for easy creation and retreival of 22 | stackprof sampling profiler dumps from a remote machine, and a wrapper around 23 | pry (stackprof-cli) to create an interactive session for navigating stackprof 24 | dumps.} 25 | gem.email = "aaron@quirkey.com" 26 | gem.authors = ["Aaron Quint"] 27 | # dependencies defined in Gemfile 28 | end 29 | Jeweler::RubygemsDotOrgTasks.new 30 | 31 | require 'rake/testtask' 32 | Rake::TestTask.new(:test) do |test| 33 | test.libs << 'lib' << 'test' 34 | test.pattern = 'test/**/test_*.rb' 35 | test.verbose = true 36 | end 37 | 38 | desc "Code coverage detail" 39 | task :simplecov do 40 | ENV['COVERAGE'] = "true" 41 | Rake::Task['test'].execute 42 | end 43 | 44 | task :default => :test 45 | 46 | require 'rdoc/task' 47 | Rake::RDocTask.new do |rdoc| 48 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 49 | 50 | rdoc.rdoc_dir = 'rdoc' 51 | rdoc.title = "stackprof-remote #{version}" 52 | rdoc.rdoc_files.include('README*') 53 | rdoc.rdoc_files.include('lib/**/*.rb') 54 | end 55 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.1.0 -------------------------------------------------------------------------------- /bin/stackprof-cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'stackprof/cli' 4 | 5 | StackProf::CLI.start(ARGV.shift) 6 | 7 | -------------------------------------------------------------------------------- /bin/stackprof-remote: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # stackprof-remote 4 | require 'stackprof/remote/client' 5 | 6 | remote = StackProf::Remote::Client.new(ARGV.shift, ARGV.shift) 7 | remote.run 8 | 9 | 10 | -------------------------------------------------------------------------------- /lib/stackprof-remote.rb: -------------------------------------------------------------------------------- 1 | require 'stackprof/remote/middleware' 2 | require 'stackprof/remote/client' 3 | -------------------------------------------------------------------------------- /lib/stackprof/cli.rb: -------------------------------------------------------------------------------- 1 | require 'pry' 2 | require 'stackprof/remote/process_report_collector' 3 | 4 | module StackProf 5 | # CLI is a simple wrapper around Pry that defines some helper 6 | # methods for navigating stackprof dumps. 7 | class CLI 8 | 9 | class << self 10 | # Set prompts and other defaults 11 | def set_defaults 12 | Pry.config.should_load_rc = false 13 | Pry.config.prompt = proc { 14 | "stackprof#{@current_report ? " (#{@current_report})" : ""}> " 15 | } 16 | end 17 | 18 | # Add the helper methods to pry 19 | def add_methods 20 | session = Session.new 21 | Pry::Commands.block_command "load-dump", "Load a stackprof dump at file" do |file| 22 | session.with_context(self) {|s| s.load_dump(file) } 23 | end 24 | Pry::Commands.block_command "top", "print the top (n) results by sample time" do |limit| 25 | session.with_context(self) {|s| s.top(limit) } 26 | end 27 | Pry::Commands.block_command "total", "print the top (n) results by total sample time" do |limit| 28 | session.with_context(self) {|s| s.total(limit) } 29 | end 30 | Pry::Commands.block_command "all", "print all results by sample time" do 31 | session.with_context(self) {|s| s.all } 32 | end 33 | Pry::Commands.block_command "method", "scope results to matching methods" do |method| 34 | session.with_context(self) {|s| s.print_method(method) } 35 | end 36 | Pry::Commands.block_command "file", "scope results to matching file" do |method| 37 | session.with_context(self) {|s| s.print_file(method) } 38 | end 39 | end 40 | 41 | # Start a Pry session with an optional file 42 | def start(file, options = {}) 43 | set_defaults 44 | add_methods 45 | initial = file ? StringIO.new("load-dump #{file}") : nil 46 | Pry.start(nil, :input => initial) 47 | end 48 | end 49 | 50 | class Session 51 | attr_reader :ctx 52 | 53 | # Load a dump into a StackProf::Report object. 54 | def load_dump(file) 55 | data = File.read(file) 56 | @report = StackProf::Remote::ProcessReportCollector.report_from_marshaled_results(data) 57 | @current_report = File.basename(file) 58 | puts ">>> #{@current_report} loaded" 59 | end 60 | 61 | # Print the top `limit` methods by sample time 62 | def top(limit = 10) 63 | check_for_report 64 | @report.print_text(false, limit.to_i, ctx.output) 65 | end 66 | 67 | # Print the top `limit` methods by total time 68 | def total(limit = 10) 69 | check_for_report 70 | @report.print_text(true, limit.to_i, ctx.output) 71 | end 72 | 73 | # Print all the methods by sample time. Paged. 74 | def all 75 | check_for_report 76 | page do |out| 77 | @report.print_text(false, nil, out) 78 | end 79 | end 80 | 81 | # Print callers/callees of methods matching method. Paged. 82 | def print_method(method) 83 | check_for_report 84 | page do |out| 85 | @report.print_method(method, out) 86 | end 87 | end 88 | 89 | def print_file(file) 90 | check_for_report 91 | 92 | page do |out| 93 | @report.print_file(file, out) 94 | end 95 | end 96 | 97 | # Simple check to see if a report has been loaded. 98 | def check_for_report 99 | if !@report 100 | puts "You have to load a dump first with load-dump" 101 | return 102 | end 103 | end 104 | 105 | # Wrap the execution of a method with a Pry context 106 | def with_context(ctx, &block) 107 | @ctx = ctx 108 | res = yield self 109 | @ctx = nil 110 | res 111 | end 112 | 113 | # Helper to delegate puts to the current context 114 | def puts(*args) 115 | ctx.output.puts(*args) 116 | end 117 | 118 | # Wrap the output in pry's pager (less) 119 | def page(&block) 120 | out = StringIO.new 121 | yield out 122 | ctx._pry_.pager.page out.string 123 | end 124 | 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/stackprof/remote/client.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'fileutils' 3 | require 'stackprof/cli' 4 | 5 | module StackProf 6 | module Remote 7 | # Client wraps the script that uses net/http to make the start/stop 8 | # requests to a host running the StackProf::Remote::Middleware 9 | class Client 10 | attr_reader :host 11 | 12 | def initialize(host, wait) 13 | @host = host 14 | @wait = (wait || 30).to_i 15 | check_host 16 | end 17 | 18 | def run 19 | start 20 | wait 21 | fetch_results 22 | save_results 23 | enter_console 24 | end 25 | 26 | def start 27 | puts "=== StackProf on #{host} ===" 28 | puts "Starting" 29 | result = Net::HTTP.get(host, "/__stackprof__/start") 30 | puts "[#{host}] #{result}" 31 | if result !~ /Started/ 32 | raise "Did not start successfully" 33 | end 34 | end 35 | 36 | def wait 37 | puts "Waiting for #{@wait} seconds" 38 | sleep @wait 39 | end 40 | 41 | def fetch_results 42 | response = Net::HTTP.get_response(host, "/__stackprof__/stop") 43 | if response.code == '200' 44 | @results = response.body 45 | if !@results 46 | raise "Could not retreive results" 47 | end 48 | puts "[#{host}] Results: #{@results.bytesize / 1024}kb" 49 | else 50 | puts "[#{host}] Returned a #{response.code} response" 51 | puts response.body 52 | raise "Bad Response" 53 | end 54 | end 55 | 56 | def result_path 57 | result_dir = File.expand_path('~/.sp') 58 | FileUtils.mkdir_p(result_dir) 59 | @result_path ||= File.expand_path(File.join(result_dir, "sp-#{@host}-#{Time.now.to_i}.dump")) 60 | end 61 | 62 | def save_results 63 | File.open(result_path, 'wb') {|f| f << @results } 64 | puts "Saved results to #{result_path}" 65 | end 66 | 67 | def enter_console 68 | StackProf::CLI.start(result_path) 69 | end 70 | 71 | private 72 | def check_host 73 | if !host || !URI.parse(host) 74 | raise "Please supply a valid host to connect to (#{host})" 75 | end 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/stackprof/remote/middleware.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | require 'stackprof' 3 | require 'stackprof/remote/process_report_collector' 4 | 5 | module StackProf 6 | module Remote 7 | # Middleware is a simple Rack middleware that handles requests to 8 | # urls matching /__stackprof__ for starting/stopping a profile 9 | # session and retreiving the dump files. It delegates to the 10 | # ProcessReportCollector to do the actual work of collecting 11 | # and combining the dumps. 12 | class Middleware 13 | class << self 14 | attr_accessor :enabled, :logger, :options 15 | 16 | def enabled?(env) 17 | if enabled.respond_to?(:call) 18 | enabled.call(env) 19 | else 20 | enabled 21 | end 22 | end 23 | end 24 | 25 | def initialize(app, options = {}) 26 | @app = app 27 | self.class.logger = options[:logger] || Logger.new(STDOUT) 28 | self.class.enabled = options[:enabled] || false 29 | self.class.options = options 30 | logger.info "[stackprof] Stackprof Middleware enabled" 31 | end 32 | 33 | def call(env) 34 | path = env['PATH_INFO'] 35 | if self.class.enabled?(env) && in_stackprof?(path) 36 | handle_stackprof(path) 37 | else 38 | @app.call(env) 39 | end 40 | end 41 | 42 | private 43 | def logger 44 | self.class.logger 45 | end 46 | 47 | def in_stackprof?(path) 48 | path =~ /^\/__stackprof__/ 49 | end 50 | 51 | def handle_stackprof(path) 52 | sp = StackProf::Remote::ProcessReportCollector.new(self.class.options) 53 | if path =~ /start/ 54 | logger.debug "[stackprof] Starting StackProf" 55 | sp.start 56 | [200, {'Content-Type' => 'text/plain'}, ["StackProf Started"]] 57 | elsif path =~ /stop/ 58 | logger.debug "[stackprof] Flushing StackProf" 59 | sp.stop 60 | sp.save 61 | if results = sp.marshaled_results 62 | [200, {'Content-Type' => 'binary/octet-stream'}, [results]] 63 | else 64 | [404, {'Content-Type' => 'text/plain'}, ["404 StackProf Results Not Found"]] 65 | end 66 | end 67 | end 68 | end 69 | 70 | module ReportSaver 71 | def self.marshaled_results 72 | if results = StackProf.results 73 | Marshal.dump(results) 74 | end 75 | end 76 | 77 | def self.save(base_path) 78 | if results = marshaled_results 79 | FileUtils.mkdir_p(base_path) 80 | filename = "stackprof-#{Process.pid}-#{Time.now.to_i}.dump" 81 | path = File.join(base_path, filename) 82 | File.open(path, 'wb') do |f| 83 | f.write results 84 | end 85 | File.readable?(path) ? path : nil 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/stackprof/remote/process_report_collector.rb: -------------------------------------------------------------------------------- 1 | require 'stackprof/report' 2 | require 'rbtrace/rbtracer' 3 | 4 | module StackProf 5 | module Remote 6 | # ProcessReportCollector handles the work of actually starting, 7 | # stopping, and collecting the dumps from the StackProf profiler. 8 | # 9 | # Internally it uses RBTrace to execute the start/stop methods 10 | # against all runnign processes that match pids found by the :pid_finder 11 | # option. By default this matches unicorn workers. 12 | class ProcessReportCollector 13 | DEFAULT_OPTIONS = { 14 | :pid_finder => -> { 15 | `pgrep -f 'unicorn worker'`.strip.split.collect {|p| p.to_i } 16 | }, 17 | :mode => :cpu, 18 | :interval => 1000, 19 | :raw => true, 20 | :path => 'tmp' 21 | }.freeze 22 | 23 | def initialize(options = {}) 24 | @options = DEFAULT_OPTIONS.merge(options) 25 | collect_pids 26 | end 27 | 28 | def logger 29 | StackProf::Remote::Middleware.logger 30 | end 31 | 32 | def start 33 | command = "StackProf.start(mode: #{@options[:mode].inspect}, interval: #{@options[:interval].inspect}, raw: #{@options[:raw].inspect})" 34 | execute(command) 35 | end 36 | 37 | def stop 38 | command = "StackProf.stop" 39 | execute(command) 40 | end 41 | 42 | def save 43 | command = "StackProf::Remote::ReportSaver.save('#{@options[:path]}')" 44 | @saved_files = execute(command) 45 | end 46 | 47 | def marshaled_results 48 | if @saved_files 49 | logger.debug "[stackprof] Saved Files #{@saved_files.inspect}" 50 | saved_data = @saved_files.collect {|f| 51 | f = f.gsub(/"/,'') # RBTrace returns double quoted strings 52 | if File.readable?(f) 53 | Marshal.load(File.read(f)) 54 | else 55 | logger.error "[stackprof] File #{f} not readable by process #{Process.pid}" 56 | end 57 | }.compact 58 | Marshal.dump(saved_data) 59 | end 60 | end 61 | 62 | def self.report_from_marshaled_results(marshaled_data) 63 | data = Marshal.load(marshaled_data) 64 | if data.is_a?(Array) 65 | data.compact.inject(nil) do |sum, d| 66 | sum ? StackProf::Report.new(d) + sum : StackProf::Report.new(d) 67 | end 68 | else 69 | StackProf::Report.new(data) 70 | end 71 | end 72 | 73 | private 74 | def collect_pids 75 | logger.debug "[stackprof] Collecting PIDs" 76 | @pids = @options[:pid_finder].call 77 | @pids -= [Process.pid] 78 | logger.debug "[stackprof] Found PIDs #{@pids.inspect} and current #{Process.pid}" 79 | end 80 | 81 | def execute(command) 82 | logger.debug "[stackprof] execute: #{command}" 83 | results = @pids.collect do |pid| 84 | begin 85 | tracer = RBTracer.new(pid) 86 | output = tracer.eval(command) 87 | ensure 88 | tracer.detach 89 | output 90 | end 91 | end 92 | results << eval(command) 93 | logger.debug "[stackprof] Results: #{results.inspect}" 94 | results 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /stackprof-remote.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec' 4 | # -*- encoding: utf-8 -*- 5 | # stub: stackprof-remote 0.1.0 ruby lib 6 | 7 | Gem::Specification.new do |s| 8 | s.name = "stackprof-remote" 9 | s.version = "0.1.0" 10 | 11 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 12 | s.require_paths = ["lib"] 13 | s.authors = ["Aaron Quint"] 14 | s.date = "2014-10-18" 15 | s.description = "stackprof-remote consists of a middleware for easy creation and retreival of\n stackprof sampling profiler dumps from a remote machine, and a wrapper around\n pry (stackprof-cli) to create an interactive session for navigating stackprof\n dumps." 16 | s.email = "aaron@quirkey.com" 17 | s.executables = ["stackprof-cli", "stackprof-remote"] 18 | s.extra_rdoc_files = [ 19 | "LICENSE.txt", 20 | "README.md" 21 | ] 22 | s.files = [ 23 | ".document", 24 | "Gemfile", 25 | "Gemfile.lock", 26 | "LICENSE.txt", 27 | "README.md", 28 | "Rakefile", 29 | "VERSION", 30 | "bin/stackprof-cli", 31 | "bin/stackprof-remote", 32 | "lib/stackprof-remote.rb", 33 | "lib/stackprof/cli.rb", 34 | "lib/stackprof/remote/client.rb", 35 | "lib/stackprof/remote/middleware.rb", 36 | "lib/stackprof/remote/process_report_collector.rb", 37 | "stackprof-remote.gemspec", 38 | "test/helper.rb", 39 | "test/test.dump", 40 | "test/test_stackprof-remote.rb" 41 | ] 42 | s.homepage = "http://github.com/quirkey/stackprof-remote" 43 | s.licenses = ["MIT"] 44 | s.rubygems_version = "2.2.2" 45 | s.summary = "A Middleware and CLI for fetching and interacting with StackProf dumps" 46 | 47 | if s.respond_to? :specification_version then 48 | s.specification_version = 4 49 | 50 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 51 | s.add_runtime_dependency(%q, ["~> 0.4.4"]) 52 | s.add_runtime_dependency(%q, ["~> 0.2.7"]) 53 | s.add_runtime_dependency(%q, ["~> 0.10.0"]) 54 | s.add_development_dependency(%q, ["~> 3.12"]) 55 | s.add_development_dependency(%q, ["~> 1.0"]) 56 | s.add_development_dependency(%q, ["~> 2.0.1"]) 57 | s.add_development_dependency(%q, [">= 0"]) 58 | else 59 | s.add_dependency(%q, ["~> 0.4.4"]) 60 | s.add_dependency(%q, ["~> 0.2.7"]) 61 | s.add_dependency(%q, ["~> 0.10.0"]) 62 | s.add_dependency(%q, ["~> 3.12"]) 63 | s.add_dependency(%q, ["~> 1.0"]) 64 | s.add_dependency(%q, ["~> 2.0.1"]) 65 | s.add_dependency(%q, [">= 0"]) 66 | end 67 | else 68 | s.add_dependency(%q, ["~> 0.4.4"]) 69 | s.add_dependency(%q, ["~> 0.2.7"]) 70 | s.add_dependency(%q, ["~> 0.10.0"]) 71 | s.add_dependency(%q, ["~> 3.12"]) 72 | s.add_dependency(%q, ["~> 1.0"]) 73 | s.add_dependency(%q, ["~> 2.0.1"]) 74 | s.add_dependency(%q, [">= 0"]) 75 | end 76 | end 77 | 78 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | 3 | module SimpleCov::Configuration 4 | def clean_filters 5 | @filters = [] 6 | end 7 | end 8 | 9 | SimpleCov.configure do 10 | clean_filters 11 | load_adapter 'test_frameworks' 12 | end 13 | 14 | ENV["COVERAGE"] && SimpleCov.start do 15 | add_filter "/.rvm/" 16 | end 17 | require 'rubygems' 18 | require 'bundler' 19 | begin 20 | Bundler.setup(:default, :development) 21 | rescue Bundler::BundlerError => e 22 | $stderr.puts e.message 23 | $stderr.puts "Run `bundle install` to install missing gems" 24 | exit e.status_code 25 | end 26 | 27 | require 'minitest/autorun' 28 | 29 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 30 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 31 | require 'stackprof-remote' 32 | -------------------------------------------------------------------------------- /test/test.dump: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quirkey/stackprof-remote/f9cdaa80112b2dd2b70dd59378772d52f38bfd2f/test/test.dump -------------------------------------------------------------------------------- /test/test_stackprof-remote.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class TestStackProfRemote < MiniTest::Unit::TestCase 4 | 5 | def test_should_load_a_marshaled_dump 6 | report = StackProf::Remote::ProcessReportCollector.report_from_marshaled_results(File.read('./test/test.dump')) 7 | assert report 8 | assert_kind_of StackProf::Report, report 9 | end 10 | 11 | def test_should_print_text 12 | report = StackProf::Remote::ProcessReportCollector.report_from_marshaled_results(File.read('./test/test.dump')) 13 | str = StringIO.new 14 | assert report.print_text(false, 10, str) 15 | assert_match(/ActiveSupport/, str.string) 16 | end 17 | 18 | end 19 | --------------------------------------------------------------------------------