├── .gitignore ├── README.md ├── img └── cube.png ├── lib ├── agent.js ├── http.js └── protocol.js ├── load.js ├── package.json └── test ├── http.js └── http_server.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled source # 2 | ################### 3 | *.class 4 | *.dll 5 | *.exe 6 | *.o 7 | *.so 8 | 9 | # Packages # 10 | ############ 11 | # it's better to unpack these files and commit the raw source 12 | # git has its own built in compression methods 13 | *.7z 14 | *.dmg 15 | *.gz 16 | *.iso 17 | *.jar 18 | *.rar 19 | *.tar 20 | *.zip 21 | 22 | # Logs and databases # 23 | ###################### 24 | *.log 25 | *.sql 26 | *.sqlite 27 | 28 | # OS generated files # 29 | ###################### 30 | .DS_Store? 31 | ehthumbs.db 32 | Icon? 33 | Thumbs.db 34 | 35 | # SASS Cache # 36 | ###################### 37 | .sass-cache 38 | 39 | *~ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | Spray is a load testing tool designed for HTTP testing. It allows for easy testing of RESTful APIs and provides useful statistics. Spray integrates with [cube](https://github.com/square/cube) to provide realtime graphs of the running load test. To use multiple cores, just run the same test in parallel. Cube can handle the aggregation. 3 | 4 | ![graph](https://github.com/bozuko/spray/raw/master/img/cube.png) 5 | 6 | ## Example 7 | 8 | ```javascript 9 | var async = require('async'), 10 | Load = require('spray'), 11 | inspect = require('util').inspect 12 | ; 13 | 14 | process.on('exit', function() { 15 | console.log(load.stats); 16 | }); 17 | 18 | var options = { 19 | protocol: 'https', 20 | hostname: 'example.com', 21 | port: 8000, 22 | rate: 100, // req/sec 23 | time: 300, // sec 24 | timeout: 20000, //ms -- socket timeout 25 | max_sessions: 500, 26 | enable_cube:true, 27 | sessions: [{ 28 | weight: 1, 29 | start: start_session 30 | }] 31 | }; 32 | 33 | var load = new Load(options); 34 | load.run(function(err, results) { 35 | if (err) return console.error("Err = "+err); 36 | console.log("\nresults = "+require('util').inspect(results)); 37 | }); 38 | 39 | load.on('sec', function(stats) { 40 | console.log("ONE SEC!"); 41 | }); 42 | 43 | load.on('min', function(stats) { 44 | console.log("ONE MIN"); 45 | console.log(stats); 46 | }); 47 | 48 | function start_session(http, callback) { 49 | return http.request({ 50 | headers: {'content-type': 'application/json'}, 51 | encoding: 'utf-8', 52 | path: '/api', 53 | method: 'GET' 54 | }, function(err, res) { 55 | if (err) return callback(err); 56 | if (res.statusCode != 200) return callback(res.statusCode); 57 | return callback(null); 58 | }); 59 | } 60 | ``` 61 | 62 | ## API 63 | 64 | ### Constructor 65 | 66 | ```javascript 67 | var Spray = require('spray'); 68 | var config = { 69 | protocol: 'https', 70 | hostname: 'example.com', 71 | port: 8000, 72 | rate: 100, // req/sec 73 | time: 300, // sec 74 | timeout: 20000, //ms -- socket timeout 75 | max_sessions: 500, 76 | enable_cube:true, 77 | sessions: [{ 78 | weight: 1, 79 | start: start_session // function defined elsewhere 80 | }] 81 | }; 82 | var spray = new Spray(config); 83 | ``` 84 | 85 | #### config object 86 | 87 | * **protocol**: string - 'http' || 'https' 88 | * **hostname**: string - The hostname used in http.request 89 | * **port**: number - The port used in http.request 90 | * **rate**: number - Requests/second 91 | * **time**: number - Duration of the test in seconds 92 | * **timeout**: number - The timeout for http requests (ms) 93 | * **max_sessions**: number - The maximum number of concurrent sessions to run 94 | * **sessions**: [sessions] 95 | * **weight**: number - A weight which changes the probabilities for session selection. 96 | * **start**: function - ```start_session(http, callback)```. Make http requests with the ```http.request``` method and call ```callback``` when the session is over. 97 | * **enable_cube**: boolean - Whether or not to enable cube graphing. Requires mongodb and cube. defaults to falsy. 98 | * **agent**: mixed - the http agent setting for ```http.request``` - Defaults to a custom agent implementation with maxSockets set to 1. Can be passed another agent or ```false``` to use no agent. 99 | 100 | 101 | ### http.request 102 | 103 | An http object gets passed to the session ``start`` function. This function keeps stats on all requests and should be used to send all requests. The options are the same as the http.request function provided by node. The only difference is the agent option described in the **Sessions** section below. 104 | 105 | ```javascript 106 | function start_session(http, callback) { 107 | return http.request({ 108 | headers: { 109 | 'content-type': 'application/json', 110 | 'connection': 'keep-alive' // use the same socket for the next request 111 | }, 112 | encoding: 'utf-8', 113 | path: '/api', 114 | method: 'GET' 115 | }, function(err, res) { 116 | if (err) return callback(err); 117 | if (res.statusCode != 200) return callback(res.statusCode); 118 | var user = JSON.parse(res.body); 119 | return http.request({ 120 | headers: { 121 | 'content-type': 'application/json' 122 | // no 'connection': 'keep-alive' header closes the socket as this is the last req in this session 123 | }, 124 | encoding: 'utf-8', 125 | path: user.links.checkin+'/?token='+token, 126 | method: 'POST', 127 | body: JSON.stringify({ 128 | ll: [42.3, -71.8] 129 | }) 130 | }, function(err, res) { 131 | if (err) return callback(err); 132 | if (res.statusCode != 200) return callback(res.statusCode); 133 | return callback(null); 134 | }); 135 | }); 136 | } 137 | ``` 138 | 139 | ## Sessions 140 | In order to simulate real clients we want each client to use the same socket for each request in the same session if the 'connection' header is set to 'keep-alive'. A custom [agent](https://github.com/bozuko/spray/blob/master/lib/agent.js) is used to facilitate this, and the default is to use that agent. A different agent can be used by passing it in the agent parameter of the Spray constructor options. Setting agent to false uses no agent. It is recommended to use the custom agent if keep-alive is used by clients of your API. 141 | 142 | ## Install 143 | 144 | npm install spray 145 | 146 | If you would like live charts with cube, you must install [cube](https://github.com/square/cube/wiki) and [MongoDb](http://www.mongodb.org/display/DOCS/Quickstart) 147 | 148 | ## Cube properties 149 | 150 | **Event Type**: 'random' 151 | 152 | This event type is used because it allows us to use cube's default collections. 153 | 154 | **Properties**: 155 | 156 | Spray reports to cube every second. The properties below are totals for that second, except for latency, which is the average latency of all packets during that second. 157 | 158 | * sent 159 | * received 160 | * latency 161 | * timeouts 162 | * errors 163 | 164 | Example Cube Query 165 | 166 | median(random(latency)) 167 | 168 | ## License 169 | 170 | ### The MIT License (MIT) 171 | 172 | Copyright (c) 2011 Bozuko, Inc. 173 | 174 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 175 | 176 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 177 | 178 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /img/cube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bozuko/spray/986c82d1b2a174f3f564d5af98b3564fb21413c6/img/cube.png -------------------------------------------------------------------------------- /lib/agent.js: -------------------------------------------------------------------------------- 1 | var HttpAgentBase = require('http').Agent, 2 | HttpsAgentBase = require('https').Agent, 3 | util = require('util'), 4 | net = require('net'), 5 | tls = require('tls') 6 | ; 7 | 8 | var HttpAgent = function(options) { 9 | var self = this; 10 | self.options = options || {}; 11 | self.requests = {}; 12 | self.sockets = {}; 13 | self.maxSockets = 1; 14 | self.on('free', function(socket, host, port) { 15 | var name = host + ':' + port; 16 | if (self.requests[name] && self.requests[name].length) { 17 | self.requests[name].shift().onSocket(socket); 18 | } else { 19 | if (self.spray_session_complete) { 20 | socket.destroy(); 21 | } 22 | } 23 | }); 24 | self.createConnection = net.createConnection; 25 | }; 26 | 27 | util.inherits(HttpAgent, HttpAgentBase); 28 | 29 | var HttpsAgent = function(options) { 30 | var self = this; 31 | self.options = options || {}; 32 | self.requests = {}; 33 | self.sockets = {}; 34 | self.maxSockets = 1; 35 | self.on('free', function(socket, host, port) { 36 | var name = host + ':' + port; 37 | if (self.requests[name] && self.requests[name].length) { 38 | self.requests[name].shift().onSocket(socket); 39 | } else { 40 | if (self.spray_session_complete) { 41 | socket.destroy(); 42 | } 43 | } 44 | }); 45 | self.createConnection = function(port, host, options) { 46 | return tls.connect(port, host, options); 47 | }; 48 | }; 49 | 50 | util.inherits(HttpsAgent, HttpsAgentBase); 51 | 52 | exports.create = function(protocol) { 53 | if (protocol === 'https') return new HttpsAgent(); 54 | return new HttpAgent(); 55 | }; 56 | -------------------------------------------------------------------------------- /lib/http.js: -------------------------------------------------------------------------------- 1 | var util = require('util'), 2 | Protocol = require('./protocol'), 3 | agent = require('./agent') 4 | ; 5 | 6 | var Http = module.exports = function(config, stats) { 7 | Protocol.call(this, config, stats); 8 | this.http = require(config.protocol); 9 | this.agent = agent.create(config.protocol); 10 | }; 11 | 12 | util.inherits(Http, Protocol); 13 | 14 | Http.prototype.request = function(options, callback) { 15 | var config = this.config; 16 | 17 | // Node v0.6 prefers hostname to support url.parse 18 | options.hostname = options.hostname || config.hostname; 19 | // Node v0.4 requires host 20 | options.host = options.hostname || config.hostname; 21 | options.port = options.port || config.port; 22 | options.timeout = options.timeout || config.timeout; 23 | if (!(options.agent || options.agent == false)) { 24 | options.agent = this.agent; 25 | } 26 | var http = this.http; 27 | 28 | if (!(options.headers && options.headers.connection && options.headers.connection === 'keep-alive')) { 29 | this.agent.spray_session_complete = true; 30 | } 31 | 32 | function request(cb) { 33 | var tid; 34 | var request = http.request(options, function(response) { 35 | var data = ''; 36 | response.setEncoding(options.encoding); 37 | response.on('data', function(chunk) { 38 | data += chunk; 39 | }); 40 | response.on('end', function() { 41 | clearTimeout(tid); 42 | response.body = data; 43 | cb(null, response); 44 | }); 45 | }); 46 | 47 | tid = setTimeout(function() { 48 | request.abort(); 49 | return cb('timeout'); 50 | }, options.timeout); 51 | 52 | request.on('error', function(err) { 53 | clearTimeout(tid); 54 | cb(err); 55 | }); 56 | 57 | var body = options.body || null, 58 | encoding = options.encoding || null; 59 | request.end(body, encoding); 60 | } 61 | return Protocol.prototype.request.call(this, request, callback); 62 | }; 63 | -------------------------------------------------------------------------------- /lib/protocol.js: -------------------------------------------------------------------------------- 1 | var Protocol = module.exports = function(config, stats) { 2 | this.config = config; 3 | this.stats = stats; 4 | }; 5 | 6 | Protocol.prototype.request = function(request, callback) { 7 | var stats = this.stats; 8 | var self = this; 9 | 10 | var now = Date.now(); 11 | if (stats.sec.sent >= this.config.rate) { 12 | stats.queued++; 13 | if (stats.queued > stats.max_queued) stats.max_queued = stats.queued; 14 | var delta = 1000 - (now - stats.sec.start); 15 | 16 | // Don't immediately retry. This causes CPU churn. 17 | if (delta < 5) delta = this.config.interval; 18 | 19 | return setTimeout(function() { 20 | stats.queued--; 21 | Protocol.prototype.request.call(self, request, callback); 22 | }, delta); 23 | } 24 | 25 | // update send stats 26 | stats.sent++; 27 | stats.sec.sent++; 28 | stats.min.sent++; 29 | stats.in_progress++; 30 | 31 | var start = Date.now(); 32 | request(function(err, response) { 33 | stats.in_progress--; 34 | 35 | if (err) { 36 | if (err === 'timeout') { 37 | stats.timeouts++; 38 | stats.sec.timeouts++; 39 | stats.min.timeouts++; 40 | } else { 41 | console.error('Error '+err); 42 | } 43 | stats.errors++; 44 | stats.sec.errors++; 45 | stats.min.errors++; 46 | return callback(err); 47 | } 48 | 49 | var latency = Date.now() - start; 50 | 51 | // update latency stats 52 | if (latency > stats.max_latency) stats.max_latency = latency; 53 | if (latency < stats.min_latency) stats.min_latency = latency; 54 | stats.sec.total_latency += latency; 55 | stats.min.total_latency += latency; 56 | stats.total_latency += latency; 57 | 58 | // update receive stats 59 | stats.received++; 60 | stats.sec.received++; 61 | stats.min.received++; 62 | 63 | return callback(null, response); 64 | }); 65 | }; -------------------------------------------------------------------------------- /load.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter, 2 | util = require('util'), 3 | fs = require('fs'), 4 | Http = require('./lib/http'), 5 | cube = require('cube') 6 | ; 7 | 8 | var Stats = function() { 9 | this.start = 0; 10 | this.sent = 0; 11 | this.received = 0; 12 | this.total_latency = 0; 13 | this.errors = 0; 14 | this.timeouts = 0; 15 | }; 16 | 17 | var Load = module.exports = function(options) { 18 | var stats = this.stats = new Stats(); 19 | stats.sec = new Stats(); 20 | stats.min = new Stats(); 21 | stats.in_progress = 0; 22 | stats.sessions = 0; 23 | stats.max_sessions = 0; 24 | stats.total_sessions_started = 0; 25 | stats.total_sessions_completed = 0; 26 | stats.total_sessions_error = 0; 27 | stats.max_queued = 0; 28 | stats.queued = 0; 29 | stats.min_latency = 1000000000000; 30 | stats.max_latency = 0; 31 | 32 | this.buckets = []; 33 | this.tid = null; 34 | options.time = options.time*1000; 35 | options.interval = options.interval || Math.floor(1000/options.rate); 36 | this.options = options; 37 | this.init_buckets(); 38 | this.cube_client = options.enable_cube ? cube.emitter().open('127.0.0.1', 1080) : null; 39 | }; 40 | 41 | util.inherits(Load, EventEmitter); 42 | 43 | Load.prototype.run = function(callback) { 44 | var options = this.options; 45 | var self = this; 46 | var time = Date.now(); 47 | self.stats.start = time; 48 | self.stats.sec.start = time; 49 | self.stats.min.start = time; 50 | this.tid = setInterval(function() { 51 | self.loop(callback); 52 | }, options.interval); 53 | }; 54 | 55 | Load.prototype.init_buckets = function() { 56 | var session; 57 | var options = this.options; 58 | for (var i = 0; i < options.sessions.length; i++) { 59 | session = options.sessions[i]; 60 | if (!session.weight || session.weight < 0) { 61 | this.buckets.push(i); 62 | } else { 63 | for (var j = 0; j < session.weight; j++) { 64 | this.buckets.push(i); 65 | } 66 | } 67 | } 68 | }; 69 | 70 | Load.prototype.loop = function(callback) { 71 | var options = this.options; 72 | var stats = this.stats; 73 | var now = Date.now(); 74 | this.reset_counters(now); 75 | if (((now - stats.start) >= options.time) && (stats.start != 0)) { 76 | stats.end = now; 77 | stats.duration = (stats.end - stats.start)/1000; 78 | stats.avg_send_rate = stats.sent/stats.duration; 79 | stats.avg_receive_rate = stats.received/stats.duration; 80 | clearInterval(this.tid); 81 | callback(null, stats); 82 | } 83 | this.start_session(); 84 | }; 85 | 86 | Load.prototype.start_session = function() { 87 | var options = this.options; 88 | var stats = this.stats; 89 | var buckets = this.buckets; 90 | if (!stats.queued && stats.sessions < options.max_sessions && 91 | stats.sec.sent <= options.rate) { 92 | var rand = Math.floor(Math.random()*buckets.length); 93 | var index = buckets[rand]; 94 | var session = options.sessions[index]; 95 | stats.sessions++; 96 | if (stats.sessions > stats.max_sessions) { 97 | stats.max_sessions = stats.sessions; 98 | } 99 | stats.total_sessions_started++; 100 | var http = new Http(options, stats); 101 | return session.start(http, function(err) { 102 | stats.sessions--; 103 | if (err) { 104 | stats.total_sessions_error++; 105 | stats.errors++; 106 | stats.sec.errors++; 107 | stats.min.errors++; 108 | } else { 109 | stats.total_sessions_completed++; 110 | } 111 | }); 112 | } 113 | }; 114 | 115 | Load.prototype.reset_counters = function(now) { 116 | var stats = this.stats; 117 | var delta = now - stats.sec.start; 118 | 119 | // reset the one second counters 120 | if (delta >= 1000) { 121 | this.emit('sec', stats); 122 | this.update_cube(now); 123 | stats.sec.start = now; 124 | stats.sec.sent = 0; 125 | stats.sec.received = 0; 126 | stats.sec.total_latency = 0; 127 | stats.sec.errors = 0; 128 | stats.sec.timeouts = 0; 129 | } 130 | 131 | // reset the one minute counters 132 | var minDelta = now - stats.min.start; 133 | if (minDelta > 60000) { 134 | stats.min.avg_latency = stats.min.total_latency/stats.min.received; 135 | this.emit('min', stats); 136 | stats.min.start = now; 137 | stats.min.sent = 0; 138 | stats.min.received = 0; 139 | stats.min.total_latency = 0; 140 | stats.min.errors = 0; 141 | stats.min.timeouts = 0; 142 | stats.min.avg_latency = 0; 143 | } 144 | }; 145 | 146 | Load.prototype.update_cube = function(now) { 147 | var stats = this.stats; 148 | if (this.cube_client) { 149 | this.cube_client.send({ 150 | type: 'random', 151 | time: now, 152 | data: { 153 | sent: stats.sec.sent, 154 | received: stats.sec.received, 155 | latency: stats.sec.total_latency/stats.sec.received, 156 | errors: stats.sec.errors, 157 | timeouts: stats.sec.timeouts 158 | } 159 | }); 160 | } 161 | }; 162 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Andrew J. Stone ", 3 | "name": "spray", 4 | "description": "Flexible load tester that allows real world testing similar to tsung", 5 | "version": "0.3.4", 6 | "repository": { 7 | "url": "https://github.com/bozuko/spray" 8 | }, 9 | "engines": { 10 | "node": "*" 11 | }, 12 | "main": "./load.js", 13 | "dependencies": {}, 14 | "devDependencies": {} 15 | } 16 | -------------------------------------------------------------------------------- /test/http.js: -------------------------------------------------------------------------------- 1 | var Load = require('spray'), 2 | inspect = require('util').inspect 3 | ; 4 | 5 | process.on('exit', function() { 6 | console.log(load.stats); 7 | }); 8 | 9 | var options = { 10 | protocol: 'http', 11 | hostname: '127.0.0.1', 12 | port: 2899, 13 | rate: 500, // req/sec 14 | time: 60, // sec 15 | timeout: 20000, //ms -- socket timeout 16 | max_sessions: 1000, 17 | enable_cube:true, 18 | sessions: [{ 19 | weight: 1, 20 | start: start_session 21 | }] 22 | }; 23 | 24 | var load = new Load(options); 25 | load.run(function(err, results) { 26 | if (err) return console.error("Err = "+err); 27 | console.log("\nresults = "+require('util').inspect(results)); 28 | }); 29 | 30 | load.on('sec', function(stats) { 31 | console.log('sent '+stats.sec.sent+' packets'); 32 | console.log('received '+stats.sec.received+' packets'); 33 | }); 34 | 35 | load.on('min', function(stats) { 36 | console.log(stats); 37 | }); 38 | 39 | function start_session(http, callback) { 40 | var token = Math.floor(Math.random()*100000); 41 | return http.request({ 42 | headers: { 43 | 'content-type': 'application/json', 44 | 'connection': 'keep-alive' 45 | }, 46 | encoding: 'utf-8', 47 | path: '/user/?token='+token, 48 | method: 'GET' 49 | }, function(err, res) { 50 | if (err) return callback(err); 51 | if (res.statusCode != 200) return callback(res.statusCode); 52 | var user = JSON.parse(res.body); 53 | return http.request({ 54 | headers: { 55 | 'content-type': 'application/json' 56 | }, 57 | encoding: 'utf-8', 58 | path: user.links.checkin+'/?token='+token, 59 | method: 'POST', 60 | body: JSON.stringify({ 61 | ll: [42.3, -71.8] 62 | }) 63 | }, function(err, res) { 64 | if (err) return callback(err); 65 | if (res.statusCode != 200) return callback(res.statusCode); 66 | return callback(null); 67 | }); 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /test/http_server.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | http.createServer(function (req, res) { 3 | setTimeout(function() { 4 | res.writeHead(200, {'Content-Type': 'text/plain'}); 5 | res.end(JSON.stringify({ 6 | links: { 7 | checkin: '/checkin' 8 | } 9 | })); 10 | }, 30); 11 | }).listen(2899, "127.0.0.1"); 12 | 13 | console.log('Server running at http://127.0.0.1:2701/'); 14 | --------------------------------------------------------------------------------