├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md └── src └── main.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "welle" 3 | version = "0.1.0" 4 | description = "Welle is a tool for benchmarking servers similar to ApacheBench" 5 | authors = ["Ryan Levick "] 6 | readme = "README.md" 7 | keywords = ["benchmark", "http"] 8 | repository = "https://github.com/rylev/welle" 9 | license = "MIT" 10 | edition = "2018" 11 | 12 | [dependencies] 13 | http = "0.1" 14 | reqwest = "0.9" 15 | tokio = "0.1" 16 | futures = "0.1" 17 | structopt = "0.2" 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Andrew Gallant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welle 2 | 3 | Welle is a tool for benchmarking servers similar to [ApacheBench](https://httpd.apache.org/docs/2.4/programs/ab.html). 4 | 5 | ## Usage 6 | 7 | ```txt 8 | USAGE: 9 | welle [OPTIONS] --num-requests 10 | 11 | FLAGS: 12 | -h, --help Prints help information 13 | -V, --version Prints version information 14 | 15 | OPTIONS: 16 | -c, --concurrent-requests Number of in flight requests allowed at a time [default: 1] 17 | -m, --method HTTP method to use [default: GET] 18 | -n, --num-requests Total number of requests to make 19 | 20 | ARGS: 21 | URL to request 22 | ``` 23 | 24 | ## Building 25 | 26 | The tool requires `Rust` and `Cargo` which you can get [here](https://rustup.rs/). Once Rust and Cargo are installed, building is as easy as: 27 | 28 | ```bash 29 | cargo build --release 30 | ``` 31 | 32 | And you can find your binary in "./target/release/welle". 33 | 34 | ## Roadmap 35 | 36 | * [ ] Customization the type of request made 37 | * [ ] Ability to log structured result data 38 | * [ ] Graphing and charting of data 39 | * [ ] Finer tuned diagnostics of how long individual parts of the request take (e.g., connection time) 40 | * [ ] Control of how the test runs over time 41 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use futures::{stream, Future}; 2 | use http::HttpTryFrom; 3 | use reqwest::{r#async::Client, Method, Url}; 4 | use std::time::Duration; 5 | use structopt::StructOpt; 6 | use tokio::prelude::*; 7 | 8 | /// A tool for load testing servers 9 | #[derive(StructOpt, Debug)] 10 | #[structopt(name = "welle")] 11 | struct Config { 12 | /// Total number of requests to make 13 | #[structopt( 14 | short = "n", 15 | long = "num-requests", 16 | value_name = "NUMBER", 17 | required = true, 18 | raw(validator = "parse_number_of_requests") 19 | )] 20 | request_count: usize, 21 | 22 | /// Number of in flight requests allowed at a time 23 | #[structopt( 24 | short = "c", 25 | long = "concurrent-requests", 26 | value_name = "NUMBER", 27 | default_value = "1" 28 | )] 29 | concurrent_count: usize, 30 | 31 | /// HTTP method to use 32 | #[structopt( 33 | short = "m", 34 | long = "method", 35 | default_value = "GET", 36 | value_name = "METHOD", 37 | parse(try_from_str = "parse_method") 38 | )] 39 | method: Method, 40 | 41 | /// URL to request 42 | #[structopt(name = "URL")] 43 | url: Url, 44 | } 45 | 46 | fn main() { 47 | let config = Config::from_args(); 48 | 49 | tokio::run(run(config)); 50 | } 51 | 52 | fn run(config: Config) -> impl Future { 53 | let client = Client::new(); 54 | 55 | let url = config.url.into_string(); 56 | let request_count = config.request_count; 57 | let concurrent_count = config.concurrent_count; 58 | let method = config.method; 59 | 60 | let requests = (0..request_count) 61 | .into_iter() 62 | .map(move |_| make_request(&client, &url, &method)); 63 | 64 | let outcomes = stream::iter_ok(requests) 65 | .buffer_unordered(concurrent_count) 66 | .collect(); 67 | 68 | timed(outcomes) 69 | .map(move |(outcomes_result, duration)| match outcomes_result { 70 | Ok(outcomes) => TestOutcome::new(outcomes, duration, concurrent_count), 71 | _ => unreachable!("The outcomes future cannot fail"), 72 | }) 73 | .map(|test_outcome| println!("{}", test_outcome)) 74 | } 75 | 76 | fn make_request( 77 | client: &Client, 78 | url: &str, 79 | method: &Method, 80 | ) -> impl Future + Send { 81 | let request = client.request(method.clone(), url); 82 | 83 | timed(request.send()).map(move |(result, duration)| { 84 | let result = result.map(|resp| resp.status()); 85 | RequestOutcome::new(result, duration, 0) 86 | }) 87 | } 88 | 89 | #[derive(Debug)] 90 | enum FutureState { 91 | Unpolled, 92 | Polled(std::time::Instant), 93 | } 94 | struct TimedFuture { 95 | inner: F, 96 | state: FutureState, 97 | } 98 | 99 | fn timed(future: F) -> TimedFuture { 100 | TimedFuture { 101 | inner: future, 102 | state: FutureState::Unpolled, 103 | } 104 | } 105 | 106 | impl Future for TimedFuture 107 | where 108 | F: Future, 109 | { 110 | type Item = (Result, Duration); 111 | type Error = (); 112 | 113 | fn poll(&mut self) -> Result, Self::Error> { 114 | let now = std::time::Instant::now(); 115 | let result = self.inner.poll(); 116 | 117 | let t1 = match self.state { 118 | FutureState::Unpolled => now, 119 | FutureState::Polled(t) => t, 120 | }; 121 | 122 | match result { 123 | Ok(Async::Ready(v)) => Ok(Async::Ready((Ok(v), now - t1))), 124 | Err(e) => Ok(Async::Ready((Err(e), now - t1))), 125 | Ok(Async::NotReady) => { 126 | self.state = FutureState::Polled(t1); 127 | Ok(Async::NotReady) 128 | } 129 | } 130 | } 131 | } 132 | 133 | type RequestResult = Result; 134 | struct RequestOutcome { 135 | result: RequestResult, 136 | duration: Duration, 137 | // payload_size: usize, 138 | } 139 | impl RequestOutcome { 140 | fn new(result: RequestResult, duration: Duration, _payload_size: usize) -> RequestOutcome { 141 | RequestOutcome { 142 | result, 143 | duration, 144 | // payload_size, 145 | } 146 | } 147 | } 148 | 149 | struct TestOutcome { 150 | request_outcomes: Vec, 151 | total_time: Duration, 152 | concurrent_count: usize, 153 | } 154 | 155 | impl TestOutcome { 156 | fn new( 157 | request_outcomes: Vec, 158 | total_time: Duration, 159 | concurrent_count: usize, 160 | ) -> TestOutcome { 161 | TestOutcome { 162 | request_outcomes, 163 | total_time, 164 | concurrent_count, 165 | } 166 | } 167 | } 168 | 169 | impl std::fmt::Display for TestOutcome { 170 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 171 | let total_requests = self.request_outcomes.len(); 172 | let (ok_count, server_err_count, err_count) = 173 | self.request_outcomes 174 | .iter() 175 | .fold((0, 0, 0), |(ok, server_err, err), next| match next.result { 176 | Ok(s) if s.is_server_error() => (ok + 1, server_err + 1, err), 177 | Ok(_) => (ok + 1, server_err, err), 178 | Err(_) => (ok, server_err, err + 1), 179 | }); 180 | let mut durations: Vec = self 181 | .request_outcomes 182 | .iter() 183 | .map(|outcome| outcome.duration) 184 | .collect(); 185 | let total_time_in_flight: Duration = durations.iter().cloned().sum(); 186 | let avg_time_in_flight = total_time_in_flight / total_requests as u32; 187 | 188 | durations.sort_unstable(); 189 | 190 | let fifty_percent = percenteil(&durations, 0.5); 191 | let sixty_six = percenteil(&durations, 0.66); 192 | let seventy_five_percent = percenteil(&durations, 0.75); 193 | let eighty = percenteil(&durations, 0.80); 194 | let ninety = percenteil(&durations, 0.90); 195 | let ninety_five = percenteil(&durations, 0.95); 196 | let ninety_nine = percenteil(&durations, 0.99); 197 | let longest = durations.last().unwrap(); 198 | 199 | writeln!(f, "Total Requests: {:?}", total_requests)?; 200 | writeln!(f, "Concurrency Count: {}", self.concurrent_count)?; 201 | writeln!(f, "Total Completed Requests: {:?}", ok_count)?; 202 | writeln!(f, "Total Errored Requests: {:?}", err_count)?; 203 | writeln!(f, "Total 5XX Requests: {:?}", server_err_count)?; 204 | writeln!(f, ""); 205 | writeln!(f, "Total Time Taken: {:?}", self.total_time)?; 206 | writeln!( 207 | f, 208 | "Avg Time Taken: {:?}", 209 | self.total_time / total_requests as u32 210 | )?; 211 | writeln!(f, "Total Time In Flight: {:?}", total_time_in_flight)?; 212 | writeln!(f, "Avg Time In Flight: {:?}", avg_time_in_flight)?; 213 | writeln!(f, ""); 214 | writeln!( 215 | f, 216 | "Percentage of the requests served within a certain time:" 217 | ); 218 | writeln!(f, "50%: {:?}", fifty_percent)?; 219 | writeln!(f, "66%: {:?}", sixty_six)?; 220 | writeln!(f, "75%: {:?}", seventy_five_percent)?; 221 | writeln!(f, "80%: {:?}", eighty)?; 222 | writeln!(f, "90%: {:?}", ninety)?; 223 | writeln!(f, "95%: {:?}", ninety_five)?; 224 | writeln!(f, "99%: {:?}", ninety_nine)?; 225 | writeln!(f, "100%: {:?}", longest)?; 226 | 227 | Ok(()) 228 | } 229 | } 230 | 231 | fn percenteil(durations: &Vec, percentage: f64) -> Duration { 232 | let last_index = durations.len() - 1; 233 | let i = last_index as f64 * percentage; 234 | let ceil = i.ceil(); 235 | if (ceil as usize) >= last_index { 236 | return *durations.last().unwrap(); 237 | } 238 | 239 | if i != ceil { 240 | durations[ceil as usize] + durations[ceil as usize + 1] / 2 241 | } else { 242 | durations[ceil as usize] 243 | } 244 | } 245 | 246 | fn parse_method(str: &str) -> Result { 247 | Method::try_from(str).map_err(|_| format!("unrecognized method: {}", str)) 248 | } 249 | 250 | fn parse_number_of_requests(n: String) -> Result<(), String> { 251 | n.parse::() 252 | .map_err(|_| format!("'{}' is not a valid positive number", n)) 253 | .and_then(|n| { 254 | if n != 0 { 255 | Ok(()) 256 | } else { 257 | Err(String::from("number of requests must be greater than 0")) 258 | } 259 | }) 260 | } 261 | --------------------------------------------------------------------------------