├── VERSION ├── urls.log ├── LICENSE ├── spec ├── spec.opts ├── lib │ ├── mp_perf_spec.rb │ └── httperf_spec.rb ├── spec_helper.rb └── httperf_session_based_output.txt ├── .document ├── .gitignore ├── bin ├── stresser ├── stresser-loggen └── stresser-grapher ├── HISTORY.markdown ├── lib ├── reports.yml ├── grapher.rb ├── mp_perf.rb └── httperf.rb ├── sample.conf ├── Rakefile ├── stresser.gemspec └── README.markdown /VERSION: -------------------------------------------------------------------------------- 1 | 0.5.0 -------------------------------------------------------------------------------- /urls.log: -------------------------------------------------------------------------------- 1 | /status think=1 2 | / 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Moviepilot GmbH 2 | 3 | -------------------------------------------------------------------------------- /spec/spec.opts: -------------------------------------------------------------------------------- 1 | --colour 2 | --loadby mtime 3 | -u 4 | -f o 5 | -b 6 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | README.rdoc 2 | lib/**/*.rb 3 | bin/* 4 | features/**/*.feature 5 | LICENSE 6 | -------------------------------------------------------------------------------- /spec/lib/mp_perf_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe MPPerf do 4 | 5 | 6 | end 7 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.join(File.dirname(__FILE__), '..', 'lib') 2 | 3 | require 'rubygems' 4 | require 'rspec' 5 | require 'progressbar' 6 | require 'httperf' 7 | require 'mp_perf' 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## MAC OS 2 | .DS_Store 3 | 4 | ## TEXTMATE 5 | *.tmproj 6 | tmtags 7 | 8 | ## EMACS 9 | *~ 10 | \#* 11 | .\#* 12 | 13 | ## VIM 14 | *.swp 15 | *.swo 16 | 17 | ## PROJECT::GENERAL 18 | coverage 19 | rdoc 20 | pkg 21 | 22 | ## PROJECT::SPECIFIC 23 | -------------------------------------------------------------------------------- /bin/stresser: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib') 3 | require 'rubygems' 4 | require 'mp_perf' 5 | 6 | 7 | trap("INT") { 8 | puts "Terminating tests." 9 | Process.exit 10 | } 11 | 12 | MPPerf.new 13 | -------------------------------------------------------------------------------- /bin/stresser-loggen: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | begin 4 | template = File.open(ARGV[0], "r").read 5 | amount = ARGV[1].to_i 6 | puts "# Autogenerated with stresser-loggen #{ARGV.join(' ')}\n\n" 7 | amount.times do |i| 8 | puts template.gsub '{{n}}', i.to_s 9 | end 10 | rescue Exception => e 11 | puts "ERROR:" 12 | puts e 13 | puts "~"*80 14 | puts "Usage (e.g. to generate 15 sessions): stresser-loggen some.log.tpl 15 > some.log" 15 | end 16 | -------------------------------------------------------------------------------- /HISTORY.markdown: -------------------------------------------------------------------------------- 1 | ## 0.5 2 | Added a 'shuffle' option that will take your wlog file and shuffle the 3 | lines between each run. What it does in detail is: 4 | 5 | 1. Transform \0 to \n in your file 6 | 2. sort --random-sort your file 7 | 3. Transform \n to \0 8 | 4. Write to your_wlog.shuffled 9 | 10 | This can be used to confuse caches. Just set `shuffle=true` in your 11 | conf file. 12 | 13 | ## 0.4 14 | - added 'started at' column to csv export 15 | - refactored options pasing in stresser bin to match stresser-grapher and stresser-loggen 16 | -------------------------------------------------------------------------------- /bin/stresser-grapher: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib') 3 | 4 | require 'rubygems' 5 | require 'gruff' 6 | require 'grapher' 7 | require 'trollop' 8 | 9 | opts = Trollop::options do 10 | banner <<-EOS 11 | Takes a stresser csv file and creates graph images from it. 12 | 13 | Usage: 14 | stresser-grapher [options] csv_file 15 | where [options] are: 16 | EOS 17 | opt :report, "The report to generate", 18 | :type => String 19 | opt :report_definitions, "You can provide your own yaml file - it maps csv columns to data rows in an image", 20 | :type => String, 21 | :default => "lib/reports.yml" 22 | opt :output_dir, "Output directory", 23 | :type => String 24 | end.merge(:csv_file => ARGV.last) 25 | 26 | Trollop::die :output_dir, "must be writeable" unless opts[:output_dir] and File.stat(opts[:output_dir]).writable? 27 | Trollop::die :output_dir, "must be a directory" unless opts[:output_dir] and File.stat(opts[:output_dir]).directory? 28 | 29 | Grapher.generate_reports(opts) 30 | 31 | `open #{ARGV[1]}` if ARGV.last == "open" 32 | puts "Done." 33 | -------------------------------------------------------------------------------- /spec/httperf_session_based_output.txt: -------------------------------------------------------------------------------- 1 | 2 | Maximum connect burst length: 1 3 | 4 | Total: connections 500 requests 600 replies 300 test-duration 50.354 s 5 | 6 | Connection rate: 9.9 conn/s (100.7 ms/conn, <=8 concurrent connections) 7 | Connection time [ms]: min 449.7 avg 465.1 max 2856.6 median 451.5 stddev 132.1 8 | Connection time [ms]: connect 74.1 9 | Connection length [replies/conn]: 1.000 10 | 11 | Request rate: 9.9 req/s (100.7 ms/req) 12 | Request size [B]: 65.0 13 | 14 | Reply rate [replies/s]: min 9.2 avg 9.9 max 10.0 stddev 0.3 (10 samples) 15 | Reply time [ms]: response 88.1 transfer 302.9 16 | Reply size [B]: header 274.0 content 54744.0 footer 2.0 (total 55020.0) 17 | Reply status: 1xx=1 2xx=500 3xx=3 4xx=4 5xx=5 18 | 19 | CPU time [s]: user 15.65 system 34.65 (user 31.1% system 68.8% total 99.9%) 20 | Net I/O: 534.1 KB/s (4.4*10^6 bps) 21 | 22 | Errors: total 1234 client-timo 2345 socket-timo 3456 connrefused 4567 connreset 5678 23 | Errors: fd-unavail 1 addrunavail 2 ftab-full 3 other 4 24 | 25 | Session rate [sess/s]: min 35.80 avg 37.04 max 38.20 stddev 0.98 (1000/1000) 26 | Session: avg 2.00 connections/session 27 | Session lifetime [s]: 0.3 28 | Session failtime [s]: 0.0 29 | Session length histogram: 0 0 1000 30 | 31 | -------------------------------------------------------------------------------- /lib/reports.yml: -------------------------------------------------------------------------------- 1 | stati_per_second: 2 | [Requests/s, req/s, 3 | 1xx/s, status 1xx/s, 4 | 2xx/s, status 2xx/s, 5 | 3xx/s, status 3xx/s, 6 | 4xx/s, status 4xx/s, 7 | 5xx/s, status 5xx/s ] 8 | 9 | errors: 10 | [Requests, requests, 11 | Client timeouts, errors client-timo, 12 | Socket timeouts, errors socket-timo, 13 | Filedescriptor unavail, errors fd-unavail, 14 | Ftab full, errors ftab-full, 15 | Addr unavail, errors addrunavail, 16 | Conn. refused, errors connrefused, 17 | Conn. reset, errors connreset, 18 | Total errors, errors total] 19 | 20 | connection_time: 21 | [Average, conn time avg, 22 | Max, conn time max, 23 | Median, conn time median, 24 | Min, conn time min, 25 | Std. dev., conn time stddev] 26 | 27 | replies_per_second: 28 | [Average, replies/s avg, 29 | Max, replies/s max, 30 | Min, replies/s min, 31 | Std. dev., replies/s stddev] 32 | 33 | cpu: 34 | [System, cpu time system %, 35 | Total, cpu time total %, 36 | User, cpu time user %] 37 | 38 | session_rate: 39 | [Average, session rate avg, 40 | Maximum, session rate max, 41 | Minimum, session rate min, 42 | Std. dev., session rate stddev] 43 | 44 | milliseconds_per: 45 | [ms per connection, ms/connection, 46 | ms per request, ms/req] 47 | 48 | net_io: 49 | [KB/s, net i/o (KB/s)] 50 | 51 | connections: 52 | [Requests/s, req/s, 53 | Connections per second, conn/s] 54 | -------------------------------------------------------------------------------- /sample.conf: -------------------------------------------------------------------------------- 1 | # MPPerf Configuration File 2 | 3 | # The host, URI (relative to the document root) and port to test. 4 | host = localhost 5 | uri = / 6 | port = 80 7 | 8 | # The 'rate' is the number of number of connections to open per second. 9 | # A series of tests will be conducted, starting at low rate, 10 | # increasing by rate sep, and finishing at high_rate. 11 | low_rate = 5 12 | high_rate = 50 13 | rate_step = 5 14 | 15 | # The amount of seconds to sleep between each rate (to avoid pending 16 | # requests from one run influencing the next run) 17 | sleep_time = 10 18 | 19 | # httperf options 20 | 21 | # wlog specifies a replay log file (null terminated requests paths) 22 | # 'n' prefix tells httperf to stop after all requests in the file 23 | # have been replayed 24 | # httperf_wlog = "y,anonymous_forecasts_20.log" 25 | httperf_wsesslog = "100,10,urls.log" 26 | 27 | # num_conn is the total number of connections to make during a test 28 | # num_call is the number of requests per connection (if keep alive is supported) 29 | # The product of num_call and rate is the the approximate number of 30 | # requests per second that will be attempted. 31 | httperf_num-conns = 100 32 | httperf_num-calls = 1 33 | 34 | # timeout sets the maximimum time (in seconds) that httperf will wait 35 | # for replies from the web server. If the timeout is exceeded, the 36 | # reply concerned is counted as an error. 37 | httperf_timeout = 5 38 | 39 | httperf_burst-length=1 40 | 41 | httperf_add-header = "'Authorization: Basic hash\n'" 42 | httperf_session-cookie = nil 43 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rake' 3 | 4 | begin 5 | require 'jeweler' 6 | Jeweler::Tasks.new do |gem| 7 | gem.name = "stresser" 8 | gem.summary = %Q{Wrapper around httperf for stresstesting your app.} 9 | gem.description = %Q{Wrapper around httperf for stresstesting your app. Runs httperf multiple times with different concurrency levels and generates an executive summary in .csv} 10 | gem.email = "jannis@moviepilot.com" 11 | gem.homepage = "http://github.com/moviepilot/stresser" 12 | gem.authors = ["Jannis Hermanns"] 13 | gem.add_dependency 'ruport' 14 | gem.add_dependency 'gruff' 15 | gem.add_dependency 'OptionParser' 16 | gem.add_dependency 'trollop' 17 | end 18 | Jeweler::GemcutterTasks.new 19 | rescue LoadError 20 | puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler" 21 | end 22 | 23 | require 'rake/testtask' 24 | Rake::TestTask.new(:test) do |test| 25 | test.libs << 'lib' << 'test' 26 | test.pattern = 'test/**/test_*.rb' 27 | test.verbose = true 28 | end 29 | 30 | begin 31 | require 'rcov/rcovtask' 32 | Rcov::RcovTask.new do |test| 33 | test.libs << 'test' 34 | test.pattern = 'test/**/test_*.rb' 35 | test.verbose = true 36 | end 37 | rescue LoadError 38 | task :rcov do 39 | abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov" 40 | end 41 | end 42 | 43 | task :test => :check_dependencies 44 | 45 | task :default => :test 46 | 47 | require 'rake/rdoctask' 48 | Rake::RDocTask.new do |rdoc| 49 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 50 | rdoc.rdoc_dir = 'rdoc' 51 | rdoc.title = "stresser #{version}" 52 | rdoc.rdoc_files.include('README*') 53 | rdoc.rdoc_files.include('lib/**/*.rb') 54 | end 55 | 56 | 57 | require 'spec/rake/spectask' 58 | 59 | desc "Run all examples" 60 | Spec::Rake::SpecTask.new('spec') do |t| 61 | t.spec_files = FileList['spec/**/*.rb'] 62 | end 63 | 64 | -------------------------------------------------------------------------------- /stresser.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command 4 | # -*- encoding: utf-8 -*- 5 | 6 | Gem::Specification.new do |s| 7 | s.name = %q{stresser} 8 | s.version = "0.4.0" 9 | 10 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 11 | s.authors = ["Jannis Hermanns"] 12 | s.date = %q{2010-11-30} 13 | s.description = %q{Wrapper around httperf for stresstesting your app. Runs httperf multiple times with different concurrency levels and generates an executive summary™ in .csv"} 14 | s.email = %q{jannis@moviepilot.com} 15 | s.executables = ["stresser", "stresser-grapher", "stresser-loggen"] 16 | s.extra_rdoc_files = [ 17 | "LICENSE", 18 | "README.markdown" 19 | ] 20 | s.files = [ 21 | ".document", 22 | ".gitignore", 23 | "LICENSE", 24 | "README.markdown", 25 | "Rakefile", 26 | "VERSION", 27 | "bin/stresser", 28 | "bin/stresser-grapher", 29 | "bin/stresser-loggen", 30 | "lib/grapher.rb", 31 | "lib/httperf.rb", 32 | "lib/mp_perf.rb", 33 | "lib/reports.yml", 34 | "sample.conf", 35 | "spec/httperf_session_based_output.txt", 36 | "spec/lib/httperf_spec.rb", 37 | "spec/lib/mp_perf_spec.rb", 38 | "spec/spec.opts", 39 | "spec/spec_helper.rb", 40 | "stresser.gemspec", 41 | "urls.log" 42 | ] 43 | s.homepage = %q{http://github.com/moviepilot/stresser} 44 | s.rdoc_options = ["--charset=UTF-8"] 45 | s.require_paths = ["lib"] 46 | s.rubygems_version = %q{1.3.7} 47 | s.summary = %q{Wrapper around httperf for stresstesting your app.} 48 | s.test_files = [ 49 | "spec/lib/httperf_spec.rb", 50 | "spec/lib/mp_perf_spec.rb", 51 | "spec/spec_helper.rb" 52 | ] 53 | 54 | if s.respond_to? :specification_version then 55 | current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION 56 | s.specification_version = 3 57 | 58 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 59 | s.add_runtime_dependency(%q, [">= 0"]) 60 | s.add_runtime_dependency(%q, [">= 0"]) 61 | s.add_runtime_dependency(%q, [">= 0"]) 62 | s.add_runtime_dependency(%q, [">= 0"]) 63 | else 64 | s.add_dependency(%q, [">= 0"]) 65 | s.add_dependency(%q, [">= 0"]) 66 | s.add_dependency(%q, [">= 0"]) 67 | s.add_dependency(%q, [">= 0"]) 68 | end 69 | else 70 | s.add_dependency(%q, [">= 0"]) 71 | s.add_dependency(%q, [">= 0"]) 72 | s.add_dependency(%q, [">= 0"]) 73 | s.add_dependency(%q, [">= 0"]) 74 | end 75 | end 76 | 77 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Stresser 2 | 3 | This gem is a wrapper around the httperf command which 4 | can put all types of loads on a webserver. It's like 5 | apachebench, but you can replay log files, define 6 | sessions, and so forth. 7 | 8 | This gem calls httperf many times with different 9 | concurrency settings and parses httperf's output into 10 | a csv file, that you can then use to visualize your 11 | application's performance at different concurrency 12 | levels 13 | 14 | ## Sample graphs 15 | 16 | Here's a collection of graphs that this gem currently 17 | creates (though you can create your own by creating a 18 | YML file that maps columns from the generated csv file 19 | to labels for the image). 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ## Installation 42 | 43 | First install the gem 44 | 45 | $ gem install stresser 46 | 47 | ## Configuration 48 | 49 | Please refer to the supplied `sample.conf` on how to 50 | configure stresser. Also, see `man httperf` as all 51 | options in `sample.conf` beginning with `httperf_` 52 | go directly to the httperf commands. 53 | 54 | ## Examples 55 | 56 | ### Stresstest 57 | You can call stresser from the command line: 58 | 59 | $ stresser -c your_app.conf -o /tmp/stress/result.csv 60 | ... lots of httperf output... 61 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 62 | Great, now create a graph with 63 | stresser-grapher -o /tmp/stress /tmp/stress/result.csv 64 | $ 65 | 66 | You will see the output of the httperf commands that 67 | are issued, and a full report will be written to 68 | result.csv. 69 | 70 | ### Creating graphs 71 | When you're done, you can create a graph of your testrun like this: 72 | 73 | $ stresser-grapher -o /tmp/stress /tmp/stress/result.csv 74 | Generating stati_per_second to /tmp/stress/2010_10_25_17_28_stati_per_second.png... 75 | Generating replies_per_second to /tmp/stress/2010_10_25_17_28_replies_per_second.png... 76 | Generating errors to /tmp/stress/2010_10_25_17_28_errors.png... 77 | Generating connection_time to /tmp/stress/2010_10_25_17_28_connection_time.png... 78 | Generating cpu to /tmp/stress/2010_10_25_17_28_cpu.png... 79 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 80 | Great, now open the images with 81 | open /tmp/2010_10_25_17_28*.png 82 | $ 83 | 84 | ### Log generator 85 | As a little helper to generate log files defining some 86 | session workload that requires different urls, 87 | `stresser-loggen` is supplied. Just create a log template 88 | named `mylog.tpl` like this 89 | 90 | # My session workload 91 | /users/{{n}} 92 | /images/foo.gif 93 | /images/bar.gif 94 | /users{{n}}/dashboard 95 | 96 | And then use `stresser-loggen` to reproduce these lines 97 | as often as you like: 98 | 99 | stresser-loggen mylog.tpl 100 > mylog.conf 100 | 101 | The `{{n}}` will be replaced with the numbers 0-99. 102 | 103 | ## Tests 104 | Run `rake spec` to run them tests. Currently, only httperf's output of a session based 105 | replay log is parsed, but I will add more. 106 | 107 | ## Thanks 108 | 109 | Stresser is based on igvita's autoperf driver for httperf. 110 | -------------------------------------------------------------------------------- /lib/grapher.rb: -------------------------------------------------------------------------------- 1 | require 'ruport' 2 | require 'gruff' 3 | require 'yaml' 4 | require 'csv' 5 | 6 | $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib') 7 | 8 | module Grapher 9 | extend self 10 | 11 | # 12 | # Parses command line options and creates one or a bunch of 13 | # reports, stores them in the given directory, and advises 14 | # the user to go ahead and open them 15 | # 16 | def generate_reports(options) 17 | 18 | # Let's keep things clean 19 | prefix = Time.now.strftime("%Y_%m_%d_%H_%M") 20 | 21 | # Generate a single report or all of them? 22 | report_keys = reports(options[:report_definitions]).keys 23 | report_keys = [options[:report]] if report_keys.include?(options[:report]) 24 | 25 | # Generate report(s) 26 | report_keys.each do |report| 27 | begin 28 | outfile = File.join(options[:output_dir], "#{prefix}_#{report}.png") 29 | generate_report(report, options[:csv_file], outfile) 30 | rescue => e 31 | puts "Error generating #{report}: #{e.inspect}" 32 | end 33 | end 34 | 35 | # Tell user what to do next 36 | puts "~"*80 37 | puts "Great, now open the images with" 38 | puts " open #{File.join(options[:output_dir], prefix)}*.png" 39 | end 40 | 41 | # 42 | # Generates a single report given by name. Uses the yml file for 43 | # report names 44 | # 45 | def generate_report(report_type, csv_file, outfile) 46 | puts "Generating #{report_type} to #{outfile}..." 47 | columns = (reports[report_type] or reports[reports.keys.first]) 48 | save_graph(csv_file, columns, outfile, title: report_type) 49 | end 50 | 51 | # 52 | # Creates and saves a graph 53 | # 54 | def save_graph(csv_file, columns, outfile, options = {}) 55 | # Draw graph 56 | g = graph(csv_file, columns, title: options[:title] ) 57 | 58 | # Save graph 59 | g.write(outfile) 60 | end 61 | 62 | # 63 | # Creates a graph from a csv file 64 | # 65 | # The headers are converted to symbols in the Ruby 1.9.X CSV library 66 | def graph(csv_file, columns, options = {}) 67 | table = CSV.table(csv_file, headers: true) 68 | 69 | # Prepare data structure 70 | data = Hash.new 71 | 72 | labels = table.values_at(:rate).flatten 73 | columns.each_index do |i| 74 | next unless i%2==0 75 | col_name = columns[i+1].gsub(' ','_').gsub('/','') 76 | data[columns[i]] = table.values_at(col_name.to_sym).flatten 77 | end 78 | 79 | # Draw graph 80 | line_graph( options[:title], data, labels ) 81 | end 82 | 83 | # 84 | # Reads a YAML file that defines how reports are built 85 | # 86 | def reports(report = nil, yaml_file = File.join(File.dirname(__FILE__), "reports.yml")) 87 | YAML.load(File.read(yaml_file)) 88 | end 89 | 90 | protected 91 | 92 | def line_graph(title, data, labels) 93 | # Prepare line graph 94 | g = Gruff::Line.new 95 | g.title = title 96 | set_defaults(g) 97 | 98 | # Add datas 99 | data.each do |name, values| 100 | g.data(name, values.map(&:to_i)) 101 | end 102 | 103 | # Add labels 104 | g.labels = to_hash(labels) 105 | 106 | # Return graph 107 | g 108 | end 109 | 110 | def to_hash(array) 111 | return array if array.class == Hash 112 | hash = Hash.new 113 | array.each_with_index {|v, i| hash[i] = v.to_s } 114 | hash 115 | end 116 | 117 | def set_defaults(g) 118 | g.hide_dots = true 119 | g.line_width = 2 120 | g.legend_font_size = 20 121 | g.marker_font_size = 10 122 | g.sort = false 123 | g.x_axis_label = "concurrency (amount of parallel req)" 124 | 125 | colors = %w{EFD279 95CBE9 024769 AFD775 2C5700 DE9D7F B6212D 7F5417}.map{|c| "\##{c}"} 126 | 127 | g.theme = { 128 | colors: colors, 129 | marker_color: "#cdcdcd", 130 | font_color: 'black', 131 | background_colors: ['#fefeee', '#ffffff'] 132 | } 133 | end 134 | 135 | end 136 | 137 | -------------------------------------------------------------------------------- /lib/mp_perf.rb: -------------------------------------------------------------------------------- 1 | require 'optparse' 2 | require 'ruport' 3 | require 'httperf' 4 | require 'trollop' 5 | require 'csv' 6 | 7 | # 8 | # Takes command line options and attempts to make a benchmark. 9 | # 10 | class MPPerf 11 | 12 | def initialize(opts = {}) 13 | parse_options 14 | parse_config 15 | run_suite 16 | display_hints 17 | end 18 | 19 | # 20 | # Parse command line options 21 | # 22 | def parse_options 23 | @conf = Trollop::options do 24 | banner = MPPerf.options_banner 25 | opt :output_file, "The name of the csv file to write the results to. Warning: overwrites that file!", 26 | :type => String 27 | opt :config_file, "The name of the .conf file defining your testsuite. See http://github.com/moviepilot/stresser", 28 | :type => String 29 | end 30 | 31 | Trollop::die :output_file, "must be a writeable file" unless @conf[:output_file] 32 | Trollop::die :config_file, "must be a readable file" unless @conf[:config_file] 33 | end 34 | 35 | # 36 | # Taken from http://github.com/igrigorik/autoperf 37 | # 38 | def parse_config 39 | config_file = @conf[:config_file] 40 | raise Errno::EACCES, "#{config_file} is not readable" unless File.readable?(config_file) 41 | 42 | conf = {} 43 | open(config_file).each { |line| 44 | line.chomp 45 | unless (/^\#/.match(line)) 46 | if(/\s*=\s*/.match(line)) 47 | param, value = line.split(/\s*=\s*/, 2) 48 | var_name = "#{param}".chomp.strip 49 | value = value.chomp.strip 50 | new_value = '' 51 | if (value) 52 | if value =~ /^['"](.*)['"]$/ 53 | new_value = $1 54 | else 55 | new_value = value 56 | end 57 | else 58 | new_value = '' 59 | end 60 | conf[var_name] = new_value =~ /^\d+$/ ? new_value.to_i : new_value 61 | end 62 | end 63 | } 64 | 65 | @conf.merge! conf 66 | end 67 | 68 | # 69 | # Runs a single benchmark (this method will be called many times 70 | # with different concurrency levels) 71 | # 72 | def single_benchmark(conf) 73 | cloned_conf = conf.clone 74 | 75 | # Shuffle the logfile around? 76 | if conf['httperf_wlog'] and conf['shuffle']=='true' 77 | file = conf['httperf_wlog'].split(',').last 78 | `cat #{file} | tr "\\0" "\\n" | sort --random-sort | tr "\\n" "\\0" > #{file}.shuffled` 79 | cloned_conf['httperf_wlog'] = conf['httperf_wlog']+'.shuffled' 80 | end 81 | 82 | # Run httperf 83 | res = Httperf.run(cloned_conf) 84 | 85 | return res 86 | end 87 | 88 | def run_suite 89 | results = {} 90 | # report = nil 91 | report = CSV::Table.new([]) 92 | (@conf['low_rate']..@conf['high_rate']).step(@conf['rate_step']) do |rate| 93 | 94 | # Run httperf 95 | results[rate] = single_benchmark(@conf.merge({'httperf_rate' => rate})) 96 | 97 | # Show that we're alive 98 | puts "#{results[rate].delete('output')}\n" 99 | puts "~"*80 100 | 101 | # Init table unless it's there already 102 | # report ||= CSV::Table.new(:column_names => ['rate'] + results[rate].keys.sort) 103 | # table_headers ||= ['rate'] + results[rate].keys 104 | table_headers ||= results[rate].keys + ['rate'] 105 | report[0] ||= CSV::Row.new(table_headers, [], true) 106 | 107 | # Save results of this run 108 | # report << results[rate].merge({'rate' => rate}) 109 | report_hash = results[rate].merge({'rate' => rate}) 110 | report << report_hash.values 111 | 112 | # Try to keep old pending requests from influencing the next round 113 | sleep(@conf['sleep_time'] || 0) 114 | end 115 | 116 | # Write csv 117 | File.new(@conf[:output_file], "w").puts report.to_csv unless report.nil? 118 | end 119 | 120 | def display_hints 121 | puts "~"*80 122 | puts "Great, now create a graph with" 123 | puts " stresser-grapher -o #{File.expand_path(File.dirname(@conf[:output_file]))} #{@conf[:output_file]}" 124 | puts "" 125 | end 126 | 127 | def self.options_banner 128 | <&1") do |pipe| 10 | res.merge! parse_output(pipe) 11 | 12 | # Now calculate the amount of stati per second 13 | (1..5).each do |i| 14 | begin 15 | res["status #{i}xx/s"] = res["status #{i}xx"].to_i / res["duration"].to_i 16 | rescue 17 | res["status #{i}xx/s"] = -1 18 | end 19 | end 20 | end 21 | res 22 | end 23 | 24 | def parse_output(pipe) 25 | res = Hash.new("") 26 | 27 | while((line = pipe.gets)) 28 | res['output'] += line 29 | 30 | title, data = line.split(':') 31 | next unless title and data 32 | nrs = grep_numbers(data) 33 | 34 | case title 35 | when "Total" then 36 | res['conns'] = nrs[0] 37 | res['requests'] = nrs[1] 38 | res['replies'] = nrs[2] 39 | res['duration'] = nrs[3] 40 | when "Connection rate" then 41 | res['conn/s'] = nrs[0] 42 | res['ms/connection'] = nrs[1] 43 | res['concurrent connections max'] = nrs[2] 44 | when "Connection time [ms]" then 45 | if data.start_with?(" min") 46 | res['conn time min'] = nrs[0] 47 | res['conn time avg'] = nrs[1] 48 | res['conn time max'] = nrs[2] 49 | res['conn time median'] = nrs[3] 50 | res['conn time stddev'] = nrs[4] 51 | else 52 | next unless data.start_with?(" connect") 53 | res['conn time connect'] = nrs[0] 54 | end 55 | when "Connection length [replies/conn]" then 56 | res['conn length replies/conn'] = nrs[0] 57 | when "Request rate" then 58 | res['req/s'] = nrs[0] 59 | res['ms/req'] = nrs[1] 60 | when "Request size [B]" 61 | res['request size'] = nrs[0] 62 | when "Reply rate [replies/s]" then 63 | res['replies/s min'] = nrs[0] 64 | res['replies/s avg'] = nrs[1] 65 | res['replies/s max'] = nrs[2] 66 | res['replies/s stddev'] = nrs[3] 67 | when "Reply time [ms]" then 68 | res['reply time response'] = nrs[0] 69 | res['reply time transfer'] = nrs[1] 70 | when "Reply size [B]" then 71 | res['reply size header'] = nrs[0] 72 | res['reply size content'] = nrs[1] 73 | res['reply size footer'] = nrs[2] 74 | res['reply size total'] = nrs[3] 75 | when "Reply status" then 76 | res['status 1xx'] = nrs[0] 77 | res['status 2xx'] = nrs[1] 78 | res['status 3xx'] = nrs[2] 79 | res['status 4xx'] = nrs[3] 80 | res['status 5xx'] = nrs[4] 81 | when "CPU time [s]" then 82 | res['cpu time user'] = nrs[0] 83 | res['cpu time system'] = nrs[1] 84 | res['cpu time user %'] = nrs[2] 85 | res['cpu time system %'] = nrs[3] 86 | res['cpu time total %'] = nrs[4] 87 | when "Net I/O" then 88 | unit = line.match(/Net I\/O: [\d]+\.[\d+] ([^ ]+)/) 89 | res["net i/o (#{unit[1]})"] = nrs[0] 90 | when "Errors" then 91 | if data.start_with?(' total') 92 | res['errors total'] = nrs[0] 93 | res['errors client-timo'] = nrs[1] 94 | res['errors socket-timo'] = nrs[2] 95 | res['errors connrefused'] = nrs[3] 96 | res['errors connreset'] = nrs[4] 97 | else 98 | res['errors fd-unavail'] = nrs[0] 99 | res['errors addrunavail'] = nrs[1] 100 | res['errors ftab-full'] = nrs[2] 101 | res['errors other'] = nrs[3] 102 | end 103 | when "Session rate [sess/s]" then 104 | res['session rate min'] = nrs[0] 105 | res['session rate avg'] = nrs[1] 106 | res['session rate max'] = nrs[2] 107 | res['session rate stddev'] = nrs[3] 108 | res['session rate quota'] = "#{nrs[4]}/#{nrs[5]}" 109 | when "Session" then 110 | res['session avg conns/sess'] = nrs[0] 111 | when "Session lifetime [s]" then 112 | res['session lifetime [s]'] = nrs[0] 113 | when "Session failtime [s]" then 114 | res['session failtime [s]'] = nrs[0] 115 | when "Session length histogram" then 116 | res['session length histogram'] = nrs.join(" ") 117 | end 118 | end 119 | res 120 | end 121 | 122 | private 123 | 124 | def grep_numbers(line) 125 | line.scan(/(\d+\.?\d*)[^x]/).flatten.map do |s| 126 | s.include?(".") ? s.to_f : s.to_i 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/lib/httperf_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Httperf do 4 | 5 | describe "converting config to command line options" do 6 | let(:conf) do 7 | { 8 | "host"=>"localhost", "uri"=>"/", "port"=>80, 9 | "low_rate"=>5, "high_rate"=>50, "rate_step"=>5, "sleep_time"=>10, 10 | "httperf_wsesslog"=>"100,10,urls.log", "httperf_num-conns"=>100, 11 | "httperf_num-calls"=>1, "httperf_timeout"=>5, 12 | "httperf_burst-length"=>1, 13 | "httperf_rate"=>5 14 | } 15 | end 16 | 17 | context "when conf value is nil" do 18 | it "doesn't set value for command line option" do 19 | IO.should_receive(:popen).with(/ --session-cookie /) 20 | 21 | Httperf.run(conf.merge({"httperf_session-cookie"=>"nil"})) 22 | end 23 | end 24 | 25 | context "add-header is double quoted" do 26 | it "sets correct --add-header option" do 27 | IO.should_receive(:popen).with(/ --add-header='Authorization: Basic hash\n' /) 28 | 29 | Httperf.run(conf.merge({"httperf_add-header"=>"'Authorization: Basic hash\n'"})) 30 | end 31 | end 32 | end 33 | describe "parsing httperf output" do 34 | 35 | before(:each) do 36 | @pipe = File.open("spec/httperf_session_based_output.txt") 37 | Time.stub!(:now).and_return "Tue Nov 30 15:49:08 0100 2010" 38 | 39 | # 40 | # The friendly snail is greeting you! 41 | # 42 | IO.should_receive(:popen).and_yield @pipe 43 | @httperf = Httperf.run({}) 44 | end 45 | 46 | it "should parse the 'Total' line correctly" do 47 | @httperf['conns'].should == 500 48 | @httperf['requests'].should == 600 49 | @httperf['replies'].should == 300 50 | @httperf['duration'].should == 50.354 51 | end 52 | 53 | it "should parse the 'Connection rate' line correctly" do 54 | @httperf['conn/s'].should == 9.9 55 | @httperf['ms/connection'].should == 100.7 56 | @httperf['concurrent connections max'].should == 8 57 | end 58 | 59 | it "should parse the 'Connection time' line correctly" do 60 | @httperf['conn time min'].should == 449.7 61 | @httperf['conn time avg'].should == 465.1 62 | @httperf['conn time max'].should == 2856.6 63 | @httperf['conn time median'].should == 451.5 64 | @httperf['conn time stddev'].should == 132.1 65 | end 66 | 67 | it "should parse the second 'Connection time' line correctly" do 68 | @httperf['conn time connect'].should == 74.1 69 | end 70 | 71 | it "should parse the 'Connection length' line correctly" do 72 | @httperf['conn length replies/conn'].should == 1.0 73 | end 74 | 75 | it "should parse the 'Request rate' line correctly" do 76 | @httperf['req/s'].should == 9.9 77 | @httperf['ms/req'].should == 100.7 78 | end 79 | 80 | it "should parse the 'Request size' line correctly" do 81 | @httperf['request size'].should == 65.0 82 | end 83 | 84 | it "should parse the 'Reply rate' line correctly" do 85 | @httperf['replies/s min'].should == 9.2 86 | @httperf['replies/s avg'].should == 9.9 87 | @httperf['replies/s max'].should == 10.0 88 | @httperf['replies/s stddev'].should == 0.3 89 | end 90 | 91 | it "should parse the 'Reply time' line correctly" do 92 | @httperf['reply time response'].should == 88.1 93 | @httperf['reply time transfer'].should == 302.9 94 | end 95 | 96 | it "should parse the 'Reply size' line correctly" do 97 | @httperf['reply size header'].should == 274.0 98 | @httperf['reply size content'].should == 54744.0 99 | @httperf['reply size footer'].should == 2.0 100 | @httperf['reply size total'].should == 55020.0 101 | end 102 | 103 | it "should parse the 'Reply status' line correctly" do 104 | @httperf['status 1xx'].should == 1 105 | @httperf['status 2xx'].should == 500 106 | @httperf['status 3xx'].should == 3 107 | @httperf['status 4xx'].should == 4 108 | @httperf['status 5xx'].should == 5 109 | end 110 | 111 | it "should parse the 'CPU time' line correctly" do 112 | @httperf['cpu time user'].should == 15.65 113 | @httperf['cpu time system'].should == 34.65 114 | @httperf['cpu time user %'].should == 31.1 115 | @httperf['cpu time system %'].should == 68.8 116 | @httperf['cpu time total %'].should == 99.9 117 | end 118 | 119 | it "should parse the 'Net I/O' line correctly" do 120 | @httperf['net i/o (KB/s)'].should == 534.1 121 | end 122 | 123 | it "should parse the first 'Errors' line correctly" do 124 | @httperf['errors total'].should == 1234 125 | @httperf['errors client-timo'].should == 2345 126 | @httperf['errors socket-timo'].should == 3456 127 | @httperf['errors connrefused'].should == 4567 128 | @httperf['errors connreset'].should == 5678 129 | end 130 | 131 | it "should parse the second 'Errors' line correctly" do 132 | @httperf['errors fd-unavail'].should == 1 133 | @httperf['errors addrunavail'].should == 2 134 | @httperf['errors ftab-full'].should == 3 135 | @httperf['errors other'].should == 4 136 | end 137 | 138 | it "should parse the 'Session rate' line correctly" do 139 | @httperf['session rate min'].should == 35.80 140 | @httperf['session rate avg'].should == 37.04 141 | @httperf['session rate max'].should == 38.20 142 | @httperf['session rate stddev'].should == 0.98 143 | @httperf['session rate quota'].should == "1000/1000" 144 | end 145 | 146 | it "should parse the 'Session' line correctly" do 147 | @httperf['session avg conns/sess'].should == 2.00 148 | end 149 | 150 | it "should parse the 'Session lifetime' line correctly" do 151 | @httperf['session lifetime [s]'].should == 0.3 152 | end 153 | 154 | it "should parse the 'Session failtime' line correctly" do 155 | @httperf['session failtime [s]'].should == 0.0 156 | end 157 | 158 | it "should parse the 'Session length histogram' correctly" do 159 | @httperf['session length histogram'].should == "0 0 1000" 160 | end 161 | 162 | it "should add a started at timestamp for each rate" do 163 | @httperf['started at'].should == "Tue Nov 30 15:49:08 0100 2010" 164 | end 165 | 166 | end 167 | end 168 | 169 | --------------------------------------------------------------------------------