#!/bin/bash # curl-timing - Time HTTP requests with curl and report summary statistics # # Usage: # curl-timing [options] [url...] # # Options: # -n, --num N Number of timed requests per URL (default: 10) # -w, --warmup N Warmup requests before timing (not logged, default: 0) # -s, --sleep SECONDS Sleep between requests (default: none) # -X, --method METHOD HTTP method (default: GET; POST inferred if -d is given) # -H, --header HEADER Extra request header, repeatable # -d, --data DATA Request body (implies POST unless -X is set) # -A, --user-agent UA User-Agent header (default: curl-timing/1.0) # --no-save Do not write per-URL log or stats files # -q, --quiet Suppress per-request output; only show summary # -h, --help Show this help message _curl_timing() ( local SCRIPT_NAME; SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) SCRIPT_NAME="" ;; esac case "$SCRIPT_NAME" in ""|bash|sh|zsh|dash) SCRIPT_NAME="curl-timing" ;; esac _error() { echo "[ERR][$SCRIPT_NAME] $*" >&2; } _show_help() { local s; [ -t 1 ] && s=$'\033[4m' local r; [ -t 1 ] && r=$'\033[24m' echo "NAME" echo " $SCRIPT_NAME - time HTTP requests and report summary statistics" echo "SYNOPSIS" echo " $SCRIPT_NAME [${s}options${r}] <${s}url${r}> [${s}url${r}...]" echo " $SCRIPT_NAME -h" echo "DESCRIPTION" echo " Sends N requests to each URL with curl, measures response time in" echo " milliseconds, and prints count/total/average/range plus outliers" echo " detected via IQR. Useful for spot-checking endpoint latency," echo " comparing URLs head-to-head, or tracking performance regressions." echo " Per-URL timings and a stats summary are written to files in the" echo " current directory unless --no-save is used." echo "OPTIONS" echo " -n, --num ${s}N${r} Number of timed requests per URL (default: 10)" echo " -w, --warmup ${s}N${r} Warmup requests before timing (not logged, default: 0)" echo " -s, --sleep ${s}SECONDS${r} Sleep between requests (default: none)" echo " -X, --method ${s}METHOD${r} HTTP method (default: GET; POST inferred if -d is given)" echo " -H, --header ${s}HEADER${r} Extra request header, repeatable" echo " -d, --data ${s}DATA${r} Request body (implies POST unless -X is set)" echo " -A, --user-agent ${s}UA${r} User-Agent header (default: curl-timing/1.0)" echo " --no-save Do not write per-URL log or stats files" echo " -q, --quiet Suppress per-request output; only show summary" echo " -h, --help Show this help message" echo "EXIT STATUS" echo " 0 All requests sent successfully" echo " 2 Usage / argument error" echo " 3 Dependency error (curl or bc missing)" echo "DEPENDENCIES" echo " curl, bc" echo "SEE ALSO" echo " stats, pin-dns, httpcode" } _req() { local url="$1" local -a args=(-sS -w '%{time_total}\n' -o /dev/null -A "$user_agent" -X "$method") local h for h in "${headers[@]}"; do args+=(-H "$h") done if [ -n "$data_set" ]; then args+=(--data "$data") fi curl "${args[@]}" "$url" \ | sed '# Convert fractional seconds to milliseconds (0.136015 -> 136, 31.734642 -> 31734) s/[0-9]\{3\}$//; # Remove last 3 digits as we get microsecond precision (6 decimal places) s/\.//; # Remove decimal point s/^0\{1,\}//; # Remove leading zeroes' } _stats() { # Read a list of newline-delimited integers from stdin local -a numbers local line while IFS= read -r line || [ -n "$line" ]; do numbers+=("$line"); done # Check for any non-digits (which would most likely be a decimal point) - allow spaces as the numbers are concatenated with spaces grep -q '[^0-9 ]' <<<"${numbers[*]}" && { _error "Integers only"; return 1; } # If -n is provided, list the dataset first [ "$1" = "-n" ] && printf '%s, ' "${numbers[@]}" | sed 's/, $/\n/' # Sort the numbers in ascending order (for outlier calculation, but it gets us min and max too) local -a sorted while IFS= read -r line || [ -n "$line" ]; do sorted+=("$line"); done < <(printf '%s\n' "${numbers[@]}" | sort -n) # Find outliers via Interquartile Range (IQR) local -i n=${#sorted[@]} local -i Q1="${sorted[$((n / 4))]}" local -i Q3="${sorted[$((n * 3 / 4))]}" local -i IQR=$((Q3 - Q1)) local -i lower_bound; lower_bound="$(printf '%.0f' "$(bc <<< "$Q1 - 1.5 * $IQR")")" local -i upper_bound; upper_bound="$(printf '%.0f' "$(bc <<< "$Q3 + 1.5 * $IQR")")" local -i lower_index=0 local -i upper_index=$((n - 1)) while ((sorted[lower_index] < lower_bound)); do ((lower_index++)) done while ((sorted[upper_index] > upper_bound)); do ((upper_index--)) done local -a outliers=("${sorted[@]:0:$lower_index}" "${sorted[@]:$((upper_index + 1))}") local percent_outliers; percent_outliers="$(printf '%.1f%%' "$(bc <<< "scale=4; ${#outliers[@]} / ${#numbers[@]} * 100")")" local -i total; total=$(printf '%s+' "${sorted[@]}" | sed 's/+$//' | bc) local -i min="${sorted[0]}" local -i max="${sorted[$((${#sorted[@]} - 1))]}" local -i range=$((max - min)) echo "Count: ${#numbers[@]}" echo "Total: $total" echo "Average: $(bc <<< "$total / ${#numbers[@]}")" echo "Range: $range ($min to $max)" echo "Outliers: ${outliers[*]}" echo " ${#outliers[@]} of ${#numbers[@]} ($percent_outliers)" } _colorize_url() { local url="$1" [ -t 1 ] || { echo "$url"; return 0; } _color() { local base=30 local bright_base=90 local code if [ "$1" = 'bright' ]; then code=$bright_base shift else code=$base fi local adj case "$1" in black) adj=0 ;; red) adj=1 ;; green) adj=2 ;; yellow) adj=3 ;; blue) adj=4 ;; magenta) adj=5 ;; cyan) adj=6 ;; white) adj=7 ;; esac code=$((code + adj)) printf '\033[%sm' "$code" } local c_protocol; c_protocol="$(_color bright black)" local c_hostname; c_hostname="$(_color bright white)" local c_path; c_path="$(_color bright cyan)" local c_key; c_key="$(_color bright yellow)" local c_value; c_value="$(_color green)" local c_slash; c_slash="$(_color white)" local c_question; c_question="$(_color black)" local c_equals; c_equals="$(_color white)" local c_and; c_and="$(_color black)" local c_reset; c_reset=$'\033[0m' local protocol="${url%%://*}" local url_no_protocol="${url#*://}" local hostname="${url_no_protocol%%/*}" local path_and_query="${url_no_protocol#*/}" local path="${path_and_query%%\?*}" local query="" if [ "$path_and_query" != "$path" ]; then query="${path_and_query#*\?}" local -a query_params IFS='&' read -r -a query_params <<<"${query}" local colorized_query="" local param for param in "${query_params[@]}"; do local -a param_parts IFS='=' read -r -a param_parts <<<"$param" local key="${param_parts[0]}" local value="${param_parts[1]}" colorized_query+="$c_and&$c_key${key}$c_equals=$c_value${value}$c_reset" done query="${colorized_query/#"$c_and&"/"$c_question?"}" fi local colorized_path="" if [ "$url_no_protocol" != "$hostname" ]; then colorized_path="$c_slash/$c_path${path//"/"/"$c_slash/$c_path"}" fi local colorized_url="$c_protocol${protocol}://$c_hostname${hostname}${colorized_path}$c_question${query}$c_reset" echo "$colorized_url" unset -f _color } _expand_short_opts() { # $1 = string of short-opt letters that take a value (e.g. "nXHd"); "" for flag-only scripts # $2..$N = "$@" # Populates _EXPANDED; caller does: set -- "${_EXPANDED[@]}"; unset _EXPANDED local value_opts="$1"; shift _EXPANDED=() local passthru="" local arg local rest local c for arg in "$@"; do if [ -n "$passthru" ]; then _EXPANDED+=("$arg"); continue; fi case "$arg" in --) passthru=1; _EXPANDED+=("$arg") ;; --*|-|"") _EXPANDED+=("$arg") ;; -[a-zA-Z]?*) rest="${arg#-}" while [ -n "$rest" ]; do c="${rest%"${rest#?}"}"; rest="${rest#?}" _EXPANDED+=("-$c") case "$value_opts" in *"$c"*) [ -n "$rest" ] && _EXPANDED+=("$rest") rest="" ;; esac done ;; *) _EXPANDED+=("$arg") ;; esac done } # --- defaults and parse arguments --- local num_requests=10 local warmup=0 local sleep_dur="" local method="" local method_set="" local data="" local data_set="" local user_agent="curl-timing/1.0" local save=1 local quiet="" local -a headers=() local -a urls=() _expand_short_opts "nwsXHdA" "$@" set -- "${_EXPANDED[@]}"; unset _EXPANDED while [ "$#" -gt 0 ]; do case "$1" in -h|--help) _show_help; return 0 ;; -n|--num) [ -n "${2-}" ] || { _error "--num requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } num_requests="$2"; shift 2 ;; --num=*) num_requests="${1#*=}" [ -n "$num_requests" ] || { _error "--num requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -w|--warmup) [ -n "${2-}" ] || { _error "--warmup requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } warmup="$2"; shift 2 ;; --warmup=*) warmup="${1#*=}" [ -n "$warmup" ] || { _error "--warmup requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -s|--sleep) [ -n "${2-}" ] || { _error "--sleep requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } sleep_dur="$2"; shift 2 ;; --sleep=*) sleep_dur="${1#*=}" [ -n "$sleep_dur" ] || { _error "--sleep requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -X|--method) [ -n "${2-}" ] || { _error "--method requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } method="$2"; method_set=1; shift 2 ;; --method=*) method="${1#*=}" [ -n "$method" ] || { _error "--method requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } method_set=1; shift ;; -H|--header) [ -n "${2-}" ] || { _error "--header requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } headers+=("$2"); shift 2 ;; --header=*) local _hdr="${1#*=}" [ -n "$_hdr" ] || { _error "--header requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } headers+=("$_hdr"); shift ;; -d|--data) [ -n "${2-}" ] || { _error "--data requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } data="$2"; data_set=1; shift 2 ;; --data=*) data="${1#*=}" [ -n "$data" ] || { _error "--data requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } data_set=1; shift ;; -A|--user-agent) [ -n "${2-}" ] || { _error "--user-agent requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } user_agent="$2"; shift 2 ;; --user-agent=*) user_agent="${1#*=}" [ -n "$user_agent" ] || { _error "--user-agent requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; --no-save) save=""; shift ;; -q|--quiet) quiet=1; shift ;; --) shift; while [ "$#" -gt 0 ]; do urls+=("$1"); shift; done ;; -*) _error "Unknown argument '$1'. Run \`$SCRIPT_NAME -h\` for usage" return 2 ;; *) urls+=("$1"); shift ;; esac done if [ "${#urls[@]}" -eq 0 ]; then _error "At least one URL is required. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi case "$num_requests" in ''|*[!0-9]*) _error "Invalid --num '$num_requests' (must be a non-negative integer). Run \`$SCRIPT_NAME -h\` for usage"; return 2 ;; esac case "$warmup" in ''|*[!0-9]*) _error "Invalid --warmup '$warmup' (must be a non-negative integer). Run \`$SCRIPT_NAME -h\` for usage"; return 2 ;; esac if ! command -v curl >/dev/null 2>&1; then _error "curl is required" return 3 fi if ! command -v bc >/dev/null 2>&1; then _error "bc is required" return 3 fi # Default method: POST if -d given, else GET if [ -z "$method_set" ]; then if [ -n "$data_set" ]; then method="POST" else method="GET" fi fi # Per-URL log filenames. Single URL: curl-timing.txt. Multiple: curl-timing-1.txt, -2.txt, ... local -a files=() if [ "${#urls[@]}" -eq 1 ]; then files=("curl-timing.txt") else local idx for ((idx = 1; idx <= ${#urls[@]}; idx++)); do files+=("curl-timing-${idx}.txt") done fi # Summarize local warmup_note="" [ "$warmup" -gt 0 ] && warmup_note=" (plus $warmup warmup)" echo "Sending $num_requests requests${warmup_note} to ${#urls[@]} URL(s):" local -i i=0 while (( i < ${#urls[@]} )); do echo "[$((i + 1))]: $(_colorize_url "${urls[$i]}")" i=$((i + 1)) done [ "$num_requests" -eq 0 ] && return 0 echo i=0 local file local url local px local px2 local stats_output local stats_file local timings while (( i < ${#urls[@]} )); do file="${files[$i]}" url="${urls[$i]}" px="[$((i + 1))/${#urls[@]}]" if [ -n "$save" ]; then [ -f "$file" ] && mv -v "$file" "$file.bak" printf '' > "$file" fi echo "$px Sending $num_requests requests to URL $((i + 1)) of ${#urls[@]}..." _colorize_url "$url" if [ -n "$save" ]; then echo "$px Time logged to: $(realpath "$file" 2>/dev/null || printf '%s/%s\n' "$PWD" "$file")" fi echo # Warmup runs: not timed, not logged local -i warm=0 while (( warm < warmup )); do _req "$url" > /dev/null [ -n "$sleep_dur" ] && sleep "$sleep_dur" warm=$((warm + 1)) done # Timed runs: collect into a variable, optionally mirror to file timings="" local -i count=0 local ms while (( count < num_requests )); do count=$((count + 1)) px2="$px$(printf "(%0${#num_requests}d/$num_requests)" "$count")" ms="$(_req "$url")" timings+="${ms}"$'\n' if [ -n "$save" ]; then printf '%s\n' "$ms" >> "$file" fi if [ -z "$quiet" ]; then echo "$px2 ${ms}ms" fi [ -n "$sleep_dur" ] && sleep "$sleep_dur" done stats_output="$(printf '%s' "$timings" | _stats -n)" if [ -n "$save" ]; then stats_file="${file%.txt}_stats.txt" [ -f "$stats_file" ] && mv -v "$stats_file" "$stats_file.bak" printf '%s\n' "$stats_output" > "$stats_file" echo "Statistics saved to: $(realpath "$stats_file" 2>/dev/null || printf '%s/%s\n' "$PWD" "$stats_file")" fi printf '\n%s\n\n' "$stats_output" i=$((i + 1)) done return 0 ) _curl_timing "$@" __curl_timing_rc=$? unset -f _curl_timing if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then eval "unset __curl_timing_rc; return $__curl_timing_rc" fi eval "unset __curl_timing_rc; exit $__curl_timing_rc"