#!/usr/bin/env bash set -euo pipefail # monitor.sh # Poll one or more HTTP endpoints once per minute for 30 minutes (default). # Captures status, latency, key rate-limit headers, and optional JSON fields. # Requires: curl, jq (jq used only when response is JSON and JQ_FILTER is set). DURATION_MINUTES="${DURATION_MINUTES:-30}" INTERVAL_SECONDS="${INTERVAL_SECONDS:-60}" ENDPOINTS_FILE="${ENDPOINTS_FILE:-}" OUT_DIR="${OUT_DIR:-}" # Optional curl args/auth # Examples: # export CURL_HEADERS=$'Authorization: Bearer ...\nX-Api-Key: ...' # export CURL_EXTRA_ARGS='--http1.1' CURL_HEADERS="${CURL_HEADERS:-}" CURL_EXTRA_ARGS="${CURL_EXTRA_ARGS:-}" # Optional: if you want to extract specific JSON fields into the CSV # Example: export JQ_FILTER='.usage as $u | {prompt_tokens:$u.prompt_tokens, completion_tokens:$u.completion_tokens, total_tokens:$u.total_tokens}' JQ_FILTER="${JQ_FILTER:-}" usage() { cat <<'EOF' Usage: monitor.sh [--out ] [--minutes ] [--interval ] [url2 ...] monitor.sh [--endpoints-file ] Environment variables: DURATION_MINUTES (default: 30) INTERVAL_SECONDS (default: 60) ENDPOINTS_FILE (alternative to positional urls) OUT_DIR (default: ./artifacts/) CURL_HEADERS (newline-separated header lines) CURL_EXTRA_ARGS (extra args appended to curl) JQ_FILTER (jq filter to extract JSON fields into a compact object) Artifacts: /samples.csv (one line per endpoint per tick) /responses/_.json (when content-type is json) EOF } # Parse args urls=() while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage; exit 0 ;; --out) OUT_DIR="$2"; shift 2 ;; --minutes) DURATION_MINUTES="$2"; shift 2 ;; --interval) INTERVAL_SECONDS="$2"; shift 2 ;; --endpoints-file) ENDPOINTS_FILE="$2"; shift 2 ;; --) shift; break ;; -*) echo "Unknown flag: $1" >&2; usage; exit 2 ;; *) urls+=("$1"); shift ;; esac done if [[ -n "$ENDPOINTS_FILE" ]]; then if [[ ! -f "$ENDPOINTS_FILE" ]]; then echo "ENDPOINTS_FILE not found: $ENDPOINTS_FILE" >&2 exit 2 fi while IFS= read -r line; do [[ -z "$line" ]] && continue [[ "$line" =~ ^\s*# ]] && continue urls+=("$line") done < "$ENDPOINTS_FILE" fi if [[ ${#urls[@]} -eq 0 ]]; then echo "No endpoints provided." >&2 usage exit 2 fi stamp="$(date -u +%Y%m%dT%H%M%SZ)" if [[ -z "$OUT_DIR" ]]; then OUT_DIR="$(pwd)/artifacts/$stamp" fi mkdir -p "$OUT_DIR/responses" csv="$OUT_DIR/samples.csv" # Base columns are stable. JSON fields (if any) are stored as a compact JSON object in json_extract. echo "ts_utc,tick,endpoint_idx,url,http_status,total_time_ms,namelookup_ms,connect_ms,appconnect_ms,pretransfer_ms,starttransfer_ms,size_download,content_type,ratelimit_limit,ratelimit_remaining,ratelimit_reset,retry_after,x_ratelimit_limit,x_ratelimit_remaining,x_ratelimit_reset,rate_limit_limit,rate_limit_remaining,rate_limit_reset,other_rl_headers,json_extract" > "$csv" # Build curl header args header_args=() if [[ -n "$CURL_HEADERS" ]]; then while IFS= read -r h; do [[ -z "$h" ]] && continue header_args+=( -H "$h" ) done <<< "$CURL_HEADERS" fi # shellcheck disable=SC2206 extra_args=( $CURL_EXTRA_ARGS ) get_header() { # $1=header block, $2=case-insensitive header name awk -v key="$2" 'BEGIN{IGNORECASE=1} $0 ~ "^"key":" {sub(/\r$/, ""); sub("^[^:]*: ?", ""); print; exit}' <<< "$1" } collect_other_rl_headers() { # Extract any headers containing ratelimit/limit/remaining/reset (best-effort) other than the explicit ones. awk 'BEGIN{IGNORECASE=1} /^[^:]+:/{ k=tolower($1); gsub(/:$/, "", k); if (k ~ /rate/ || k ~ /limit/ || k ~ /remaining/ || k ~ /reset/) { line=$0; sub(/\r$/, "", line); print line; } }' <<< "$1" | jq -R -s -c 'split("\n") | map(select(length>0))' } minutes="$DURATION_MINUTES" interval="$INTERVAL_SECONDS" ticks=$minutes echo "Writing artifacts to: $OUT_DIR" >&2 echo "Polling ${#urls[@]} endpoint(s) every ${interval}s for ${minutes} minute(s) (ticks=${ticks})." >&2 for ((tick=1; tick<=ticks; tick++)); do ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" for i in "${!urls[@]}"; do url="${urls[$i]}" # Use curl to capture headers + body. We also capture timing in a single line via -w. # We intentionally do NOT send any modifying verbs; caller may include them via CURL_EXTRA_ARGS at their own risk. tmp_headers="$OUT_DIR/.headers_${tick}_$i.txt" tmp_body="$OUT_DIR/.body_${tick}_$i.bin" tmp_meta="$OUT_DIR/.meta_${tick}_$i.txt" curl_exit=0 curl -sS -D "$tmp_headers" -o "$tmp_body" \ -w "%{http_code} %{time_total} %{time_namelookup} %{time_connect} %{time_appconnect} %{time_pretransfer} %{time_starttransfer} %{size_download} %{content_type}" \ "${header_args[@]}" "${extra_args[@]}" \ "$url" > "$tmp_meta" || curl_exit=$? if [[ $curl_exit -ne 0 ]]; then # curl failures still get recorded http_status="0" total_ms="0" name_ms="0" conn_ms="0" app_ms="0" pre_ms="0" start_ms="0" size_dl="0" ctype="" headers_block="$(cat "$tmp_headers" 2>/dev/null || true)" else read -r http_status total name conn app pre start size_dl ctype < "$tmp_meta" total_ms="$(awk -v t="$total" 'BEGIN{printf("%.0f", t*1000)}')" name_ms="$(awk -v t="$name" 'BEGIN{printf("%.0f", t*1000)}')" conn_ms="$(awk -v t="$conn" 'BEGIN{printf("%.0f", t*1000)}')" app_ms="$(awk -v t="$app" 'BEGIN{printf("%.0f", t*1000)}')" pre_ms="$(awk -v t="$pre" 'BEGIN{printf("%.0f", t*1000)}')" start_ms="$(awk -v t="$start" 'BEGIN{printf("%.0f", t*1000)}')" headers_block="$(cat "$tmp_headers" 2>/dev/null || true)" fi # Extract common rate-limit headers across ecosystems rl_limit="$(get_header "$headers_block" "ratelimit-limit")" rl_remaining="$(get_header "$headers_block" "ratelimit-remaining")" rl_reset="$(get_header "$headers_block" "ratelimit-reset")" retry_after="$(get_header "$headers_block" "retry-after")" x_rl_limit="$(get_header "$headers_block" "x-ratelimit-limit")" x_rl_remaining="$(get_header "$headers_block" "x-ratelimit-remaining")" x_rl_reset="$(get_header "$headers_block" "x-ratelimit-reset")" rate_limit_limit="$(get_header "$headers_block" "rate-limit-limit")" rate_limit_remaining="$(get_header "$headers_block" "rate-limit-remaining")" rate_limit_reset="$(get_header "$headers_block" "rate-limit-reset")" other_rl="$(collect_other_rl_headers "$headers_block" 2>/dev/null || echo '[]')" json_extract="" if [[ -n "$ctype" && "$ctype" == *"json"* ]]; then resp_path="$OUT_DIR/responses/${tick}_${i}.json" # Best-effort: ensure body is valid UTF-8-ish before saving cp "$tmp_body" "$resp_path" 2>/dev/null || true if [[ -n "$JQ_FILTER" ]]; then json_extract="$(jq -c "$JQ_FILTER" "$resp_path" 2>/dev/null || echo '')" fi fi # CSV escaping: wrap potentially complex fields in JSON strings url_json="$(jq -R -s -c . <<< "$url")" ctype_json="$(jq -R -s -c . <<< "$ctype")" rl_limit_json="$(jq -R -s -c . <<< "${rl_limit:-}")" rl_remaining_json="$(jq -R -s -c . <<< "${rl_remaining:-}")" rl_reset_json="$(jq -R -s -c . <<< "${rl_reset:-}")" retry_after_json="$(jq -R -s -c . <<< "${retry_after:-}")" x_rl_limit_json="$(jq -R -s -c . <<< "${x_rl_limit:-}")" x_rl_remaining_json="$(jq -R -s -c . <<< "${x_rl_remaining:-}")" x_rl_reset_json="$(jq -R -s -c . <<< "${x_rl_reset:-}")" rate_limit_limit_json="$(jq -R -s -c . <<< "${rate_limit_limit:-}")" rate_limit_remaining_json="$(jq -R -s -c . <<< "${rate_limit_remaining:-}")" rate_limit_reset_json="$(jq -R -s -c . <<< "${rate_limit_reset:-}")" json_extract_json="$(jq -R -s -c . <<< "${json_extract:-}")" printf '%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n' \ "$ts" \ "$tick" \ "$i" \ "${url_json}" \ "$http_status" \ "$total_ms" \ "$name_ms" \ "$conn_ms" \ "$app_ms" \ "$pre_ms" \ "$start_ms" \ "$size_dl" \ "${ctype_json}" \ "${rl_limit_json}" \ "${rl_remaining_json}" \ "${rl_reset_json}" \ "${retry_after_json}" \ "${x_rl_limit_json}" \ "${x_rl_remaining_json}" \ "${x_rl_reset_json}" \ "${rate_limit_limit_json}" \ "${rate_limit_remaining_json}" \ "${rate_limit_reset_json}" \ "${other_rl}" \ "${json_extract_json}" >> "$csv" rm -f "$tmp_headers" "$tmp_body" "$tmp_meta" 2>/dev/null || true done if [[ $tick -lt $ticks ]]; then sleep "$interval" fi done echo "Done. CSV: $csv" >&2